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/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 inputs = inputRepo.findByServiceId(service.id);
2250
- if (inputs.length === 0) {
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 input of inputs) {
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.1.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
  }