chainlesschain 0.47.8 → 0.47.9

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.
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Social Graph — typed directed edges between DIDs, with a real-time
3
+ * event stream for "graph changed" notifications.
4
+ *
5
+ * Scope (CLI-first MVP):
6
+ * - add/remove edges (follow / friend / like / mention / block)
7
+ * - neighbor queries (out/in, optional edge type filter)
8
+ * - graph snapshot (nodes + edges) for one-shot exports
9
+ * - EventEmitter-style subscribe/unsubscribe so any consumer
10
+ * (CLI `watch`, future WebSocket route, Desktop renderer) can
11
+ * render graph changes live instead of polling
12
+ *
13
+ * What this module is NOT:
14
+ * - A centrality / community-detection engine. That lives in the
15
+ * desktop `social-graph.js` and operates on snapshots produced
16
+ * here. Keeping the CLI lib thin makes the event stream easy to
17
+ * reason about and test.
18
+ *
19
+ * Persistence: optional. When a `db` handle is passed into
20
+ * `ensureGraphTables(db)` and mutation helpers, edges survive
21
+ * process restarts; otherwise the graph is in-memory only.
22
+ */
23
+
24
+ import { EventEmitter } from "events";
25
+
26
+ /* ── Edge types ────────────────────────────────────────────── */
27
+
28
+ export const EDGE_TYPES = Object.freeze([
29
+ "follow",
30
+ "friend",
31
+ "like",
32
+ "mention",
33
+ "block",
34
+ ]);
35
+
36
+ function _validateEdgeType(edgeType) {
37
+ if (!EDGE_TYPES.includes(edgeType)) {
38
+ throw new Error(
39
+ `Invalid edge type "${edgeType}"; expected one of ${EDGE_TYPES.join(", ")}`,
40
+ );
41
+ }
42
+ }
43
+
44
+ /* ── In-memory state ──────────────────────────────────────── */
45
+
46
+ // Adjacency: Map<sourceDid, Map<`${targetDid}|${type}`, edge>>
47
+ const _outgoing = new Map();
48
+ // Reverse adjacency: Map<targetDid, Map<`${sourceDid}|${type}`, edge>>
49
+ const _incoming = new Map();
50
+ // Node metadata: Map<did, { did, firstSeen, lastSeen, edgeCount }>
51
+ const _nodes = new Map();
52
+
53
+ // Shared EventEmitter. Bumped to a high max so many CLI `watch`
54
+ // processes or downstream WS clients don't trip the listener warning.
55
+ const _bus = new EventEmitter();
56
+ _bus.setMaxListeners(0);
57
+
58
+ /* ── Schema ───────────────────────────────────────────────── */
59
+
60
+ export function ensureGraphTables(db) {
61
+ if (!db) return;
62
+ db.exec(`
63
+ CREATE TABLE IF NOT EXISTS social_graph_edges (
64
+ source_did TEXT NOT NULL,
65
+ target_did TEXT NOT NULL,
66
+ edge_type TEXT NOT NULL,
67
+ weight REAL DEFAULT 1.0,
68
+ metadata TEXT,
69
+ created_at TEXT DEFAULT (datetime('now')),
70
+ updated_at TEXT DEFAULT (datetime('now')),
71
+ PRIMARY KEY (source_did, target_did, edge_type)
72
+ )
73
+ `);
74
+ db.exec(`
75
+ CREATE INDEX IF NOT EXISTS idx_social_graph_source
76
+ ON social_graph_edges (source_did, edge_type);
77
+ `);
78
+ db.exec(`
79
+ CREATE INDEX IF NOT EXISTS idx_social_graph_target
80
+ ON social_graph_edges (target_did, edge_type);
81
+ `);
82
+ }
83
+
84
+ /* ── Helpers ──────────────────────────────────────────────── */
85
+
86
+ function _edgeKey(targetDid, edgeType) {
87
+ return `${targetDid}|${edgeType}`;
88
+ }
89
+
90
+ function _touchNode(did, now) {
91
+ let node = _nodes.get(did);
92
+ if (!node) {
93
+ node = { did, firstSeen: now, lastSeen: now, edgeCount: 0 };
94
+ _nodes.set(did, node);
95
+ _bus.emit("node:added", { did, at: now });
96
+ } else {
97
+ node.lastSeen = now;
98
+ }
99
+ return node;
100
+ }
101
+
102
+ function _incEdgeCount(did, delta) {
103
+ const node = _nodes.get(did);
104
+ if (!node) return;
105
+ node.edgeCount = Math.max(0, node.edgeCount + delta);
106
+ if (node.edgeCount === 0 && delta < 0) {
107
+ _nodes.delete(did);
108
+ _bus.emit("node:removed", { did });
109
+ }
110
+ }
111
+
112
+ /* ── Mutations ────────────────────────────────────────────── */
113
+
114
+ /**
115
+ * Add or update a directed edge. Idempotent — same (source, target,
116
+ * type) triple gets its weight/metadata updated and emits `edge:updated`
117
+ * instead of `edge:added`.
118
+ *
119
+ * @returns {{edge: object, created: boolean}}
120
+ */
121
+ export function addEdge(db, sourceDid, targetDid, edgeType, opts = {}) {
122
+ if (!sourceDid || !targetDid) {
123
+ throw new Error("sourceDid and targetDid are required");
124
+ }
125
+ if (sourceDid === targetDid) {
126
+ throw new Error("Self-edges are not allowed");
127
+ }
128
+ _validateEdgeType(edgeType);
129
+ const weight = Number.isFinite(opts.weight) ? opts.weight : 1.0;
130
+ const metadata = opts.metadata ?? null;
131
+ const now = opts.timestamp || new Date().toISOString();
132
+
133
+ _touchNode(sourceDid, now);
134
+ _touchNode(targetDid, now);
135
+
136
+ const outBucket = _outgoing.get(sourceDid) || new Map();
137
+ const inBucket = _incoming.get(targetDid) || new Map();
138
+ const outKey = _edgeKey(targetDid, edgeType);
139
+ const inKey = _edgeKey(sourceDid, edgeType);
140
+
141
+ const existing = outBucket.get(outKey);
142
+ const created = !existing;
143
+ const edge = {
144
+ sourceDid,
145
+ targetDid,
146
+ edgeType,
147
+ weight,
148
+ metadata,
149
+ createdAt: existing ? existing.createdAt : now,
150
+ updatedAt: now,
151
+ };
152
+ outBucket.set(outKey, edge);
153
+ inBucket.set(inKey, edge);
154
+ _outgoing.set(sourceDid, outBucket);
155
+ _incoming.set(targetDid, inBucket);
156
+
157
+ if (created) {
158
+ _incEdgeCount(sourceDid, 1);
159
+ _incEdgeCount(targetDid, 1);
160
+ }
161
+
162
+ if (db) {
163
+ const stmt = db.prepare(`
164
+ INSERT INTO social_graph_edges (source_did, target_did, edge_type, weight, metadata, created_at, updated_at)
165
+ VALUES (?, ?, ?, ?, ?, ?, ?)
166
+ ON CONFLICT(source_did, target_did, edge_type) DO UPDATE SET
167
+ weight = excluded.weight,
168
+ metadata = excluded.metadata,
169
+ updated_at = excluded.updated_at
170
+ `);
171
+ stmt.run(
172
+ sourceDid,
173
+ targetDid,
174
+ edgeType,
175
+ weight,
176
+ metadata ? JSON.stringify(metadata) : null,
177
+ edge.createdAt,
178
+ edge.updatedAt,
179
+ );
180
+ }
181
+
182
+ _bus.emit(created ? "edge:added" : "edge:updated", { ...edge });
183
+ _bus.emit("change", {
184
+ kind: created ? "edge:added" : "edge:updated",
185
+ edge: { ...edge },
186
+ });
187
+
188
+ return { edge: { ...edge }, created };
189
+ }
190
+
191
+ /**
192
+ * Remove an edge. Returns { removed: boolean, edge? }.
193
+ */
194
+ export function removeEdge(db, sourceDid, targetDid, edgeType) {
195
+ _validateEdgeType(edgeType);
196
+ const outBucket = _outgoing.get(sourceDid);
197
+ const inBucket = _incoming.get(targetDid);
198
+ if (!outBucket || !inBucket) return { removed: false };
199
+
200
+ const outKey = _edgeKey(targetDid, edgeType);
201
+ const edge = outBucket.get(outKey);
202
+ if (!edge) return { removed: false };
203
+
204
+ outBucket.delete(outKey);
205
+ inBucket.delete(_edgeKey(sourceDid, edgeType));
206
+ if (outBucket.size === 0) _outgoing.delete(sourceDid);
207
+ if (inBucket.size === 0) _incoming.delete(targetDid);
208
+
209
+ _incEdgeCount(sourceDid, -1);
210
+ _incEdgeCount(targetDid, -1);
211
+
212
+ if (db) {
213
+ db.prepare(
214
+ `DELETE FROM social_graph_edges
215
+ WHERE source_did = ? AND target_did = ? AND edge_type = ?`,
216
+ ).run(sourceDid, targetDid, edgeType);
217
+ }
218
+
219
+ const payload = { ...edge };
220
+ _bus.emit("edge:removed", payload);
221
+ _bus.emit("change", { kind: "edge:removed", edge: payload });
222
+
223
+ return { removed: true, edge: payload };
224
+ }
225
+
226
+ /* ── Queries ──────────────────────────────────────────────── */
227
+
228
+ export function getNode(did) {
229
+ const node = _nodes.get(did);
230
+ return node ? { ...node } : null;
231
+ }
232
+
233
+ /**
234
+ * List outgoing edges from `did`. Optionally filter by `edgeType`.
235
+ */
236
+ export function getOutgoing(did, edgeType) {
237
+ const bucket = _outgoing.get(did);
238
+ if (!bucket) return [];
239
+ const out = [];
240
+ for (const edge of bucket.values()) {
241
+ if (edgeType && edge.edgeType !== edgeType) continue;
242
+ out.push({ ...edge });
243
+ }
244
+ return out;
245
+ }
246
+
247
+ /**
248
+ * List incoming edges to `did`. Optionally filter by `edgeType`.
249
+ */
250
+ export function getIncoming(did, edgeType) {
251
+ const bucket = _incoming.get(did);
252
+ if (!bucket) return [];
253
+ const out = [];
254
+ for (const edge of bucket.values()) {
255
+ if (edgeType && edge.edgeType !== edgeType) continue;
256
+ out.push({ ...edge });
257
+ }
258
+ return out;
259
+ }
260
+
261
+ /**
262
+ * Union of outgoing + incoming neighbors (returns unique DIDs).
263
+ * Direction filter: "out" | "in" | "both" (default "both").
264
+ */
265
+ export function getNeighbors(did, opts = {}) {
266
+ const { direction = "both", edgeType } = opts;
267
+ const result = new Set();
268
+ if (direction === "out" || direction === "both") {
269
+ for (const e of getOutgoing(did, edgeType)) result.add(e.targetDid);
270
+ }
271
+ if (direction === "in" || direction === "both") {
272
+ for (const e of getIncoming(did, edgeType)) result.add(e.sourceDid);
273
+ }
274
+ return [...result];
275
+ }
276
+
277
+ /**
278
+ * Snapshot of the entire graph. Callers should treat this as a
279
+ * point-in-time view; subsequent mutations do not propagate into
280
+ * an already-returned snapshot.
281
+ */
282
+ export function getGraphSnapshot(opts = {}) {
283
+ const { edgeType } = opts;
284
+ const nodes = [..._nodes.values()].map((n) => ({ ...n }));
285
+ const edges = [];
286
+ for (const bucket of _outgoing.values()) {
287
+ for (const edge of bucket.values()) {
288
+ if (edgeType && edge.edgeType !== edgeType) continue;
289
+ edges.push({ ...edge });
290
+ }
291
+ }
292
+ return {
293
+ nodes,
294
+ edges,
295
+ stats: {
296
+ nodeCount: nodes.length,
297
+ edgeCount: edges.length,
298
+ types: _countByType(edges),
299
+ generatedAt: new Date().toISOString(),
300
+ },
301
+ };
302
+ }
303
+
304
+ function _countByType(edges) {
305
+ const out = {};
306
+ for (const e of edges) out[e.edgeType] = (out[e.edgeType] || 0) + 1;
307
+ return out;
308
+ }
309
+
310
+ /* ── Persistence ──────────────────────────────────────────── */
311
+
312
+ /**
313
+ * Load edges from SQLite into memory. Safe to call repeatedly;
314
+ * existing in-memory edges are replaced.
315
+ */
316
+ export function loadFromDb(db) {
317
+ if (!db) return 0;
318
+ _outgoing.clear();
319
+ _incoming.clear();
320
+ _nodes.clear();
321
+ const rows = db
322
+ .prepare(
323
+ `SELECT source_did, target_did, edge_type, weight, metadata, created_at, updated_at
324
+ FROM social_graph_edges`,
325
+ )
326
+ .all();
327
+ for (const row of rows) {
328
+ const metadata = row.metadata ? JSON.parse(row.metadata) : null;
329
+ // Hydrate without re-emitting events (bulk load).
330
+ _hydrateEdge({
331
+ sourceDid: row.source_did,
332
+ targetDid: row.target_did,
333
+ edgeType: row.edge_type,
334
+ weight: row.weight,
335
+ metadata,
336
+ createdAt: row.created_at,
337
+ updatedAt: row.updated_at,
338
+ });
339
+ }
340
+ return rows.length;
341
+ }
342
+
343
+ function _hydrateEdge(edge) {
344
+ _touchNode(edge.sourceDid, edge.createdAt);
345
+ _touchNode(edge.targetDid, edge.createdAt);
346
+ const outBucket = _outgoing.get(edge.sourceDid) || new Map();
347
+ const inBucket = _incoming.get(edge.targetDid) || new Map();
348
+ outBucket.set(_edgeKey(edge.targetDid, edge.edgeType), edge);
349
+ inBucket.set(_edgeKey(edge.sourceDid, edge.edgeType), edge);
350
+ _outgoing.set(edge.sourceDid, outBucket);
351
+ _incoming.set(edge.targetDid, inBucket);
352
+ _incEdgeCount(edge.sourceDid, 1);
353
+ _incEdgeCount(edge.targetDid, 1);
354
+ }
355
+
356
+ /* ── Subscription ─────────────────────────────────────────── */
357
+
358
+ /**
359
+ * Subscribe to graph events. Returns an unsubscribe function.
360
+ *
361
+ * Events emitted:
362
+ * - "edge:added" → { sourceDid, targetDid, edgeType, weight, ... }
363
+ * - "edge:updated" → (same shape; fired when addEdge upserts)
364
+ * - "edge:removed" → { sourceDid, targetDid, edgeType, ... }
365
+ * - "node:added" → { did, at }
366
+ * - "node:removed" → { did }
367
+ * - "change" → { kind, edge } (union wrapper for NDJSON tails)
368
+ */
369
+ export function subscribe(listener, opts = {}) {
370
+ const { events } = opts;
371
+ const names =
372
+ Array.isArray(events) && events.length > 0
373
+ ? events
374
+ : [
375
+ "edge:added",
376
+ "edge:updated",
377
+ "edge:removed",
378
+ "node:added",
379
+ "node:removed",
380
+ ];
381
+ const wrapped = {};
382
+ for (const name of names) {
383
+ wrapped[name] = (payload) => listener({ type: name, payload });
384
+ _bus.on(name, wrapped[name]);
385
+ }
386
+ return function unsubscribe() {
387
+ for (const name of Object.keys(wrapped)) {
388
+ _bus.removeListener(name, wrapped[name]);
389
+ }
390
+ };
391
+ }
392
+
393
+ /**
394
+ * Raw EventEmitter access — mainly for tests and the `change` union
395
+ * event that `subscribe` doesn't forward by default.
396
+ */
397
+ export function getEventBus() {
398
+ return _bus;
399
+ }
400
+
401
+ /* ── Reset (for testing) ─────────────────────────────────── */
402
+
403
+ export function _resetState() {
404
+ _outgoing.clear();
405
+ _incoming.clear();
406
+ _nodes.clear();
407
+ _bus.removeAllListeners();
408
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * STIX 2.1 Indicator Parser — extracts IoC (indicators of compromise)
3
+ * from STIX 2.1 bundles or loose objects.
4
+ *
5
+ * Supports the most common observable types used in threat feeds:
6
+ * file-hash (md5/sha1/sha256/sha512), ipv4-addr, ipv6-addr,
7
+ * domain-name, url, email-addr.
8
+ *
9
+ * Reference: https://docs.oasis-open.org/cti/stix/v2.1/stix-v2.1.html
10
+ *
11
+ * The STIX pattern grammar is too rich for a full parser to live here;
12
+ * this module handles the simple `[<object-path> = '<value>']` shape
13
+ * (optionally joined by OR / AND / FOLLOWEDBY) that covers the vast
14
+ * majority of real-world indicator feeds (AlienVault OTX, MISP export,
15
+ * MITRE ATT&CK, etc.).
16
+ */
17
+
18
+ export const IOC_TYPES = Object.freeze([
19
+ "file-md5",
20
+ "file-sha1",
21
+ "file-sha256",
22
+ "file-sha512",
23
+ "ipv4",
24
+ "ipv6",
25
+ "domain",
26
+ "url",
27
+ "email",
28
+ ]);
29
+
30
+ /**
31
+ * Map a STIX object-path + hash-key to one of our IOC_TYPES.
32
+ * Returns null for unsupported observable types.
33
+ */
34
+ function _classify(objectPath, hashKey) {
35
+ const p = objectPath.toLowerCase();
36
+ switch (p) {
37
+ case "file:hashes":
38
+ switch ((hashKey || "").toUpperCase()) {
39
+ case "MD5":
40
+ return "file-md5";
41
+ case "SHA-1":
42
+ case "SHA1":
43
+ return "file-sha1";
44
+ case "SHA-256":
45
+ case "SHA256":
46
+ return "file-sha256";
47
+ case "SHA-512":
48
+ case "SHA512":
49
+ return "file-sha512";
50
+ default:
51
+ return null;
52
+ }
53
+ case "ipv4-addr:value":
54
+ return "ipv4";
55
+ case "ipv6-addr:value":
56
+ return "ipv6";
57
+ case "domain-name:value":
58
+ return "domain";
59
+ case "url:value":
60
+ return "url";
61
+ case "email-addr:value":
62
+ return "email";
63
+ default:
64
+ return null;
65
+ }
66
+ }
67
+
68
+ const _termRegex =
69
+ /([a-z0-9_-]+(?::[a-z0-9_-]+)?)(?:\.'([^']+)'|\.([a-z0-9_-]+))?\s*=\s*'((?:\\'|[^'])*)'/gi;
70
+
71
+ /**
72
+ * Parse a STIX pattern string and yield raw matches of the simple
73
+ * comparison form: `<object:path>[.<key>] = '<value>'`. Joiners
74
+ * (OR/AND/FOLLOWEDBY) between terms are ignored — each matched term
75
+ * becomes its own IoC.
76
+ */
77
+ export function parseStixPattern(pattern) {
78
+ if (typeof pattern !== "string") return [];
79
+ const out = [];
80
+ let m;
81
+ _termRegex.lastIndex = 0;
82
+ while ((m = _termRegex.exec(pattern)) !== null) {
83
+ const [, rawObjectPath, quotedKey, bareKey, rawValue] = m;
84
+ const hashKey = quotedKey || bareKey || null;
85
+ const objectPath = hashKey
86
+ ? `${rawObjectPath.toLowerCase()}`
87
+ : rawObjectPath.toLowerCase();
88
+ const iocType = _classify(objectPath, hashKey);
89
+ if (!iocType) continue;
90
+ const value = rawValue.replace(/\\'/g, "'");
91
+ out.push({ type: iocType, value });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ /**
97
+ * Extract indicators from a parsed STIX 2.1 object. Returns an array
98
+ * of `{type, value, source: {indicatorId, name, labels, confidence,
99
+ * valid_from, valid_until}}` entries.
100
+ */
101
+ export function extractIndicatorsFromObject(obj) {
102
+ if (!obj || typeof obj !== "object") return [];
103
+ if (obj.type !== "indicator") return [];
104
+ const pattern = obj.pattern;
105
+ if (!pattern) return [];
106
+ if (obj.pattern_type && obj.pattern_type !== "stix") {
107
+ // Only STIX-pattern indicators are supported; feeds may ship
108
+ // snort/yara/pcre patterns which we ignore.
109
+ return [];
110
+ }
111
+ const iocs = parseStixPattern(pattern);
112
+ const source = {
113
+ indicatorId: obj.id || null,
114
+ name: obj.name || null,
115
+ labels: Array.isArray(obj.indicator_types)
116
+ ? obj.indicator_types.slice()
117
+ : Array.isArray(obj.labels)
118
+ ? obj.labels.slice()
119
+ : [],
120
+ confidence: typeof obj.confidence === "number" ? obj.confidence : null,
121
+ validFrom: obj.valid_from || null,
122
+ validUntil: obj.valid_until || null,
123
+ };
124
+ return iocs.map((ioc) => ({ ...ioc, source }));
125
+ }
126
+
127
+ /**
128
+ * Extract indicators from a STIX 2.1 bundle (`{type:"bundle", objects:[…]}`)
129
+ * or a loose array of STIX objects. Unknown inputs return [].
130
+ */
131
+ export function extractIndicatorsFromBundle(bundle) {
132
+ if (!bundle) return [];
133
+ const objects = Array.isArray(bundle)
134
+ ? bundle
135
+ : bundle.type === "bundle" && Array.isArray(bundle.objects)
136
+ ? bundle.objects
137
+ : null;
138
+ if (!objects) return [];
139
+ const out = [];
140
+ for (const obj of objects) {
141
+ for (const ioc of extractIndicatorsFromObject(obj)) {
142
+ out.push(ioc);
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ /**
149
+ * Best-effort classifier for an arbitrary observable string supplied
150
+ * by the user (via `cc compliance threat-intel match <value>`).
151
+ * Returns one of IOC_TYPES or "unknown".
152
+ */
153
+ export function classifyObservable(value) {
154
+ if (typeof value !== "string") return "unknown";
155
+ const v = value.trim();
156
+ if (!v) return "unknown";
157
+ if (/^[a-f0-9]{32}$/i.test(v)) return "file-md5";
158
+ if (/^[a-f0-9]{40}$/i.test(v)) return "file-sha1";
159
+ if (/^[a-f0-9]{64}$/i.test(v)) return "file-sha256";
160
+ if (/^[a-f0-9]{128}$/i.test(v)) return "file-sha512";
161
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(v)) return "ipv4";
162
+ if (/^[0-9a-f:]+$/i.test(v) && v.includes(":")) return "ipv6";
163
+ if (/^https?:\/\//i.test(v)) return "url";
164
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v)) return "email";
165
+ if (/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i.test(v)) return "domain";
166
+ return "unknown";
167
+ }