sonobat 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/README.md +161 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2546 -0
- package/dist/index.js.map +1 -0
- package/package.json +62 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,2546 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import Database from "better-sqlite3";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
|
|
7
|
+
// src/db/schema.ts
|
|
8
|
+
var SCHEMA_SQL = `
|
|
9
|
+
PRAGMA foreign_keys = ON;
|
|
10
|
+
|
|
11
|
+
-- ============================================================
|
|
12
|
+
-- \u5B9F\u884C\u5358\u4F4D\uFF08\u4EFB\u610F\uFF09
|
|
13
|
+
-- ============================================================
|
|
14
|
+
CREATE TABLE IF NOT EXISTS scans (
|
|
15
|
+
id TEXT PRIMARY KEY,
|
|
16
|
+
started_at TEXT NOT NULL,
|
|
17
|
+
finished_at TEXT,
|
|
18
|
+
notes TEXT
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
-- ============================================================
|
|
22
|
+
-- \u751F\u51FA\u529B\uFF08\u30D5\u30A1\u30A4\u30EB\uFF09\u53C2\u7167
|
|
23
|
+
-- ============================================================
|
|
24
|
+
CREATE TABLE IF NOT EXISTS artifacts (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
scan_id TEXT,
|
|
27
|
+
tool TEXT NOT NULL, -- "nmap" | "ffuf" | "nuclei"
|
|
28
|
+
kind TEXT NOT NULL, -- "tool_output" | "http_request" | "http_response"
|
|
29
|
+
path TEXT NOT NULL, -- \u30ED\u30FC\u30AB\u30EB\u30D5\u30A1\u30A4\u30EB\u30D1\u30B9 or URI
|
|
30
|
+
sha256 TEXT,
|
|
31
|
+
captured_at TEXT NOT NULL,
|
|
32
|
+
attrs_json TEXT, -- \u4EFB\u610F\u30E1\u30BF\uFF08\u30B3\u30DE\u30F3\u30C9\u3001\u5F15\u6570\u7B49\uFF09
|
|
33
|
+
FOREIGN KEY (scan_id) REFERENCES scans(id) ON DELETE SET NULL
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE INDEX IF NOT EXISTS idx_artifacts_tool ON artifacts(tool);
|
|
37
|
+
|
|
38
|
+
-- ============================================================
|
|
39
|
+
-- \u30DB\u30B9\u30C8
|
|
40
|
+
-- ============================================================
|
|
41
|
+
CREATE TABLE IF NOT EXISTS hosts (
|
|
42
|
+
id TEXT PRIMARY KEY,
|
|
43
|
+
authority_kind TEXT NOT NULL, -- "IP" | "DOMAIN"
|
|
44
|
+
authority TEXT NOT NULL UNIQUE, -- IP \u30A2\u30C9\u30EC\u30B9\u307E\u305F\u306F\u30C9\u30E1\u30A4\u30F3\u540D
|
|
45
|
+
resolved_ips_json TEXT NOT NULL DEFAULT '[]',
|
|
46
|
+
created_at TEXT NOT NULL,
|
|
47
|
+
updated_at TEXT NOT NULL
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
-- ============================================================
|
|
51
|
+
-- \u30D0\u30FC\u30C1\u30E3\u30EB\u30DB\u30B9\u30C8
|
|
52
|
+
-- ============================================================
|
|
53
|
+
CREATE TABLE IF NOT EXISTS vhosts (
|
|
54
|
+
id TEXT PRIMARY KEY,
|
|
55
|
+
host_id TEXT NOT NULL,
|
|
56
|
+
hostname TEXT NOT NULL,
|
|
57
|
+
source TEXT, -- "nmap" | "cert" | "header" | "manual"
|
|
58
|
+
evidence_artifact_id TEXT NOT NULL,
|
|
59
|
+
created_at TEXT NOT NULL,
|
|
60
|
+
UNIQUE (host_id, hostname),
|
|
61
|
+
FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE CASCADE,
|
|
62
|
+
FOREIGN KEY (evidence_artifact_id) REFERENCES artifacts(id) ON DELETE RESTRICT
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
CREATE INDEX IF NOT EXISTS idx_vhosts_host ON vhosts(host_id);
|
|
66
|
+
|
|
67
|
+
-- ============================================================
|
|
68
|
+
-- \u30B5\u30FC\u30D3\u30B9
|
|
69
|
+
-- ============================================================
|
|
70
|
+
CREATE TABLE IF NOT EXISTS services (
|
|
71
|
+
id TEXT PRIMARY KEY,
|
|
72
|
+
host_id TEXT NOT NULL,
|
|
73
|
+
transport TEXT NOT NULL, -- "tcp" | "udp"
|
|
74
|
+
port INTEGER NOT NULL,
|
|
75
|
+
app_proto TEXT NOT NULL, -- "http" | "ssh" | "ftp" \u7B49
|
|
76
|
+
proto_confidence TEXT NOT NULL, -- "high" | "medium" | "low"
|
|
77
|
+
banner TEXT,
|
|
78
|
+
product TEXT,
|
|
79
|
+
version TEXT,
|
|
80
|
+
state TEXT NOT NULL, -- "open" | "closed" | "filtered"
|
|
81
|
+
evidence_artifact_id TEXT NOT NULL,
|
|
82
|
+
created_at TEXT NOT NULL,
|
|
83
|
+
updated_at TEXT NOT NULL,
|
|
84
|
+
UNIQUE (host_id, transport, port),
|
|
85
|
+
FOREIGN KEY (host_id) REFERENCES hosts(id) ON DELETE CASCADE,
|
|
86
|
+
FOREIGN KEY (evidence_artifact_id) REFERENCES artifacts(id) ON DELETE RESTRICT
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
CREATE INDEX IF NOT EXISTS idx_services_host ON services(host_id);
|
|
90
|
+
|
|
91
|
+
-- ============================================================
|
|
92
|
+
-- \u30B5\u30FC\u30D3\u30B9\u89B3\u6E2C\uFF08key-value\uFF09
|
|
93
|
+
-- ============================================================
|
|
94
|
+
CREATE TABLE IF NOT EXISTS service_observations (
|
|
95
|
+
id TEXT PRIMARY KEY,
|
|
96
|
+
service_id TEXT NOT NULL,
|
|
97
|
+
key TEXT NOT NULL,
|
|
98
|
+
value TEXT NOT NULL,
|
|
99
|
+
confidence TEXT NOT NULL, -- "high" | "medium" | "low"
|
|
100
|
+
evidence_artifact_id TEXT NOT NULL,
|
|
101
|
+
created_at TEXT NOT NULL,
|
|
102
|
+
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE,
|
|
103
|
+
FOREIGN KEY (evidence_artifact_id) REFERENCES artifacts(id) ON DELETE RESTRICT
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
CREATE INDEX IF NOT EXISTS idx_svc_obs_service ON service_observations(service_id);
|
|
107
|
+
|
|
108
|
+
-- ============================================================
|
|
109
|
+
-- HTTP \u30A8\u30F3\u30C9\u30DD\u30A4\u30F3\u30C8
|
|
110
|
+
-- ============================================================
|
|
111
|
+
CREATE TABLE IF NOT EXISTS http_endpoints (
|
|
112
|
+
id TEXT PRIMARY KEY,
|
|
113
|
+
service_id TEXT NOT NULL,
|
|
114
|
+
vhost_id TEXT,
|
|
115
|
+
base_uri TEXT NOT NULL, -- "http://example.com:80"
|
|
116
|
+
method TEXT NOT NULL, -- "GET" | "POST" | ...
|
|
117
|
+
path TEXT NOT NULL, -- "/admin"\uFF08\u30AF\u30A8\u30EA\u306F\u542B\u3081\u306A\u3044\uFF09
|
|
118
|
+
status_code INTEGER,
|
|
119
|
+
content_length INTEGER,
|
|
120
|
+
words INTEGER,
|
|
121
|
+
lines INTEGER,
|
|
122
|
+
evidence_artifact_id TEXT NOT NULL,
|
|
123
|
+
created_at TEXT NOT NULL,
|
|
124
|
+
UNIQUE (service_id, method, path),
|
|
125
|
+
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE,
|
|
126
|
+
FOREIGN KEY (vhost_id) REFERENCES vhosts(id) ON DELETE SET NULL,
|
|
127
|
+
FOREIGN KEY (evidence_artifact_id) REFERENCES artifacts(id) ON DELETE RESTRICT
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
CREATE INDEX IF NOT EXISTS idx_endpoints_service ON http_endpoints(service_id);
|
|
131
|
+
|
|
132
|
+
-- ============================================================
|
|
133
|
+
-- \u5165\u529B\u30D1\u30E9\u30E1\u30FC\u30BF
|
|
134
|
+
-- ============================================================
|
|
135
|
+
CREATE TABLE IF NOT EXISTS inputs (
|
|
136
|
+
id TEXT PRIMARY KEY,
|
|
137
|
+
service_id TEXT NOT NULL,
|
|
138
|
+
location TEXT NOT NULL, -- "query" | "path" | "body" | "header" | "cookie"
|
|
139
|
+
name TEXT NOT NULL,
|
|
140
|
+
type_hint TEXT, -- "string" | "int" | "json" \u7B49\uFF08\u4EFB\u610F\uFF09
|
|
141
|
+
created_at TEXT NOT NULL,
|
|
142
|
+
updated_at TEXT NOT NULL,
|
|
143
|
+
UNIQUE (service_id, location, name),
|
|
144
|
+
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
CREATE INDEX IF NOT EXISTS idx_inputs_service ON inputs(service_id);
|
|
148
|
+
|
|
149
|
+
-- ============================================================
|
|
150
|
+
-- \u30A8\u30F3\u30C9\u30DD\u30A4\u30F3\u30C8 \u2194 \u5165\u529B\uFF08\u591A\u5BFE\u591A\uFF09
|
|
151
|
+
-- ============================================================
|
|
152
|
+
CREATE TABLE IF NOT EXISTS endpoint_inputs (
|
|
153
|
+
id TEXT PRIMARY KEY,
|
|
154
|
+
endpoint_id TEXT NOT NULL,
|
|
155
|
+
input_id TEXT NOT NULL,
|
|
156
|
+
evidence_artifact_id TEXT NOT NULL,
|
|
157
|
+
created_at TEXT NOT NULL,
|
|
158
|
+
UNIQUE (endpoint_id, input_id),
|
|
159
|
+
FOREIGN KEY (endpoint_id) REFERENCES http_endpoints(id) ON DELETE CASCADE,
|
|
160
|
+
FOREIGN KEY (input_id) REFERENCES inputs(id) ON DELETE CASCADE,
|
|
161
|
+
FOREIGN KEY (evidence_artifact_id) REFERENCES artifacts(id) ON DELETE RESTRICT
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
CREATE INDEX IF NOT EXISTS idx_ep_inputs_endpoint ON endpoint_inputs(endpoint_id);
|
|
165
|
+
CREATE INDEX IF NOT EXISTS idx_ep_inputs_input ON endpoint_inputs(input_id);
|
|
166
|
+
|
|
167
|
+
-- ============================================================
|
|
168
|
+
-- \u89B3\u6E2C\u5024
|
|
169
|
+
-- ============================================================
|
|
170
|
+
CREATE TABLE IF NOT EXISTS observations (
|
|
171
|
+
id TEXT PRIMARY KEY,
|
|
172
|
+
input_id TEXT NOT NULL,
|
|
173
|
+
raw_value TEXT NOT NULL,
|
|
174
|
+
norm_value TEXT NOT NULL,
|
|
175
|
+
body_path TEXT, -- JSON Pointer \u7B49\uFF08\u4F8B: "/user/name"\uFF09
|
|
176
|
+
source TEXT NOT NULL, -- "ffuf_url" | "req_query" | "req_body" | "manual"
|
|
177
|
+
confidence TEXT NOT NULL, -- "high" | "medium" | "low"
|
|
178
|
+
evidence_artifact_id TEXT NOT NULL,
|
|
179
|
+
observed_at TEXT NOT NULL,
|
|
180
|
+
FOREIGN KEY (input_id) REFERENCES inputs(id) ON DELETE CASCADE,
|
|
181
|
+
FOREIGN KEY (evidence_artifact_id) REFERENCES artifacts(id) ON DELETE RESTRICT
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
CREATE INDEX IF NOT EXISTS idx_obs_input ON observations(input_id);
|
|
185
|
+
|
|
186
|
+
-- ============================================================
|
|
187
|
+
-- \u8A8D\u8A3C\u60C5\u5831
|
|
188
|
+
-- ============================================================
|
|
189
|
+
CREATE TABLE IF NOT EXISTS credentials (
|
|
190
|
+
id TEXT PRIMARY KEY,
|
|
191
|
+
service_id TEXT NOT NULL,
|
|
192
|
+
endpoint_id TEXT, -- HTTP \u306E\u5834\u5408\u306E\u307F\uFF08\u4EFB\u610F\uFF09
|
|
193
|
+
username TEXT NOT NULL,
|
|
194
|
+
secret TEXT NOT NULL,
|
|
195
|
+
secret_type TEXT NOT NULL, -- "password" | "token" | "api_key" | "ssh_key"
|
|
196
|
+
source TEXT NOT NULL, -- "brute_force" | "default" | "leaked" | "manual"
|
|
197
|
+
confidence TEXT NOT NULL, -- "high" | "medium" | "low"
|
|
198
|
+
evidence_artifact_id TEXT NOT NULL,
|
|
199
|
+
created_at TEXT NOT NULL,
|
|
200
|
+
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE,
|
|
201
|
+
FOREIGN KEY (endpoint_id) REFERENCES http_endpoints(id) ON DELETE SET NULL,
|
|
202
|
+
FOREIGN KEY (evidence_artifact_id) REFERENCES artifacts(id) ON DELETE RESTRICT
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
CREATE INDEX IF NOT EXISTS idx_creds_service ON credentials(service_id);
|
|
206
|
+
CREATE INDEX IF NOT EXISTS idx_creds_endpoint ON credentials(endpoint_id);
|
|
207
|
+
|
|
208
|
+
-- ============================================================
|
|
209
|
+
-- \u8106\u5F31\u6027
|
|
210
|
+
-- ============================================================
|
|
211
|
+
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
|
212
|
+
id TEXT PRIMARY KEY,
|
|
213
|
+
service_id TEXT NOT NULL,
|
|
214
|
+
endpoint_id TEXT, -- HTTP \u306E\u5834\u5408\u306E\u307F\uFF08\u4EFB\u610F\uFF09
|
|
215
|
+
vuln_type TEXT NOT NULL, -- "sqli" | "xss" | "rce" | "lfi" | "ssrf" | ...
|
|
216
|
+
title TEXT NOT NULL,
|
|
217
|
+
description TEXT,
|
|
218
|
+
severity TEXT NOT NULL, -- "critical" | "high" | "medium" | "low" | "info"
|
|
219
|
+
confidence TEXT NOT NULL, -- "high" | "medium" | "low"
|
|
220
|
+
evidence_artifact_id TEXT NOT NULL,
|
|
221
|
+
created_at TEXT NOT NULL,
|
|
222
|
+
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE,
|
|
223
|
+
FOREIGN KEY (endpoint_id) REFERENCES http_endpoints(id) ON DELETE SET NULL,
|
|
224
|
+
FOREIGN KEY (evidence_artifact_id) REFERENCES artifacts(id) ON DELETE RESTRICT
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
CREATE INDEX IF NOT EXISTS idx_vulns_service ON vulnerabilities(service_id);
|
|
228
|
+
CREATE INDEX IF NOT EXISTS idx_vulns_endpoint ON vulnerabilities(endpoint_id);
|
|
229
|
+
CREATE INDEX IF NOT EXISTS idx_vulns_severity ON vulnerabilities(severity);
|
|
230
|
+
|
|
231
|
+
-- ============================================================
|
|
232
|
+
-- CVE \u60C5\u5831
|
|
233
|
+
-- ============================================================
|
|
234
|
+
CREATE TABLE IF NOT EXISTS cves (
|
|
235
|
+
id TEXT PRIMARY KEY,
|
|
236
|
+
vulnerability_id TEXT NOT NULL,
|
|
237
|
+
cve_id TEXT NOT NULL, -- "CVE-YYYY-NNNNN"
|
|
238
|
+
description TEXT,
|
|
239
|
+
cvss_score REAL,
|
|
240
|
+
cvss_vector TEXT,
|
|
241
|
+
reference_url TEXT,
|
|
242
|
+
created_at TEXT NOT NULL,
|
|
243
|
+
FOREIGN KEY (vulnerability_id) REFERENCES vulnerabilities(id) ON DELETE CASCADE
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
CREATE INDEX IF NOT EXISTS idx_cves_vuln ON cves(vulnerability_id);
|
|
247
|
+
CREATE INDEX IF NOT EXISTS idx_cves_cveid ON cves(cve_id);
|
|
248
|
+
`;
|
|
249
|
+
|
|
250
|
+
// src/db/migrate.ts
|
|
251
|
+
function migrateDatabase(db2) {
|
|
252
|
+
db2.pragma("foreign_keys = ON");
|
|
253
|
+
db2.exec(SCHEMA_SQL);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/mcp/server.ts
|
|
257
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
258
|
+
|
|
259
|
+
// src/mcp/tools/query.ts
|
|
260
|
+
import { z } from "zod";
|
|
261
|
+
|
|
262
|
+
// src/db/repository/host-repository.ts
|
|
263
|
+
import crypto from "crypto";
|
|
264
|
+
function rowToHost(row) {
|
|
265
|
+
return {
|
|
266
|
+
id: row.id,
|
|
267
|
+
authorityKind: row.authority_kind,
|
|
268
|
+
authority: row.authority,
|
|
269
|
+
resolvedIpsJson: row.resolved_ips_json,
|
|
270
|
+
createdAt: row.created_at,
|
|
271
|
+
updatedAt: row.updated_at
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
var HostRepository = class {
|
|
275
|
+
db;
|
|
276
|
+
constructor(db2) {
|
|
277
|
+
this.db = db2;
|
|
278
|
+
}
|
|
279
|
+
/** Insert a new Host and return the full entity. */
|
|
280
|
+
create(input) {
|
|
281
|
+
const id = crypto.randomUUID();
|
|
282
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
283
|
+
const stmt = this.db.prepare(
|
|
284
|
+
`INSERT INTO hosts (id, authority_kind, authority, resolved_ips_json, created_at, updated_at)
|
|
285
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
286
|
+
);
|
|
287
|
+
stmt.run(
|
|
288
|
+
id,
|
|
289
|
+
input.authorityKind,
|
|
290
|
+
input.authority,
|
|
291
|
+
input.resolvedIpsJson,
|
|
292
|
+
now,
|
|
293
|
+
now
|
|
294
|
+
);
|
|
295
|
+
return {
|
|
296
|
+
id,
|
|
297
|
+
authorityKind: input.authorityKind,
|
|
298
|
+
authority: input.authority,
|
|
299
|
+
resolvedIpsJson: input.resolvedIpsJson,
|
|
300
|
+
createdAt: now,
|
|
301
|
+
updatedAt: now
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
/** Find a Host by its primary key. Returns undefined if not found. */
|
|
305
|
+
findById(id) {
|
|
306
|
+
const stmt = this.db.prepare(
|
|
307
|
+
`SELECT id, authority_kind, authority, resolved_ips_json, created_at, updated_at
|
|
308
|
+
FROM hosts
|
|
309
|
+
WHERE id = ?`
|
|
310
|
+
);
|
|
311
|
+
const row = stmt.get(id);
|
|
312
|
+
return row ? rowToHost(row) : void 0;
|
|
313
|
+
}
|
|
314
|
+
/** Return all Hosts. */
|
|
315
|
+
findAll() {
|
|
316
|
+
const stmt = this.db.prepare(
|
|
317
|
+
`SELECT id, authority_kind, authority, resolved_ips_json, created_at, updated_at
|
|
318
|
+
FROM hosts`
|
|
319
|
+
);
|
|
320
|
+
const rows = stmt.all();
|
|
321
|
+
return rows.map(rowToHost);
|
|
322
|
+
}
|
|
323
|
+
/** Find a Host by its unique authority value. Returns undefined if not found. */
|
|
324
|
+
findByAuthority(authority) {
|
|
325
|
+
const stmt = this.db.prepare(
|
|
326
|
+
`SELECT id, authority_kind, authority, resolved_ips_json, created_at, updated_at
|
|
327
|
+
FROM hosts
|
|
328
|
+
WHERE authority = ?`
|
|
329
|
+
);
|
|
330
|
+
const row = stmt.get(authority);
|
|
331
|
+
return row ? rowToHost(row) : void 0;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Update an existing Host with the provided fields.
|
|
335
|
+
* Always bumps `updated_at`. Returns the updated entity, or undefined if
|
|
336
|
+
* the Host was not found.
|
|
337
|
+
*/
|
|
338
|
+
update(id, input) {
|
|
339
|
+
const setClauses = [];
|
|
340
|
+
const params = [];
|
|
341
|
+
if (input.resolvedIpsJson !== void 0) {
|
|
342
|
+
setClauses.push("resolved_ips_json = ?");
|
|
343
|
+
params.push(input.resolvedIpsJson);
|
|
344
|
+
}
|
|
345
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
346
|
+
setClauses.push("updated_at = ?");
|
|
347
|
+
params.push(now);
|
|
348
|
+
params.push(id);
|
|
349
|
+
const sql = `UPDATE hosts SET ${setClauses.join(", ")} WHERE id = ?`;
|
|
350
|
+
const stmt = this.db.prepare(sql);
|
|
351
|
+
const result = stmt.run(...params);
|
|
352
|
+
if (result.changes === 0) {
|
|
353
|
+
return void 0;
|
|
354
|
+
}
|
|
355
|
+
return this.findById(id);
|
|
356
|
+
}
|
|
357
|
+
/** Delete a Host by id. Returns true if a row was deleted. */
|
|
358
|
+
delete(id) {
|
|
359
|
+
const stmt = this.db.prepare("DELETE FROM hosts WHERE id = ?");
|
|
360
|
+
const result = stmt.run(id);
|
|
361
|
+
return result.changes > 0;
|
|
362
|
+
}
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// src/db/repository/service-repository.ts
|
|
366
|
+
import crypto2 from "crypto";
|
|
367
|
+
function rowToService(row) {
|
|
368
|
+
return {
|
|
369
|
+
id: row.id,
|
|
370
|
+
hostId: row.host_id,
|
|
371
|
+
transport: row.transport,
|
|
372
|
+
port: row.port,
|
|
373
|
+
appProto: row.app_proto,
|
|
374
|
+
protoConfidence: row.proto_confidence,
|
|
375
|
+
banner: row.banner ?? void 0,
|
|
376
|
+
product: row.product ?? void 0,
|
|
377
|
+
version: row.version ?? void 0,
|
|
378
|
+
state: row.state,
|
|
379
|
+
evidenceArtifactId: row.evidence_artifact_id,
|
|
380
|
+
createdAt: row.created_at,
|
|
381
|
+
updatedAt: row.updated_at
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
var ServiceRepository = class {
|
|
385
|
+
db;
|
|
386
|
+
constructor(db2) {
|
|
387
|
+
this.db = db2;
|
|
388
|
+
}
|
|
389
|
+
/** Insert a new Service and return the full entity. */
|
|
390
|
+
create(input) {
|
|
391
|
+
const id = crypto2.randomUUID();
|
|
392
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
393
|
+
const stmt = this.db.prepare(
|
|
394
|
+
`INSERT INTO services (id, host_id, transport, port, app_proto, proto_confidence, banner, product, version, state, evidence_artifact_id, created_at, updated_at)
|
|
395
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
396
|
+
);
|
|
397
|
+
stmt.run(
|
|
398
|
+
id,
|
|
399
|
+
input.hostId,
|
|
400
|
+
input.transport,
|
|
401
|
+
input.port,
|
|
402
|
+
input.appProto,
|
|
403
|
+
input.protoConfidence,
|
|
404
|
+
input.banner ?? null,
|
|
405
|
+
input.product ?? null,
|
|
406
|
+
input.version ?? null,
|
|
407
|
+
input.state,
|
|
408
|
+
input.evidenceArtifactId,
|
|
409
|
+
now,
|
|
410
|
+
now
|
|
411
|
+
);
|
|
412
|
+
return {
|
|
413
|
+
id,
|
|
414
|
+
hostId: input.hostId,
|
|
415
|
+
transport: input.transport,
|
|
416
|
+
port: input.port,
|
|
417
|
+
appProto: input.appProto,
|
|
418
|
+
protoConfidence: input.protoConfidence,
|
|
419
|
+
banner: input.banner,
|
|
420
|
+
product: input.product,
|
|
421
|
+
version: input.version,
|
|
422
|
+
state: input.state,
|
|
423
|
+
evidenceArtifactId: input.evidenceArtifactId,
|
|
424
|
+
createdAt: now,
|
|
425
|
+
updatedAt: now
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
/** Find a Service by its primary key. Returns undefined if not found. */
|
|
429
|
+
findById(id) {
|
|
430
|
+
const stmt = this.db.prepare(
|
|
431
|
+
`SELECT id, host_id, transport, port, app_proto, proto_confidence, banner, product, version, state, evidence_artifact_id, created_at, updated_at
|
|
432
|
+
FROM services
|
|
433
|
+
WHERE id = ?`
|
|
434
|
+
);
|
|
435
|
+
const row = stmt.get(id);
|
|
436
|
+
return row ? rowToService(row) : void 0;
|
|
437
|
+
}
|
|
438
|
+
/** Find all Services belonging to a given host. */
|
|
439
|
+
findByHostId(hostId) {
|
|
440
|
+
const stmt = this.db.prepare(
|
|
441
|
+
`SELECT id, host_id, transport, port, app_proto, proto_confidence, banner, product, version, state, evidence_artifact_id, created_at, updated_at
|
|
442
|
+
FROM services
|
|
443
|
+
WHERE host_id = ?`
|
|
444
|
+
);
|
|
445
|
+
const rows = stmt.all(hostId);
|
|
446
|
+
return rows.map(rowToService);
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Update an existing Service with the provided fields.
|
|
450
|
+
* Always bumps `updated_at`. Returns the updated entity, or undefined if
|
|
451
|
+
* the Service was not found.
|
|
452
|
+
*/
|
|
453
|
+
update(id, input) {
|
|
454
|
+
const setClauses = [];
|
|
455
|
+
const params = [];
|
|
456
|
+
if (input.appProto !== void 0) {
|
|
457
|
+
setClauses.push("app_proto = ?");
|
|
458
|
+
params.push(input.appProto);
|
|
459
|
+
}
|
|
460
|
+
if (input.protoConfidence !== void 0) {
|
|
461
|
+
setClauses.push("proto_confidence = ?");
|
|
462
|
+
params.push(input.protoConfidence);
|
|
463
|
+
}
|
|
464
|
+
if (input.banner !== void 0) {
|
|
465
|
+
setClauses.push("banner = ?");
|
|
466
|
+
params.push(input.banner);
|
|
467
|
+
}
|
|
468
|
+
if (input.product !== void 0) {
|
|
469
|
+
setClauses.push("product = ?");
|
|
470
|
+
params.push(input.product);
|
|
471
|
+
}
|
|
472
|
+
if (input.version !== void 0) {
|
|
473
|
+
setClauses.push("version = ?");
|
|
474
|
+
params.push(input.version);
|
|
475
|
+
}
|
|
476
|
+
if (input.state !== void 0) {
|
|
477
|
+
setClauses.push("state = ?");
|
|
478
|
+
params.push(input.state);
|
|
479
|
+
}
|
|
480
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
481
|
+
setClauses.push("updated_at = ?");
|
|
482
|
+
params.push(now);
|
|
483
|
+
params.push(id);
|
|
484
|
+
const sql = `UPDATE services SET ${setClauses.join(", ")} WHERE id = ?`;
|
|
485
|
+
const stmt = this.db.prepare(sql);
|
|
486
|
+
const result = stmt.run(...params);
|
|
487
|
+
if (result.changes === 0) {
|
|
488
|
+
return void 0;
|
|
489
|
+
}
|
|
490
|
+
return this.findById(id);
|
|
491
|
+
}
|
|
492
|
+
/** Delete a Service by id. Returns true if a row was deleted. */
|
|
493
|
+
delete(id) {
|
|
494
|
+
const stmt = this.db.prepare("DELETE FROM services WHERE id = ?");
|
|
495
|
+
const result = stmt.run(id);
|
|
496
|
+
return result.changes > 0;
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
// src/db/repository/vhost-repository.ts
|
|
501
|
+
import crypto3 from "crypto";
|
|
502
|
+
function rowToVhost(row) {
|
|
503
|
+
return {
|
|
504
|
+
id: row.id,
|
|
505
|
+
hostId: row.host_id,
|
|
506
|
+
hostname: row.hostname,
|
|
507
|
+
source: row.source ?? void 0,
|
|
508
|
+
evidenceArtifactId: row.evidence_artifact_id,
|
|
509
|
+
createdAt: row.created_at
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
var VhostRepository = class {
|
|
513
|
+
db;
|
|
514
|
+
constructor(db2) {
|
|
515
|
+
this.db = db2;
|
|
516
|
+
}
|
|
517
|
+
/** Insert a new Vhost and return the full entity. */
|
|
518
|
+
create(input) {
|
|
519
|
+
const id = crypto3.randomUUID();
|
|
520
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
521
|
+
const stmt = this.db.prepare(
|
|
522
|
+
`INSERT INTO vhosts (id, host_id, hostname, source, evidence_artifact_id, created_at)
|
|
523
|
+
VALUES (?, ?, ?, ?, ?, ?)`
|
|
524
|
+
);
|
|
525
|
+
stmt.run(
|
|
526
|
+
id,
|
|
527
|
+
input.hostId,
|
|
528
|
+
input.hostname,
|
|
529
|
+
input.source ?? null,
|
|
530
|
+
input.evidenceArtifactId,
|
|
531
|
+
now
|
|
532
|
+
);
|
|
533
|
+
return {
|
|
534
|
+
id,
|
|
535
|
+
hostId: input.hostId,
|
|
536
|
+
hostname: input.hostname,
|
|
537
|
+
source: input.source,
|
|
538
|
+
evidenceArtifactId: input.evidenceArtifactId,
|
|
539
|
+
createdAt: now
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
/** Find a Vhost by its primary key. Returns undefined if not found. */
|
|
543
|
+
findById(id) {
|
|
544
|
+
const stmt = this.db.prepare(
|
|
545
|
+
`SELECT id, host_id, hostname, source, evidence_artifact_id, created_at
|
|
546
|
+
FROM vhosts
|
|
547
|
+
WHERE id = ?`
|
|
548
|
+
);
|
|
549
|
+
const row = stmt.get(id);
|
|
550
|
+
return row ? rowToVhost(row) : void 0;
|
|
551
|
+
}
|
|
552
|
+
/** Return all Vhosts belonging to a given host. */
|
|
553
|
+
findByHostId(hostId) {
|
|
554
|
+
const stmt = this.db.prepare(
|
|
555
|
+
`SELECT id, host_id, hostname, source, evidence_artifact_id, created_at
|
|
556
|
+
FROM vhosts
|
|
557
|
+
WHERE host_id = ?`
|
|
558
|
+
);
|
|
559
|
+
const rows = stmt.all(hostId);
|
|
560
|
+
return rows.map(rowToVhost);
|
|
561
|
+
}
|
|
562
|
+
/** Delete a Vhost by id. Returns true if a row was deleted. */
|
|
563
|
+
delete(id) {
|
|
564
|
+
const stmt = this.db.prepare("DELETE FROM vhosts WHERE id = ?");
|
|
565
|
+
const result = stmt.run(id);
|
|
566
|
+
return result.changes > 0;
|
|
567
|
+
}
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
// src/db/repository/http-endpoint-repository.ts
|
|
571
|
+
import crypto4 from "crypto";
|
|
572
|
+
function rowToHttpEndpoint(row) {
|
|
573
|
+
return {
|
|
574
|
+
id: row.id,
|
|
575
|
+
serviceId: row.service_id,
|
|
576
|
+
vhostId: row.vhost_id ?? void 0,
|
|
577
|
+
baseUri: row.base_uri,
|
|
578
|
+
method: row.method,
|
|
579
|
+
path: row.path,
|
|
580
|
+
statusCode: row.status_code ?? void 0,
|
|
581
|
+
contentLength: row.content_length ?? void 0,
|
|
582
|
+
words: row.words ?? void 0,
|
|
583
|
+
lines: row.lines ?? void 0,
|
|
584
|
+
evidenceArtifactId: row.evidence_artifact_id,
|
|
585
|
+
createdAt: row.created_at
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
var HttpEndpointRepository = class {
|
|
589
|
+
db;
|
|
590
|
+
constructor(db2) {
|
|
591
|
+
this.db = db2;
|
|
592
|
+
}
|
|
593
|
+
/** Insert a new HttpEndpoint and return the full entity. */
|
|
594
|
+
create(input) {
|
|
595
|
+
const id = crypto4.randomUUID();
|
|
596
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
597
|
+
const stmt = this.db.prepare(
|
|
598
|
+
`INSERT INTO http_endpoints (id, service_id, vhost_id, base_uri, method, path, status_code, content_length, words, lines, evidence_artifact_id, created_at)
|
|
599
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
600
|
+
);
|
|
601
|
+
stmt.run(
|
|
602
|
+
id,
|
|
603
|
+
input.serviceId,
|
|
604
|
+
input.vhostId ?? null,
|
|
605
|
+
input.baseUri,
|
|
606
|
+
input.method,
|
|
607
|
+
input.path,
|
|
608
|
+
input.statusCode ?? null,
|
|
609
|
+
input.contentLength ?? null,
|
|
610
|
+
input.words ?? null,
|
|
611
|
+
input.lines ?? null,
|
|
612
|
+
input.evidenceArtifactId,
|
|
613
|
+
now
|
|
614
|
+
);
|
|
615
|
+
return {
|
|
616
|
+
id,
|
|
617
|
+
serviceId: input.serviceId,
|
|
618
|
+
vhostId: input.vhostId,
|
|
619
|
+
baseUri: input.baseUri,
|
|
620
|
+
method: input.method,
|
|
621
|
+
path: input.path,
|
|
622
|
+
statusCode: input.statusCode,
|
|
623
|
+
contentLength: input.contentLength,
|
|
624
|
+
words: input.words,
|
|
625
|
+
lines: input.lines,
|
|
626
|
+
evidenceArtifactId: input.evidenceArtifactId,
|
|
627
|
+
createdAt: now
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
/** Find an HttpEndpoint by its primary key. Returns undefined if not found. */
|
|
631
|
+
findById(id) {
|
|
632
|
+
const stmt = this.db.prepare(
|
|
633
|
+
`SELECT id, service_id, vhost_id, base_uri, method, path, status_code, content_length, words, lines, evidence_artifact_id, created_at
|
|
634
|
+
FROM http_endpoints
|
|
635
|
+
WHERE id = ?`
|
|
636
|
+
);
|
|
637
|
+
const row = stmt.get(id);
|
|
638
|
+
return row ? rowToHttpEndpoint(row) : void 0;
|
|
639
|
+
}
|
|
640
|
+
/** Return all HttpEndpoints for a given service. */
|
|
641
|
+
findByServiceId(serviceId) {
|
|
642
|
+
const stmt = this.db.prepare(
|
|
643
|
+
`SELECT id, service_id, vhost_id, base_uri, method, path, status_code, content_length, words, lines, evidence_artifact_id, created_at
|
|
644
|
+
FROM http_endpoints
|
|
645
|
+
WHERE service_id = ?`
|
|
646
|
+
);
|
|
647
|
+
const rows = stmt.all(serviceId);
|
|
648
|
+
return rows.map(rowToHttpEndpoint);
|
|
649
|
+
}
|
|
650
|
+
/** Delete an HttpEndpoint by id. Returns true if a row was deleted. */
|
|
651
|
+
delete(id) {
|
|
652
|
+
const stmt = this.db.prepare(
|
|
653
|
+
"DELETE FROM http_endpoints WHERE id = ?"
|
|
654
|
+
);
|
|
655
|
+
const result = stmt.run(id);
|
|
656
|
+
return result.changes > 0;
|
|
657
|
+
}
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
// src/db/repository/input-repository.ts
|
|
661
|
+
import crypto5 from "crypto";
|
|
662
|
+
function rowToInput(row) {
|
|
663
|
+
return {
|
|
664
|
+
id: row.id,
|
|
665
|
+
serviceId: row.service_id,
|
|
666
|
+
location: row.location,
|
|
667
|
+
name: row.name,
|
|
668
|
+
typeHint: row.type_hint ?? void 0,
|
|
669
|
+
createdAt: row.created_at,
|
|
670
|
+
updatedAt: row.updated_at
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
var InputRepository = class {
|
|
674
|
+
db;
|
|
675
|
+
constructor(db2) {
|
|
676
|
+
this.db = db2;
|
|
677
|
+
}
|
|
678
|
+
/** Insert a new Input and return the full entity. */
|
|
679
|
+
create(input) {
|
|
680
|
+
const id = crypto5.randomUUID();
|
|
681
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
682
|
+
const stmt = this.db.prepare(
|
|
683
|
+
`INSERT INTO inputs (id, service_id, location, name, type_hint, created_at, updated_at)
|
|
684
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
685
|
+
);
|
|
686
|
+
stmt.run(
|
|
687
|
+
id,
|
|
688
|
+
input.serviceId,
|
|
689
|
+
input.location,
|
|
690
|
+
input.name,
|
|
691
|
+
input.typeHint ?? null,
|
|
692
|
+
now,
|
|
693
|
+
now
|
|
694
|
+
);
|
|
695
|
+
return {
|
|
696
|
+
id,
|
|
697
|
+
serviceId: input.serviceId,
|
|
698
|
+
location: input.location,
|
|
699
|
+
name: input.name,
|
|
700
|
+
typeHint: input.typeHint,
|
|
701
|
+
createdAt: now,
|
|
702
|
+
updatedAt: now
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
/** Find an Input by its primary key. Returns undefined if not found. */
|
|
706
|
+
findById(id) {
|
|
707
|
+
const stmt = this.db.prepare(
|
|
708
|
+
`SELECT id, service_id, location, name, type_hint, created_at, updated_at
|
|
709
|
+
FROM inputs
|
|
710
|
+
WHERE id = ?`
|
|
711
|
+
);
|
|
712
|
+
const row = stmt.get(id);
|
|
713
|
+
return row ? rowToInput(row) : void 0;
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Find all Inputs for a given service.
|
|
717
|
+
* If location is provided, further filter by location.
|
|
718
|
+
*/
|
|
719
|
+
findByServiceId(serviceId, location) {
|
|
720
|
+
if (location !== void 0) {
|
|
721
|
+
const stmt2 = this.db.prepare(
|
|
722
|
+
`SELECT id, service_id, location, name, type_hint, created_at, updated_at
|
|
723
|
+
FROM inputs
|
|
724
|
+
WHERE service_id = ? AND location = ?`
|
|
725
|
+
);
|
|
726
|
+
const rows2 = stmt2.all(serviceId, location);
|
|
727
|
+
return rows2.map(rowToInput);
|
|
728
|
+
}
|
|
729
|
+
const stmt = this.db.prepare(
|
|
730
|
+
`SELECT id, service_id, location, name, type_hint, created_at, updated_at
|
|
731
|
+
FROM inputs
|
|
732
|
+
WHERE service_id = ?`
|
|
733
|
+
);
|
|
734
|
+
const rows = stmt.all(serviceId);
|
|
735
|
+
return rows.map(rowToInput);
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* Update an existing Input with the provided fields.
|
|
739
|
+
* Always bumps `updated_at`. Returns the updated entity, or undefined if
|
|
740
|
+
* the Input was not found.
|
|
741
|
+
*/
|
|
742
|
+
update(id, input) {
|
|
743
|
+
const setClauses = [];
|
|
744
|
+
const params = [];
|
|
745
|
+
if (input.typeHint !== void 0) {
|
|
746
|
+
setClauses.push("type_hint = ?");
|
|
747
|
+
params.push(input.typeHint);
|
|
748
|
+
}
|
|
749
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
750
|
+
setClauses.push("updated_at = ?");
|
|
751
|
+
params.push(now);
|
|
752
|
+
params.push(id);
|
|
753
|
+
const sql = `UPDATE inputs SET ${setClauses.join(", ")} WHERE id = ?`;
|
|
754
|
+
const stmt = this.db.prepare(sql);
|
|
755
|
+
const result = stmt.run(...params);
|
|
756
|
+
if (result.changes === 0) {
|
|
757
|
+
return void 0;
|
|
758
|
+
}
|
|
759
|
+
return this.findById(id);
|
|
760
|
+
}
|
|
761
|
+
/** Delete an Input by id. Returns true if a row was deleted. */
|
|
762
|
+
delete(id) {
|
|
763
|
+
const stmt = this.db.prepare("DELETE FROM inputs WHERE id = ?");
|
|
764
|
+
const result = stmt.run(id);
|
|
765
|
+
return result.changes > 0;
|
|
766
|
+
}
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
// src/db/repository/observation-repository.ts
|
|
770
|
+
import crypto6 from "crypto";
|
|
771
|
+
function rowToObservation(row) {
|
|
772
|
+
return {
|
|
773
|
+
id: row.id,
|
|
774
|
+
inputId: row.input_id,
|
|
775
|
+
rawValue: row.raw_value,
|
|
776
|
+
normValue: row.norm_value,
|
|
777
|
+
bodyPath: row.body_path ?? void 0,
|
|
778
|
+
source: row.source,
|
|
779
|
+
confidence: row.confidence,
|
|
780
|
+
evidenceArtifactId: row.evidence_artifact_id,
|
|
781
|
+
observedAt: row.observed_at
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
var ObservationRepository = class {
|
|
785
|
+
db;
|
|
786
|
+
constructor(db2) {
|
|
787
|
+
this.db = db2;
|
|
788
|
+
}
|
|
789
|
+
/** Insert a new Observation and return the full entity. */
|
|
790
|
+
create(input) {
|
|
791
|
+
const id = crypto6.randomUUID();
|
|
792
|
+
const stmt = this.db.prepare(
|
|
793
|
+
`INSERT INTO observations (id, input_id, raw_value, norm_value, body_path, source, confidence, evidence_artifact_id, observed_at)
|
|
794
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
795
|
+
);
|
|
796
|
+
stmt.run(
|
|
797
|
+
id,
|
|
798
|
+
input.inputId,
|
|
799
|
+
input.rawValue,
|
|
800
|
+
input.normValue,
|
|
801
|
+
input.bodyPath ?? null,
|
|
802
|
+
input.source,
|
|
803
|
+
input.confidence,
|
|
804
|
+
input.evidenceArtifactId,
|
|
805
|
+
input.observedAt
|
|
806
|
+
);
|
|
807
|
+
return {
|
|
808
|
+
id,
|
|
809
|
+
inputId: input.inputId,
|
|
810
|
+
rawValue: input.rawValue,
|
|
811
|
+
normValue: input.normValue,
|
|
812
|
+
bodyPath: input.bodyPath,
|
|
813
|
+
source: input.source,
|
|
814
|
+
confidence: input.confidence,
|
|
815
|
+
evidenceArtifactId: input.evidenceArtifactId,
|
|
816
|
+
observedAt: input.observedAt
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
/** Find an Observation by its primary key. Returns undefined if not found. */
|
|
820
|
+
findById(id) {
|
|
821
|
+
const stmt = this.db.prepare(
|
|
822
|
+
`SELECT id, input_id, raw_value, norm_value, body_path, source, confidence, evidence_artifact_id, observed_at
|
|
823
|
+
FROM observations
|
|
824
|
+
WHERE id = ?`
|
|
825
|
+
);
|
|
826
|
+
const row = stmt.get(id);
|
|
827
|
+
return row ? rowToObservation(row) : void 0;
|
|
828
|
+
}
|
|
829
|
+
/** Return all Observations for a given input ID. */
|
|
830
|
+
findByInputId(inputId) {
|
|
831
|
+
const stmt = this.db.prepare(
|
|
832
|
+
`SELECT id, input_id, raw_value, norm_value, body_path, source, confidence, evidence_artifact_id, observed_at
|
|
833
|
+
FROM observations
|
|
834
|
+
WHERE input_id = ?`
|
|
835
|
+
);
|
|
836
|
+
const rows = stmt.all(inputId);
|
|
837
|
+
return rows.map(rowToObservation);
|
|
838
|
+
}
|
|
839
|
+
/** Delete an Observation by id. Returns true if a row was deleted. */
|
|
840
|
+
delete(id) {
|
|
841
|
+
const stmt = this.db.prepare("DELETE FROM observations WHERE id = ?");
|
|
842
|
+
const result = stmt.run(id);
|
|
843
|
+
return result.changes > 0;
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
|
|
847
|
+
// src/db/repository/credential-repository.ts
|
|
848
|
+
import crypto7 from "crypto";
|
|
849
|
+
function rowToCredential(row) {
|
|
850
|
+
return {
|
|
851
|
+
id: row.id,
|
|
852
|
+
serviceId: row.service_id,
|
|
853
|
+
endpointId: row.endpoint_id ?? void 0,
|
|
854
|
+
username: row.username,
|
|
855
|
+
secret: row.secret,
|
|
856
|
+
secretType: row.secret_type,
|
|
857
|
+
source: row.source,
|
|
858
|
+
confidence: row.confidence,
|
|
859
|
+
evidenceArtifactId: row.evidence_artifact_id,
|
|
860
|
+
createdAt: row.created_at
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
var CredentialRepository = class {
|
|
864
|
+
db;
|
|
865
|
+
constructor(db2) {
|
|
866
|
+
this.db = db2;
|
|
867
|
+
}
|
|
868
|
+
/** Insert a new Credential and return the full entity. */
|
|
869
|
+
create(input) {
|
|
870
|
+
const id = crypto7.randomUUID();
|
|
871
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
872
|
+
const stmt = this.db.prepare(
|
|
873
|
+
`INSERT INTO credentials (id, service_id, endpoint_id, username, secret, secret_type, source, confidence, evidence_artifact_id, created_at)
|
|
874
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
875
|
+
);
|
|
876
|
+
stmt.run(
|
|
877
|
+
id,
|
|
878
|
+
input.serviceId,
|
|
879
|
+
input.endpointId ?? null,
|
|
880
|
+
input.username,
|
|
881
|
+
input.secret,
|
|
882
|
+
input.secretType,
|
|
883
|
+
input.source,
|
|
884
|
+
input.confidence,
|
|
885
|
+
input.evidenceArtifactId,
|
|
886
|
+
now
|
|
887
|
+
);
|
|
888
|
+
return {
|
|
889
|
+
id,
|
|
890
|
+
serviceId: input.serviceId,
|
|
891
|
+
endpointId: input.endpointId ?? void 0,
|
|
892
|
+
username: input.username,
|
|
893
|
+
secret: input.secret,
|
|
894
|
+
secretType: input.secretType,
|
|
895
|
+
source: input.source,
|
|
896
|
+
confidence: input.confidence,
|
|
897
|
+
evidenceArtifactId: input.evidenceArtifactId,
|
|
898
|
+
createdAt: now
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
/** Find a Credential by its primary key. Returns undefined if not found. */
|
|
902
|
+
findById(id) {
|
|
903
|
+
const stmt = this.db.prepare(
|
|
904
|
+
`SELECT id, service_id, endpoint_id, username, secret, secret_type, source, confidence, evidence_artifact_id, created_at
|
|
905
|
+
FROM credentials
|
|
906
|
+
WHERE id = ?`
|
|
907
|
+
);
|
|
908
|
+
const row = stmt.get(id);
|
|
909
|
+
return row ? rowToCredential(row) : void 0;
|
|
910
|
+
}
|
|
911
|
+
/** Return all Credentials for a given service. */
|
|
912
|
+
findByServiceId(serviceId) {
|
|
913
|
+
const stmt = this.db.prepare(
|
|
914
|
+
`SELECT id, service_id, endpoint_id, username, secret, secret_type, source, confidence, evidence_artifact_id, created_at
|
|
915
|
+
FROM credentials
|
|
916
|
+
WHERE service_id = ?`
|
|
917
|
+
);
|
|
918
|
+
const rows = stmt.all(serviceId);
|
|
919
|
+
return rows.map(rowToCredential);
|
|
920
|
+
}
|
|
921
|
+
/** Return all Credentials across all services. */
|
|
922
|
+
findAll() {
|
|
923
|
+
const stmt = this.db.prepare(
|
|
924
|
+
`SELECT id, service_id, endpoint_id, username, secret, secret_type, source, confidence, evidence_artifact_id, created_at
|
|
925
|
+
FROM credentials`
|
|
926
|
+
);
|
|
927
|
+
const rows = stmt.all();
|
|
928
|
+
return rows.map(rowToCredential);
|
|
929
|
+
}
|
|
930
|
+
/** Delete a Credential by id. Returns true if a row was deleted. */
|
|
931
|
+
delete(id) {
|
|
932
|
+
const stmt = this.db.prepare("DELETE FROM credentials WHERE id = ?");
|
|
933
|
+
const result = stmt.run(id);
|
|
934
|
+
return result.changes > 0;
|
|
935
|
+
}
|
|
936
|
+
};
|
|
937
|
+
|
|
938
|
+
// src/db/repository/vulnerability-repository.ts
|
|
939
|
+
import crypto8 from "crypto";
|
|
940
|
+
function rowToVulnerability(row) {
|
|
941
|
+
return {
|
|
942
|
+
id: row.id,
|
|
943
|
+
serviceId: row.service_id,
|
|
944
|
+
endpointId: row.endpoint_id ?? void 0,
|
|
945
|
+
vulnType: row.vuln_type,
|
|
946
|
+
title: row.title,
|
|
947
|
+
description: row.description ?? void 0,
|
|
948
|
+
severity: row.severity,
|
|
949
|
+
confidence: row.confidence,
|
|
950
|
+
evidenceArtifactId: row.evidence_artifact_id,
|
|
951
|
+
createdAt: row.created_at
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
var VulnerabilityRepository = class {
|
|
955
|
+
db;
|
|
956
|
+
constructor(db2) {
|
|
957
|
+
this.db = db2;
|
|
958
|
+
}
|
|
959
|
+
/** Insert a new Vulnerability and return the full entity. */
|
|
960
|
+
create(input) {
|
|
961
|
+
const id = crypto8.randomUUID();
|
|
962
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
963
|
+
const stmt = this.db.prepare(
|
|
964
|
+
`INSERT INTO vulnerabilities (id, service_id, endpoint_id, vuln_type, title, description, severity, confidence, evidence_artifact_id, created_at)
|
|
965
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
966
|
+
);
|
|
967
|
+
stmt.run(
|
|
968
|
+
id,
|
|
969
|
+
input.serviceId,
|
|
970
|
+
input.endpointId ?? null,
|
|
971
|
+
input.vulnType,
|
|
972
|
+
input.title,
|
|
973
|
+
input.description ?? null,
|
|
974
|
+
input.severity,
|
|
975
|
+
input.confidence,
|
|
976
|
+
input.evidenceArtifactId,
|
|
977
|
+
now
|
|
978
|
+
);
|
|
979
|
+
return {
|
|
980
|
+
id,
|
|
981
|
+
serviceId: input.serviceId,
|
|
982
|
+
endpointId: input.endpointId,
|
|
983
|
+
vulnType: input.vulnType,
|
|
984
|
+
title: input.title,
|
|
985
|
+
description: input.description,
|
|
986
|
+
severity: input.severity,
|
|
987
|
+
confidence: input.confidence,
|
|
988
|
+
evidenceArtifactId: input.evidenceArtifactId,
|
|
989
|
+
createdAt: now
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
/** Find a Vulnerability by its primary key. Returns undefined if not found. */
|
|
993
|
+
findById(id) {
|
|
994
|
+
const stmt = this.db.prepare(
|
|
995
|
+
`SELECT id, service_id, endpoint_id, vuln_type, title, description, severity, confidence, evidence_artifact_id, created_at
|
|
996
|
+
FROM vulnerabilities
|
|
997
|
+
WHERE id = ?`
|
|
998
|
+
);
|
|
999
|
+
const row = stmt.get(id);
|
|
1000
|
+
return row ? rowToVulnerability(row) : void 0;
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Return all Vulnerabilities for a given service.
|
|
1004
|
+
* Optionally filter by severity when the parameter is provided.
|
|
1005
|
+
*/
|
|
1006
|
+
findByServiceId(serviceId, severity) {
|
|
1007
|
+
if (severity !== void 0) {
|
|
1008
|
+
const stmt2 = this.db.prepare(
|
|
1009
|
+
`SELECT id, service_id, endpoint_id, vuln_type, title, description, severity, confidence, evidence_artifact_id, created_at
|
|
1010
|
+
FROM vulnerabilities
|
|
1011
|
+
WHERE service_id = ? AND severity = ?`
|
|
1012
|
+
);
|
|
1013
|
+
const rows2 = stmt2.all(serviceId, severity);
|
|
1014
|
+
return rows2.map(rowToVulnerability);
|
|
1015
|
+
}
|
|
1016
|
+
const stmt = this.db.prepare(
|
|
1017
|
+
`SELECT id, service_id, endpoint_id, vuln_type, title, description, severity, confidence, evidence_artifact_id, created_at
|
|
1018
|
+
FROM vulnerabilities
|
|
1019
|
+
WHERE service_id = ?`
|
|
1020
|
+
);
|
|
1021
|
+
const rows = stmt.all(serviceId);
|
|
1022
|
+
return rows.map(rowToVulnerability);
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Return all Vulnerabilities across all services.
|
|
1026
|
+
* Optionally filter by severity when the parameter is provided.
|
|
1027
|
+
*/
|
|
1028
|
+
findAll(severity) {
|
|
1029
|
+
if (severity !== void 0) {
|
|
1030
|
+
const stmt2 = this.db.prepare(
|
|
1031
|
+
`SELECT id, service_id, endpoint_id, vuln_type, title, description, severity, confidence, evidence_artifact_id, created_at
|
|
1032
|
+
FROM vulnerabilities
|
|
1033
|
+
WHERE severity = ?`
|
|
1034
|
+
);
|
|
1035
|
+
const rows2 = stmt2.all(severity);
|
|
1036
|
+
return rows2.map(rowToVulnerability);
|
|
1037
|
+
}
|
|
1038
|
+
const stmt = this.db.prepare(
|
|
1039
|
+
`SELECT id, service_id, endpoint_id, vuln_type, title, description, severity, confidence, evidence_artifact_id, created_at
|
|
1040
|
+
FROM vulnerabilities`
|
|
1041
|
+
);
|
|
1042
|
+
const rows = stmt.all();
|
|
1043
|
+
return rows.map(rowToVulnerability);
|
|
1044
|
+
}
|
|
1045
|
+
/** Delete a Vulnerability by id. Returns true if a row was deleted. */
|
|
1046
|
+
delete(id) {
|
|
1047
|
+
const stmt = this.db.prepare("DELETE FROM vulnerabilities WHERE id = ?");
|
|
1048
|
+
const result = stmt.run(id);
|
|
1049
|
+
return result.changes > 0;
|
|
1050
|
+
}
|
|
1051
|
+
};
|
|
1052
|
+
|
|
1053
|
+
// src/mcp/tools/query.ts
|
|
1054
|
+
function registerQueryTools(server2, db2) {
|
|
1055
|
+
const hostRepo = new HostRepository(db2);
|
|
1056
|
+
const serviceRepo = new ServiceRepository(db2);
|
|
1057
|
+
const vhostRepo = new VhostRepository(db2);
|
|
1058
|
+
const httpEndpointRepo = new HttpEndpointRepository(db2);
|
|
1059
|
+
const inputRepo = new InputRepository(db2);
|
|
1060
|
+
const observationRepo = new ObservationRepository(db2);
|
|
1061
|
+
const credentialRepo = new CredentialRepository(db2);
|
|
1062
|
+
const vulnRepo = new VulnerabilityRepository(db2);
|
|
1063
|
+
server2.tool("list_hosts", "List all discovered hosts", {}, async () => {
|
|
1064
|
+
const hosts = hostRepo.findAll();
|
|
1065
|
+
return { content: [{ type: "text", text: JSON.stringify(hosts, null, 2) }] };
|
|
1066
|
+
});
|
|
1067
|
+
server2.tool(
|
|
1068
|
+
"get_host",
|
|
1069
|
+
"Get detailed information about a host including services and vhosts",
|
|
1070
|
+
{ hostId: z.string().describe("Host UUID") },
|
|
1071
|
+
async ({ hostId }) => {
|
|
1072
|
+
const host = hostRepo.findById(hostId);
|
|
1073
|
+
if (!host) {
|
|
1074
|
+
return { content: [{ type: "text", text: `Host not found: ${hostId}` }], isError: true };
|
|
1075
|
+
}
|
|
1076
|
+
const services = serviceRepo.findByHostId(hostId);
|
|
1077
|
+
const vhosts = vhostRepo.findByHostId(hostId);
|
|
1078
|
+
const result = { ...host, services, vhosts };
|
|
1079
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1080
|
+
}
|
|
1081
|
+
);
|
|
1082
|
+
server2.tool(
|
|
1083
|
+
"list_services",
|
|
1084
|
+
"List all services for a host",
|
|
1085
|
+
{ hostId: z.string().describe("Host UUID") },
|
|
1086
|
+
async ({ hostId }) => {
|
|
1087
|
+
const services = serviceRepo.findByHostId(hostId);
|
|
1088
|
+
return { content: [{ type: "text", text: JSON.stringify(services, null, 2) }] };
|
|
1089
|
+
}
|
|
1090
|
+
);
|
|
1091
|
+
server2.tool(
|
|
1092
|
+
"list_endpoints",
|
|
1093
|
+
"List all HTTP endpoints for a service",
|
|
1094
|
+
{ serviceId: z.string().describe("Service UUID") },
|
|
1095
|
+
async ({ serviceId }) => {
|
|
1096
|
+
const endpoints = httpEndpointRepo.findByServiceId(serviceId);
|
|
1097
|
+
return { content: [{ type: "text", text: JSON.stringify(endpoints, null, 2) }] };
|
|
1098
|
+
}
|
|
1099
|
+
);
|
|
1100
|
+
server2.tool(
|
|
1101
|
+
"list_inputs",
|
|
1102
|
+
"List all input parameters for a service, optionally filtered by location",
|
|
1103
|
+
{
|
|
1104
|
+
serviceId: z.string().describe("Service UUID"),
|
|
1105
|
+
location: z.string().optional().describe("Filter by location (query, path, body, header, cookie)")
|
|
1106
|
+
},
|
|
1107
|
+
async ({ serviceId, location }) => {
|
|
1108
|
+
const inputs = inputRepo.findByServiceId(serviceId, location);
|
|
1109
|
+
return { content: [{ type: "text", text: JSON.stringify(inputs, null, 2) }] };
|
|
1110
|
+
}
|
|
1111
|
+
);
|
|
1112
|
+
server2.tool(
|
|
1113
|
+
"list_observations",
|
|
1114
|
+
"List all observations for an input parameter",
|
|
1115
|
+
{ inputId: z.string().describe("Input UUID") },
|
|
1116
|
+
async ({ inputId }) => {
|
|
1117
|
+
const observations = observationRepo.findByInputId(inputId);
|
|
1118
|
+
return { content: [{ type: "text", text: JSON.stringify(observations, null, 2) }] };
|
|
1119
|
+
}
|
|
1120
|
+
);
|
|
1121
|
+
server2.tool(
|
|
1122
|
+
"list_credentials",
|
|
1123
|
+
"List credentials, optionally filtered by service",
|
|
1124
|
+
{
|
|
1125
|
+
serviceId: z.string().optional().describe("Service UUID (optional, omit to list all)")
|
|
1126
|
+
},
|
|
1127
|
+
async ({ serviceId }) => {
|
|
1128
|
+
const credentials = serviceId ? credentialRepo.findByServiceId(serviceId) : credentialRepo.findAll();
|
|
1129
|
+
return { content: [{ type: "text", text: JSON.stringify(credentials, null, 2) }] };
|
|
1130
|
+
}
|
|
1131
|
+
);
|
|
1132
|
+
server2.tool(
|
|
1133
|
+
"list_vulnerabilities",
|
|
1134
|
+
"List vulnerabilities, optionally filtered by service and/or severity",
|
|
1135
|
+
{
|
|
1136
|
+
serviceId: z.string().optional().describe("Service UUID (optional, omit to list all)"),
|
|
1137
|
+
severity: z.string().optional().describe("Filter by severity (critical, high, medium, low, info)")
|
|
1138
|
+
},
|
|
1139
|
+
async ({ serviceId, severity }) => {
|
|
1140
|
+
const vulns = serviceId ? vulnRepo.findByServiceId(serviceId, severity) : vulnRepo.findAll(severity);
|
|
1141
|
+
return { content: [{ type: "text", text: JSON.stringify(vulns, null, 2) }] };
|
|
1142
|
+
}
|
|
1143
|
+
);
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
// src/mcp/tools/ingest.ts
|
|
1147
|
+
import { z as z2 } from "zod";
|
|
1148
|
+
|
|
1149
|
+
// src/engine/ingest.ts
|
|
1150
|
+
import fs from "fs";
|
|
1151
|
+
import crypto13 from "crypto";
|
|
1152
|
+
import path from "path";
|
|
1153
|
+
|
|
1154
|
+
// src/db/repository/artifact-repository.ts
|
|
1155
|
+
import crypto9 from "crypto";
|
|
1156
|
+
function rowToArtifact(row) {
|
|
1157
|
+
return {
|
|
1158
|
+
id: row.id,
|
|
1159
|
+
...row.scan_id !== null ? { scanId: row.scan_id } : {},
|
|
1160
|
+
tool: row.tool,
|
|
1161
|
+
kind: row.kind,
|
|
1162
|
+
path: row.path,
|
|
1163
|
+
...row.sha256 !== null ? { sha256: row.sha256 } : {},
|
|
1164
|
+
capturedAt: row.captured_at,
|
|
1165
|
+
...row.attrs_json !== null ? { attrsJson: row.attrs_json } : {}
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
var ArtifactRepository = class {
|
|
1169
|
+
db;
|
|
1170
|
+
insertStmt;
|
|
1171
|
+
selectByIdStmt;
|
|
1172
|
+
selectAllStmt;
|
|
1173
|
+
selectByToolStmt;
|
|
1174
|
+
constructor(db2) {
|
|
1175
|
+
this.db = db2;
|
|
1176
|
+
this.insertStmt = this.db.prepare(
|
|
1177
|
+
"INSERT INTO artifacts (id, scan_id, tool, kind, path, sha256, captured_at, attrs_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
|
1178
|
+
);
|
|
1179
|
+
this.selectByIdStmt = this.db.prepare(
|
|
1180
|
+
"SELECT id, scan_id, tool, kind, path, sha256, captured_at, attrs_json FROM artifacts WHERE id = ?"
|
|
1181
|
+
);
|
|
1182
|
+
this.selectAllStmt = this.db.prepare(
|
|
1183
|
+
"SELECT id, scan_id, tool, kind, path, sha256, captured_at, attrs_json FROM artifacts"
|
|
1184
|
+
);
|
|
1185
|
+
this.selectByToolStmt = this.db.prepare(
|
|
1186
|
+
"SELECT id, scan_id, tool, kind, path, sha256, captured_at, attrs_json FROM artifacts WHERE tool = ?"
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
/** Create a new Artifact record and return the full entity. */
|
|
1190
|
+
create(input) {
|
|
1191
|
+
const id = crypto9.randomUUID();
|
|
1192
|
+
this.insertStmt.run(
|
|
1193
|
+
id,
|
|
1194
|
+
input.scanId ?? null,
|
|
1195
|
+
input.tool,
|
|
1196
|
+
input.kind,
|
|
1197
|
+
input.path,
|
|
1198
|
+
input.sha256 ?? null,
|
|
1199
|
+
input.capturedAt,
|
|
1200
|
+
input.attrsJson ?? null
|
|
1201
|
+
);
|
|
1202
|
+
return {
|
|
1203
|
+
id,
|
|
1204
|
+
...input.scanId !== void 0 ? { scanId: input.scanId } : {},
|
|
1205
|
+
tool: input.tool,
|
|
1206
|
+
kind: input.kind,
|
|
1207
|
+
path: input.path,
|
|
1208
|
+
...input.sha256 !== void 0 ? { sha256: input.sha256 } : {},
|
|
1209
|
+
capturedAt: input.capturedAt,
|
|
1210
|
+
...input.attrsJson !== void 0 ? { attrsJson: input.attrsJson } : {}
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
/** Find an Artifact by its UUID. Returns undefined if not found. */
|
|
1214
|
+
findById(id) {
|
|
1215
|
+
const row = this.selectByIdStmt.get(id);
|
|
1216
|
+
if (row === void 0) {
|
|
1217
|
+
return void 0;
|
|
1218
|
+
}
|
|
1219
|
+
return rowToArtifact(row);
|
|
1220
|
+
}
|
|
1221
|
+
/** Return all Artifact records. */
|
|
1222
|
+
findAll() {
|
|
1223
|
+
const rows = this.selectAllStmt.all();
|
|
1224
|
+
return rows.map(rowToArtifact);
|
|
1225
|
+
}
|
|
1226
|
+
/** Return all Artifact records for the given tool name. */
|
|
1227
|
+
findByTool(tool) {
|
|
1228
|
+
const rows = this.selectByToolStmt.all(tool);
|
|
1229
|
+
return rows.map(rowToArtifact);
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
// src/parser/nmap-parser.ts
|
|
1234
|
+
import { XMLParser } from "fast-xml-parser";
|
|
1235
|
+
|
|
1236
|
+
// src/types/parser.ts
|
|
1237
|
+
function emptyParseResult() {
|
|
1238
|
+
return {
|
|
1239
|
+
hosts: [],
|
|
1240
|
+
services: [],
|
|
1241
|
+
serviceObservations: [],
|
|
1242
|
+
httpEndpoints: [],
|
|
1243
|
+
inputs: [],
|
|
1244
|
+
endpointInputs: [],
|
|
1245
|
+
observations: [],
|
|
1246
|
+
vulnerabilities: [],
|
|
1247
|
+
cves: []
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
// src/parser/nmap-parser.ts
|
|
1252
|
+
function ensureArray(value) {
|
|
1253
|
+
if (value === void 0 || value === null) {
|
|
1254
|
+
return [];
|
|
1255
|
+
}
|
|
1256
|
+
return Array.isArray(value) ? value : [value];
|
|
1257
|
+
}
|
|
1258
|
+
function toProtoConfidence(conf) {
|
|
1259
|
+
const n = conf !== void 0 ? Number(conf) : 0;
|
|
1260
|
+
if (n === 10) return "high";
|
|
1261
|
+
if (n >= 7) return "medium";
|
|
1262
|
+
return "low";
|
|
1263
|
+
}
|
|
1264
|
+
function toOsConfidence(accuracy) {
|
|
1265
|
+
const n = Number(accuracy);
|
|
1266
|
+
if (n >= 90) return "high";
|
|
1267
|
+
if (n >= 50) return "medium";
|
|
1268
|
+
return "low";
|
|
1269
|
+
}
|
|
1270
|
+
function isHttps(service) {
|
|
1271
|
+
return service["@_name"] === "https" || service["@_tunnel"] === "ssl";
|
|
1272
|
+
}
|
|
1273
|
+
function buildBanner(service) {
|
|
1274
|
+
const parts = [];
|
|
1275
|
+
if (service["@_product"]) parts.push(service["@_product"]);
|
|
1276
|
+
if (service["@_version"]) parts.push(service["@_version"]);
|
|
1277
|
+
if (service["@_extrainfo"]) parts.push(service["@_extrainfo"]);
|
|
1278
|
+
return parts.length > 0 ? parts.join(" ") : void 0;
|
|
1279
|
+
}
|
|
1280
|
+
function getIpv4Address(addresses) {
|
|
1281
|
+
const ipv4 = addresses.find((a) => a["@_addrtype"] === "ipv4");
|
|
1282
|
+
return ipv4?.["@_addr"];
|
|
1283
|
+
}
|
|
1284
|
+
function parseNmapXml(xml) {
|
|
1285
|
+
const parser = new XMLParser({
|
|
1286
|
+
ignoreAttributes: false,
|
|
1287
|
+
attributeNamePrefix: "@_",
|
|
1288
|
+
allowBooleanAttributes: true
|
|
1289
|
+
});
|
|
1290
|
+
const parsed = parser.parse(xml);
|
|
1291
|
+
const nmapRun = parsed;
|
|
1292
|
+
const result = emptyParseResult();
|
|
1293
|
+
const hosts = ensureArray(nmapRun.nmaprun?.host);
|
|
1294
|
+
for (const host of hosts) {
|
|
1295
|
+
processHost(host, result);
|
|
1296
|
+
}
|
|
1297
|
+
return result;
|
|
1298
|
+
}
|
|
1299
|
+
function processHost(host, result) {
|
|
1300
|
+
const addresses = ensureArray(host.address);
|
|
1301
|
+
const authority = getIpv4Address(addresses);
|
|
1302
|
+
if (authority === void 0) {
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
const parsedHost = {
|
|
1306
|
+
authority,
|
|
1307
|
+
authorityKind: "IP"
|
|
1308
|
+
};
|
|
1309
|
+
result.hosts.push(parsedHost);
|
|
1310
|
+
const ports = ensureArray(host.ports?.port);
|
|
1311
|
+
const services = [];
|
|
1312
|
+
for (const port of ports) {
|
|
1313
|
+
const service = processPort(port, authority);
|
|
1314
|
+
services.push(service);
|
|
1315
|
+
result.services.push(service);
|
|
1316
|
+
}
|
|
1317
|
+
const osMatches = ensureArray(host.os?.osmatch);
|
|
1318
|
+
if (osMatches.length > 0) {
|
|
1319
|
+
processOsMatches(osMatches, authority, services, result);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
function processPort(port, hostAuthority) {
|
|
1323
|
+
const service = port.service;
|
|
1324
|
+
const serviceName = service?.["@_name"] ?? "";
|
|
1325
|
+
const appProto = service !== void 0 && isHttps(service) ? "https" : serviceName;
|
|
1326
|
+
const banner = service !== void 0 ? buildBanner(service) : void 0;
|
|
1327
|
+
const protoConfidence = toProtoConfidence(service?.["@_conf"]);
|
|
1328
|
+
return {
|
|
1329
|
+
hostAuthority,
|
|
1330
|
+
transport: port["@_protocol"],
|
|
1331
|
+
port: Number(port["@_portid"]),
|
|
1332
|
+
appProto,
|
|
1333
|
+
protoConfidence,
|
|
1334
|
+
banner,
|
|
1335
|
+
product: service?.["@_product"],
|
|
1336
|
+
version: service?.["@_version"],
|
|
1337
|
+
state: port.state["@_state"]
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
function processOsMatches(osMatches, hostAuthority, services, result) {
|
|
1341
|
+
const firstService = services.length > 0 ? services[0] : void 0;
|
|
1342
|
+
const transport2 = firstService?.transport ?? "tcp";
|
|
1343
|
+
const port = firstService?.port ?? 0;
|
|
1344
|
+
for (const osMatch of osMatches) {
|
|
1345
|
+
const observation = {
|
|
1346
|
+
hostAuthority,
|
|
1347
|
+
transport: transport2,
|
|
1348
|
+
port,
|
|
1349
|
+
key: "os",
|
|
1350
|
+
value: osMatch["@_name"],
|
|
1351
|
+
confidence: toOsConfidence(osMatch["@_accuracy"])
|
|
1352
|
+
};
|
|
1353
|
+
result.serviceObservations.push(observation);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// src/parser/ffuf-parser.ts
|
|
1358
|
+
var IP_REGEX = /^\d{1,3}(\.\d{1,3}){3}$/;
|
|
1359
|
+
function isRecord(value) {
|
|
1360
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1361
|
+
}
|
|
1362
|
+
function validateFfufJson(raw) {
|
|
1363
|
+
if (!isRecord(raw)) {
|
|
1364
|
+
throw new Error("ffuf JSON: root must be an object");
|
|
1365
|
+
}
|
|
1366
|
+
if (typeof raw["commandline"] !== "string") {
|
|
1367
|
+
throw new Error("ffuf JSON: commandline must be a string");
|
|
1368
|
+
}
|
|
1369
|
+
const config = raw["config"];
|
|
1370
|
+
if (!isRecord(config)) {
|
|
1371
|
+
throw new Error("ffuf JSON: config must be an object");
|
|
1372
|
+
}
|
|
1373
|
+
if (typeof config["url"] !== "string" || typeof config["method"] !== "string") {
|
|
1374
|
+
throw new Error("ffuf JSON: config.url and config.method must be strings");
|
|
1375
|
+
}
|
|
1376
|
+
if (!Array.isArray(raw["results"])) {
|
|
1377
|
+
throw new Error("ffuf JSON: results must be an array");
|
|
1378
|
+
}
|
|
1379
|
+
const results = [];
|
|
1380
|
+
for (const item of raw["results"]) {
|
|
1381
|
+
if (!isRecord(item)) {
|
|
1382
|
+
throw new Error("ffuf JSON: each result must be an object");
|
|
1383
|
+
}
|
|
1384
|
+
results.push({
|
|
1385
|
+
input: isRecord(item["input"]) ? Object.fromEntries(
|
|
1386
|
+
Object.entries(item["input"]).map(([k, v]) => [
|
|
1387
|
+
k,
|
|
1388
|
+
String(v)
|
|
1389
|
+
])
|
|
1390
|
+
) : {},
|
|
1391
|
+
status: typeof item["status"] === "number" ? item["status"] : 0,
|
|
1392
|
+
length: typeof item["length"] === "number" ? item["length"] : 0,
|
|
1393
|
+
words: typeof item["words"] === "number" ? item["words"] : 0,
|
|
1394
|
+
lines: typeof item["lines"] === "number" ? item["lines"] : 0,
|
|
1395
|
+
url: typeof item["url"] === "string" ? item["url"] : "",
|
|
1396
|
+
host: typeof item["host"] === "string" ? item["host"] : ""
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
return {
|
|
1400
|
+
commandline: raw["commandline"],
|
|
1401
|
+
config: {
|
|
1402
|
+
url: config["url"],
|
|
1403
|
+
method: config["method"]
|
|
1404
|
+
},
|
|
1405
|
+
results
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
function parseUrl(urlStr) {
|
|
1409
|
+
const parsed = new URL(urlStr);
|
|
1410
|
+
const scheme = parsed.protocol.replace(":", "");
|
|
1411
|
+
let port;
|
|
1412
|
+
if (parsed.port !== "") {
|
|
1413
|
+
port = Number(parsed.port);
|
|
1414
|
+
} else {
|
|
1415
|
+
port = scheme === "https" ? 443 : 80;
|
|
1416
|
+
}
|
|
1417
|
+
return {
|
|
1418
|
+
scheme,
|
|
1419
|
+
hostname: parsed.hostname,
|
|
1420
|
+
port,
|
|
1421
|
+
pathname: parsed.pathname,
|
|
1422
|
+
searchParams: parsed.searchParams
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
function determineAuthorityKind(hostname) {
|
|
1426
|
+
return IP_REGEX.test(hostname) ? "IP" : "DOMAIN";
|
|
1427
|
+
}
|
|
1428
|
+
function parseFfufJson(jsonContent) {
|
|
1429
|
+
const raw = JSON.parse(jsonContent);
|
|
1430
|
+
const ffuf = validateFfufJson(raw);
|
|
1431
|
+
const method = ffuf.config.method;
|
|
1432
|
+
if (ffuf.results.length === 0) {
|
|
1433
|
+
return emptyParseResult();
|
|
1434
|
+
}
|
|
1435
|
+
const hostsMap = /* @__PURE__ */ new Map();
|
|
1436
|
+
const servicesMap = /* @__PURE__ */ new Map();
|
|
1437
|
+
const endpointsMap = /* @__PURE__ */ new Map();
|
|
1438
|
+
const inputsMap = /* @__PURE__ */ new Map();
|
|
1439
|
+
const endpointInputsMap = /* @__PURE__ */ new Map();
|
|
1440
|
+
const observationsMap = /* @__PURE__ */ new Map();
|
|
1441
|
+
for (const result of ffuf.results) {
|
|
1442
|
+
if (result.url === "") {
|
|
1443
|
+
continue;
|
|
1444
|
+
}
|
|
1445
|
+
const parsed = parseUrl(result.url);
|
|
1446
|
+
const { scheme, hostname, port, pathname, searchParams } = parsed;
|
|
1447
|
+
const authorityKind = determineAuthorityKind(hostname);
|
|
1448
|
+
const baseUri = `${scheme}://${hostname}:${port}`;
|
|
1449
|
+
if (!hostsMap.has(hostname)) {
|
|
1450
|
+
hostsMap.set(hostname, {
|
|
1451
|
+
authority: hostname,
|
|
1452
|
+
authorityKind
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
const serviceKey = `${hostname}:${port}`;
|
|
1456
|
+
if (!servicesMap.has(serviceKey)) {
|
|
1457
|
+
servicesMap.set(serviceKey, {
|
|
1458
|
+
hostAuthority: hostname,
|
|
1459
|
+
transport: "tcp",
|
|
1460
|
+
port,
|
|
1461
|
+
appProto: scheme,
|
|
1462
|
+
protoConfidence: "high",
|
|
1463
|
+
state: "open"
|
|
1464
|
+
});
|
|
1465
|
+
}
|
|
1466
|
+
const endpointKey = `${method}:${pathname}`;
|
|
1467
|
+
if (!endpointsMap.has(endpointKey)) {
|
|
1468
|
+
endpointsMap.set(endpointKey, {
|
|
1469
|
+
hostAuthority: hostname,
|
|
1470
|
+
port,
|
|
1471
|
+
baseUri,
|
|
1472
|
+
method,
|
|
1473
|
+
path: pathname,
|
|
1474
|
+
statusCode: result.status,
|
|
1475
|
+
contentLength: result.length,
|
|
1476
|
+
words: result.words,
|
|
1477
|
+
lines: result.lines
|
|
1478
|
+
});
|
|
1479
|
+
}
|
|
1480
|
+
for (const [paramName, paramValue] of searchParams.entries()) {
|
|
1481
|
+
if (!inputsMap.has(paramName)) {
|
|
1482
|
+
inputsMap.set(paramName, {
|
|
1483
|
+
hostAuthority: hostname,
|
|
1484
|
+
port,
|
|
1485
|
+
location: "query",
|
|
1486
|
+
name: paramName
|
|
1487
|
+
});
|
|
1488
|
+
}
|
|
1489
|
+
const eiKey = `${method}:${pathname}:query:${paramName}`;
|
|
1490
|
+
if (!endpointInputsMap.has(eiKey)) {
|
|
1491
|
+
endpointInputsMap.set(eiKey, {
|
|
1492
|
+
hostAuthority: hostname,
|
|
1493
|
+
port,
|
|
1494
|
+
method,
|
|
1495
|
+
path: pathname,
|
|
1496
|
+
inputLocation: "query",
|
|
1497
|
+
inputName: paramName
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
const obsKey = `query:${paramName}:${paramValue}`;
|
|
1501
|
+
if (!observationsMap.has(obsKey)) {
|
|
1502
|
+
observationsMap.set(obsKey, {
|
|
1503
|
+
hostAuthority: hostname,
|
|
1504
|
+
port,
|
|
1505
|
+
inputLocation: "query",
|
|
1506
|
+
inputName: paramName,
|
|
1507
|
+
rawValue: paramValue,
|
|
1508
|
+
normValue: paramValue,
|
|
1509
|
+
source: "ffuf_url",
|
|
1510
|
+
confidence: "high"
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return {
|
|
1516
|
+
hosts: [...hostsMap.values()],
|
|
1517
|
+
services: [...servicesMap.values()],
|
|
1518
|
+
serviceObservations: [],
|
|
1519
|
+
httpEndpoints: [...endpointsMap.values()],
|
|
1520
|
+
inputs: [...inputsMap.values()],
|
|
1521
|
+
endpointInputs: [...endpointInputsMap.values()],
|
|
1522
|
+
observations: [...observationsMap.values()],
|
|
1523
|
+
vulnerabilities: [],
|
|
1524
|
+
cves: []
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
// src/parser/nuclei-parser.ts
|
|
1529
|
+
function isRecord2(value) {
|
|
1530
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1531
|
+
}
|
|
1532
|
+
function isStringArray(value) {
|
|
1533
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
1534
|
+
}
|
|
1535
|
+
function isNucleiClassification(value) {
|
|
1536
|
+
if (!isRecord2(value)) return false;
|
|
1537
|
+
if ("cve-id" in value && !isStringArray(value["cve-id"])) return false;
|
|
1538
|
+
if ("cvss-metrics" in value && typeof value["cvss-metrics"] !== "string" && value["cvss-metrics"] !== void 0)
|
|
1539
|
+
return false;
|
|
1540
|
+
if ("cvss-score" in value && typeof value["cvss-score"] !== "number" && value["cvss-score"] !== void 0)
|
|
1541
|
+
return false;
|
|
1542
|
+
return true;
|
|
1543
|
+
}
|
|
1544
|
+
function isNucleiInfo(value) {
|
|
1545
|
+
if (!isRecord2(value)) return false;
|
|
1546
|
+
if (typeof value.name !== "string") return false;
|
|
1547
|
+
if (typeof value.severity !== "string") return false;
|
|
1548
|
+
if (!isStringArray(value.tags)) return false;
|
|
1549
|
+
if ("classification" in value && value.classification !== void 0 && !isNucleiClassification(value.classification))
|
|
1550
|
+
return false;
|
|
1551
|
+
return true;
|
|
1552
|
+
}
|
|
1553
|
+
function isNucleiFinding(value) {
|
|
1554
|
+
if (!isRecord2(value)) return false;
|
|
1555
|
+
if (typeof value["template-id"] !== "string") return false;
|
|
1556
|
+
if (!isNucleiInfo(value.info)) return false;
|
|
1557
|
+
if (typeof value.type !== "string") return false;
|
|
1558
|
+
if (typeof value.host !== "string") return false;
|
|
1559
|
+
if (typeof value["matched-at"] !== "string") return false;
|
|
1560
|
+
if (typeof value.ip !== "string") return false;
|
|
1561
|
+
if (typeof value.port !== "string") return false;
|
|
1562
|
+
if (typeof value.scheme !== "string") return false;
|
|
1563
|
+
if (typeof value.url !== "string") return false;
|
|
1564
|
+
return true;
|
|
1565
|
+
}
|
|
1566
|
+
function extractRawPathname(urlStr) {
|
|
1567
|
+
const schemeEnd = urlStr.indexOf("://");
|
|
1568
|
+
if (schemeEnd === -1) {
|
|
1569
|
+
return "/";
|
|
1570
|
+
}
|
|
1571
|
+
const afterAuthority = urlStr.indexOf("/", schemeEnd + 3);
|
|
1572
|
+
if (afterAuthority === -1) {
|
|
1573
|
+
return "/";
|
|
1574
|
+
}
|
|
1575
|
+
const queryStart = urlStr.indexOf("?", afterAuthority);
|
|
1576
|
+
const fragmentStart = urlStr.indexOf("#", afterAuthority);
|
|
1577
|
+
let pathEnd = urlStr.length;
|
|
1578
|
+
if (queryStart !== -1 && queryStart < pathEnd) {
|
|
1579
|
+
pathEnd = queryStart;
|
|
1580
|
+
}
|
|
1581
|
+
if (fragmentStart !== -1 && fragmentStart < pathEnd) {
|
|
1582
|
+
pathEnd = fragmentStart;
|
|
1583
|
+
}
|
|
1584
|
+
return urlStr.substring(afterAuthority, pathEnd);
|
|
1585
|
+
}
|
|
1586
|
+
function inferVulnType(tags) {
|
|
1587
|
+
const priorityTags = ["sqli", "xss", "rce", "lfi", "ssrf"];
|
|
1588
|
+
for (const tag of priorityTags) {
|
|
1589
|
+
if (tags.includes(tag)) {
|
|
1590
|
+
return tag;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
return "other";
|
|
1594
|
+
}
|
|
1595
|
+
function parseNucleiJsonl(jsonl) {
|
|
1596
|
+
const result = emptyParseResult();
|
|
1597
|
+
if (jsonl.trim() === "") {
|
|
1598
|
+
return result;
|
|
1599
|
+
}
|
|
1600
|
+
const lines = jsonl.split("\n");
|
|
1601
|
+
const seenHosts = /* @__PURE__ */ new Set();
|
|
1602
|
+
const seenServices = /* @__PURE__ */ new Set();
|
|
1603
|
+
for (const line of lines) {
|
|
1604
|
+
const trimmed = line.trim();
|
|
1605
|
+
if (trimmed === "") {
|
|
1606
|
+
continue;
|
|
1607
|
+
}
|
|
1608
|
+
const parsed = JSON.parse(trimmed);
|
|
1609
|
+
if (!isNucleiFinding(parsed)) {
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
processFinding(parsed, result, seenHosts, seenServices);
|
|
1613
|
+
}
|
|
1614
|
+
return result;
|
|
1615
|
+
}
|
|
1616
|
+
function processFinding(finding, result, seenHosts, seenServices) {
|
|
1617
|
+
const ip = finding.ip;
|
|
1618
|
+
const port = Number(finding.port);
|
|
1619
|
+
const scheme = finding.scheme;
|
|
1620
|
+
const matchedAt = finding["matched-at"];
|
|
1621
|
+
if (!seenHosts.has(ip)) {
|
|
1622
|
+
seenHosts.add(ip);
|
|
1623
|
+
const host = {
|
|
1624
|
+
authority: ip,
|
|
1625
|
+
authorityKind: "IP"
|
|
1626
|
+
};
|
|
1627
|
+
result.hosts.push(host);
|
|
1628
|
+
}
|
|
1629
|
+
const serviceKey = `${ip}:${port}`;
|
|
1630
|
+
if (!seenServices.has(serviceKey)) {
|
|
1631
|
+
seenServices.add(serviceKey);
|
|
1632
|
+
const service = {
|
|
1633
|
+
hostAuthority: ip,
|
|
1634
|
+
transport: "tcp",
|
|
1635
|
+
port,
|
|
1636
|
+
appProto: scheme,
|
|
1637
|
+
protoConfidence: "high",
|
|
1638
|
+
state: "open"
|
|
1639
|
+
};
|
|
1640
|
+
result.services.push(service);
|
|
1641
|
+
}
|
|
1642
|
+
const rawPath = extractRawPathname(matchedAt);
|
|
1643
|
+
const baseUri = `${scheme}://${ip}:${port}`;
|
|
1644
|
+
const endpoint = {
|
|
1645
|
+
hostAuthority: ip,
|
|
1646
|
+
port,
|
|
1647
|
+
baseUri,
|
|
1648
|
+
method: "GET",
|
|
1649
|
+
path: rawPath
|
|
1650
|
+
};
|
|
1651
|
+
result.httpEndpoints.push(endpoint);
|
|
1652
|
+
const info = finding.info;
|
|
1653
|
+
const vulnerability = {
|
|
1654
|
+
hostAuthority: ip,
|
|
1655
|
+
port,
|
|
1656
|
+
method: "GET",
|
|
1657
|
+
path: rawPath,
|
|
1658
|
+
vulnType: inferVulnType(info.tags),
|
|
1659
|
+
title: info.name,
|
|
1660
|
+
severity: info.severity,
|
|
1661
|
+
confidence: "high"
|
|
1662
|
+
};
|
|
1663
|
+
result.vulnerabilities.push(vulnerability);
|
|
1664
|
+
const classification = info.classification;
|
|
1665
|
+
if (classification !== void 0 && isRecord2(classification) && "cve-id" in classification && isStringArray(classification["cve-id"]) && classification["cve-id"].length > 0) {
|
|
1666
|
+
for (const cveId of classification["cve-id"]) {
|
|
1667
|
+
const cve = {
|
|
1668
|
+
vulnerabilityTitle: info.name,
|
|
1669
|
+
cveId,
|
|
1670
|
+
cvssScore: classification["cvss-score"],
|
|
1671
|
+
cvssVector: classification["cvss-metrics"]
|
|
1672
|
+
};
|
|
1673
|
+
result.cves.push(cve);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
// src/db/repository/service-observation-repository.ts
|
|
1679
|
+
import crypto10 from "crypto";
|
|
1680
|
+
function rowToServiceObservation(row) {
|
|
1681
|
+
return {
|
|
1682
|
+
id: row.id,
|
|
1683
|
+
serviceId: row.service_id,
|
|
1684
|
+
key: row.key,
|
|
1685
|
+
value: row.value,
|
|
1686
|
+
confidence: row.confidence,
|
|
1687
|
+
evidenceArtifactId: row.evidence_artifact_id,
|
|
1688
|
+
createdAt: row.created_at
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
var ServiceObservationRepository = class {
|
|
1692
|
+
db;
|
|
1693
|
+
constructor(db2) {
|
|
1694
|
+
this.db = db2;
|
|
1695
|
+
}
|
|
1696
|
+
/** Insert a new ServiceObservation and return the full entity. */
|
|
1697
|
+
create(input) {
|
|
1698
|
+
const id = crypto10.randomUUID();
|
|
1699
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1700
|
+
const stmt = this.db.prepare(
|
|
1701
|
+
`INSERT INTO service_observations (id, service_id, key, value, confidence, evidence_artifact_id, created_at)
|
|
1702
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
|
1703
|
+
);
|
|
1704
|
+
stmt.run(
|
|
1705
|
+
id,
|
|
1706
|
+
input.serviceId,
|
|
1707
|
+
input.key,
|
|
1708
|
+
input.value,
|
|
1709
|
+
input.confidence,
|
|
1710
|
+
input.evidenceArtifactId,
|
|
1711
|
+
now
|
|
1712
|
+
);
|
|
1713
|
+
return {
|
|
1714
|
+
id,
|
|
1715
|
+
serviceId: input.serviceId,
|
|
1716
|
+
key: input.key,
|
|
1717
|
+
value: input.value,
|
|
1718
|
+
confidence: input.confidence,
|
|
1719
|
+
evidenceArtifactId: input.evidenceArtifactId,
|
|
1720
|
+
createdAt: now
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
/** Find a ServiceObservation by its primary key. Returns undefined if not found. */
|
|
1724
|
+
findById(id) {
|
|
1725
|
+
const stmt = this.db.prepare(
|
|
1726
|
+
`SELECT id, service_id, key, value, confidence, evidence_artifact_id, created_at
|
|
1727
|
+
FROM service_observations
|
|
1728
|
+
WHERE id = ?`
|
|
1729
|
+
);
|
|
1730
|
+
const row = stmt.get(id);
|
|
1731
|
+
return row ? rowToServiceObservation(row) : void 0;
|
|
1732
|
+
}
|
|
1733
|
+
/** Return all ServiceObservations for a given service. */
|
|
1734
|
+
findByServiceId(serviceId) {
|
|
1735
|
+
const stmt = this.db.prepare(
|
|
1736
|
+
`SELECT id, service_id, key, value, confidence, evidence_artifact_id, created_at
|
|
1737
|
+
FROM service_observations
|
|
1738
|
+
WHERE service_id = ?`
|
|
1739
|
+
);
|
|
1740
|
+
const rows = stmt.all(serviceId);
|
|
1741
|
+
return rows.map(rowToServiceObservation);
|
|
1742
|
+
}
|
|
1743
|
+
/** Delete a ServiceObservation by id. Returns true if a row was deleted. */
|
|
1744
|
+
delete(id) {
|
|
1745
|
+
const stmt = this.db.prepare("DELETE FROM service_observations WHERE id = ?");
|
|
1746
|
+
const result = stmt.run(id);
|
|
1747
|
+
return result.changes > 0;
|
|
1748
|
+
}
|
|
1749
|
+
};
|
|
1750
|
+
|
|
1751
|
+
// src/db/repository/endpoint-input-repository.ts
|
|
1752
|
+
import crypto11 from "crypto";
|
|
1753
|
+
function rowToEndpointInput(row) {
|
|
1754
|
+
return {
|
|
1755
|
+
id: row.id,
|
|
1756
|
+
endpointId: row.endpoint_id,
|
|
1757
|
+
inputId: row.input_id,
|
|
1758
|
+
evidenceArtifactId: row.evidence_artifact_id,
|
|
1759
|
+
createdAt: row.created_at
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
var EndpointInputRepository = class {
|
|
1763
|
+
db;
|
|
1764
|
+
constructor(db2) {
|
|
1765
|
+
this.db = db2;
|
|
1766
|
+
}
|
|
1767
|
+
/** Insert a new EndpointInput and return the full entity. */
|
|
1768
|
+
create(input) {
|
|
1769
|
+
const id = crypto11.randomUUID();
|
|
1770
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1771
|
+
const stmt = this.db.prepare(
|
|
1772
|
+
`INSERT INTO endpoint_inputs (id, endpoint_id, input_id, evidence_artifact_id, created_at)
|
|
1773
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
1774
|
+
);
|
|
1775
|
+
stmt.run(
|
|
1776
|
+
id,
|
|
1777
|
+
input.endpointId,
|
|
1778
|
+
input.inputId,
|
|
1779
|
+
input.evidenceArtifactId,
|
|
1780
|
+
now
|
|
1781
|
+
);
|
|
1782
|
+
return {
|
|
1783
|
+
id,
|
|
1784
|
+
endpointId: input.endpointId,
|
|
1785
|
+
inputId: input.inputId,
|
|
1786
|
+
evidenceArtifactId: input.evidenceArtifactId,
|
|
1787
|
+
createdAt: now
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
/** Find an EndpointInput by its primary key. Returns undefined if not found. */
|
|
1791
|
+
findById(id) {
|
|
1792
|
+
const stmt = this.db.prepare(
|
|
1793
|
+
`SELECT id, endpoint_id, input_id, evidence_artifact_id, created_at
|
|
1794
|
+
FROM endpoint_inputs
|
|
1795
|
+
WHERE id = ?`
|
|
1796
|
+
);
|
|
1797
|
+
const row = stmt.get(id);
|
|
1798
|
+
return row ? rowToEndpointInput(row) : void 0;
|
|
1799
|
+
}
|
|
1800
|
+
/** Find all EndpointInputs for a given endpoint. */
|
|
1801
|
+
findByEndpointId(endpointId) {
|
|
1802
|
+
const stmt = this.db.prepare(
|
|
1803
|
+
`SELECT id, endpoint_id, input_id, evidence_artifact_id, created_at
|
|
1804
|
+
FROM endpoint_inputs
|
|
1805
|
+
WHERE endpoint_id = ?`
|
|
1806
|
+
);
|
|
1807
|
+
const rows = stmt.all(endpointId);
|
|
1808
|
+
return rows.map(rowToEndpointInput);
|
|
1809
|
+
}
|
|
1810
|
+
/** Find all EndpointInputs for a given input. */
|
|
1811
|
+
findByInputId(inputId) {
|
|
1812
|
+
const stmt = this.db.prepare(
|
|
1813
|
+
`SELECT id, endpoint_id, input_id, evidence_artifact_id, created_at
|
|
1814
|
+
FROM endpoint_inputs
|
|
1815
|
+
WHERE input_id = ?`
|
|
1816
|
+
);
|
|
1817
|
+
const rows = stmt.all(inputId);
|
|
1818
|
+
return rows.map(rowToEndpointInput);
|
|
1819
|
+
}
|
|
1820
|
+
/** Delete an EndpointInput by id. Returns true if a row was deleted. */
|
|
1821
|
+
delete(id) {
|
|
1822
|
+
const stmt = this.db.prepare("DELETE FROM endpoint_inputs WHERE id = ?");
|
|
1823
|
+
const result = stmt.run(id);
|
|
1824
|
+
return result.changes > 0;
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
|
|
1828
|
+
// src/db/repository/cve-repository.ts
|
|
1829
|
+
import crypto12 from "crypto";
|
|
1830
|
+
function rowToCve(row) {
|
|
1831
|
+
return {
|
|
1832
|
+
id: row.id,
|
|
1833
|
+
vulnerabilityId: row.vulnerability_id,
|
|
1834
|
+
cveId: row.cve_id,
|
|
1835
|
+
description: row.description ?? void 0,
|
|
1836
|
+
cvssScore: row.cvss_score ?? void 0,
|
|
1837
|
+
cvssVector: row.cvss_vector ?? void 0,
|
|
1838
|
+
referenceUrl: row.reference_url ?? void 0,
|
|
1839
|
+
createdAt: row.created_at
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
var CveRepository = class {
|
|
1843
|
+
db;
|
|
1844
|
+
constructor(db2) {
|
|
1845
|
+
this.db = db2;
|
|
1846
|
+
}
|
|
1847
|
+
/** Insert a new Cve and return the full entity. */
|
|
1848
|
+
create(input) {
|
|
1849
|
+
const id = crypto12.randomUUID();
|
|
1850
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1851
|
+
const stmt = this.db.prepare(
|
|
1852
|
+
`INSERT INTO cves (id, vulnerability_id, cve_id, description, cvss_score, cvss_vector, reference_url, created_at)
|
|
1853
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
1854
|
+
);
|
|
1855
|
+
stmt.run(
|
|
1856
|
+
id,
|
|
1857
|
+
input.vulnerabilityId,
|
|
1858
|
+
input.cveId,
|
|
1859
|
+
input.description ?? null,
|
|
1860
|
+
input.cvssScore ?? null,
|
|
1861
|
+
input.cvssVector ?? null,
|
|
1862
|
+
input.referenceUrl ?? null,
|
|
1863
|
+
now
|
|
1864
|
+
);
|
|
1865
|
+
return {
|
|
1866
|
+
id,
|
|
1867
|
+
vulnerabilityId: input.vulnerabilityId,
|
|
1868
|
+
cveId: input.cveId,
|
|
1869
|
+
description: input.description,
|
|
1870
|
+
cvssScore: input.cvssScore,
|
|
1871
|
+
cvssVector: input.cvssVector,
|
|
1872
|
+
referenceUrl: input.referenceUrl,
|
|
1873
|
+
createdAt: now
|
|
1874
|
+
};
|
|
1875
|
+
}
|
|
1876
|
+
/** Find a Cve by its primary key. Returns undefined if not found. */
|
|
1877
|
+
findById(id) {
|
|
1878
|
+
const stmt = this.db.prepare(
|
|
1879
|
+
`SELECT id, vulnerability_id, cve_id, description, cvss_score, cvss_vector, reference_url, created_at
|
|
1880
|
+
FROM cves
|
|
1881
|
+
WHERE id = ?`
|
|
1882
|
+
);
|
|
1883
|
+
const row = stmt.get(id);
|
|
1884
|
+
return row ? rowToCve(row) : void 0;
|
|
1885
|
+
}
|
|
1886
|
+
/** Return all Cves associated with a given vulnerability. */
|
|
1887
|
+
findByVulnerabilityId(vulnerabilityId) {
|
|
1888
|
+
const stmt = this.db.prepare(
|
|
1889
|
+
`SELECT id, vulnerability_id, cve_id, description, cvss_score, cvss_vector, reference_url, created_at
|
|
1890
|
+
FROM cves
|
|
1891
|
+
WHERE vulnerability_id = ?`
|
|
1892
|
+
);
|
|
1893
|
+
const rows = stmt.all(vulnerabilityId);
|
|
1894
|
+
return rows.map(rowToCve);
|
|
1895
|
+
}
|
|
1896
|
+
/** Delete a Cve by id. Returns true if a row was deleted. */
|
|
1897
|
+
delete(id) {
|
|
1898
|
+
const stmt = this.db.prepare("DELETE FROM cves WHERE id = ?");
|
|
1899
|
+
const result = stmt.run(id);
|
|
1900
|
+
return result.changes > 0;
|
|
1901
|
+
}
|
|
1902
|
+
};
|
|
1903
|
+
|
|
1904
|
+
// src/engine/normalizer.ts
|
|
1905
|
+
function normalize(db2, artifactId, parseResult) {
|
|
1906
|
+
const hostRepo = new HostRepository(db2);
|
|
1907
|
+
const serviceRepo = new ServiceRepository(db2);
|
|
1908
|
+
const serviceObsRepo = new ServiceObservationRepository(db2);
|
|
1909
|
+
const httpEndpointRepo = new HttpEndpointRepository(db2);
|
|
1910
|
+
const inputRepo = new InputRepository(db2);
|
|
1911
|
+
const endpointInputRepo = new EndpointInputRepository(db2);
|
|
1912
|
+
const observationRepo = new ObservationRepository(db2);
|
|
1913
|
+
const vulnRepo = new VulnerabilityRepository(db2);
|
|
1914
|
+
const cveRepo = new CveRepository(db2);
|
|
1915
|
+
const run = db2.transaction(() => {
|
|
1916
|
+
const result = {
|
|
1917
|
+
hostsCreated: 0,
|
|
1918
|
+
servicesCreated: 0,
|
|
1919
|
+
serviceObservationsCreated: 0,
|
|
1920
|
+
httpEndpointsCreated: 0,
|
|
1921
|
+
inputsCreated: 0,
|
|
1922
|
+
endpointInputsCreated: 0,
|
|
1923
|
+
observationsCreated: 0,
|
|
1924
|
+
vulnerabilitiesCreated: 0,
|
|
1925
|
+
cvesCreated: 0
|
|
1926
|
+
};
|
|
1927
|
+
const hostIdByAuthority = /* @__PURE__ */ new Map();
|
|
1928
|
+
const serviceIdByKey = /* @__PURE__ */ new Map();
|
|
1929
|
+
const endpointIdByKey = /* @__PURE__ */ new Map();
|
|
1930
|
+
const inputIdByKey = /* @__PURE__ */ new Map();
|
|
1931
|
+
const vulnIdByTitle = /* @__PURE__ */ new Map();
|
|
1932
|
+
for (const parsed of parseResult.hosts) {
|
|
1933
|
+
const existing = hostRepo.findByAuthority(parsed.authority);
|
|
1934
|
+
if (existing) {
|
|
1935
|
+
hostIdByAuthority.set(parsed.authority, existing.id);
|
|
1936
|
+
} else {
|
|
1937
|
+
const host = hostRepo.create({
|
|
1938
|
+
authorityKind: parsed.authorityKind,
|
|
1939
|
+
authority: parsed.authority,
|
|
1940
|
+
resolvedIpsJson: JSON.stringify(parsed.resolvedIps ?? [])
|
|
1941
|
+
});
|
|
1942
|
+
hostIdByAuthority.set(parsed.authority, host.id);
|
|
1943
|
+
result.hostsCreated++;
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
for (const parsed of parseResult.services) {
|
|
1947
|
+
const hostId = hostIdByAuthority.get(parsed.hostAuthority);
|
|
1948
|
+
if (!hostId) continue;
|
|
1949
|
+
const svcKey = `${hostId}:${parsed.transport}:${parsed.port}`;
|
|
1950
|
+
if (serviceIdByKey.has(svcKey)) continue;
|
|
1951
|
+
const existingServices = serviceRepo.findByHostId(hostId);
|
|
1952
|
+
const existing = existingServices.find(
|
|
1953
|
+
(s) => s.transport === parsed.transport && s.port === parsed.port
|
|
1954
|
+
);
|
|
1955
|
+
if (existing) {
|
|
1956
|
+
serviceIdByKey.set(svcKey, existing.id);
|
|
1957
|
+
} else {
|
|
1958
|
+
const service = serviceRepo.create({
|
|
1959
|
+
hostId,
|
|
1960
|
+
transport: parsed.transport,
|
|
1961
|
+
port: parsed.port,
|
|
1962
|
+
appProto: parsed.appProto,
|
|
1963
|
+
protoConfidence: parsed.protoConfidence,
|
|
1964
|
+
banner: parsed.banner,
|
|
1965
|
+
product: parsed.product,
|
|
1966
|
+
version: parsed.version,
|
|
1967
|
+
state: parsed.state,
|
|
1968
|
+
evidenceArtifactId: artifactId
|
|
1969
|
+
});
|
|
1970
|
+
serviceIdByKey.set(svcKey, service.id);
|
|
1971
|
+
result.servicesCreated++;
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
function resolveServiceId(hostAuthority, port) {
|
|
1975
|
+
const hostId = hostIdByAuthority.get(hostAuthority);
|
|
1976
|
+
if (!hostId) return void 0;
|
|
1977
|
+
return serviceIdByKey.get(`${hostId}:tcp:${port}`);
|
|
1978
|
+
}
|
|
1979
|
+
for (const parsed of parseResult.serviceObservations) {
|
|
1980
|
+
const hostId = hostIdByAuthority.get(parsed.hostAuthority);
|
|
1981
|
+
if (!hostId) continue;
|
|
1982
|
+
const svcKey = `${hostId}:${parsed.transport}:${parsed.port}`;
|
|
1983
|
+
const serviceId = serviceIdByKey.get(svcKey);
|
|
1984
|
+
if (!serviceId) continue;
|
|
1985
|
+
serviceObsRepo.create({
|
|
1986
|
+
serviceId,
|
|
1987
|
+
key: parsed.key,
|
|
1988
|
+
value: parsed.value,
|
|
1989
|
+
confidence: parsed.confidence,
|
|
1990
|
+
evidenceArtifactId: artifactId
|
|
1991
|
+
});
|
|
1992
|
+
result.serviceObservationsCreated++;
|
|
1993
|
+
}
|
|
1994
|
+
for (const parsed of parseResult.httpEndpoints) {
|
|
1995
|
+
const serviceId = resolveServiceId(parsed.hostAuthority, parsed.port);
|
|
1996
|
+
if (!serviceId) continue;
|
|
1997
|
+
const epKey = `${serviceId}:${parsed.method}:${parsed.path}`;
|
|
1998
|
+
if (endpointIdByKey.has(epKey)) continue;
|
|
1999
|
+
const existingEndpoints = httpEndpointRepo.findByServiceId(serviceId);
|
|
2000
|
+
const existing = existingEndpoints.find(
|
|
2001
|
+
(e) => e.method === parsed.method && e.path === parsed.path
|
|
2002
|
+
);
|
|
2003
|
+
if (existing) {
|
|
2004
|
+
endpointIdByKey.set(epKey, existing.id);
|
|
2005
|
+
} else {
|
|
2006
|
+
const endpoint = httpEndpointRepo.create({
|
|
2007
|
+
serviceId,
|
|
2008
|
+
baseUri: parsed.baseUri,
|
|
2009
|
+
method: parsed.method,
|
|
2010
|
+
path: parsed.path,
|
|
2011
|
+
statusCode: parsed.statusCode,
|
|
2012
|
+
contentLength: parsed.contentLength,
|
|
2013
|
+
words: parsed.words,
|
|
2014
|
+
lines: parsed.lines,
|
|
2015
|
+
evidenceArtifactId: artifactId
|
|
2016
|
+
});
|
|
2017
|
+
endpointIdByKey.set(epKey, endpoint.id);
|
|
2018
|
+
result.httpEndpointsCreated++;
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
for (const parsed of parseResult.inputs) {
|
|
2022
|
+
const serviceId = resolveServiceId(parsed.hostAuthority, parsed.port);
|
|
2023
|
+
if (!serviceId) continue;
|
|
2024
|
+
const inKey = `${serviceId}:${parsed.location}:${parsed.name}`;
|
|
2025
|
+
if (inputIdByKey.has(inKey)) continue;
|
|
2026
|
+
const existingInputs = inputRepo.findByServiceId(serviceId);
|
|
2027
|
+
const existing = existingInputs.find(
|
|
2028
|
+
(i) => i.location === parsed.location && i.name === parsed.name
|
|
2029
|
+
);
|
|
2030
|
+
if (existing) {
|
|
2031
|
+
inputIdByKey.set(inKey, existing.id);
|
|
2032
|
+
} else {
|
|
2033
|
+
const input = inputRepo.create({
|
|
2034
|
+
serviceId,
|
|
2035
|
+
location: parsed.location,
|
|
2036
|
+
name: parsed.name,
|
|
2037
|
+
typeHint: parsed.typeHint
|
|
2038
|
+
});
|
|
2039
|
+
inputIdByKey.set(inKey, input.id);
|
|
2040
|
+
result.inputsCreated++;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
for (const parsed of parseResult.endpointInputs) {
|
|
2044
|
+
const serviceId = resolveServiceId(parsed.hostAuthority, parsed.port);
|
|
2045
|
+
if (!serviceId) continue;
|
|
2046
|
+
const epKey = `${serviceId}:${parsed.method}:${parsed.path}`;
|
|
2047
|
+
const endpointId = endpointIdByKey.get(epKey);
|
|
2048
|
+
if (!endpointId) continue;
|
|
2049
|
+
const inKey = `${serviceId}:${parsed.inputLocation}:${parsed.inputName}`;
|
|
2050
|
+
const inputId = inputIdByKey.get(inKey);
|
|
2051
|
+
if (!inputId) continue;
|
|
2052
|
+
const existingLinks = endpointInputRepo.findByEndpointId(endpointId);
|
|
2053
|
+
const alreadyLinked = existingLinks.some((l) => l.inputId === inputId);
|
|
2054
|
+
if (alreadyLinked) continue;
|
|
2055
|
+
endpointInputRepo.create({
|
|
2056
|
+
endpointId,
|
|
2057
|
+
inputId,
|
|
2058
|
+
evidenceArtifactId: artifactId
|
|
2059
|
+
});
|
|
2060
|
+
result.endpointInputsCreated++;
|
|
2061
|
+
}
|
|
2062
|
+
for (const parsed of parseResult.observations) {
|
|
2063
|
+
const serviceId = resolveServiceId(parsed.hostAuthority, parsed.port);
|
|
2064
|
+
if (!serviceId) continue;
|
|
2065
|
+
const inKey = `${serviceId}:${parsed.inputLocation}:${parsed.inputName}`;
|
|
2066
|
+
const inputId = inputIdByKey.get(inKey);
|
|
2067
|
+
if (!inputId) continue;
|
|
2068
|
+
observationRepo.create({
|
|
2069
|
+
inputId,
|
|
2070
|
+
rawValue: parsed.rawValue,
|
|
2071
|
+
normValue: parsed.normValue,
|
|
2072
|
+
source: parsed.source,
|
|
2073
|
+
confidence: parsed.confidence,
|
|
2074
|
+
evidenceArtifactId: artifactId,
|
|
2075
|
+
observedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2076
|
+
});
|
|
2077
|
+
result.observationsCreated++;
|
|
2078
|
+
}
|
|
2079
|
+
for (const parsed of parseResult.vulnerabilities) {
|
|
2080
|
+
const serviceId = resolveServiceId(parsed.hostAuthority, parsed.port);
|
|
2081
|
+
if (!serviceId) continue;
|
|
2082
|
+
let endpointId;
|
|
2083
|
+
if (parsed.method && parsed.path) {
|
|
2084
|
+
const epKey = `${serviceId}:${parsed.method}:${parsed.path}`;
|
|
2085
|
+
endpointId = endpointIdByKey.get(epKey);
|
|
2086
|
+
}
|
|
2087
|
+
const vuln = vulnRepo.create({
|
|
2088
|
+
serviceId,
|
|
2089
|
+
endpointId,
|
|
2090
|
+
vulnType: parsed.vulnType,
|
|
2091
|
+
title: parsed.title,
|
|
2092
|
+
description: parsed.description,
|
|
2093
|
+
severity: parsed.severity,
|
|
2094
|
+
confidence: parsed.confidence,
|
|
2095
|
+
evidenceArtifactId: artifactId
|
|
2096
|
+
});
|
|
2097
|
+
vulnIdByTitle.set(parsed.title, vuln.id);
|
|
2098
|
+
result.vulnerabilitiesCreated++;
|
|
2099
|
+
}
|
|
2100
|
+
for (const parsed of parseResult.cves) {
|
|
2101
|
+
const vulnId = vulnIdByTitle.get(parsed.vulnerabilityTitle);
|
|
2102
|
+
if (!vulnId) continue;
|
|
2103
|
+
cveRepo.create({
|
|
2104
|
+
vulnerabilityId: vulnId,
|
|
2105
|
+
cveId: parsed.cveId,
|
|
2106
|
+
description: parsed.description,
|
|
2107
|
+
cvssScore: parsed.cvssScore,
|
|
2108
|
+
cvssVector: parsed.cvssVector,
|
|
2109
|
+
referenceUrl: parsed.referenceUrl
|
|
2110
|
+
});
|
|
2111
|
+
result.cvesCreated++;
|
|
2112
|
+
}
|
|
2113
|
+
return result;
|
|
2114
|
+
});
|
|
2115
|
+
return run();
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
// src/engine/ingest.ts
|
|
2119
|
+
function ingestContent(db2, tool, content, filePath) {
|
|
2120
|
+
const sha256 = crypto13.createHash("sha256").update(content).digest("hex");
|
|
2121
|
+
const artifactRepo = new ArtifactRepository(db2);
|
|
2122
|
+
const artifact = artifactRepo.create({
|
|
2123
|
+
tool,
|
|
2124
|
+
kind: "tool_output",
|
|
2125
|
+
path: filePath,
|
|
2126
|
+
sha256,
|
|
2127
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2128
|
+
});
|
|
2129
|
+
let parseResult;
|
|
2130
|
+
switch (tool) {
|
|
2131
|
+
case "nmap":
|
|
2132
|
+
parseResult = parseNmapXml(content);
|
|
2133
|
+
break;
|
|
2134
|
+
case "ffuf":
|
|
2135
|
+
parseResult = parseFfufJson(content);
|
|
2136
|
+
break;
|
|
2137
|
+
case "nuclei":
|
|
2138
|
+
parseResult = parseNucleiJsonl(content);
|
|
2139
|
+
break;
|
|
2140
|
+
default: {
|
|
2141
|
+
const _exhaustive = tool;
|
|
2142
|
+
throw new Error(`Unknown tool: ${String(_exhaustive)}`);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
const normalizeResult = normalize(db2, artifact.id, parseResult);
|
|
2146
|
+
return {
|
|
2147
|
+
artifactId: artifact.id,
|
|
2148
|
+
normalizeResult
|
|
2149
|
+
};
|
|
2150
|
+
}
|
|
2151
|
+
function ingest(db2, input) {
|
|
2152
|
+
const resolved = path.resolve(input.path);
|
|
2153
|
+
const content = fs.readFileSync(resolved, "utf-8");
|
|
2154
|
+
return ingestContent(db2, input.tool, content, resolved);
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// src/mcp/tools/ingest.ts
|
|
2158
|
+
function registerIngestTool(server2, db2) {
|
|
2159
|
+
server2.tool(
|
|
2160
|
+
"ingest_file",
|
|
2161
|
+
"Ingest a tool output file (nmap XML, ffuf JSON, nuclei JSONL) into the AttackDataGraph",
|
|
2162
|
+
{
|
|
2163
|
+
path: z2.string().describe("Absolute path to the tool output file"),
|
|
2164
|
+
tool: z2.enum(["nmap", "ffuf", "nuclei"]).describe("Tool that produced the output")
|
|
2165
|
+
},
|
|
2166
|
+
async ({ path: path2, tool }) => {
|
|
2167
|
+
try {
|
|
2168
|
+
const result = ingest(db2, { path: path2, tool });
|
|
2169
|
+
const nr = result.normalizeResult;
|
|
2170
|
+
const summary = [
|
|
2171
|
+
`Ingested ${tool} output from ${path2}`,
|
|
2172
|
+
`Artifact ID: ${result.artifactId}`,
|
|
2173
|
+
`Created: ${nr.hostsCreated} hosts, ${nr.servicesCreated} services, ${nr.httpEndpointsCreated} endpoints, ${nr.inputsCreated} inputs, ${nr.observationsCreated} observations, ${nr.vulnerabilitiesCreated} vulnerabilities, ${nr.cvesCreated} CVEs`
|
|
2174
|
+
].join("\n");
|
|
2175
|
+
return { content: [{ type: "text", text: summary }] };
|
|
2176
|
+
} catch (err) {
|
|
2177
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2178
|
+
return { content: [{ type: "text", text: `Ingest failed: ${message}` }], isError: true };
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
);
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
// src/mcp/tools/propose.ts
|
|
2185
|
+
import { z as z3 } from "zod";
|
|
2186
|
+
|
|
2187
|
+
// src/engine/proposer.ts
|
|
2188
|
+
function propose(db2, hostId) {
|
|
2189
|
+
const hostRepo = new HostRepository(db2);
|
|
2190
|
+
const serviceRepo = new ServiceRepository(db2);
|
|
2191
|
+
const httpEndpointRepo = new HttpEndpointRepository(db2);
|
|
2192
|
+
const inputRepo = new InputRepository(db2);
|
|
2193
|
+
const observationRepo = new ObservationRepository(db2);
|
|
2194
|
+
const vhostRepo = new VhostRepository(db2);
|
|
2195
|
+
const vulnRepo = new VulnerabilityRepository(db2);
|
|
2196
|
+
const actions = [];
|
|
2197
|
+
let hosts;
|
|
2198
|
+
if (hostId !== void 0) {
|
|
2199
|
+
const host = hostRepo.findById(hostId);
|
|
2200
|
+
if (host === void 0) {
|
|
2201
|
+
return [];
|
|
2202
|
+
}
|
|
2203
|
+
hosts = [host];
|
|
2204
|
+
} else {
|
|
2205
|
+
hosts = hostRepo.findAll();
|
|
2206
|
+
}
|
|
2207
|
+
for (const host of hosts) {
|
|
2208
|
+
const services = serviceRepo.findByHostId(host.id);
|
|
2209
|
+
if (services.length === 0) {
|
|
2210
|
+
actions.push({
|
|
2211
|
+
kind: "nmap_scan",
|
|
2212
|
+
description: `Port scan ${host.authority} to discover services`,
|
|
2213
|
+
command: `nmap -p- -sV ${host.authority}`,
|
|
2214
|
+
params: { hostId: host.id }
|
|
2215
|
+
});
|
|
2216
|
+
continue;
|
|
2217
|
+
}
|
|
2218
|
+
for (const service of services) {
|
|
2219
|
+
if (service.appProto !== "http" && service.appProto !== "https") {
|
|
2220
|
+
continue;
|
|
2221
|
+
}
|
|
2222
|
+
const baseUri = `${service.appProto}://${host.authority}:${service.port}`;
|
|
2223
|
+
proposeForHttpService(
|
|
2224
|
+
actions,
|
|
2225
|
+
host,
|
|
2226
|
+
service,
|
|
2227
|
+
baseUri,
|
|
2228
|
+
httpEndpointRepo,
|
|
2229
|
+
inputRepo,
|
|
2230
|
+
observationRepo,
|
|
2231
|
+
vhostRepo,
|
|
2232
|
+
vulnRepo
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
}
|
|
2236
|
+
return actions;
|
|
2237
|
+
}
|
|
2238
|
+
function proposeForHttpService(actions, host, service, baseUri, httpEndpointRepo, inputRepo, observationRepo, vhostRepo, vulnRepo) {
|
|
2239
|
+
const endpoints = httpEndpointRepo.findByServiceId(service.id);
|
|
2240
|
+
if (endpoints.length === 0) {
|
|
2241
|
+
actions.push({
|
|
2242
|
+
kind: "ffuf_discovery",
|
|
2243
|
+
description: `Discover endpoints on ${baseUri}`,
|
|
2244
|
+
command: `ffuf -u ${baseUri}/FUZZ -w /usr/share/wordlists/dirb/common.txt`,
|
|
2245
|
+
params: { hostId: host.id, serviceId: service.id }
|
|
2246
|
+
});
|
|
2247
|
+
}
|
|
2248
|
+
for (const endpoint of endpoints) {
|
|
2249
|
+
const inputs = inputRepo.findByServiceId(service.id);
|
|
2250
|
+
if (inputs.length === 0) {
|
|
2251
|
+
actions.push({
|
|
2252
|
+
kind: "parameter_discovery",
|
|
2253
|
+
description: `Discover input parameters for ${baseUri}${endpoint.path}`,
|
|
2254
|
+
params: { hostId: host.id, serviceId: service.id, endpointId: endpoint.id }
|
|
2255
|
+
});
|
|
2256
|
+
}
|
|
2257
|
+
for (const input of inputs) {
|
|
2258
|
+
const observations = observationRepo.findByInputId(input.id);
|
|
2259
|
+
if (observations.length === 0) {
|
|
2260
|
+
actions.push({
|
|
2261
|
+
kind: "value_collection",
|
|
2262
|
+
description: `Collect observed values for input "${input.name}" (${input.location})`,
|
|
2263
|
+
params: {
|
|
2264
|
+
hostId: host.id,
|
|
2265
|
+
serviceId: service.id,
|
|
2266
|
+
endpointId: endpoint.id,
|
|
2267
|
+
inputId: input.id
|
|
2268
|
+
}
|
|
2269
|
+
});
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
const vhosts = vhostRepo.findByHostId(host.id);
|
|
2274
|
+
if (vhosts.length === 0) {
|
|
2275
|
+
actions.push({
|
|
2276
|
+
kind: "vhost_discovery",
|
|
2277
|
+
description: `Discover virtual hosts for ${host.authority}`,
|
|
2278
|
+
params: { hostId: host.id, serviceId: service.id }
|
|
2279
|
+
});
|
|
2280
|
+
}
|
|
2281
|
+
const vulns = vulnRepo.findByServiceId(service.id);
|
|
2282
|
+
if (vulns.length === 0) {
|
|
2283
|
+
actions.push({
|
|
2284
|
+
kind: "nuclei_scan",
|
|
2285
|
+
description: `Scan ${baseUri} for known vulnerabilities`,
|
|
2286
|
+
command: `nuclei -u ${baseUri} -jsonl`,
|
|
2287
|
+
params: { hostId: host.id, serviceId: service.id }
|
|
2288
|
+
});
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
// src/mcp/tools/propose.ts
|
|
2293
|
+
function registerProposeTool(server2, db2) {
|
|
2294
|
+
server2.tool(
|
|
2295
|
+
"propose",
|
|
2296
|
+
"Analyze the AttackDataGraph for missing data and propose next-step actions",
|
|
2297
|
+
{
|
|
2298
|
+
hostId: z3.string().optional().describe("Limit proposals to a specific host (optional)")
|
|
2299
|
+
},
|
|
2300
|
+
async ({ hostId }) => {
|
|
2301
|
+
const actions = propose(db2, hostId);
|
|
2302
|
+
if (actions.length === 0) {
|
|
2303
|
+
return {
|
|
2304
|
+
content: [{ type: "text", text: "No actions proposed. All discovered data appears complete." }]
|
|
2305
|
+
};
|
|
2306
|
+
}
|
|
2307
|
+
return { content: [{ type: "text", text: JSON.stringify(actions, null, 2) }] };
|
|
2308
|
+
}
|
|
2309
|
+
);
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
// src/mcp/tools/mutation.ts
|
|
2313
|
+
import { z as z4 } from "zod";
|
|
2314
|
+
function getOrCreateManualArtifact(db2) {
|
|
2315
|
+
const artifactRepo = new ArtifactRepository(db2);
|
|
2316
|
+
const existing = artifactRepo.findByTool("manual");
|
|
2317
|
+
if (existing.length > 0) {
|
|
2318
|
+
return existing[0].id;
|
|
2319
|
+
}
|
|
2320
|
+
const artifact = artifactRepo.create({
|
|
2321
|
+
tool: "manual",
|
|
2322
|
+
kind: "manual_entry",
|
|
2323
|
+
path: "manual",
|
|
2324
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2325
|
+
});
|
|
2326
|
+
return artifact.id;
|
|
2327
|
+
}
|
|
2328
|
+
function registerMutationTools(server2, db2) {
|
|
2329
|
+
server2.tool(
|
|
2330
|
+
"add_host",
|
|
2331
|
+
"Manually add a host to the AttackDataGraph",
|
|
2332
|
+
{
|
|
2333
|
+
authority: z4.string().describe("IP address or domain name"),
|
|
2334
|
+
authorityKind: z4.enum(["IP", "DOMAIN"]).describe("Type of authority")
|
|
2335
|
+
},
|
|
2336
|
+
async ({ authority, authorityKind }) => {
|
|
2337
|
+
const hostRepo = new HostRepository(db2);
|
|
2338
|
+
const existing = hostRepo.findByAuthority(authority);
|
|
2339
|
+
if (existing) {
|
|
2340
|
+
return {
|
|
2341
|
+
content: [{ type: "text", text: `Host already exists: ${JSON.stringify(existing, null, 2)}` }]
|
|
2342
|
+
};
|
|
2343
|
+
}
|
|
2344
|
+
const host = hostRepo.create({
|
|
2345
|
+
authorityKind,
|
|
2346
|
+
authority,
|
|
2347
|
+
resolvedIpsJson: "[]"
|
|
2348
|
+
});
|
|
2349
|
+
return { content: [{ type: "text", text: JSON.stringify(host, null, 2) }] };
|
|
2350
|
+
}
|
|
2351
|
+
);
|
|
2352
|
+
server2.tool(
|
|
2353
|
+
"add_credential",
|
|
2354
|
+
"Manually add a credential for a service",
|
|
2355
|
+
{
|
|
2356
|
+
serviceId: z4.string().describe("Service UUID"),
|
|
2357
|
+
username: z4.string().describe("Username"),
|
|
2358
|
+
secret: z4.string().describe("Secret value (password, token, etc.)"),
|
|
2359
|
+
secretType: z4.enum(["password", "token", "api_key", "ssh_key"]).describe("Type of secret"),
|
|
2360
|
+
source: z4.enum(["brute_force", "default", "leaked", "manual"]).describe("How the credential was obtained"),
|
|
2361
|
+
confidence: z4.enum(["high", "medium", "low"]).describe("Confidence level").default("medium")
|
|
2362
|
+
},
|
|
2363
|
+
async ({ serviceId, username, secret, secretType, source, confidence }) => {
|
|
2364
|
+
const artifactId = getOrCreateManualArtifact(db2);
|
|
2365
|
+
const credentialRepo = new CredentialRepository(db2);
|
|
2366
|
+
const credential = credentialRepo.create({
|
|
2367
|
+
serviceId,
|
|
2368
|
+
username,
|
|
2369
|
+
secret,
|
|
2370
|
+
secretType,
|
|
2371
|
+
source,
|
|
2372
|
+
confidence,
|
|
2373
|
+
evidenceArtifactId: artifactId
|
|
2374
|
+
});
|
|
2375
|
+
return { content: [{ type: "text", text: JSON.stringify(credential, null, 2) }] };
|
|
2376
|
+
}
|
|
2377
|
+
);
|
|
2378
|
+
server2.tool(
|
|
2379
|
+
"add_vulnerability",
|
|
2380
|
+
"Manually add a vulnerability for a service",
|
|
2381
|
+
{
|
|
2382
|
+
serviceId: z4.string().describe("Service UUID"),
|
|
2383
|
+
vulnType: z4.string().describe("Vulnerability type (sqli, xss, rce, lfi, ssrf, etc.)"),
|
|
2384
|
+
title: z4.string().describe("Vulnerability title"),
|
|
2385
|
+
severity: z4.enum(["critical", "high", "medium", "low", "info"]).describe("Severity level"),
|
|
2386
|
+
confidence: z4.enum(["high", "medium", "low"]).describe("Confidence level").default("medium"),
|
|
2387
|
+
endpointId: z4.string().optional().describe("HTTP endpoint UUID (optional)"),
|
|
2388
|
+
description: z4.string().optional().describe("Detailed description (optional)")
|
|
2389
|
+
},
|
|
2390
|
+
async ({ serviceId, vulnType, title, severity, confidence, endpointId, description }) => {
|
|
2391
|
+
const artifactId = getOrCreateManualArtifact(db2);
|
|
2392
|
+
const vulnRepo = new VulnerabilityRepository(db2);
|
|
2393
|
+
const vuln = vulnRepo.create({
|
|
2394
|
+
serviceId,
|
|
2395
|
+
endpointId,
|
|
2396
|
+
vulnType,
|
|
2397
|
+
title,
|
|
2398
|
+
description,
|
|
2399
|
+
severity,
|
|
2400
|
+
confidence,
|
|
2401
|
+
evidenceArtifactId: artifactId
|
|
2402
|
+
});
|
|
2403
|
+
return { content: [{ type: "text", text: JSON.stringify(vuln, null, 2) }] };
|
|
2404
|
+
}
|
|
2405
|
+
);
|
|
2406
|
+
server2.tool(
|
|
2407
|
+
"link_cve",
|
|
2408
|
+
"Link a CVE record to an existing vulnerability",
|
|
2409
|
+
{
|
|
2410
|
+
vulnerabilityId: z4.string().describe("Vulnerability UUID"),
|
|
2411
|
+
cveId: z4.string().describe("CVE identifier (e.g. CVE-2021-44228)"),
|
|
2412
|
+
description: z4.string().optional().describe("CVE description"),
|
|
2413
|
+
cvssScore: z4.number().optional().describe("CVSS score (0.0 - 10.0)"),
|
|
2414
|
+
cvssVector: z4.string().optional().describe("CVSS vector string"),
|
|
2415
|
+
referenceUrl: z4.string().optional().describe("Reference URL")
|
|
2416
|
+
},
|
|
2417
|
+
async ({ vulnerabilityId, cveId, description, cvssScore, cvssVector, referenceUrl }) => {
|
|
2418
|
+
const cveRepo = new CveRepository(db2);
|
|
2419
|
+
const cve = cveRepo.create({
|
|
2420
|
+
vulnerabilityId,
|
|
2421
|
+
cveId,
|
|
2422
|
+
description,
|
|
2423
|
+
cvssScore,
|
|
2424
|
+
cvssVector,
|
|
2425
|
+
referenceUrl
|
|
2426
|
+
});
|
|
2427
|
+
return { content: [{ type: "text", text: JSON.stringify(cve, null, 2) }] };
|
|
2428
|
+
}
|
|
2429
|
+
);
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// src/mcp/resources.ts
|
|
2433
|
+
function registerResources(server2, db2) {
|
|
2434
|
+
const hostRepo = new HostRepository(db2);
|
|
2435
|
+
const serviceRepo = new ServiceRepository(db2);
|
|
2436
|
+
const vhostRepo = new VhostRepository(db2);
|
|
2437
|
+
const httpEndpointRepo = new HttpEndpointRepository(db2);
|
|
2438
|
+
const inputRepo = new InputRepository(db2);
|
|
2439
|
+
const vulnRepo = new VulnerabilityRepository(db2);
|
|
2440
|
+
server2.resource("hosts", "sonobat://hosts", { description: "List of all discovered hosts" }, async () => {
|
|
2441
|
+
const hosts = hostRepo.findAll();
|
|
2442
|
+
return {
|
|
2443
|
+
contents: [
|
|
2444
|
+
{
|
|
2445
|
+
uri: "sonobat://hosts",
|
|
2446
|
+
mimeType: "application/json",
|
|
2447
|
+
text: JSON.stringify(hosts, null, 2)
|
|
2448
|
+
}
|
|
2449
|
+
]
|
|
2450
|
+
};
|
|
2451
|
+
});
|
|
2452
|
+
server2.resource(
|
|
2453
|
+
"host-detail",
|
|
2454
|
+
"sonobat://hosts/{id}",
|
|
2455
|
+
{ description: "Detailed host tree with services, endpoints, inputs, and vulnerabilities" },
|
|
2456
|
+
async (uri) => {
|
|
2457
|
+
const hostId = uri.pathname.split("/").pop() ?? "";
|
|
2458
|
+
const host = hostRepo.findById(hostId);
|
|
2459
|
+
if (!host) {
|
|
2460
|
+
return {
|
|
2461
|
+
contents: [
|
|
2462
|
+
{
|
|
2463
|
+
uri: uri.href,
|
|
2464
|
+
mimeType: "application/json",
|
|
2465
|
+
text: JSON.stringify({ error: `Host not found: ${hostId}` })
|
|
2466
|
+
}
|
|
2467
|
+
]
|
|
2468
|
+
};
|
|
2469
|
+
}
|
|
2470
|
+
const services = serviceRepo.findByHostId(hostId);
|
|
2471
|
+
const vhosts = vhostRepo.findByHostId(hostId);
|
|
2472
|
+
const serviceTree = services.map((service) => {
|
|
2473
|
+
const endpoints = httpEndpointRepo.findByServiceId(service.id);
|
|
2474
|
+
const inputs = inputRepo.findByServiceId(service.id);
|
|
2475
|
+
const vulnerabilities = vulnRepo.findByServiceId(service.id);
|
|
2476
|
+
return { ...service, endpoints, inputs, vulnerabilities };
|
|
2477
|
+
});
|
|
2478
|
+
const result = { ...host, services: serviceTree, vhosts };
|
|
2479
|
+
return {
|
|
2480
|
+
contents: [
|
|
2481
|
+
{
|
|
2482
|
+
uri: uri.href,
|
|
2483
|
+
mimeType: "application/json",
|
|
2484
|
+
text: JSON.stringify(result, null, 2)
|
|
2485
|
+
}
|
|
2486
|
+
]
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
);
|
|
2490
|
+
server2.resource(
|
|
2491
|
+
"summary",
|
|
2492
|
+
"sonobat://summary",
|
|
2493
|
+
{ description: "Summary statistics of the AttackDataGraph" },
|
|
2494
|
+
async () => {
|
|
2495
|
+
const counts = {};
|
|
2496
|
+
const tables = [
|
|
2497
|
+
"hosts",
|
|
2498
|
+
"services",
|
|
2499
|
+
"http_endpoints",
|
|
2500
|
+
"inputs",
|
|
2501
|
+
"observations",
|
|
2502
|
+
"credentials",
|
|
2503
|
+
"vulnerabilities",
|
|
2504
|
+
"cves",
|
|
2505
|
+
"vhosts",
|
|
2506
|
+
"artifacts"
|
|
2507
|
+
];
|
|
2508
|
+
for (const table of tables) {
|
|
2509
|
+
const row = db2.prepare(`SELECT COUNT(*) as count FROM ${table}`).get();
|
|
2510
|
+
counts[table] = row.count;
|
|
2511
|
+
}
|
|
2512
|
+
return {
|
|
2513
|
+
contents: [
|
|
2514
|
+
{
|
|
2515
|
+
uri: "sonobat://summary",
|
|
2516
|
+
mimeType: "application/json",
|
|
2517
|
+
text: JSON.stringify(counts, null, 2)
|
|
2518
|
+
}
|
|
2519
|
+
]
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
);
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// src/mcp/server.ts
|
|
2526
|
+
function createMcpServer(db2) {
|
|
2527
|
+
const server2 = new McpServer({
|
|
2528
|
+
name: "sonobat",
|
|
2529
|
+
version: "0.1.0"
|
|
2530
|
+
});
|
|
2531
|
+
registerQueryTools(server2, db2);
|
|
2532
|
+
registerIngestTool(server2, db2);
|
|
2533
|
+
registerProposeTool(server2, db2);
|
|
2534
|
+
registerMutationTools(server2, db2);
|
|
2535
|
+
registerResources(server2, db2);
|
|
2536
|
+
return server2;
|
|
2537
|
+
}
|
|
2538
|
+
|
|
2539
|
+
// src/index.ts
|
|
2540
|
+
var DB_PATH = process.env["SONOBAT_DB_PATH"] ?? "sonobat.db";
|
|
2541
|
+
var db = new Database(DB_PATH);
|
|
2542
|
+
migrateDatabase(db);
|
|
2543
|
+
var server = createMcpServer(db);
|
|
2544
|
+
var transport = new StdioServerTransport();
|
|
2545
|
+
await server.connect(transport);
|
|
2546
|
+
//# sourceMappingURL=index.js.map
|