@datasynx/agentic-ai-cartography 2.9.0 → 2.11.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
@@ -5840,9 +5840,146 @@ async function resolveNlQuery(db, sessionId, search, raw, opts) {
5840
5840
  return executeNlQuery(db, sessionId, search, parseNlQuery(raw), opts);
5841
5841
  }
5842
5842
 
5843
+ // src/correlation/signals.ts
5844
+ var IPV4 = /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g;
5845
+ var DNS = /\b(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.){1,8}[a-z]{2,24}\b/gi;
5846
+ var ENDPOINT = /\b((?:[a-z0-9][a-z0-9.-]{0,253})):(\d{1,5})\b/gi;
5847
+ function isPrivateIp(ip) {
5848
+ return /^(?:10\.|127\.|169\.254\.|192\.168\.|172\.(?:1[6-9]|2\d|3[01])\.)/.test(ip);
5849
+ }
5850
+ function sources(node) {
5851
+ const out = [node.id, node.name];
5852
+ const meta = node.metadata;
5853
+ if (meta) {
5854
+ for (const k of ["host", "hostname", "ip", "address", "dns", "dnsName", "endpoint", "fqdn", "publicIp", "privateIp", "url"]) {
5855
+ const v = meta[k];
5856
+ if (typeof v === "string") out.push(v);
5857
+ }
5858
+ }
5859
+ return out;
5860
+ }
5861
+ var KNOWN_PROVIDERS = /* @__PURE__ */ new Set(["aws", "gcp", "azure", "k8s", "kubernetes", "localhost", "docker"]);
5862
+ function parseProvider(id) {
5863
+ const seg = id.split(":");
5864
+ if (seg.length >= 3 && KNOWN_PROVIDERS.has(seg[1])) return seg[1];
5865
+ return void 0;
5866
+ }
5867
+ function extractSignals(node) {
5868
+ const text = sources(node).join(" \n ");
5869
+ const publicIps = /* @__PURE__ */ new Set();
5870
+ const privateIps = /* @__PURE__ */ new Set();
5871
+ const dnsNames = /* @__PURE__ */ new Set();
5872
+ const endpoints = /* @__PURE__ */ new Set();
5873
+ for (const m of text.matchAll(IPV4)) (isPrivateIp(m[0]) ? privateIps : publicIps).add(m[0]);
5874
+ for (const m of text.matchAll(DNS)) dnsNames.add(m[0].toLowerCase());
5875
+ for (const m of text.matchAll(ENDPOINT)) endpoints.add(`${m[1].toLowerCase()}:${m[2]}`);
5876
+ const provider = parseProvider(node.id);
5877
+ const sortUniq = (s) => [...s].sort();
5878
+ return {
5879
+ ...provider ? { provider } : {},
5880
+ endpoints: sortUniq(endpoints),
5881
+ publicIps: sortUniq(publicIps),
5882
+ privateIps: sortUniq(privateIps),
5883
+ // The DNS regex requires an alphabetic TLD, so pure IPv4s never land here.
5884
+ dnsNames: sortUniq(dnsNames)
5885
+ };
5886
+ }
5887
+
5888
+ // src/correlation/correlate.ts
5889
+ var CORRELATION_CONFIDENCE = {
5890
+ "global-identity": 1,
5891
+ "dns-name": 0.95,
5892
+ "public-ip": 0.9,
5893
+ "endpoint": 0.85,
5894
+ "private-ip": 0.5
5895
+ };
5896
+ var MERGE_THRESHOLD = 0.85;
5897
+ var DSU = class {
5898
+ parent;
5899
+ constructor(n) {
5900
+ this.parent = Array.from({ length: n }, (_, i) => i);
5901
+ }
5902
+ find(x) {
5903
+ while (this.parent[x] !== x) {
5904
+ this.parent[x] = this.parent[this.parent[x]];
5905
+ x = this.parent[x];
5906
+ }
5907
+ return x;
5908
+ }
5909
+ union(a, b) {
5910
+ const ra = this.find(a), rb = this.find(b);
5911
+ if (ra !== rb) this.parent[Math.max(ra, rb)] = Math.min(ra, rb);
5912
+ }
5913
+ };
5914
+ function correlateTopology(nodes, _edges = []) {
5915
+ const n = nodes.length;
5916
+ const signals = nodes.map(extractSignals);
5917
+ const dsu = new DSU(n);
5918
+ const correlations = [];
5919
+ const merged = /* @__PURE__ */ new Set();
5920
+ const buckets = /* @__PURE__ */ new Map();
5921
+ const add = (sig, val, i) => {
5922
+ const k = `${sig}|${val}`;
5923
+ const arr = buckets.get(k);
5924
+ if (arr) arr.push(i);
5925
+ else buckets.set(k, [i]);
5926
+ };
5927
+ nodes.forEach((node, i) => {
5928
+ if (node.globalId) add("global-identity", node.globalId, i);
5929
+ for (const d of signals[i].dnsNames) add("dns-name", d, i);
5930
+ for (const ip of signals[i].publicIps) add("public-ip", ip, i);
5931
+ for (const e of signals[i].endpoints) add("endpoint", e, i);
5932
+ });
5933
+ const order = ["global-identity", "dns-name", "public-ip", "endpoint"];
5934
+ for (const sig of order) {
5935
+ const conf = CORRELATION_CONFIDENCE[sig];
5936
+ const keys = [...buckets.keys()].filter((k) => k.startsWith(`${sig}|`)).sort();
5937
+ for (const key of keys) {
5938
+ const members = buckets.get(key).slice().sort((x, y) => nodes[x].id.localeCompare(nodes[y].id));
5939
+ const value = key.slice(sig.length + 1);
5940
+ for (let j = 1; j < members.length; j++) {
5941
+ const a = members[0], b = members[j];
5942
+ if (conf < MERGE_THRESHOLD) continue;
5943
+ dsu.union(a, b);
5944
+ merged.add(a);
5945
+ merged.add(b);
5946
+ const [s, t] = [nodes[a].id, nodes[b].id].sort();
5947
+ correlations.push({ sourceId: s, targetId: t, relationship: "same_as", signal: sig, confidence: conf, evidence: `[${sig}] shared ${value}` });
5948
+ }
5949
+ }
5950
+ }
5951
+ const clusters = /* @__PURE__ */ new Map();
5952
+ for (let i = 0; i < n; i++) {
5953
+ const r = dsu.find(i);
5954
+ const arr = clusters.get(r);
5955
+ if (arr) arr.push(i);
5956
+ else clusters.set(r, [i]);
5957
+ }
5958
+ const canonical = [];
5959
+ let crossCloud = 0;
5960
+ for (const idxs of clusters.values()) {
5961
+ const memberNodes = idxs.map((i) => nodes[i]);
5962
+ const members = memberNodes.map((m) => m.id).sort();
5963
+ const providers = [...new Set(idxs.map((i) => signals[i].provider).filter((p) => !!p))].sort();
5964
+ const rep = memberNodes.reduce((a, b) => a.id < b.id ? a : b);
5965
+ const memberIds = new Set(members);
5966
+ const internal = correlations.filter((c) => memberIds.has(c.sourceId) && memberIds.has(c.targetId));
5967
+ const confidence = internal.length ? Math.min(...internal.map((c) => c.confidence)) : 1;
5968
+ if (providers.length > 1) crossCloud += 1;
5969
+ canonical.push({ id: rep.id, type: rep.type, name: rep.name, members, providers, confidence });
5970
+ }
5971
+ canonical.sort((a, b) => a.id.localeCompare(b.id));
5972
+ correlations.sort((a, b) => b.confidence - a.confidence || a.sourceId.localeCompare(b.sourceId) || a.targetId.localeCompare(b.targetId));
5973
+ return {
5974
+ canonical,
5975
+ correlations,
5976
+ summary: { rawNodes: n, canonicalNodes: canonical.length, collapsed: n - canonical.length, crossCloud }
5977
+ };
5978
+ }
5979
+
5843
5980
  // src/mcp/server.ts
5844
5981
  var SERVER_NAME = "cartography";
5845
- var SERVER_VERSION = "2.9.0";
5982
+ var SERVER_VERSION = "2.11.0";
5846
5983
  var SERVICE_TYPES = NODE_TYPE_GROUPS.web;
5847
5984
  var DATA_TYPES = NODE_TYPE_GROUPS.data;
5848
5985
  var lexicalSearch = async (db, sessionId, query, opts) => db.searchNodes(sessionId, query, { types: opts.types, limit: opts.limit }).map((node) => ({ node }));
@@ -6002,6 +6139,15 @@ function createMcpServer(opts = {}) {
6002
6139
  return json(db.getGraphSummary(sid));
6003
6140
  }
6004
6141
  );
6142
+ server.registerTool(
6143
+ "correlate_topology",
6144
+ { title: "Correlate multi-cloud topology", description: "Collapse the same logical resource discovered across clouds/on-prem (by global identity or a shared DNS name / public IP / endpoint) into canonical entities with confidence-scored same_as links. Read-only, non-destructive (5.1).", inputSchema: {}, annotations: readOnly },
6145
+ () => {
6146
+ const sid = resolveSession();
6147
+ if (!sid) return json({ error: "No discovery session found." });
6148
+ return json(correlateTopology(db.getNodes(sid), db.getEdges(sid)));
6149
+ }
6150
+ );
6005
6151
  server.registerTool(
6006
6152
  "get_cost_summary",
6007
6153
  { title: "Get cost summary", description: "FinOps rollup: cost by domain and owner, currency/period-bucketed (3.3).", inputSchema: {}, annotations: readOnly },
@@ -7243,9 +7389,132 @@ var serviceConfigScanner = {
7243
7389
  }
7244
7390
  };
7245
7391
 
7392
+ // src/scanners/terraform.ts
7393
+ import { readFileSync as readFileSync4 } from "fs";
7394
+ var TYPE_RULES = [
7395
+ [/(db_instance|_rds|sql_database|sql_instance|database_instance|cosmosdb|dynamodb|spanner|bigtable|documentdb|redshift)/, "database_server"],
7396
+ [/(elasticache|_redis|memcached|memorystore)/, "cache_server"],
7397
+ [/(s3_bucket|storage_bucket|gcs_bucket|storage_account|_blob)/, "database"],
7398
+ [/(_sqs|_queue|servicebus_queue)/, "queue"],
7399
+ [/(_sns|_topic|pubsub_topic|servicebus_topic)/, "topic"],
7400
+ [/(kafka|_msk|event_hub|kinesis)/, "message_broker"],
7401
+ [/(_eks|_gke|_aks|kubernetes_cluster|container_cluster)/, "k8s_cluster"],
7402
+ [/(ecs_|_container|fargate)/, "container"],
7403
+ [/(lambda|cloud_function|cloudfunctions|function_app|cloud_run)/, "web_service"],
7404
+ [/(_lb$|load_balancer|_alb|_elb|application_gateway)/, "web_service"],
7405
+ [/(api_gateway|apigateway)/, "api_endpoint"],
7406
+ [/(_instance|virtual_machine|_vm$|compute_instance)/, "host"]
7407
+ ];
7408
+ function terraformTypeToNode(tfType) {
7409
+ const t = tfType.toLowerCase();
7410
+ for (const [re, nt] of TYPE_RULES) if (re.test(t)) return nt;
7411
+ return "unknown";
7412
+ }
7413
+ var IDENTITY_ATTRS = ["id", "arn", "region", "location", "instance_type", "engine", "machine_type"];
7414
+ var OWNER_TAGS = ["Owner", "owner", "Team", "team"];
7415
+ var SAFE_TAG_KEYS = /* @__PURE__ */ new Set(["Name", "name", "Owner", "owner", "Team", "team", "Env", "env", "Environment", "environment", "Service", "service", "Component", "component", "App", "app", "Project", "project", "Tier", "tier", "Role", "role"]);
7416
+ var SECRET_KEY = /pass|secret|token|key|pwd|cred|private/i;
7417
+ function attrTags(tags) {
7418
+ if (!tags || typeof tags !== "object") return [];
7419
+ return Object.entries(tags).filter(([k]) => SAFE_TAG_KEYS.has(k) && !SECRET_KEY.test(k)).map(([k, v]) => `${k}:${redactSecrets(String(v))}`);
7420
+ }
7421
+ function parseTerraformState(json2) {
7422
+ let state;
7423
+ try {
7424
+ state = JSON.parse(json2);
7425
+ } catch {
7426
+ return { nodes: [], edges: [] };
7427
+ }
7428
+ const resources = Array.isArray(state?.resources) ? state.resources : [];
7429
+ const nodes = [];
7430
+ const edges = [];
7431
+ const addrToId = /* @__PURE__ */ new Map();
7432
+ for (const raw of resources) {
7433
+ const r = raw;
7434
+ if (r.mode && r.mode !== "managed") continue;
7435
+ if (typeof r.type !== "string" || typeof r.name !== "string") continue;
7436
+ const address = `${r.type}.${r.name}`;
7437
+ const nt = terraformTypeToNode(r.type);
7438
+ const id = `${nt}:terraform:${address}`;
7439
+ if (addrToId.has(address)) continue;
7440
+ addrToId.set(address, id);
7441
+ const inst = Array.isArray(r.instances) ? r.instances[0] : void 0;
7442
+ const attrs = inst?.attributes ?? {};
7443
+ const identity = { source: "terraform", tfType: r.type };
7444
+ for (const k of IDENTITY_ATTRS) if (attrs[k] !== void 0) identity[k] = attrs[k];
7445
+ const owner = OWNER_TAGS.map((k) => attrs.tags?.[k]).find((v) => typeof v === "string");
7446
+ nodes.push({
7447
+ id,
7448
+ type: nt,
7449
+ name: address,
7450
+ discoveredVia: "terraform-state",
7451
+ confidence: 0.9,
7452
+ // IaC is authoritative declared intent.
7453
+ metadata: redactValue(identity),
7454
+ tags: attrTags(attrs.tags),
7455
+ ...owner ? { owner } : {}
7456
+ });
7457
+ }
7458
+ for (const raw of resources) {
7459
+ const r = raw;
7460
+ if (r.mode && r.mode !== "managed") continue;
7461
+ if (typeof r.type !== "string" || typeof r.name !== "string") continue;
7462
+ const srcId = addrToId.get(`${r.type}.${r.name}`);
7463
+ if (!srcId) continue;
7464
+ const inst = Array.isArray(r.instances) ? r.instances[0] : void 0;
7465
+ const deps = Array.isArray(inst?.dependencies) ? inst.dependencies : [];
7466
+ for (const dep of deps) {
7467
+ if (typeof dep !== "string") continue;
7468
+ const tgtId = addrToId.get(dep) ?? addrToId.get(dep.split("[")[0]);
7469
+ if (!tgtId || tgtId === srcId) continue;
7470
+ edges.push({ sourceId: srcId, targetId: tgtId, relationship: "depends_on", evidence: evidenceLine("config-declared", `terraform depends_on ${dep}`), confidence: 0.85 });
7471
+ }
7472
+ }
7473
+ return { nodes, edges };
7474
+ }
7475
+ function stateDirs() {
7476
+ return [".", "./terraform", "./infra", "./infrastructure", "./deploy", "./terraform/environments"];
7477
+ }
7478
+ function hintPath(hint) {
7479
+ if (!hint) return void 0;
7480
+ const m = /(?:^|[\s,])tfstate=([^\s,]+)/.exec(hint);
7481
+ return m ? m[1] : void 0;
7482
+ }
7483
+ function resolveStatePath(ctx) {
7484
+ const explicit = hintPath(ctx.hint);
7485
+ if (explicit) return explicit;
7486
+ const found = (ctx.findFiles ?? findFiles)(stateDirs(), ["*.tfstate"], 4, 20).split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
7487
+ return found[0];
7488
+ }
7489
+ function readStateFile(path) {
7490
+ try {
7491
+ return readFileSync4(path, "utf8");
7492
+ } catch {
7493
+ return "";
7494
+ }
7495
+ }
7496
+ var terraformScanner = {
7497
+ id: "terraform-state",
7498
+ title: "Terraform state (IaC)",
7499
+ platforms: "all",
7500
+ // No shell commands — the state file is read directly via node:fs, so an
7501
+ // operator-supplied path can never inject a command (no `cat "${path}"` interpolation).
7502
+ detect(ctx) {
7503
+ return resolveStatePath(ctx) !== void 0;
7504
+ },
7505
+ async scan(ctx) {
7506
+ const path = resolveStatePath(ctx);
7507
+ if (!path) return { nodes: [], edges: [] };
7508
+ const json2 = (ctx.readFile ?? readStateFile)(path);
7509
+ if (!json2) return { nodes: [], edges: [] };
7510
+ const result = parseTerraformState(json2);
7511
+ return { ...result, report: `terraform-state: ${result.nodes.length} resources, ${result.edges.length} dependencies from ${path}` };
7512
+ }
7513
+ };
7514
+
7246
7515
  // src/scanners/registry.ts
7247
7516
  function defaultRegistry() {
7248
- return new ScannerRegistry().register(bookmarksScanner).register(installedAppsScanner).register(portsScanner).register(cloudAwsScanner).register(cloudGcpScanner).register(cloudAzureScanner).register(k8sScanner).register(databasesScanner).register(connectionsScanner).register(serviceConfigScanner);
7517
+ return new ScannerRegistry().register(bookmarksScanner).register(installedAppsScanner).register(portsScanner).register(cloudAwsScanner).register(cloudGcpScanner).register(cloudAzureScanner).register(k8sScanner).register(databasesScanner).register(connectionsScanner).register(serviceConfigScanner).register(terraformScanner);
7249
7518
  }
7250
7519
 
7251
7520
  // src/scanners/loader.ts
@@ -8613,13 +8882,13 @@ var AuthConfigSchema = z9.object({
8613
8882
  });
8614
8883
 
8615
8884
  // src/api/start.ts
8616
- import { readFileSync as readFileSync4 } from "fs";
8885
+ import { readFileSync as readFileSync5 } from "fs";
8617
8886
  import { dirname as dirname3, resolve } from "path";
8618
8887
  import { fileURLToPath } from "url";
8619
8888
  function readVersion() {
8620
8889
  try {
8621
8890
  const dir = import.meta.dirname ?? dirname3(fileURLToPath(import.meta.url));
8622
- return JSON.parse(readFileSync4(resolve(dir, "..", "package.json"), "utf-8")).version ?? "0.0.0";
8891
+ return JSON.parse(readFileSync5(resolve(dir, "..", "package.json"), "utf-8")).version ?? "0.0.0";
8623
8892
  } catch {
8624
8893
  return "0.0.0";
8625
8894
  }
@@ -8750,7 +9019,7 @@ function defaultServerEntry(opts = {}) {
8750
9019
  }
8751
9020
 
8752
9021
  // src/installer/install.ts
8753
- import { mkdirSync as mkdirSync4, readFileSync as readFileSync5, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "fs";
9022
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "fs";
8754
9023
  import { dirname as dirname4 } from "path";
8755
9024
  import { homedir as homedir3 } from "os";
8756
9025
  function currentOs() {
@@ -8767,7 +9036,7 @@ function planInstall(spec, ctx, opts) {
8767
9036
  throw new Error(`${spec.label} does not support the "${ctx.scope}" scope.`);
8768
9037
  }
8769
9038
  const fileExists = existsSync4(path);
8770
- const before = fileExists ? readFileSync5(path, "utf8") : "";
9039
+ const before = fileExists ? readFileSync6(path, "utf8") : "";
8771
9040
  const existing = parseConfig(before, spec.format);
8772
9041
  const merged = spec.apply(existing, opts.serverName ?? DEFAULT_SERVER_NAME, opts.entry);
8773
9042
  const after = serializeConfig(merged, spec.format);
@@ -9633,7 +9902,7 @@ Use ask_user when you need context from the user.`;
9633
9902
  }
9634
9903
 
9635
9904
  // src/cost.ts
9636
- import { readFileSync as readFileSync6 } from "fs";
9905
+ import { readFileSync as readFileSync7 } from "fs";
9637
9906
  import { resolve as resolve2 } from "path";
9638
9907
  function splitCsvLine(line) {
9639
9908
  const out = [];
@@ -9712,7 +9981,7 @@ var CsvCostSource = class {
9712
9981
  }
9713
9982
  id;
9714
9983
  async fetch() {
9715
- const text = readFileSync6(resolve2(this.opts.filePath), "utf-8");
9984
+ const text = readFileSync7(resolve2(this.opts.filePath), "utf-8");
9716
9985
  const records = parseCostCsv(text);
9717
9986
  const match = this.opts.match ?? "nodeId";
9718
9987
  const out = /* @__PURE__ */ new Map();
@@ -11477,7 +11746,7 @@ function formatComplianceText(report) {
11477
11746
  }
11478
11747
 
11479
11748
  // src/config.ts
11480
- import { readFileSync as readFileSync7 } from "fs";
11749
+ import { readFileSync as readFileSync8 } from "fs";
11481
11750
  var ConfigError = class extends Error {
11482
11751
  constructor(message) {
11483
11752
  super(message);
@@ -11502,7 +11771,7 @@ function loadConfig(path) {
11502
11771
  function readConfigFile(path) {
11503
11772
  let raw;
11504
11773
  try {
11505
- raw = readFileSync7(path, "utf-8");
11774
+ raw = readFileSync8(path, "utf-8");
11506
11775
  } catch (err) {
11507
11776
  throw new ConfigError(
11508
11777
  `Cannot read config file ${path}: ${err instanceof Error ? err.message : String(err)}`
@@ -11860,14 +12129,14 @@ function runSyncClassify(db, sessionId, config, opts = {}) {
11860
12129
 
11861
12130
  // src/preflight.ts
11862
12131
  import { execSync as execSync2 } from "child_process";
11863
- import { existsSync as existsSync5, readFileSync as readFileSync8 } from "fs";
12132
+ import { existsSync as existsSync5, readFileSync as readFileSync9 } from "fs";
11864
12133
  import { join as join6 } from "path";
11865
12134
  function isOAuthLoggedIn() {
11866
12135
  const home = process.env.HOME ?? process.env.USERPROFILE ?? "/tmp";
11867
12136
  const credFile = join6(home, ".claude", ".credentials.json");
11868
12137
  if (!existsSync5(credFile)) return false;
11869
12138
  try {
11870
- const creds = JSON.parse(readFileSync8(credFile, "utf8"));
12139
+ const creds = JSON.parse(readFileSync9(credFile, "utf8"));
11871
12140
  const oauth = creds["claudeAiOauth"];
11872
12141
  return typeof oauth?.["accessToken"] === "string" && oauth["accessToken"].length > 0;
11873
12142
  } catch {
@@ -11924,6 +12193,7 @@ export {
11924
12193
  AuthorizationError,
11925
12194
  CLIENTS,
11926
12195
  CONFIDENCE,
12196
+ CORRELATION_CONFIDENCE,
11927
12197
  CartographyDB,
11928
12198
  ComplianceReportSchema,
11929
12199
  ComplianceRuleSchema,
@@ -12010,6 +12280,7 @@ export {
12010
12280
  computeIdentity,
12011
12281
  connectionsScanner,
12012
12282
  contentHash,
12283
+ correlateTopology,
12013
12284
  createBashTool,
12014
12285
  createCartographyTools,
12015
12286
  createClaudeProvider,
@@ -12057,6 +12328,7 @@ export {
12057
12328
  exportJGF,
12058
12329
  exportJSON,
12059
12330
  extractListeningPorts,
12331
+ extractSignals,
12060
12332
  filterBySeverity,
12061
12333
  findAnonViolations,
12062
12334
  formatComplianceText,
@@ -12123,6 +12395,7 @@ export {
12123
12395
  parseNginxUpstreams,
12124
12396
  parseNlQuery,
12125
12397
  parseScanHint,
12398
+ parseTerraformState,
12126
12399
  pixelToHex,
12127
12400
  planInstall,
12128
12401
  portsScanner,
@@ -12172,6 +12445,8 @@ export {
12172
12445
  stableStringify,
12173
12446
  startApi,
12174
12447
  stripSensitive,
12448
+ terraformScanner,
12449
+ terraformTypeToNode,
12175
12450
  timingSafeEqual,
12176
12451
  toBackstageEntities,
12177
12452
  validateScanner,