sonobat 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -3
- package/dist/index.js +1256 -8
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -245,6 +245,22 @@ CREATE TABLE IF NOT EXISTS cves (
|
|
|
245
245
|
|
|
246
246
|
CREATE INDEX IF NOT EXISTS idx_cves_vuln ON cves(vulnerability_id);
|
|
247
247
|
CREATE INDEX IF NOT EXISTS idx_cves_cveid ON cves(cve_id);
|
|
248
|
+
|
|
249
|
+
-- ============================================================
|
|
250
|
+
-- Datalog \u30EB\u30FC\u30EB\u4FDD\u5B58
|
|
251
|
+
-- ============================================================
|
|
252
|
+
CREATE TABLE IF NOT EXISTS datalog_rules (
|
|
253
|
+
id TEXT PRIMARY KEY,
|
|
254
|
+
name TEXT NOT NULL UNIQUE,
|
|
255
|
+
description TEXT,
|
|
256
|
+
rule_text TEXT NOT NULL,
|
|
257
|
+
generated_by TEXT NOT NULL, -- "human" | "ai" | "preset"
|
|
258
|
+
is_preset INTEGER NOT NULL DEFAULT 0,
|
|
259
|
+
created_at TEXT NOT NULL,
|
|
260
|
+
updated_at TEXT NOT NULL
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
CREATE INDEX IF NOT EXISTS idx_datalog_rules_name ON datalog_rules(name);
|
|
248
264
|
`;
|
|
249
265
|
|
|
250
266
|
// src/db/migrate.ts
|
|
@@ -1667,8 +1683,8 @@ function processFinding(finding, result, seenHosts, seenServices) {
|
|
|
1667
1683
|
const cve = {
|
|
1668
1684
|
vulnerabilityTitle: info.name,
|
|
1669
1685
|
cveId,
|
|
1670
|
-
cvssScore: classification["cvss-score"],
|
|
1671
|
-
cvssVector: classification["cvss-metrics"]
|
|
1686
|
+
cvssScore: typeof classification["cvss-score"] === "number" ? classification["cvss-score"] : void 0,
|
|
1687
|
+
cvssVector: typeof classification["cvss-metrics"] === "string" ? classification["cvss-metrics"] : void 0
|
|
1672
1688
|
};
|
|
1673
1689
|
result.cves.push(cve);
|
|
1674
1690
|
}
|
|
@@ -2190,6 +2206,7 @@ function propose(db2, hostId) {
|
|
|
2190
2206
|
const serviceRepo = new ServiceRepository(db2);
|
|
2191
2207
|
const httpEndpointRepo = new HttpEndpointRepository(db2);
|
|
2192
2208
|
const inputRepo = new InputRepository(db2);
|
|
2209
|
+
const endpointInputRepo = new EndpointInputRepository(db2);
|
|
2193
2210
|
const observationRepo = new ObservationRepository(db2);
|
|
2194
2211
|
const vhostRepo = new VhostRepository(db2);
|
|
2195
2212
|
const vulnRepo = new VulnerabilityRepository(db2);
|
|
@@ -2227,6 +2244,7 @@ function propose(db2, hostId) {
|
|
|
2227
2244
|
baseUri,
|
|
2228
2245
|
httpEndpointRepo,
|
|
2229
2246
|
inputRepo,
|
|
2247
|
+
endpointInputRepo,
|
|
2230
2248
|
observationRepo,
|
|
2231
2249
|
vhostRepo,
|
|
2232
2250
|
vulnRepo
|
|
@@ -2235,7 +2253,7 @@ function propose(db2, hostId) {
|
|
|
2235
2253
|
}
|
|
2236
2254
|
return actions;
|
|
2237
2255
|
}
|
|
2238
|
-
function proposeForHttpService(actions, host, service, baseUri, httpEndpointRepo, inputRepo, observationRepo, vhostRepo, vulnRepo) {
|
|
2256
|
+
function proposeForHttpService(actions, host, service, baseUri, httpEndpointRepo, inputRepo, endpointInputRepo, observationRepo, vhostRepo, vulnRepo) {
|
|
2239
2257
|
const endpoints = httpEndpointRepo.findByServiceId(service.id);
|
|
2240
2258
|
if (endpoints.length === 0) {
|
|
2241
2259
|
actions.push({
|
|
@@ -2245,16 +2263,21 @@ function proposeForHttpService(actions, host, service, baseUri, httpEndpointRepo
|
|
|
2245
2263
|
params: { hostId: host.id, serviceId: service.id }
|
|
2246
2264
|
});
|
|
2247
2265
|
}
|
|
2266
|
+
const vulns = vulnRepo.findByServiceId(service.id);
|
|
2248
2267
|
for (const endpoint of endpoints) {
|
|
2249
|
-
const
|
|
2250
|
-
if (
|
|
2268
|
+
const endpointInputs = endpointInputRepo.findByEndpointId(endpoint.id);
|
|
2269
|
+
if (endpointInputs.length === 0) {
|
|
2251
2270
|
actions.push({
|
|
2252
2271
|
kind: "parameter_discovery",
|
|
2253
2272
|
description: `Discover input parameters for ${baseUri}${endpoint.path}`,
|
|
2254
2273
|
params: { hostId: host.id, serviceId: service.id, endpointId: endpoint.id }
|
|
2255
2274
|
});
|
|
2256
2275
|
}
|
|
2257
|
-
for (const
|
|
2276
|
+
for (const ei of endpointInputs) {
|
|
2277
|
+
const input = inputRepo.findById(ei.inputId);
|
|
2278
|
+
if (input === void 0) {
|
|
2279
|
+
continue;
|
|
2280
|
+
}
|
|
2258
2281
|
const observations = observationRepo.findByInputId(input.id);
|
|
2259
2282
|
if (observations.length === 0) {
|
|
2260
2283
|
actions.push({
|
|
@@ -2267,6 +2290,17 @@ function proposeForHttpService(actions, host, service, baseUri, httpEndpointRepo
|
|
|
2267
2290
|
inputId: input.id
|
|
2268
2291
|
}
|
|
2269
2292
|
});
|
|
2293
|
+
} else if (vulns.length === 0) {
|
|
2294
|
+
actions.push({
|
|
2295
|
+
kind: "value_fuzz",
|
|
2296
|
+
description: `Fuzz input "${input.name}" (${input.location}) on ${baseUri}${endpoint.path}`,
|
|
2297
|
+
params: {
|
|
2298
|
+
hostId: host.id,
|
|
2299
|
+
serviceId: service.id,
|
|
2300
|
+
endpointId: endpoint.id,
|
|
2301
|
+
inputId: input.id
|
|
2302
|
+
}
|
|
2303
|
+
});
|
|
2270
2304
|
}
|
|
2271
2305
|
}
|
|
2272
2306
|
}
|
|
@@ -2278,7 +2312,6 @@ function proposeForHttpService(actions, host, service, baseUri, httpEndpointRepo
|
|
|
2278
2312
|
params: { hostId: host.id, serviceId: service.id }
|
|
2279
2313
|
});
|
|
2280
2314
|
}
|
|
2281
|
-
const vulns = vulnRepo.findByServiceId(service.id);
|
|
2282
2315
|
if (vulns.length === 0) {
|
|
2283
2316
|
actions.push({
|
|
2284
2317
|
kind: "nuclei_scan",
|
|
@@ -2429,6 +2462,1220 @@ function registerMutationTools(server2, db2) {
|
|
|
2429
2462
|
);
|
|
2430
2463
|
}
|
|
2431
2464
|
|
|
2465
|
+
// src/mcp/tools/datalog.ts
|
|
2466
|
+
import { z as z5 } from "zod";
|
|
2467
|
+
|
|
2468
|
+
// src/engine/datalog/types.ts
|
|
2469
|
+
var DEFAULT_EVAL_CONFIG = {
|
|
2470
|
+
maxIterations: 1e3,
|
|
2471
|
+
maxTuples: 1e5,
|
|
2472
|
+
maxRules: 200,
|
|
2473
|
+
timeoutMs: 5e3
|
|
2474
|
+
};
|
|
2475
|
+
var DatalogError = class extends Error {
|
|
2476
|
+
constructor(message) {
|
|
2477
|
+
super(message);
|
|
2478
|
+
this.name = "DatalogError";
|
|
2479
|
+
}
|
|
2480
|
+
};
|
|
2481
|
+
var DatalogSyntaxError = class extends DatalogError {
|
|
2482
|
+
line;
|
|
2483
|
+
col;
|
|
2484
|
+
constructor(message, line, col) {
|
|
2485
|
+
super(`Syntax error at ${line}:${col}: ${message}`);
|
|
2486
|
+
this.name = "DatalogSyntaxError";
|
|
2487
|
+
this.line = line;
|
|
2488
|
+
this.col = col;
|
|
2489
|
+
}
|
|
2490
|
+
};
|
|
2491
|
+
var DatalogSafetyError = class extends DatalogError {
|
|
2492
|
+
constructor(message) {
|
|
2493
|
+
super(message);
|
|
2494
|
+
this.name = "DatalogSafetyError";
|
|
2495
|
+
}
|
|
2496
|
+
};
|
|
2497
|
+
var DatalogResourceError = class extends DatalogError {
|
|
2498
|
+
constructor(message) {
|
|
2499
|
+
super(message);
|
|
2500
|
+
this.name = "DatalogResourceError";
|
|
2501
|
+
}
|
|
2502
|
+
};
|
|
2503
|
+
|
|
2504
|
+
// src/engine/datalog/tokenizer.ts
|
|
2505
|
+
function tokenize(source) {
|
|
2506
|
+
const tokens = [];
|
|
2507
|
+
let pos = 0;
|
|
2508
|
+
let line = 1;
|
|
2509
|
+
let col = 1;
|
|
2510
|
+
while (pos < source.length) {
|
|
2511
|
+
const ch = source[pos];
|
|
2512
|
+
if (ch === " " || ch === " " || ch === "\r") {
|
|
2513
|
+
pos++;
|
|
2514
|
+
col++;
|
|
2515
|
+
continue;
|
|
2516
|
+
}
|
|
2517
|
+
if (ch === "\n") {
|
|
2518
|
+
pos++;
|
|
2519
|
+
line++;
|
|
2520
|
+
col = 1;
|
|
2521
|
+
continue;
|
|
2522
|
+
}
|
|
2523
|
+
if (ch === "%") {
|
|
2524
|
+
while (pos < source.length && source[pos] !== "\n") {
|
|
2525
|
+
pos++;
|
|
2526
|
+
}
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
if (ch === '"') {
|
|
2530
|
+
const startCol2 = col;
|
|
2531
|
+
pos++;
|
|
2532
|
+
col++;
|
|
2533
|
+
let value = "";
|
|
2534
|
+
while (pos < source.length && source[pos] !== '"') {
|
|
2535
|
+
if (source[pos] === "\n") {
|
|
2536
|
+
throw new DatalogSyntaxError("Unterminated string literal", line, startCol2);
|
|
2537
|
+
}
|
|
2538
|
+
if (source[pos] === "\\" && pos + 1 < source.length) {
|
|
2539
|
+
pos++;
|
|
2540
|
+
col++;
|
|
2541
|
+
const escaped = source[pos];
|
|
2542
|
+
if (escaped === '"') value += '"';
|
|
2543
|
+
else if (escaped === "\\") value += "\\";
|
|
2544
|
+
else if (escaped === "n") value += "\n";
|
|
2545
|
+
else if (escaped === "t") value += " ";
|
|
2546
|
+
else value += escaped;
|
|
2547
|
+
} else {
|
|
2548
|
+
value += source[pos];
|
|
2549
|
+
}
|
|
2550
|
+
pos++;
|
|
2551
|
+
col++;
|
|
2552
|
+
}
|
|
2553
|
+
if (pos >= source.length) {
|
|
2554
|
+
throw new DatalogSyntaxError("Unterminated string literal", line, startCol2);
|
|
2555
|
+
}
|
|
2556
|
+
pos++;
|
|
2557
|
+
col++;
|
|
2558
|
+
tokens.push({ kind: "STRING", value, line, col: startCol2 });
|
|
2559
|
+
continue;
|
|
2560
|
+
}
|
|
2561
|
+
if (ch >= "0" && ch <= "9") {
|
|
2562
|
+
const startCol2 = col;
|
|
2563
|
+
let value = "";
|
|
2564
|
+
while (pos < source.length && source[pos] >= "0" && source[pos] <= "9") {
|
|
2565
|
+
value += source[pos];
|
|
2566
|
+
pos++;
|
|
2567
|
+
col++;
|
|
2568
|
+
}
|
|
2569
|
+
if (pos < source.length && source[pos] === "." && pos + 1 < source.length && source[pos + 1] >= "0" && source[pos + 1] <= "9") {
|
|
2570
|
+
value += ".";
|
|
2571
|
+
pos++;
|
|
2572
|
+
col++;
|
|
2573
|
+
while (pos < source.length && source[pos] >= "0" && source[pos] <= "9") {
|
|
2574
|
+
value += source[pos];
|
|
2575
|
+
pos++;
|
|
2576
|
+
col++;
|
|
2577
|
+
}
|
|
2578
|
+
}
|
|
2579
|
+
tokens.push({ kind: "NUMBER", value, line, col: startCol2 });
|
|
2580
|
+
continue;
|
|
2581
|
+
}
|
|
2582
|
+
if (isIdentStart(ch)) {
|
|
2583
|
+
const startCol2 = col;
|
|
2584
|
+
let value = "";
|
|
2585
|
+
while (pos < source.length && isIdentPart(source[pos])) {
|
|
2586
|
+
value += source[pos];
|
|
2587
|
+
pos++;
|
|
2588
|
+
col++;
|
|
2589
|
+
}
|
|
2590
|
+
if (value === "not") {
|
|
2591
|
+
tokens.push({ kind: "NOT", value, line, col: startCol2 });
|
|
2592
|
+
} else if (value === "_") {
|
|
2593
|
+
tokens.push({ kind: "UNDERSCORE", value, line, col: startCol2 });
|
|
2594
|
+
} else if (ch >= "A" && ch <= "Z") {
|
|
2595
|
+
tokens.push({ kind: "VARIABLE", value, line, col: startCol2 });
|
|
2596
|
+
} else {
|
|
2597
|
+
tokens.push({ kind: "IDENT", value, line, col: startCol2 });
|
|
2598
|
+
}
|
|
2599
|
+
continue;
|
|
2600
|
+
}
|
|
2601
|
+
if (ch === "_") {
|
|
2602
|
+
const startCol2 = col;
|
|
2603
|
+
let value = "_";
|
|
2604
|
+
pos++;
|
|
2605
|
+
col++;
|
|
2606
|
+
while (pos < source.length && isIdentPart(source[pos])) {
|
|
2607
|
+
value += source[pos];
|
|
2608
|
+
pos++;
|
|
2609
|
+
col++;
|
|
2610
|
+
}
|
|
2611
|
+
if (value === "_") {
|
|
2612
|
+
tokens.push({ kind: "UNDERSCORE", value, line, col: startCol2 });
|
|
2613
|
+
} else {
|
|
2614
|
+
tokens.push({ kind: "VARIABLE", value, line, col: startCol2 });
|
|
2615
|
+
}
|
|
2616
|
+
continue;
|
|
2617
|
+
}
|
|
2618
|
+
const startCol = col;
|
|
2619
|
+
if (ch === "(") {
|
|
2620
|
+
tokens.push({ kind: "LPAREN", value: "(", line, col: startCol });
|
|
2621
|
+
pos++;
|
|
2622
|
+
col++;
|
|
2623
|
+
continue;
|
|
2624
|
+
}
|
|
2625
|
+
if (ch === ")") {
|
|
2626
|
+
tokens.push({ kind: "RPAREN", value: ")", line, col: startCol });
|
|
2627
|
+
pos++;
|
|
2628
|
+
col++;
|
|
2629
|
+
continue;
|
|
2630
|
+
}
|
|
2631
|
+
if (ch === ",") {
|
|
2632
|
+
tokens.push({ kind: "COMMA", value: ",", line, col: startCol });
|
|
2633
|
+
pos++;
|
|
2634
|
+
col++;
|
|
2635
|
+
continue;
|
|
2636
|
+
}
|
|
2637
|
+
if (ch === ".") {
|
|
2638
|
+
tokens.push({ kind: "DOT", value: ".", line, col: startCol });
|
|
2639
|
+
pos++;
|
|
2640
|
+
col++;
|
|
2641
|
+
continue;
|
|
2642
|
+
}
|
|
2643
|
+
if (ch === ":" && pos + 1 < source.length && source[pos + 1] === "-") {
|
|
2644
|
+
tokens.push({ kind: "COLON_DASH", value: ":-", line, col: startCol });
|
|
2645
|
+
pos += 2;
|
|
2646
|
+
col += 2;
|
|
2647
|
+
continue;
|
|
2648
|
+
}
|
|
2649
|
+
if (ch === "?" && pos + 1 < source.length && source[pos + 1] === "-") {
|
|
2650
|
+
tokens.push({ kind: "QUERY", value: "?-", line, col: startCol });
|
|
2651
|
+
pos += 2;
|
|
2652
|
+
col += 2;
|
|
2653
|
+
continue;
|
|
2654
|
+
}
|
|
2655
|
+
if (ch === "!" && pos + 1 < source.length && source[pos + 1] === "=") {
|
|
2656
|
+
tokens.push({ kind: "NEQ", value: "!=", line, col: startCol });
|
|
2657
|
+
pos += 2;
|
|
2658
|
+
col += 2;
|
|
2659
|
+
continue;
|
|
2660
|
+
}
|
|
2661
|
+
if (ch === "<" && pos + 1 < source.length && source[pos + 1] === "=") {
|
|
2662
|
+
tokens.push({ kind: "LTE", value: "<=", line, col: startCol });
|
|
2663
|
+
pos += 2;
|
|
2664
|
+
col += 2;
|
|
2665
|
+
continue;
|
|
2666
|
+
}
|
|
2667
|
+
if (ch === ">" && pos + 1 < source.length && source[pos + 1] === "=") {
|
|
2668
|
+
tokens.push({ kind: "GTE", value: ">=", line, col: startCol });
|
|
2669
|
+
pos += 2;
|
|
2670
|
+
col += 2;
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2673
|
+
if (ch === "<") {
|
|
2674
|
+
tokens.push({ kind: "LT", value: "<", line, col: startCol });
|
|
2675
|
+
pos++;
|
|
2676
|
+
col++;
|
|
2677
|
+
continue;
|
|
2678
|
+
}
|
|
2679
|
+
if (ch === ">") {
|
|
2680
|
+
tokens.push({ kind: "GT", value: ">", line, col: startCol });
|
|
2681
|
+
pos++;
|
|
2682
|
+
col++;
|
|
2683
|
+
continue;
|
|
2684
|
+
}
|
|
2685
|
+
if (ch === "=") {
|
|
2686
|
+
tokens.push({ kind: "EQ", value: "=", line, col: startCol });
|
|
2687
|
+
pos++;
|
|
2688
|
+
col++;
|
|
2689
|
+
continue;
|
|
2690
|
+
}
|
|
2691
|
+
throw new DatalogSyntaxError(`Unexpected character '${ch}'`, line, col);
|
|
2692
|
+
}
|
|
2693
|
+
tokens.push({ kind: "EOF", value: "", line, col });
|
|
2694
|
+
return tokens;
|
|
2695
|
+
}
|
|
2696
|
+
function isIdentStart(ch) {
|
|
2697
|
+
return ch >= "a" && ch <= "z" || ch >= "A" && ch <= "Z";
|
|
2698
|
+
}
|
|
2699
|
+
function isIdentPart(ch) {
|
|
2700
|
+
return ch >= "a" && ch <= "z" || ch >= "A" && ch <= "Z" || ch >= "0" && ch <= "9" || ch === "_";
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
// src/engine/datalog/parser.ts
|
|
2704
|
+
var COMPARISON_OPS = /* @__PURE__ */ new Set([
|
|
2705
|
+
"EQ",
|
|
2706
|
+
"NEQ",
|
|
2707
|
+
"LT",
|
|
2708
|
+
"GT",
|
|
2709
|
+
"LTE",
|
|
2710
|
+
"GTE"
|
|
2711
|
+
]);
|
|
2712
|
+
var TOKEN_TO_COMP_OP = {
|
|
2713
|
+
EQ: "=",
|
|
2714
|
+
NEQ: "!=",
|
|
2715
|
+
LT: "<",
|
|
2716
|
+
GT: ">",
|
|
2717
|
+
LTE: "<=",
|
|
2718
|
+
GTE: ">="
|
|
2719
|
+
};
|
|
2720
|
+
function parse(source) {
|
|
2721
|
+
const tokens = tokenize(source);
|
|
2722
|
+
const parser = new Parser(tokens);
|
|
2723
|
+
return parser.parseProgram();
|
|
2724
|
+
}
|
|
2725
|
+
var Parser = class {
|
|
2726
|
+
tokens;
|
|
2727
|
+
pos;
|
|
2728
|
+
anonCounter;
|
|
2729
|
+
constructor(tokens) {
|
|
2730
|
+
this.tokens = tokens;
|
|
2731
|
+
this.pos = 0;
|
|
2732
|
+
this.anonCounter = 0;
|
|
2733
|
+
}
|
|
2734
|
+
// ===========================================================
|
|
2735
|
+
// Token stream helpers
|
|
2736
|
+
// ===========================================================
|
|
2737
|
+
/** Return the current token without advancing. */
|
|
2738
|
+
peek() {
|
|
2739
|
+
return this.tokens[this.pos];
|
|
2740
|
+
}
|
|
2741
|
+
/** Advance and return the consumed token. */
|
|
2742
|
+
advance() {
|
|
2743
|
+
const token = this.tokens[this.pos];
|
|
2744
|
+
this.pos++;
|
|
2745
|
+
return token;
|
|
2746
|
+
}
|
|
2747
|
+
/** If the current token matches `kind`, consume and return it; otherwise return null. */
|
|
2748
|
+
match(kind) {
|
|
2749
|
+
if (this.peek().kind === kind) {
|
|
2750
|
+
return this.advance();
|
|
2751
|
+
}
|
|
2752
|
+
return null;
|
|
2753
|
+
}
|
|
2754
|
+
/** Consume the current token if it matches `kind`; throw if it does not. */
|
|
2755
|
+
expect(kind) {
|
|
2756
|
+
const token = this.peek();
|
|
2757
|
+
if (token.kind !== kind) {
|
|
2758
|
+
throw new DatalogSyntaxError(
|
|
2759
|
+
`Expected ${kind}, got ${token.kind} ('${token.value}')`,
|
|
2760
|
+
token.line,
|
|
2761
|
+
token.col
|
|
2762
|
+
);
|
|
2763
|
+
}
|
|
2764
|
+
return this.advance();
|
|
2765
|
+
}
|
|
2766
|
+
/** Generate a unique name for an anonymous variable. */
|
|
2767
|
+
freshAnon() {
|
|
2768
|
+
const name = `_anon_${this.anonCounter}`;
|
|
2769
|
+
this.anonCounter++;
|
|
2770
|
+
return name;
|
|
2771
|
+
}
|
|
2772
|
+
// ===========================================================
|
|
2773
|
+
// Grammar productions
|
|
2774
|
+
// ===========================================================
|
|
2775
|
+
/** program = (rule | query)* EOF */
|
|
2776
|
+
parseProgram() {
|
|
2777
|
+
const rules = [];
|
|
2778
|
+
const queries = [];
|
|
2779
|
+
while (this.peek().kind !== "EOF") {
|
|
2780
|
+
if (this.peek().kind === "QUERY") {
|
|
2781
|
+
queries.push(this.parseQuery());
|
|
2782
|
+
} else {
|
|
2783
|
+
rules.push(this.parseRule());
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
return { rules, queries };
|
|
2787
|
+
}
|
|
2788
|
+
/** query = "?-" atom "." */
|
|
2789
|
+
parseQuery() {
|
|
2790
|
+
this.expect("QUERY");
|
|
2791
|
+
const atom = this.parseAtom();
|
|
2792
|
+
this.expect("DOT");
|
|
2793
|
+
return { atom };
|
|
2794
|
+
}
|
|
2795
|
+
/**
|
|
2796
|
+
* rule = atom ":-" body "." | atom "."
|
|
2797
|
+
*
|
|
2798
|
+
* A fact (atom followed by ".") is represented as a rule with an empty body.
|
|
2799
|
+
* After parsing, safety validation is performed for non-fact rules.
|
|
2800
|
+
*/
|
|
2801
|
+
parseRule() {
|
|
2802
|
+
const head = this.parseAtom();
|
|
2803
|
+
let body = [];
|
|
2804
|
+
if (this.match("COLON_DASH")) {
|
|
2805
|
+
body = this.parseBody();
|
|
2806
|
+
}
|
|
2807
|
+
this.expect("DOT");
|
|
2808
|
+
const rule = { head, body };
|
|
2809
|
+
this.validateSafety(rule);
|
|
2810
|
+
return rule;
|
|
2811
|
+
}
|
|
2812
|
+
/** body = bodyLiteral ("," bodyLiteral)* */
|
|
2813
|
+
parseBody() {
|
|
2814
|
+
const literals = [];
|
|
2815
|
+
literals.push(this.parseBodyLiteral());
|
|
2816
|
+
while (this.match("COMMA")) {
|
|
2817
|
+
literals.push(this.parseBodyLiteral());
|
|
2818
|
+
}
|
|
2819
|
+
return literals;
|
|
2820
|
+
}
|
|
2821
|
+
/**
|
|
2822
|
+
* bodyLiteral = "not" atom | comparison | atom
|
|
2823
|
+
*
|
|
2824
|
+
* Disambiguation logic:
|
|
2825
|
+
* - If the next token is NOT, parse a negated atom.
|
|
2826
|
+
* - If the next token is a VARIABLE or constant that could start a comparison
|
|
2827
|
+
* (i.e. the token after it is a comparison operator), parse a comparison.
|
|
2828
|
+
* - Otherwise, parse a positive atom.
|
|
2829
|
+
*/
|
|
2830
|
+
parseBodyLiteral() {
|
|
2831
|
+
if (this.peek().kind === "NOT") {
|
|
2832
|
+
this.advance();
|
|
2833
|
+
const atom2 = this.parseAtom();
|
|
2834
|
+
return { kind: "negated", atom: atom2 };
|
|
2835
|
+
}
|
|
2836
|
+
if (this.isComparisonStart()) {
|
|
2837
|
+
return this.parseComparison();
|
|
2838
|
+
}
|
|
2839
|
+
const atom = this.parseAtom();
|
|
2840
|
+
return { kind: "positive", atom };
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* Detect whether the current position starts a comparison.
|
|
2844
|
+
*
|
|
2845
|
+
* A comparison starts with a term (VARIABLE, STRING, NUMBER, UNDERSCORE)
|
|
2846
|
+
* followed by a comparison operator. We look ahead to distinguish this
|
|
2847
|
+
* from an atom (which starts with IDENT followed by LPAREN).
|
|
2848
|
+
*/
|
|
2849
|
+
isComparisonStart() {
|
|
2850
|
+
const current = this.peek();
|
|
2851
|
+
if (current.kind === "VARIABLE" || current.kind === "STRING" || current.kind === "NUMBER" || current.kind === "UNDERSCORE") {
|
|
2852
|
+
if (this.pos + 1 < this.tokens.length) {
|
|
2853
|
+
const next = this.tokens[this.pos + 1];
|
|
2854
|
+
return COMPARISON_OPS.has(next.kind);
|
|
2855
|
+
}
|
|
2856
|
+
}
|
|
2857
|
+
return false;
|
|
2858
|
+
}
|
|
2859
|
+
/** comparison = term compOp term */
|
|
2860
|
+
parseComparison() {
|
|
2861
|
+
const left = this.parseTerm();
|
|
2862
|
+
const opToken = this.advance();
|
|
2863
|
+
if (!COMPARISON_OPS.has(opToken.kind)) {
|
|
2864
|
+
throw new DatalogSyntaxError(
|
|
2865
|
+
`Expected comparison operator, got ${opToken.kind}`,
|
|
2866
|
+
opToken.line,
|
|
2867
|
+
opToken.col
|
|
2868
|
+
);
|
|
2869
|
+
}
|
|
2870
|
+
const op = TOKEN_TO_COMP_OP[opToken.kind];
|
|
2871
|
+
const right = this.parseTerm();
|
|
2872
|
+
return { kind: "comparison", op, left, right };
|
|
2873
|
+
}
|
|
2874
|
+
/** atom = IDENT "(" termList ")" */
|
|
2875
|
+
parseAtom() {
|
|
2876
|
+
const identToken = this.expect("IDENT");
|
|
2877
|
+
const predicate = identToken.value;
|
|
2878
|
+
this.expect("LPAREN");
|
|
2879
|
+
const args = this.parseTermList();
|
|
2880
|
+
this.expect("RPAREN");
|
|
2881
|
+
return { predicate, args };
|
|
2882
|
+
}
|
|
2883
|
+
/** termList = term ("," term)* */
|
|
2884
|
+
parseTermList() {
|
|
2885
|
+
const terms = [];
|
|
2886
|
+
terms.push(this.parseTerm());
|
|
2887
|
+
while (this.match("COMMA")) {
|
|
2888
|
+
terms.push(this.parseTerm());
|
|
2889
|
+
}
|
|
2890
|
+
return terms;
|
|
2891
|
+
}
|
|
2892
|
+
/** term = VARIABLE | STRING | NUMBER | UNDERSCORE */
|
|
2893
|
+
parseTerm() {
|
|
2894
|
+
const token = this.peek();
|
|
2895
|
+
if (token.kind === "VARIABLE") {
|
|
2896
|
+
this.advance();
|
|
2897
|
+
return { kind: "variable", name: token.value };
|
|
2898
|
+
}
|
|
2899
|
+
if (token.kind === "STRING") {
|
|
2900
|
+
this.advance();
|
|
2901
|
+
return { kind: "constant", value: token.value };
|
|
2902
|
+
}
|
|
2903
|
+
if (token.kind === "NUMBER") {
|
|
2904
|
+
this.advance();
|
|
2905
|
+
return { kind: "constant", value: Number(token.value) };
|
|
2906
|
+
}
|
|
2907
|
+
if (token.kind === "UNDERSCORE") {
|
|
2908
|
+
this.advance();
|
|
2909
|
+
return { kind: "variable", name: this.freshAnon() };
|
|
2910
|
+
}
|
|
2911
|
+
throw new DatalogSyntaxError(
|
|
2912
|
+
`Expected term (variable, string, number, or _), got ${token.kind} ('${token.value}')`,
|
|
2913
|
+
token.line,
|
|
2914
|
+
token.col
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
// ===========================================================
|
|
2918
|
+
// Safety validation
|
|
2919
|
+
// ===========================================================
|
|
2920
|
+
/**
|
|
2921
|
+
* Validate that every variable in the rule head appears in at least
|
|
2922
|
+
* one positive body literal.
|
|
2923
|
+
*
|
|
2924
|
+
* Facts (rules with empty body) are exempt: their head may only
|
|
2925
|
+
* contain constants, or variables that are trivially safe because
|
|
2926
|
+
* there is no body at all. In standard Datalog, facts should only
|
|
2927
|
+
* have constants, but we allow variable-free heads to pass.
|
|
2928
|
+
*
|
|
2929
|
+
* @throws DatalogSafetyError if a head variable is not grounded
|
|
2930
|
+
*/
|
|
2931
|
+
validateSafety(rule) {
|
|
2932
|
+
if (rule.body.length === 0) {
|
|
2933
|
+
return;
|
|
2934
|
+
}
|
|
2935
|
+
const positiveVars = /* @__PURE__ */ new Set();
|
|
2936
|
+
for (const literal of rule.body) {
|
|
2937
|
+
if (literal.kind === "positive") {
|
|
2938
|
+
for (const arg of literal.atom.args) {
|
|
2939
|
+
if (arg.kind === "variable") {
|
|
2940
|
+
positiveVars.add(arg.name);
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
for (const arg of rule.head.args) {
|
|
2946
|
+
if (arg.kind === "variable") {
|
|
2947
|
+
if (!positiveVars.has(arg.name)) {
|
|
2948
|
+
throw new DatalogSafetyError(
|
|
2949
|
+
`Unsafe variable '${arg.name}' in head of rule '${rule.head.predicate}': it does not appear in any positive body literal`
|
|
2950
|
+
);
|
|
2951
|
+
}
|
|
2952
|
+
}
|
|
2953
|
+
}
|
|
2954
|
+
}
|
|
2955
|
+
};
|
|
2956
|
+
|
|
2957
|
+
// src/engine/datalog/evaluator.ts
|
|
2958
|
+
var FactDB = class {
|
|
2959
|
+
store = /* @__PURE__ */ new Map();
|
|
2960
|
+
seen = /* @__PURE__ */ new Map();
|
|
2961
|
+
totalCount = 0;
|
|
2962
|
+
/** Get all tuples for a predicate. */
|
|
2963
|
+
get(predicate) {
|
|
2964
|
+
return this.store.get(predicate) ?? [];
|
|
2965
|
+
}
|
|
2966
|
+
/** Total number of stored tuples across all predicates. */
|
|
2967
|
+
get size() {
|
|
2968
|
+
return this.totalCount;
|
|
2969
|
+
}
|
|
2970
|
+
/**
|
|
2971
|
+
* Add a tuple for a predicate. Returns true if it was new.
|
|
2972
|
+
* Uses a serialized key for deduplication.
|
|
2973
|
+
*/
|
|
2974
|
+
add(predicate, tuple) {
|
|
2975
|
+
const key = serializeTuple(tuple);
|
|
2976
|
+
let seenSet = this.seen.get(predicate);
|
|
2977
|
+
if (!seenSet) {
|
|
2978
|
+
seenSet = /* @__PURE__ */ new Set();
|
|
2979
|
+
this.seen.set(predicate, seenSet);
|
|
2980
|
+
}
|
|
2981
|
+
if (seenSet.has(key)) {
|
|
2982
|
+
return false;
|
|
2983
|
+
}
|
|
2984
|
+
seenSet.add(key);
|
|
2985
|
+
let tuples = this.store.get(predicate);
|
|
2986
|
+
if (!tuples) {
|
|
2987
|
+
tuples = [];
|
|
2988
|
+
this.store.set(predicate, tuples);
|
|
2989
|
+
}
|
|
2990
|
+
tuples.push(tuple);
|
|
2991
|
+
this.totalCount++;
|
|
2992
|
+
return true;
|
|
2993
|
+
}
|
|
2994
|
+
};
|
|
2995
|
+
function serializeTuple(tuple) {
|
|
2996
|
+
return tuple.map((v) => typeof v === "number" ? `n:${v}` : `s:${v}`).join("\0");
|
|
2997
|
+
}
|
|
2998
|
+
function evaluate(program, baseFacts, config) {
|
|
2999
|
+
const cfg = { ...DEFAULT_EVAL_CONFIG, ...config };
|
|
3000
|
+
const startTime = performance.now();
|
|
3001
|
+
const inlineFacts = [];
|
|
3002
|
+
const realRules = [];
|
|
3003
|
+
for (const rule of program.rules) {
|
|
3004
|
+
if (rule.body.length === 0) {
|
|
3005
|
+
inlineFacts.push(rule);
|
|
3006
|
+
} else {
|
|
3007
|
+
realRules.push(rule);
|
|
3008
|
+
}
|
|
3009
|
+
}
|
|
3010
|
+
if (realRules.length > cfg.maxRules) {
|
|
3011
|
+
throw new DatalogResourceError(
|
|
3012
|
+
`Number of rules (${realRules.length}) exceeds maxRules limit (${cfg.maxRules})`
|
|
3013
|
+
);
|
|
3014
|
+
}
|
|
3015
|
+
const db2 = new FactDB();
|
|
3016
|
+
for (const fact of baseFacts) {
|
|
3017
|
+
db2.add(fact.predicate, fact.values);
|
|
3018
|
+
}
|
|
3019
|
+
for (const rule of inlineFacts) {
|
|
3020
|
+
const tuple = rule.head.args.map((arg) => {
|
|
3021
|
+
if (arg.kind === "constant") {
|
|
3022
|
+
return arg.value;
|
|
3023
|
+
}
|
|
3024
|
+
return arg.name;
|
|
3025
|
+
});
|
|
3026
|
+
db2.add(rule.head.predicate, tuple);
|
|
3027
|
+
}
|
|
3028
|
+
const stats = { iterations: 0, totalDerived: 0, elapsedMs: 0 };
|
|
3029
|
+
if (realRules.length > 0) {
|
|
3030
|
+
let changed = true;
|
|
3031
|
+
while (changed) {
|
|
3032
|
+
const elapsed = performance.now() - startTime;
|
|
3033
|
+
if (elapsed > cfg.timeoutMs) {
|
|
3034
|
+
throw new DatalogResourceError(
|
|
3035
|
+
`Evaluation timeout: exceeded ${cfg.timeoutMs}ms`
|
|
3036
|
+
);
|
|
3037
|
+
}
|
|
3038
|
+
if (stats.iterations >= cfg.maxIterations) {
|
|
3039
|
+
throw new DatalogResourceError(
|
|
3040
|
+
`Iteration limit exceeded: ${cfg.maxIterations}`
|
|
3041
|
+
);
|
|
3042
|
+
}
|
|
3043
|
+
changed = false;
|
|
3044
|
+
stats.iterations++;
|
|
3045
|
+
for (const rule of realRules) {
|
|
3046
|
+
const derivedTuples = evaluateRule(rule, db2);
|
|
3047
|
+
for (const tuple of derivedTuples) {
|
|
3048
|
+
if (db2.size >= cfg.maxTuples) {
|
|
3049
|
+
throw new DatalogResourceError(
|
|
3050
|
+
`Tuple limit exceeded: ${cfg.maxTuples}`
|
|
3051
|
+
);
|
|
3052
|
+
}
|
|
3053
|
+
const isNew = db2.add(rule.head.predicate, tuple);
|
|
3054
|
+
if (isNew) {
|
|
3055
|
+
changed = true;
|
|
3056
|
+
stats.totalDerived++;
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
}
|
|
3062
|
+
stats.elapsedMs = performance.now() - startTime;
|
|
3063
|
+
const answers = program.queries.map(
|
|
3064
|
+
(q) => evaluateQuery(q.atom, db2)
|
|
3065
|
+
);
|
|
3066
|
+
return { answers, stats };
|
|
3067
|
+
}
|
|
3068
|
+
function evaluateRule(rule, db2) {
|
|
3069
|
+
const bindings = evaluateBody(rule.body, 0, /* @__PURE__ */ new Map(), db2);
|
|
3070
|
+
const result = [];
|
|
3071
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3072
|
+
for (const binding of bindings) {
|
|
3073
|
+
const tuple = instantiateHead(rule.head, binding);
|
|
3074
|
+
const key = serializeTuple(tuple);
|
|
3075
|
+
if (!seen.has(key)) {
|
|
3076
|
+
seen.add(key);
|
|
3077
|
+
result.push(tuple);
|
|
3078
|
+
}
|
|
3079
|
+
}
|
|
3080
|
+
return result;
|
|
3081
|
+
}
|
|
3082
|
+
function evaluateBody(body, index, binding, db2) {
|
|
3083
|
+
if (index >= body.length) {
|
|
3084
|
+
return [binding];
|
|
3085
|
+
}
|
|
3086
|
+
const literal = body[index];
|
|
3087
|
+
const results = [];
|
|
3088
|
+
if (literal.kind === "positive") {
|
|
3089
|
+
const facts = db2.get(literal.atom.predicate);
|
|
3090
|
+
for (const factTuple of facts) {
|
|
3091
|
+
const newBinding = unifyAtom(literal.atom, factTuple, binding);
|
|
3092
|
+
if (newBinding !== null) {
|
|
3093
|
+
const subResults = evaluateBody(body, index + 1, newBinding, db2);
|
|
3094
|
+
for (const r of subResults) {
|
|
3095
|
+
results.push(r);
|
|
3096
|
+
}
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
} else if (literal.kind === "negated") {
|
|
3100
|
+
const facts = db2.get(literal.atom.predicate);
|
|
3101
|
+
let anyMatch = false;
|
|
3102
|
+
for (const factTuple of facts) {
|
|
3103
|
+
const newBinding = unifyAtom(literal.atom, factTuple, binding);
|
|
3104
|
+
if (newBinding !== null) {
|
|
3105
|
+
anyMatch = true;
|
|
3106
|
+
break;
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
if (!anyMatch) {
|
|
3110
|
+
const subResults = evaluateBody(body, index + 1, binding, db2);
|
|
3111
|
+
for (const r of subResults) {
|
|
3112
|
+
results.push(r);
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
} else if (literal.kind === "comparison") {
|
|
3116
|
+
if (evaluateComparison(literal.op, literal.left, literal.right, binding)) {
|
|
3117
|
+
const subResults = evaluateBody(body, index + 1, binding, db2);
|
|
3118
|
+
for (const r of subResults) {
|
|
3119
|
+
results.push(r);
|
|
3120
|
+
}
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
return results;
|
|
3124
|
+
}
|
|
3125
|
+
function unifyAtom(atom, factTuple, binding) {
|
|
3126
|
+
if (atom.args.length !== factTuple.length) {
|
|
3127
|
+
return null;
|
|
3128
|
+
}
|
|
3129
|
+
let current = new Map(binding);
|
|
3130
|
+
for (let i = 0; i < atom.args.length; i++) {
|
|
3131
|
+
const term = atom.args[i];
|
|
3132
|
+
const value = factTuple[i];
|
|
3133
|
+
const result = unifyTerm(term, value, current);
|
|
3134
|
+
if (result === null) {
|
|
3135
|
+
return null;
|
|
3136
|
+
}
|
|
3137
|
+
current = result;
|
|
3138
|
+
}
|
|
3139
|
+
return current;
|
|
3140
|
+
}
|
|
3141
|
+
function unifyTerm(term, value, binding) {
|
|
3142
|
+
if (term.kind === "constant") {
|
|
3143
|
+
return term.value === value ? binding : null;
|
|
3144
|
+
}
|
|
3145
|
+
const existing = binding.get(term.name);
|
|
3146
|
+
if (existing !== void 0) {
|
|
3147
|
+
return existing === value ? binding : null;
|
|
3148
|
+
}
|
|
3149
|
+
const newBinding = new Map(binding);
|
|
3150
|
+
newBinding.set(term.name, value);
|
|
3151
|
+
return newBinding;
|
|
3152
|
+
}
|
|
3153
|
+
function evaluateComparison(op, left, right, binding) {
|
|
3154
|
+
const leftVal = resolveTerm(left, binding);
|
|
3155
|
+
const rightVal = resolveTerm(right, binding);
|
|
3156
|
+
if (leftVal === null || rightVal === null) {
|
|
3157
|
+
return false;
|
|
3158
|
+
}
|
|
3159
|
+
switch (op) {
|
|
3160
|
+
case "=":
|
|
3161
|
+
return leftVal === rightVal;
|
|
3162
|
+
case "!=":
|
|
3163
|
+
return leftVal !== rightVal;
|
|
3164
|
+
case "<":
|
|
3165
|
+
return leftVal < rightVal;
|
|
3166
|
+
case ">":
|
|
3167
|
+
return leftVal > rightVal;
|
|
3168
|
+
case "<=":
|
|
3169
|
+
return leftVal <= rightVal;
|
|
3170
|
+
case ">=":
|
|
3171
|
+
return leftVal >= rightVal;
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
function resolveTerm(term, binding) {
|
|
3175
|
+
if (term.kind === "constant") {
|
|
3176
|
+
return term.value;
|
|
3177
|
+
}
|
|
3178
|
+
const val = binding.get(term.name);
|
|
3179
|
+
return val !== void 0 ? val : null;
|
|
3180
|
+
}
|
|
3181
|
+
function instantiateHead(head, binding) {
|
|
3182
|
+
return head.args.map((term) => {
|
|
3183
|
+
if (term.kind === "constant") {
|
|
3184
|
+
return term.value;
|
|
3185
|
+
}
|
|
3186
|
+
const val = binding.get(term.name);
|
|
3187
|
+
if (val === void 0) {
|
|
3188
|
+
return term.name;
|
|
3189
|
+
}
|
|
3190
|
+
return val;
|
|
3191
|
+
});
|
|
3192
|
+
}
|
|
3193
|
+
function evaluateQuery(atom, db2) {
|
|
3194
|
+
const columns = [];
|
|
3195
|
+
for (const arg of atom.args) {
|
|
3196
|
+
if (arg.kind === "variable") {
|
|
3197
|
+
columns.push(arg.name);
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
const facts = db2.get(atom.predicate);
|
|
3201
|
+
const tuples = [];
|
|
3202
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3203
|
+
for (const factTuple of facts) {
|
|
3204
|
+
const binding = unifyAtom(atom, factTuple, /* @__PURE__ */ new Map());
|
|
3205
|
+
if (binding !== null) {
|
|
3206
|
+
const key = serializeTuple(factTuple);
|
|
3207
|
+
if (!seen.has(key)) {
|
|
3208
|
+
seen.add(key);
|
|
3209
|
+
tuples.push(factTuple);
|
|
3210
|
+
}
|
|
3211
|
+
}
|
|
3212
|
+
}
|
|
3213
|
+
return {
|
|
3214
|
+
query: atom,
|
|
3215
|
+
tuples,
|
|
3216
|
+
columns
|
|
3217
|
+
};
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
// src/engine/datalog/fact-extractor.ts
|
|
3221
|
+
function extractHosts(db2, limit) {
|
|
3222
|
+
const sql = limit !== void 0 ? "SELECT id, authority, authority_kind FROM hosts LIMIT ?" : "SELECT id, authority, authority_kind FROM hosts";
|
|
3223
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3224
|
+
return rows.map((r) => ({
|
|
3225
|
+
predicate: "host",
|
|
3226
|
+
values: [r.id, r.authority, r.authority_kind]
|
|
3227
|
+
}));
|
|
3228
|
+
}
|
|
3229
|
+
function extractServices(db2, limit) {
|
|
3230
|
+
const sql = limit !== void 0 ? "SELECT host_id, id, transport, port, app_proto, state FROM services LIMIT ?" : "SELECT host_id, id, transport, port, app_proto, state FROM services";
|
|
3231
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3232
|
+
return rows.map((r) => ({
|
|
3233
|
+
predicate: "service",
|
|
3234
|
+
values: [r.host_id, r.id, r.transport, r.port, r.app_proto, r.state]
|
|
3235
|
+
}));
|
|
3236
|
+
}
|
|
3237
|
+
function extractHttpEndpoints(db2, limit) {
|
|
3238
|
+
const sql = limit !== void 0 ? "SELECT service_id, id, method, path, status_code FROM http_endpoints LIMIT ?" : "SELECT service_id, id, method, path, status_code FROM http_endpoints";
|
|
3239
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3240
|
+
return rows.map((r) => ({
|
|
3241
|
+
predicate: "http_endpoint",
|
|
3242
|
+
values: [r.service_id, r.id, r.method, r.path, r.status_code ?? 0]
|
|
3243
|
+
}));
|
|
3244
|
+
}
|
|
3245
|
+
function extractInputs(db2, limit) {
|
|
3246
|
+
const sql = limit !== void 0 ? "SELECT service_id, id, location, name FROM inputs LIMIT ?" : "SELECT service_id, id, location, name FROM inputs";
|
|
3247
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3248
|
+
return rows.map((r) => ({
|
|
3249
|
+
predicate: "input",
|
|
3250
|
+
values: [r.service_id, r.id, r.location, r.name]
|
|
3251
|
+
}));
|
|
3252
|
+
}
|
|
3253
|
+
function extractEndpointInputs(db2, limit) {
|
|
3254
|
+
const sql = limit !== void 0 ? "SELECT endpoint_id, input_id FROM endpoint_inputs LIMIT ?" : "SELECT endpoint_id, input_id FROM endpoint_inputs";
|
|
3255
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3256
|
+
return rows.map((r) => ({
|
|
3257
|
+
predicate: "endpoint_input",
|
|
3258
|
+
values: [r.endpoint_id, r.input_id]
|
|
3259
|
+
}));
|
|
3260
|
+
}
|
|
3261
|
+
function extractObservations(db2, limit) {
|
|
3262
|
+
const sql = limit !== void 0 ? "SELECT input_id, id, raw_value, source, confidence FROM observations LIMIT ?" : "SELECT input_id, id, raw_value, source, confidence FROM observations";
|
|
3263
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3264
|
+
return rows.map((r) => ({
|
|
3265
|
+
predicate: "observation",
|
|
3266
|
+
values: [r.input_id, r.id, r.raw_value, r.source, r.confidence]
|
|
3267
|
+
}));
|
|
3268
|
+
}
|
|
3269
|
+
function extractCredentials(db2, limit) {
|
|
3270
|
+
const sql = limit !== void 0 ? "SELECT service_id, id, username, secret_type, source, confidence FROM credentials LIMIT ?" : "SELECT service_id, id, username, secret_type, source, confidence FROM credentials";
|
|
3271
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3272
|
+
return rows.map((r) => ({
|
|
3273
|
+
predicate: "credential",
|
|
3274
|
+
values: [r.service_id, r.id, r.username, r.secret_type, r.source, r.confidence]
|
|
3275
|
+
}));
|
|
3276
|
+
}
|
|
3277
|
+
function extractVulnerabilities(db2, limit) {
|
|
3278
|
+
const sql = limit !== void 0 ? "SELECT service_id, id, vuln_type, title, severity, confidence, endpoint_id FROM vulnerabilities LIMIT ?" : "SELECT service_id, id, vuln_type, title, severity, confidence, endpoint_id FROM vulnerabilities";
|
|
3279
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3280
|
+
const facts = [];
|
|
3281
|
+
for (const r of rows) {
|
|
3282
|
+
facts.push({
|
|
3283
|
+
predicate: "vulnerability",
|
|
3284
|
+
values: [r.service_id, r.id, r.vuln_type, r.title, r.severity, r.confidence]
|
|
3285
|
+
});
|
|
3286
|
+
}
|
|
3287
|
+
return facts;
|
|
3288
|
+
}
|
|
3289
|
+
function extractVulnerabilityEndpoints(db2, limit) {
|
|
3290
|
+
const sql = limit !== void 0 ? "SELECT id, endpoint_id FROM vulnerabilities WHERE endpoint_id IS NOT NULL LIMIT ?" : "SELECT id, endpoint_id FROM vulnerabilities WHERE endpoint_id IS NOT NULL";
|
|
3291
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3292
|
+
return rows.map((r) => ({
|
|
3293
|
+
predicate: "vulnerability_endpoint",
|
|
3294
|
+
values: [r.id, r.endpoint_id]
|
|
3295
|
+
}));
|
|
3296
|
+
}
|
|
3297
|
+
function extractCves(db2, limit) {
|
|
3298
|
+
const sql = limit !== void 0 ? "SELECT vulnerability_id, cve_id, cvss_score FROM cves LIMIT ?" : "SELECT vulnerability_id, cve_id, cvss_score FROM cves";
|
|
3299
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3300
|
+
return rows.map((r) => ({
|
|
3301
|
+
predicate: "cve",
|
|
3302
|
+
values: [r.vulnerability_id, r.cve_id, r.cvss_score ?? 0]
|
|
3303
|
+
}));
|
|
3304
|
+
}
|
|
3305
|
+
function extractVhosts(db2, limit) {
|
|
3306
|
+
const sql = limit !== void 0 ? "SELECT host_id, id, hostname, source FROM vhosts LIMIT ?" : "SELECT host_id, id, hostname, source FROM vhosts";
|
|
3307
|
+
const rows = limit !== void 0 ? db2.prepare(sql).all(limit) : db2.prepare(sql).all();
|
|
3308
|
+
return rows.map((r) => ({
|
|
3309
|
+
predicate: "vhost",
|
|
3310
|
+
values: [r.host_id, r.id, r.hostname, r.source ?? ""]
|
|
3311
|
+
}));
|
|
3312
|
+
}
|
|
3313
|
+
var EXTRACTORS = /* @__PURE__ */ new Map([
|
|
3314
|
+
["host", extractHosts],
|
|
3315
|
+
["service", extractServices],
|
|
3316
|
+
["http_endpoint", extractHttpEndpoints],
|
|
3317
|
+
["input", extractInputs],
|
|
3318
|
+
["endpoint_input", extractEndpointInputs],
|
|
3319
|
+
["observation", extractObservations],
|
|
3320
|
+
["credential", extractCredentials],
|
|
3321
|
+
["vulnerability", extractVulnerabilities],
|
|
3322
|
+
["vulnerability_endpoint", extractVulnerabilityEndpoints],
|
|
3323
|
+
["cve", extractCves],
|
|
3324
|
+
["vhost", extractVhosts]
|
|
3325
|
+
]);
|
|
3326
|
+
function extractFacts(db2) {
|
|
3327
|
+
const facts = [];
|
|
3328
|
+
for (const extractor of EXTRACTORS.values()) {
|
|
3329
|
+
facts.push(...extractor(db2));
|
|
3330
|
+
}
|
|
3331
|
+
return facts;
|
|
3332
|
+
}
|
|
3333
|
+
function extractFactsByPredicate(db2, predicate, limit) {
|
|
3334
|
+
const extractor = EXTRACTORS.get(predicate);
|
|
3335
|
+
if (!extractor) {
|
|
3336
|
+
return [];
|
|
3337
|
+
}
|
|
3338
|
+
return extractor(db2, limit);
|
|
3339
|
+
}
|
|
3340
|
+
|
|
3341
|
+
// src/engine/datalog/preset-rules.ts
|
|
3342
|
+
var PRESET_RULES = [
|
|
3343
|
+
{
|
|
3344
|
+
name: "reachable_services",
|
|
3345
|
+
description: "Find all open services on each host with their port and application protocol.",
|
|
3346
|
+
ruleText: [
|
|
3347
|
+
'reachable(Host, Port, AppProto) :- service(Host, _, _, Port, AppProto, "open").',
|
|
3348
|
+
"?- reachable(Host, Port, AppProto)."
|
|
3349
|
+
].join("\n")
|
|
3350
|
+
},
|
|
3351
|
+
{
|
|
3352
|
+
name: "authenticated_access",
|
|
3353
|
+
description: "Services with discovered credentials, showing host, port, and username.",
|
|
3354
|
+
ruleText: [
|
|
3355
|
+
'auth_access(Host, Port, Username) :- service(Host, Svc, _, Port, _, "open"), credential(Svc, _, Username, _, _, _).',
|
|
3356
|
+
"?- auth_access(Host, Port, Username)."
|
|
3357
|
+
].join("\n")
|
|
3358
|
+
},
|
|
3359
|
+
{
|
|
3360
|
+
name: "exploitable_endpoints",
|
|
3361
|
+
description: "HTTP endpoints with known vulnerabilities, including vulnerability type and severity.",
|
|
3362
|
+
ruleText: [
|
|
3363
|
+
"exploitable(Host, Port, Path, VulnType, Severity) :- service(Host, Svc, _, Port, _, _), http_endpoint(Svc, Ep, _, Path, _), vulnerability_endpoint(Vuln, Ep), vulnerability(Svc, Vuln, VulnType, _, Severity, _).",
|
|
3364
|
+
"?- exploitable(Host, Port, Path, VulnType, Severity)."
|
|
3365
|
+
].join("\n")
|
|
3366
|
+
},
|
|
3367
|
+
{
|
|
3368
|
+
name: "critical_vulns",
|
|
3369
|
+
description: "Critical severity vulnerabilities with host, port, title, and type.",
|
|
3370
|
+
ruleText: [
|
|
3371
|
+
'critical(Host, Port, Title, VulnType) :- service(Host, Svc, _, Port, _, _), vulnerability(Svc, _, VulnType, Title, "critical", _).',
|
|
3372
|
+
"?- critical(Host, Port, Title, VulnType)."
|
|
3373
|
+
].join("\n")
|
|
3374
|
+
},
|
|
3375
|
+
{
|
|
3376
|
+
name: "attack_surface",
|
|
3377
|
+
description: "Full attack surface overview combining host, port, endpoint path, input name, and input location.",
|
|
3378
|
+
ruleText: [
|
|
3379
|
+
'surface(Host, Port, Path, InputName, Location) :- service(Host, Svc, _, Port, _, "open"), http_endpoint(Svc, Ep, _, Path, _), endpoint_input(Ep, Inp), input(Svc, Inp, Location, InputName).',
|
|
3380
|
+
"?- surface(Host, Port, Path, InputName, Location)."
|
|
3381
|
+
].join("\n")
|
|
3382
|
+
},
|
|
3383
|
+
{
|
|
3384
|
+
name: "unfuzzed_inputs",
|
|
3385
|
+
description: "Inputs with observations but no associated vulnerability endpoint \u2014 candidates for fuzzing.",
|
|
3386
|
+
ruleText: [
|
|
3387
|
+
'unfuzzed(Host, Port, Path, InputName) :- service(Host, Svc, _, Port, _, "open"), http_endpoint(Svc, Ep, _, Path, _), endpoint_input(Ep, Inp), input(Svc, Inp, _, InputName), observation(Inp, _, _, _, _), not vulnerability_endpoint(_, Ep).',
|
|
3388
|
+
"?- unfuzzed(Host, Port, Path, InputName)."
|
|
3389
|
+
].join("\n")
|
|
3390
|
+
}
|
|
3391
|
+
];
|
|
3392
|
+
function getPresetRules() {
|
|
3393
|
+
return [...PRESET_RULES];
|
|
3394
|
+
}
|
|
3395
|
+
function getPresetRule(name) {
|
|
3396
|
+
return PRESET_RULES.find((r) => r.name === name);
|
|
3397
|
+
}
|
|
3398
|
+
|
|
3399
|
+
// src/db/repository/datalog-rule-repository.ts
|
|
3400
|
+
import crypto14 from "crypto";
|
|
3401
|
+
function rowToDatalogRule(row) {
|
|
3402
|
+
return {
|
|
3403
|
+
id: row.id,
|
|
3404
|
+
name: row.name,
|
|
3405
|
+
description: row.description ?? void 0,
|
|
3406
|
+
ruleText: row.rule_text,
|
|
3407
|
+
generatedBy: row.generated_by,
|
|
3408
|
+
isPreset: row.is_preset === 1,
|
|
3409
|
+
createdAt: row.created_at,
|
|
3410
|
+
updatedAt: row.updated_at
|
|
3411
|
+
};
|
|
3412
|
+
}
|
|
3413
|
+
var DatalogRuleRepository = class {
|
|
3414
|
+
db;
|
|
3415
|
+
constructor(db2) {
|
|
3416
|
+
this.db = db2;
|
|
3417
|
+
}
|
|
3418
|
+
/** Insert a new DatalogRule and return the full entity. */
|
|
3419
|
+
create(input) {
|
|
3420
|
+
const id = crypto14.randomUUID();
|
|
3421
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3422
|
+
const stmt = this.db.prepare(
|
|
3423
|
+
`INSERT INTO datalog_rules (id, name, description, rule_text, generated_by, is_preset, created_at, updated_at)
|
|
3424
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
3425
|
+
);
|
|
3426
|
+
stmt.run(
|
|
3427
|
+
id,
|
|
3428
|
+
input.name,
|
|
3429
|
+
input.description ?? null,
|
|
3430
|
+
input.ruleText,
|
|
3431
|
+
input.generatedBy,
|
|
3432
|
+
input.isPreset ? 1 : 0,
|
|
3433
|
+
now,
|
|
3434
|
+
now
|
|
3435
|
+
);
|
|
3436
|
+
return {
|
|
3437
|
+
id,
|
|
3438
|
+
name: input.name,
|
|
3439
|
+
description: input.description,
|
|
3440
|
+
ruleText: input.ruleText,
|
|
3441
|
+
generatedBy: input.generatedBy,
|
|
3442
|
+
isPreset: input.isPreset ?? false,
|
|
3443
|
+
createdAt: now,
|
|
3444
|
+
updatedAt: now
|
|
3445
|
+
};
|
|
3446
|
+
}
|
|
3447
|
+
/** Find a DatalogRule by its primary key. */
|
|
3448
|
+
findById(id) {
|
|
3449
|
+
const stmt = this.db.prepare(
|
|
3450
|
+
`SELECT id, name, description, rule_text, generated_by, is_preset, created_at, updated_at
|
|
3451
|
+
FROM datalog_rules WHERE id = ?`
|
|
3452
|
+
);
|
|
3453
|
+
const row = stmt.get(id);
|
|
3454
|
+
return row ? rowToDatalogRule(row) : void 0;
|
|
3455
|
+
}
|
|
3456
|
+
/** Find a DatalogRule by name. */
|
|
3457
|
+
findByName(name) {
|
|
3458
|
+
const stmt = this.db.prepare(
|
|
3459
|
+
`SELECT id, name, description, rule_text, generated_by, is_preset, created_at, updated_at
|
|
3460
|
+
FROM datalog_rules WHERE name = ?`
|
|
3461
|
+
);
|
|
3462
|
+
const row = stmt.get(name);
|
|
3463
|
+
return row ? rowToDatalogRule(row) : void 0;
|
|
3464
|
+
}
|
|
3465
|
+
/** Return all DatalogRules. */
|
|
3466
|
+
findAll() {
|
|
3467
|
+
const stmt = this.db.prepare(
|
|
3468
|
+
`SELECT id, name, description, rule_text, generated_by, is_preset, created_at, updated_at
|
|
3469
|
+
FROM datalog_rules ORDER BY created_at`
|
|
3470
|
+
);
|
|
3471
|
+
return stmt.all().map(rowToDatalogRule);
|
|
3472
|
+
}
|
|
3473
|
+
/** Delete a DatalogRule by id. Returns true if a row was deleted. */
|
|
3474
|
+
delete(id) {
|
|
3475
|
+
const stmt = this.db.prepare("DELETE FROM datalog_rules WHERE id = ?");
|
|
3476
|
+
const result = stmt.run(id);
|
|
3477
|
+
return result.changes > 0;
|
|
3478
|
+
}
|
|
3479
|
+
};
|
|
3480
|
+
|
|
3481
|
+
// src/engine/datalog/index.ts
|
|
3482
|
+
function listFacts(db2, predicate, limit) {
|
|
3483
|
+
if (predicate !== void 0) {
|
|
3484
|
+
return extractFactsByPredicate(db2, predicate, limit);
|
|
3485
|
+
}
|
|
3486
|
+
const facts = extractFacts(db2);
|
|
3487
|
+
if (limit !== void 0 && limit > 0) {
|
|
3488
|
+
return facts.slice(0, limit);
|
|
3489
|
+
}
|
|
3490
|
+
return facts;
|
|
3491
|
+
}
|
|
3492
|
+
function runDatalog(db2, program, options) {
|
|
3493
|
+
const ast = parse(program);
|
|
3494
|
+
const facts = extractFacts(db2);
|
|
3495
|
+
const result = evaluate(ast, facts, options?.config);
|
|
3496
|
+
if (options?.saveName !== void 0) {
|
|
3497
|
+
const ruleRepo = new DatalogRuleRepository(db2);
|
|
3498
|
+
ruleRepo.create({
|
|
3499
|
+
name: options.saveName,
|
|
3500
|
+
description: options.saveDescription,
|
|
3501
|
+
ruleText: program,
|
|
3502
|
+
generatedBy: options.generatedBy ?? "ai"
|
|
3503
|
+
});
|
|
3504
|
+
}
|
|
3505
|
+
return result;
|
|
3506
|
+
}
|
|
3507
|
+
function queryAttackPaths(db2, pattern, config) {
|
|
3508
|
+
const preset = getPresetRule(pattern);
|
|
3509
|
+
if (preset !== void 0) {
|
|
3510
|
+
const ast = parse(preset.ruleText);
|
|
3511
|
+
const facts = extractFacts(db2);
|
|
3512
|
+
return evaluate(ast, facts, config);
|
|
3513
|
+
}
|
|
3514
|
+
const ruleRepo = new DatalogRuleRepository(db2);
|
|
3515
|
+
const savedRule = ruleRepo.findByName(pattern);
|
|
3516
|
+
if (savedRule !== void 0) {
|
|
3517
|
+
const ast = parse(savedRule.ruleText);
|
|
3518
|
+
const facts = extractFacts(db2);
|
|
3519
|
+
return evaluate(ast, facts, config);
|
|
3520
|
+
}
|
|
3521
|
+
return {
|
|
3522
|
+
answers: [],
|
|
3523
|
+
stats: { iterations: 0, totalDerived: 0, elapsedMs: 0 }
|
|
3524
|
+
};
|
|
3525
|
+
}
|
|
3526
|
+
function listPatterns(db2) {
|
|
3527
|
+
const presets = getPresetRules().map((p) => ({
|
|
3528
|
+
name: p.name,
|
|
3529
|
+
description: p.description,
|
|
3530
|
+
source: "preset"
|
|
3531
|
+
}));
|
|
3532
|
+
const ruleRepo = new DatalogRuleRepository(db2);
|
|
3533
|
+
const saved = ruleRepo.findAll().map((r) => ({
|
|
3534
|
+
name: r.name,
|
|
3535
|
+
description: r.description,
|
|
3536
|
+
source: "saved",
|
|
3537
|
+
generatedBy: r.generatedBy
|
|
3538
|
+
}));
|
|
3539
|
+
return [...presets, ...saved];
|
|
3540
|
+
}
|
|
3541
|
+
|
|
3542
|
+
// src/mcp/tools/datalog.ts
|
|
3543
|
+
function formatFact(fact) {
|
|
3544
|
+
const args = fact.values.map((v) => typeof v === "number" ? String(v) : `"${v}"`).join(", ");
|
|
3545
|
+
return `${fact.predicate}(${args}).`;
|
|
3546
|
+
}
|
|
3547
|
+
function formatEvalResult(result) {
|
|
3548
|
+
if (result.answers.length === 0) {
|
|
3549
|
+
return `No query results.
|
|
3550
|
+
|
|
3551
|
+
Stats: ${result.stats.iterations} iterations, ${result.stats.totalDerived} derived facts, ${result.stats.elapsedMs}ms`;
|
|
3552
|
+
}
|
|
3553
|
+
const sections = [];
|
|
3554
|
+
for (const answer of result.answers) {
|
|
3555
|
+
const queryArgs = answer.query.args.map((a) => a.kind === "variable" ? a.name : a.kind === "constant" ? String(a.value) : "_").join(", ");
|
|
3556
|
+
const header = `Query: ${answer.query.predicate}(${queryArgs})`;
|
|
3557
|
+
if (answer.tuples.length === 0) {
|
|
3558
|
+
sections.push(`${header}
|
|
3559
|
+
Results: (empty)`);
|
|
3560
|
+
continue;
|
|
3561
|
+
}
|
|
3562
|
+
const columns = answer.columns;
|
|
3563
|
+
const widths = columns.map(
|
|
3564
|
+
(col, i) => Math.max(
|
|
3565
|
+
col.length,
|
|
3566
|
+
...answer.tuples.map((t) => String(t[i]).length)
|
|
3567
|
+
)
|
|
3568
|
+
);
|
|
3569
|
+
const headerRow = columns.map((col, i) => col.padEnd(widths[i])).join(" | ");
|
|
3570
|
+
const dataRows = answer.tuples.map(
|
|
3571
|
+
(tuple) => " " + tuple.map((val, i) => String(val).padEnd(widths[i])).join(" | ")
|
|
3572
|
+
);
|
|
3573
|
+
sections.push(
|
|
3574
|
+
`${header}
|
|
3575
|
+
Results (${answer.tuples.length} rows):
|
|
3576
|
+
${headerRow}
|
|
3577
|
+
${dataRows.join("\n")}`
|
|
3578
|
+
);
|
|
3579
|
+
}
|
|
3580
|
+
sections.push(
|
|
3581
|
+
`
|
|
3582
|
+
Stats: ${result.stats.iterations} iterations, ${result.stats.totalDerived} derived facts, ${result.stats.elapsedMs}ms`
|
|
3583
|
+
);
|
|
3584
|
+
return sections.join("\n\n");
|
|
3585
|
+
}
|
|
3586
|
+
function registerDatalogTools(server2, db2) {
|
|
3587
|
+
server2.tool(
|
|
3588
|
+
"list_facts",
|
|
3589
|
+
"List database contents as Datalog facts. Optionally filter by predicate name and limit the number of results.",
|
|
3590
|
+
{
|
|
3591
|
+
predicate: z5.string().optional().describe(
|
|
3592
|
+
"Filter by predicate name (host, service, http_endpoint, input, endpoint_input, observation, credential, vulnerability, vulnerability_endpoint, cve, vhost)"
|
|
3593
|
+
),
|
|
3594
|
+
limit: z5.number().optional().describe("Maximum number of facts to return")
|
|
3595
|
+
},
|
|
3596
|
+
async ({ predicate, limit }) => {
|
|
3597
|
+
const facts = listFacts(db2, predicate, limit);
|
|
3598
|
+
if (facts.length === 0) {
|
|
3599
|
+
return {
|
|
3600
|
+
content: [{ type: "text", text: "No facts found." }]
|
|
3601
|
+
};
|
|
3602
|
+
}
|
|
3603
|
+
const text = facts.map(formatFact).join("\n");
|
|
3604
|
+
return { content: [{ type: "text", text }] };
|
|
3605
|
+
}
|
|
3606
|
+
);
|
|
3607
|
+
server2.tool(
|
|
3608
|
+
"run_datalog",
|
|
3609
|
+
"Execute a custom Datalog program against the AttackDataGraph. Supports rules with :- and queries with ?-. Optionally save the program as a named rule for future reuse.",
|
|
3610
|
+
{
|
|
3611
|
+
program: z5.string().describe("Datalog program text (rules and queries)"),
|
|
3612
|
+
save_name: z5.string().optional().describe("Save the program as a named rule for future reuse"),
|
|
3613
|
+
save_description: z5.string().optional().describe("Description of the saved rule"),
|
|
3614
|
+
generated_by: z5.string().optional().describe('Who generated this rule: "human" or "ai" (default: "ai")')
|
|
3615
|
+
},
|
|
3616
|
+
async ({ program, save_name, save_description, generated_by }) => {
|
|
3617
|
+
try {
|
|
3618
|
+
const generatedBy = generated_by === "human" ? "human" : "ai";
|
|
3619
|
+
const result = runDatalog(db2, program, {
|
|
3620
|
+
saveName: save_name,
|
|
3621
|
+
saveDescription: save_description,
|
|
3622
|
+
generatedBy
|
|
3623
|
+
});
|
|
3624
|
+
const text = formatEvalResult(result);
|
|
3625
|
+
return { content: [{ type: "text", text }] };
|
|
3626
|
+
} catch (err) {
|
|
3627
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3628
|
+
return {
|
|
3629
|
+
content: [{ type: "text", text: `Datalog error: ${message}` }],
|
|
3630
|
+
isError: true
|
|
3631
|
+
};
|
|
3632
|
+
}
|
|
3633
|
+
}
|
|
3634
|
+
);
|
|
3635
|
+
server2.tool(
|
|
3636
|
+
"query_attack_paths",
|
|
3637
|
+
'Run a preset or saved attack pattern query. Use pattern "list" to see all available patterns.',
|
|
3638
|
+
{
|
|
3639
|
+
pattern: z5.string().describe(
|
|
3640
|
+
'Pattern name (e.g. "reachable_services", "critical_vulns") or "list" to see available patterns'
|
|
3641
|
+
)
|
|
3642
|
+
},
|
|
3643
|
+
async ({ pattern }) => {
|
|
3644
|
+
if (pattern === "list") {
|
|
3645
|
+
const patterns = listPatterns(db2);
|
|
3646
|
+
if (patterns.length === 0) {
|
|
3647
|
+
return {
|
|
3648
|
+
content: [{ type: "text", text: "No patterns available." }]
|
|
3649
|
+
};
|
|
3650
|
+
}
|
|
3651
|
+
const lines = patterns.map(
|
|
3652
|
+
(p) => `- ${p.name} [${p.source}]${p.description ? `: ${p.description}` : ""}${p.generatedBy ? ` (by ${p.generatedBy})` : ""}`
|
|
3653
|
+
);
|
|
3654
|
+
return {
|
|
3655
|
+
content: [
|
|
3656
|
+
{
|
|
3657
|
+
type: "text",
|
|
3658
|
+
text: `Available patterns:
|
|
3659
|
+
${lines.join("\n")}`
|
|
3660
|
+
}
|
|
3661
|
+
]
|
|
3662
|
+
};
|
|
3663
|
+
}
|
|
3664
|
+
try {
|
|
3665
|
+
const result = queryAttackPaths(db2, pattern);
|
|
3666
|
+
const text = formatEvalResult(result);
|
|
3667
|
+
return { content: [{ type: "text", text }] };
|
|
3668
|
+
} catch (err) {
|
|
3669
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
3670
|
+
return {
|
|
3671
|
+
content: [{ type: "text", text: `Datalog error: ${message}` }],
|
|
3672
|
+
isError: true
|
|
3673
|
+
};
|
|
3674
|
+
}
|
|
3675
|
+
}
|
|
3676
|
+
);
|
|
3677
|
+
}
|
|
3678
|
+
|
|
2432
3679
|
// src/mcp/resources.ts
|
|
2433
3680
|
function registerResources(server2, db2) {
|
|
2434
3681
|
const hostRepo = new HostRepository(db2);
|
|
@@ -2526,12 +3773,13 @@ function registerResources(server2, db2) {
|
|
|
2526
3773
|
function createMcpServer(db2) {
|
|
2527
3774
|
const server2 = new McpServer({
|
|
2528
3775
|
name: "sonobat",
|
|
2529
|
-
version: "0.
|
|
3776
|
+
version: "0.2.0"
|
|
2530
3777
|
});
|
|
2531
3778
|
registerQueryTools(server2, db2);
|
|
2532
3779
|
registerIngestTool(server2, db2);
|
|
2533
3780
|
registerProposeTool(server2, db2);
|
|
2534
3781
|
registerMutationTools(server2, db2);
|
|
3782
|
+
registerDatalogTools(server2, db2);
|
|
2535
3783
|
registerResources(server2, db2);
|
|
2536
3784
|
return server2;
|
|
2537
3785
|
}
|