@tracegraph/graph-engine 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,937 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ ANALYSE_RULES: () => ANALYSE_RULES,
24
+ analyseTraceFindings: () => analyseTraceFindings,
25
+ classifyRole: () => classifyRole,
26
+ computeFingerprint: () => computeFingerprint,
27
+ deriveTestId: () => deriveTestId,
28
+ diffBaseline: () => diffBaseline,
29
+ diffToFindings: () => diffToFindings,
30
+ evaluateFindings: () => evaluateFindings,
31
+ eventToSignature: () => eventToSignature,
32
+ extractShape: () => extractShape,
33
+ sessionToBaseline: () => sessionToBaseline,
34
+ signatureToIdentityHash: () => signatureToIdentityHash,
35
+ traceSessionToGraph: () => traceSessionToGraph
36
+ });
37
+ module.exports = __toCommonJS(index_exports);
38
+
39
+ // src/graph.ts
40
+ var NODE_COLORS = {
41
+ http_request: "#3b82f6",
42
+ // blue
43
+ http_response: "#3b82f6",
44
+ // blue
45
+ db_query: "#f97316",
46
+ // orange
47
+ authorization_check: "#ef4444",
48
+ // red
49
+ auth_check: "#ef4444",
50
+ // red
51
+ external_http_call: "#a855f7",
52
+ // purple
53
+ function_call: "#6b7280",
54
+ // grey
55
+ method_call: "#6b7280",
56
+ // grey
57
+ return: "#9ca3af",
58
+ // light grey
59
+ error: "#dc2626",
60
+ // crimson
61
+ queue_event: "#14b8a6",
62
+ // teal
63
+ trace_start: "#94a3b8",
64
+ // slate
65
+ trace_end: "#94a3b8",
66
+ // slate
67
+ log: "#a3a3a3",
68
+ // neutral
69
+ // Test runner nodes — green spectrum, dimmed for skips
70
+ test_file: "#4ade80",
71
+ // bright green
72
+ test_suite: "#86efac",
73
+ // medium green
74
+ test_run: "#bbf7d0",
75
+ // light green (pass colour; fail overridden in code)
76
+ vendor: "#cbd5e1"
77
+ // very light
78
+ };
79
+ var DEFAULT_COLOR = "#6b7280";
80
+ function getNodeColor(type) {
81
+ return NODE_COLORS[type] ?? DEFAULT_COLOR;
82
+ }
83
+ function getTestAwareColor(event) {
84
+ if (event.type === "test_run") {
85
+ const status = event.metadata?.["testStatus"];
86
+ if (status === "fail") return "#ef4444";
87
+ if (status === "skip") return "#d1d5db";
88
+ return NODE_COLORS.test_run ?? DEFAULT_COLOR;
89
+ }
90
+ return getNodeColor(event.type);
91
+ }
92
+ var VENDOR_PATH_RE = /(?:node_modules|\/vendor\/)[\\/](@[^\\/]+[\\/][^\\/]+|[^\\/]+)/;
93
+ function isVendorEvent(event) {
94
+ const f = event.file ?? "";
95
+ return VENDOR_PATH_RE.test(f);
96
+ }
97
+ function extractPackageName(file) {
98
+ const m = file.match(VENDOR_PATH_RE);
99
+ return m?.[1] ?? "vendor";
100
+ }
101
+ function vendorNodeId(pkg) {
102
+ return `vendor__${pkg.replace(/[^a-z0-9@]/gi, "_")}`;
103
+ }
104
+ function computeNodeSize(event) {
105
+ if (!event.durationMs) return 1;
106
+ return Math.max(1, Math.min(10, Math.ceil(Math.log10(event.durationMs + 1))));
107
+ }
108
+ function traceSessionToGraph(session) {
109
+ const nodes = [];
110
+ const edges = [];
111
+ const eventToNodeId = /* @__PURE__ */ new Map();
112
+ const vendorNodes = /* @__PURE__ */ new Map();
113
+ for (const event of session.events) {
114
+ if (event.name?.includes("tracegraph_xdebug_marker")) continue;
115
+ if (event.type === "trace_start" || event.type === "trace_end") {
116
+ }
117
+ if (isVendorEvent(event)) {
118
+ const pkg = extractPackageName(event.file);
119
+ const nodeId = vendorNodeId(pkg);
120
+ if (!vendorNodes.has(nodeId)) {
121
+ const vendorNode = {
122
+ id: nodeId,
123
+ label: pkg,
124
+ type: "vendor",
125
+ language: event.language,
126
+ color: NODE_COLORS.vendor,
127
+ size: 1,
128
+ data: event
129
+ };
130
+ vendorNodes.set(nodeId, vendorNode);
131
+ nodes.push(vendorNode);
132
+ }
133
+ eventToNodeId.set(event.eventId, nodeId);
134
+ continue;
135
+ }
136
+ const nodeType = event.type;
137
+ const node = {
138
+ id: event.eventId,
139
+ label: event.displayName ?? event.name,
140
+ displayName: event.displayName,
141
+ type: nodeType,
142
+ language: event.language,
143
+ framework: event.framework,
144
+ durationMs: event.durationMs,
145
+ file: event.file,
146
+ line: event.line,
147
+ color: getTestAwareColor(event),
148
+ size: computeNodeSize(event),
149
+ data: event
150
+ };
151
+ nodes.push(node);
152
+ eventToNodeId.set(event.eventId, event.eventId);
153
+ }
154
+ const asyncGroupSeen = /* @__PURE__ */ new Map();
155
+ for (const event of session.events) {
156
+ if (!event.parentEventId) continue;
157
+ const sourceNodeId = eventToNodeId.get(event.parentEventId);
158
+ const targetNodeId = eventToNodeId.get(event.eventId);
159
+ if (!sourceNodeId || !targetNodeId) continue;
160
+ if (sourceNodeId === targetNodeId) continue;
161
+ let edgeType = "parent";
162
+ if (event.asyncGroupId) {
163
+ const group = asyncGroupSeen.get(event.asyncGroupId) ?? [];
164
+ if (group.length > 0) edgeType = "parallel_branch";
165
+ group.push(targetNodeId);
166
+ asyncGroupSeen.set(event.asyncGroupId, group);
167
+ }
168
+ const edgeId = `${sourceNodeId}\u2192${targetNodeId}`;
169
+ if (!edges.some((e) => e.id === edgeId)) {
170
+ edges.push({ id: edgeId, source: sourceNodeId, target: targetNodeId, type: edgeType });
171
+ }
172
+ }
173
+ return { nodes, edges, captureLevel: session.captureLevel };
174
+ }
175
+
176
+ // src/signature.ts
177
+ var import_node_crypto = require("crypto");
178
+ var VALIDATION_RE = /validate|verify|check|assert|ensure|guard|permission|authorize/i;
179
+ var AUTH_EVENT_TYPES = /* @__PURE__ */ new Set(["auth_check", "authorization_check"]);
180
+ function classifyRole(event) {
181
+ if (AUTH_EVENT_TYPES.has(event.type)) return "authorization";
182
+ if (event.type === "db_query") return "db";
183
+ if (event.type === "external_http_call") return "external_call";
184
+ const namesToCheck = [
185
+ event.functionName,
186
+ event.name,
187
+ event.displayName,
188
+ event.className
189
+ ].filter(Boolean).join(" ");
190
+ if (VALIDATION_RE.test(namesToCheck)) return "validation";
191
+ return "business_logic";
192
+ }
193
+ function eventToSignature(event) {
194
+ const role = classifyRole(event);
195
+ const routePathPattern = event.metadata?.["route"] ? normaliseRoutePath(String(event.metadata["route"])) : void 0;
196
+ const routeMethod = event.metadata?.["method"] ? String(event.metadata["method"]).toUpperCase() : void 0;
197
+ return {
198
+ eventType: event.type,
199
+ language: event.language,
200
+ framework: event.framework,
201
+ className: event.className,
202
+ methodName: event.functionName ?? void 0,
203
+ // functionName field on the event
204
+ functionName: event.name,
205
+ routeMethod,
206
+ routePathPattern,
207
+ resourceType: event.resource?.type,
208
+ resourceKey: event.resource?.key,
209
+ resourceOperation: event.resource?.operation,
210
+ role
211
+ };
212
+ }
213
+ function normaliseRoutePath(path) {
214
+ return path.replace(
215
+ /\/([^/]+)/g,
216
+ (_, segment) => {
217
+ if (segment.startsWith(":")) return `/${segment}`;
218
+ if (/^[0-9a-f-]{8,}$/i.test(segment) || /^\d+$/.test(segment)) return "/:param";
219
+ return `/${segment}`;
220
+ }
221
+ );
222
+ }
223
+ function signatureToIdentityHash(sig) {
224
+ const parts = [
225
+ sig.eventType ?? "",
226
+ sig.language ?? "",
227
+ sig.framework ?? "",
228
+ sig.className ?? "",
229
+ sig.methodName ?? "",
230
+ sig.functionName ?? "",
231
+ sig.routeMethod ?? "",
232
+ sig.routePathPattern ?? "",
233
+ sig.resourceType ?? "",
234
+ sig.resourceKey ?? "",
235
+ sig.resourceOperation ?? "",
236
+ sig.role ?? ""
237
+ ];
238
+ return (0, import_node_crypto.createHash)("sha256").update(parts.join("\0")).digest("hex").slice(0, 16);
239
+ }
240
+
241
+ // src/baseline.ts
242
+ var import_node_crypto2 = require("crypto");
243
+ var import_shared_types = require("@tracegraph/shared-types");
244
+ function sessionToBaseline(session, meta) {
245
+ const signatureMap = /* @__PURE__ */ new Map();
246
+ const resourceMap = /* @__PURE__ */ new Map();
247
+ let responseShape = { type: "unknown" };
248
+ for (const event of session.events) {
249
+ if (event.type === "trace_start" || event.type === "trace_end") continue;
250
+ if (event.type === "return") continue;
251
+ const sig = eventToSignature(event);
252
+ const hash = signatureToIdentityHash(sig);
253
+ const role = classifyRole(event);
254
+ const existing = signatureMap.get(hash);
255
+ if (existing) {
256
+ existing.count++;
257
+ } else {
258
+ signatureMap.set(hash, {
259
+ signature: sig,
260
+ role: String(role),
261
+ count: 1,
262
+ critical: isSecurityCritical(event)
263
+ });
264
+ }
265
+ if (event.type === "db_query" && event.resource) {
266
+ const rKey = `${event.resource.type}:${event.resource.key}:${event.resource.operation}`;
267
+ const r = resourceMap.get(rKey);
268
+ if (r) {
269
+ r.count++;
270
+ } else {
271
+ resourceMap.set(rKey, {
272
+ type: event.resource.type,
273
+ key: event.resource.key,
274
+ operation: event.resource.operation,
275
+ count: 1
276
+ });
277
+ }
278
+ }
279
+ if (event.type === "http_response" && event.output) {
280
+ responseShape = extractShape(event.output);
281
+ }
282
+ }
283
+ return {
284
+ schemaVersion: import_shared_types.SCHEMA_VERSIONS.baseline,
285
+ baselineId: `baseline_${createBaselineId(session)}`,
286
+ testId: deriveTestId(session.entrypoint),
287
+ entrypoint: session.entrypoint,
288
+ approvedAt: Date.now(),
289
+ approvedBy: meta.approvedBy,
290
+ reason: meta.reason,
291
+ captureLevel: session.captureLevel.overall,
292
+ events: Array.from(signatureMap.values()),
293
+ resources: Array.from(resourceMap.values()),
294
+ responseShape
295
+ };
296
+ }
297
+ function deriveTestId(entrypoint) {
298
+ let key;
299
+ switch (entrypoint.type) {
300
+ case "http_request":
301
+ key = `${entrypoint.method}:${entrypoint.path}`;
302
+ break;
303
+ case "test_case":
304
+ key = entrypoint.testName;
305
+ break;
306
+ case "function":
307
+ key = `fn:${entrypoint.functionName}`;
308
+ break;
309
+ case "cli_command":
310
+ key = `cmd:${entrypoint.command}`;
311
+ break;
312
+ default:
313
+ key = JSON.stringify(entrypoint);
314
+ }
315
+ return (0, import_node_crypto2.createHash)("sha256").update(key).digest("hex").slice(0, 12);
316
+ }
317
+ function createBaselineId(session) {
318
+ return (0, import_node_crypto2.createHash)("sha256").update(session.traceId + session.sessionId).digest("hex").slice(0, 12);
319
+ }
320
+ var SECURITY_CRITICAL_TYPES = /* @__PURE__ */ new Set(["auth_check", "authorization_check"]);
321
+ function isSecurityCritical(event) {
322
+ return SECURITY_CRITICAL_TYPES.has(event.type) || Boolean(event.security?.["critical"]);
323
+ }
324
+ function extractShape(value, depth = 0, maxDepth = 4) {
325
+ if (depth >= maxDepth) return { type: "unknown" };
326
+ if (value === null || value === void 0) return { type: "null" };
327
+ switch (typeof value) {
328
+ case "string":
329
+ return { type: "string" };
330
+ case "number":
331
+ return { type: "number" };
332
+ case "boolean":
333
+ return { type: "boolean" };
334
+ }
335
+ if (Array.isArray(value)) {
336
+ const itemShape = value.length > 0 ? extractShape(value[0], depth + 1, maxDepth) : { type: "unknown" };
337
+ return { type: "array", items: itemShape };
338
+ }
339
+ if (typeof value === "object") {
340
+ const properties = {};
341
+ for (const [k, v] of Object.entries(value)) {
342
+ properties[k] = extractShape(v, depth + 1, maxDepth);
343
+ }
344
+ return { type: "object", properties };
345
+ }
346
+ return { type: "unknown" };
347
+ }
348
+
349
+ // src/diff.ts
350
+ var import_trace_sanitizer = require("@tracegraph/trace-sanitizer");
351
+ function diffBaseline(baseline, candidate) {
352
+ const candidateHashes = /* @__PURE__ */ new Set();
353
+ const candidateSignatureChanges = [];
354
+ for (const event of candidate.events) {
355
+ if (event.type === "trace_start" || event.type === "trace_end") continue;
356
+ if (event.type === "return") continue;
357
+ const sig = eventToSignature(event);
358
+ const hash = signatureToIdentityHash(sig);
359
+ const role = classifyRole(event);
360
+ candidateHashes.add(hash);
361
+ candidateSignatureChanges.push({
362
+ signature: sig,
363
+ identityHash: hash,
364
+ role,
365
+ critical: isSecurityCritical2(event),
366
+ eventId: event.eventId,
367
+ eventName: event.name
368
+ });
369
+ }
370
+ const baselineHashes = new Set(
371
+ baseline.events.map((e) => signatureToIdentityHash(e.signature))
372
+ );
373
+ const removedSignatures = [];
374
+ for (const entry of baseline.events) {
375
+ const hash = signatureToIdentityHash(entry.signature);
376
+ if (!candidateHashes.has(hash)) {
377
+ removedSignatures.push({
378
+ signature: entry.signature,
379
+ identityHash: hash,
380
+ role: entry.role,
381
+ critical: entry.critical ?? false
382
+ });
383
+ }
384
+ }
385
+ const seenAdded = /* @__PURE__ */ new Set();
386
+ const addedSignatures = [];
387
+ for (const item of candidateSignatureChanges) {
388
+ if (!baselineHashes.has(item.identityHash) && !seenAdded.has(item.identityHash)) {
389
+ seenAdded.add(item.identityHash);
390
+ addedSignatures.push(item);
391
+ }
392
+ }
393
+ const candidateResources = buildResourceMap(candidate);
394
+ const changedResources = [];
395
+ const allResourceKeys = /* @__PURE__ */ new Set([
396
+ ...baseline.resources.map((r) => `${r.type}:${r.key}:${r.operation}`),
397
+ ...Object.keys(candidateResources)
398
+ ]);
399
+ for (const key of allResourceKeys) {
400
+ const [type, rKey, operation] = key.split(":");
401
+ const baselineCount = baseline.resources.find(
402
+ (r) => `${r.type}:${r.key}:${r.operation}` === key
403
+ )?.count ?? 0;
404
+ const candidateCount = candidateResources[key] ?? 0;
405
+ if (baselineCount !== candidateCount) {
406
+ changedResources.push({ type, key: rKey, operation, baselineCount, candidateCount });
407
+ }
408
+ }
409
+ const responseShapeChange = diffResponseShapes(baseline, candidate);
410
+ return {
411
+ traceId: candidate.traceId,
412
+ baselineId: baseline.baselineId,
413
+ addedSignatures,
414
+ removedSignatures,
415
+ changedResources,
416
+ ...responseShapeChange ? { responseShapeChange } : {}
417
+ };
418
+ }
419
+ function isSecurityCritical2(event) {
420
+ return event.type === "auth_check" || event.type === "authorization_check" || Boolean(event.security?.["critical"]);
421
+ }
422
+ function buildResourceMap(session) {
423
+ const map = {};
424
+ for (const event of session.events) {
425
+ if (event.type === "db_query" && event.resource) {
426
+ const key = `${event.resource.type}:${event.resource.key}:${event.resource.operation}`;
427
+ map[key] = (map[key] ?? 0) + 1;
428
+ }
429
+ }
430
+ return map;
431
+ }
432
+ function diffResponseShapes(baseline, candidate) {
433
+ let candidateShape = { type: "unknown" };
434
+ for (const event of candidate.events) {
435
+ if (event.type === "http_response" && event.output) {
436
+ const normalised = (0, import_trace_sanitizer.normaliseForDiff)(event.output);
437
+ candidateShape = extractShape(normalised);
438
+ break;
439
+ }
440
+ }
441
+ const baselineShape = baseline.responseShape;
442
+ if (baselineShape.type !== "object" || candidateShape.type !== "object") {
443
+ return null;
444
+ }
445
+ const baselineFields = new Set(Object.keys(baselineShape.properties ?? {}));
446
+ const candidateFields = new Set(Object.keys(candidateShape.properties ?? {}));
447
+ const addedFields = [...candidateFields].filter((f) => !baselineFields.has(f));
448
+ const removedFields = [...baselineFields].filter((f) => !candidateFields.has(f));
449
+ const typeChanges = [];
450
+ for (const field of baselineFields) {
451
+ if (!candidateFields.has(field)) continue;
452
+ const bType = (baselineShape.properties ?? {})[field]?.type;
453
+ const cType = (candidateShape.properties ?? {})[field]?.type;
454
+ if (bType !== cType && bType && cType) {
455
+ typeChanges.push({ field, from: String(bType), to: String(cType) });
456
+ }
457
+ }
458
+ if (addedFields.length === 0 && removedFields.length === 0 && typeChanges.length === 0) {
459
+ return null;
460
+ }
461
+ return { addedFields, removedFields, typeChanges };
462
+ }
463
+
464
+ // src/findings.ts
465
+ var import_node_crypto3 = require("crypto");
466
+ var RULES = {
467
+ AUTHORIZATION_REMOVED: "behavior.authorization.removed",
468
+ MIDDLEWARE_REMOVED: "security.authorization.middleware_removed",
469
+ VALIDATION_REMOVED: "behavior.validation.removed",
470
+ BUSINESS_LOGIC_REMOVED: "behavior.business_logic.removed",
471
+ AUTHORIZATION_ADDED: "behavior.authorization.added",
472
+ RESOURCE_COUNT_CHANGED: "behavior.resource_count.changed",
473
+ RESPONSE_FIELD_REMOVED: "behavior.response_shape.field_removed",
474
+ RESPONSE_FIELD_ADDED: "behavior.response_shape.field_added"
475
+ };
476
+ function diffToFindings(diff) {
477
+ const findings = [];
478
+ const seen = /* @__PURE__ */ new Set();
479
+ for (const removed of diff.removedSignatures) {
480
+ const { ruleId, severity, category, title, description, recommendation } = classifyRemovedSignature(removed);
481
+ const fingerprint = computeFingerprint({
482
+ ruleId,
483
+ removed
484
+ });
485
+ if (seen.has(fingerprint)) continue;
486
+ seen.add(fingerprint);
487
+ findings.push({
488
+ id: `find_${fingerprint}`,
489
+ fingerprint,
490
+ ruleId,
491
+ severity,
492
+ category,
493
+ title,
494
+ description,
495
+ evidence: [{ traceId: diff.traceId, eventIds: [] }],
496
+ recommendation
497
+ });
498
+ }
499
+ for (const added of diff.addedSignatures) {
500
+ if (added.role !== "authorization" && !added.critical) continue;
501
+ const ruleId = RULES.AUTHORIZATION_ADDED;
502
+ const fingerprint = computeFingerprint({ ruleId, removed: added });
503
+ if (seen.has(fingerprint)) continue;
504
+ seen.add(fingerprint);
505
+ findings.push({
506
+ id: `find_${fingerprint}`,
507
+ fingerprint,
508
+ ruleId,
509
+ severity: "high",
510
+ category: "behavior_change",
511
+ title: `Authorization check added: ${sigLabel(added)}`,
512
+ description: `A new authorization check was introduced: "${added.eventName ?? sigLabel(added)}". Verify this is intentional and does not break existing authorized flows.`,
513
+ evidence: [{ traceId: diff.traceId, eventIds: added.eventId ? [added.eventId] : [] }],
514
+ recommendation: "Review the new authorization check and ensure all legitimate callers are still permitted."
515
+ });
516
+ }
517
+ for (const rc of diff.changedResources) {
518
+ const ruleId = RULES.RESOURCE_COUNT_CHANGED;
519
+ const fingerprint = (0, import_node_crypto3.createHash)("sha256").update(`${ruleId}:${rc.type}:${rc.key}:${rc.operation}`).digest("hex").slice(0, 16);
520
+ if (seen.has(fingerprint)) continue;
521
+ seen.add(fingerprint);
522
+ findings.push({
523
+ id: `find_${fingerprint}`,
524
+ fingerprint,
525
+ ruleId,
526
+ severity: "medium",
527
+ category: "behavior_change",
528
+ title: `Resource operation count changed: ${rc.type}.${rc.operation}`,
529
+ description: `The number of ${rc.operation} operations on "${rc.type}:${rc.key}" changed from ${rc.baselineCount} (baseline) to ${rc.candidateCount} (candidate).`,
530
+ evidence: [{ traceId: diff.traceId, eventIds: [] }],
531
+ recommendation: "Verify the change in operation count is expected."
532
+ });
533
+ }
534
+ if (diff.responseShapeChange) {
535
+ const rsc = diff.responseShapeChange;
536
+ for (const field of rsc.removedFields) {
537
+ const fingerprint = (0, import_node_crypto3.createHash)("sha256").update(`${RULES.RESPONSE_FIELD_REMOVED}:${diff.baselineId}:${field}`).digest("hex").slice(0, 16);
538
+ if (!seen.has(fingerprint)) {
539
+ seen.add(fingerprint);
540
+ findings.push({
541
+ id: `find_${fingerprint}`,
542
+ fingerprint,
543
+ ruleId: RULES.RESPONSE_FIELD_REMOVED,
544
+ severity: "low",
545
+ category: "behavior_change",
546
+ title: `Response field removed: "${field}"`,
547
+ description: `The field "${field}" was present in the baseline response shape but is absent in the candidate.`,
548
+ evidence: [{ traceId: diff.traceId, eventIds: [] }],
549
+ recommendation: "Verify this is a planned API change and update dependent clients."
550
+ });
551
+ }
552
+ }
553
+ for (const field of rsc.addedFields) {
554
+ const fingerprint = (0, import_node_crypto3.createHash)("sha256").update(`${RULES.RESPONSE_FIELD_ADDED}:${diff.baselineId}:${field}`).digest("hex").slice(0, 16);
555
+ if (!seen.has(fingerprint)) {
556
+ seen.add(fingerprint);
557
+ findings.push({
558
+ id: `find_${fingerprint}`,
559
+ fingerprint,
560
+ ruleId: RULES.RESPONSE_FIELD_ADDED,
561
+ severity: "info",
562
+ category: "behavior_change",
563
+ title: `Response field added: "${field}"`,
564
+ description: `The field "${field}" is present in the candidate response but was absent in the baseline.`,
565
+ evidence: [{ traceId: diff.traceId, eventIds: [] }]
566
+ });
567
+ }
568
+ }
569
+ }
570
+ return findings;
571
+ }
572
+ function computeFingerprint(input) {
573
+ const { ruleId, removed: sig } = input;
574
+ const s = sig.signature;
575
+ const parts = [
576
+ ruleId,
577
+ s.role ?? "",
578
+ s.routePathPattern ?? "",
579
+ s.routeMethod ?? "",
580
+ s.className ?? "",
581
+ s.methodName ?? "",
582
+ s.functionName ?? "",
583
+ s.resourceOperation ?? "",
584
+ s.resourceType ?? "",
585
+ s.resourceKey ?? ""
586
+ ];
587
+ return (0, import_node_crypto3.createHash)("sha256").update(parts.join("\0")).digest("hex").slice(0, 16);
588
+ }
589
+ function classifyRemovedSignature(removed) {
590
+ const label = sigLabel(removed);
591
+ const eventLabel = removed.eventName ?? label;
592
+ if (removed.role === "authorization" && removed.signature.routePathPattern && !removed.critical) {
593
+ return {
594
+ ruleId: RULES.MIDDLEWARE_REMOVED,
595
+ severity: "critical",
596
+ category: "security_authorization",
597
+ title: `Authorization middleware removed: ${eventLabel}`,
598
+ description: `A route-level authorization middleware "${eventLabel}" that guarded "${removed.signature.routeMethod ?? ""} ${removed.signature.routePathPattern}" in the baseline is absent in the candidate trace. Removing route middleware exposes every request to that route to unauthenticated or unauthorized access.`,
599
+ recommendation: "Restore the middleware or confirm the route is now protected by an equivalent mechanism."
600
+ };
601
+ }
602
+ if (removed.critical || removed.role === "authorization") {
603
+ return {
604
+ ruleId: RULES.AUTHORIZATION_REMOVED,
605
+ severity: "critical",
606
+ category: "security_authorization",
607
+ title: `Authorization check removed: ${eventLabel}`,
608
+ description: `An authorization check "${eventLabel}" that was present in the baseline is no longer present in the candidate trace. This may indicate a security regression.`,
609
+ recommendation: "Restore the authorization check or confirm this removal is intentional and safe."
610
+ };
611
+ }
612
+ if (removed.role === "validation") {
613
+ return {
614
+ ruleId: RULES.VALIDATION_REMOVED,
615
+ severity: "high",
616
+ category: "behavior_change",
617
+ title: `Validation step removed: ${eventLabel}`,
618
+ description: `A validation step "${eventLabel}" present in the baseline is absent in the candidate trace. Input may no longer be validated before processing.`,
619
+ recommendation: "Verify validation is still performed (possibly in a different location) or restore the validation step."
620
+ };
621
+ }
622
+ return {
623
+ ruleId: RULES.BUSINESS_LOGIC_REMOVED,
624
+ severity: "medium",
625
+ category: "behavior_change",
626
+ title: `Behaviour change: ${eventLabel} removed`,
627
+ description: `The event "${eventLabel}" was present in the baseline but is absent in the candidate trace.`,
628
+ recommendation: "Verify this change is intentional."
629
+ };
630
+ }
631
+ function sigLabel(change) {
632
+ const s = change.signature;
633
+ if (s.className && s.methodName) return `${s.className}.${s.methodName}`;
634
+ if (s.functionName) return s.functionName;
635
+ if (s.routePathPattern) return `${s.routeMethod ?? ""} ${s.routePathPattern}`.trim();
636
+ return s.eventType;
637
+ }
638
+
639
+ // src/evaluator.ts
640
+ function evaluateFindings(findings, session, suppressions, approvals) {
641
+ const now = Date.now();
642
+ return findings.map((finding) => evaluate(finding, session, suppressions, approvals, now));
643
+ }
644
+ function evaluate(finding, session, suppressions, approvals, now) {
645
+ for (const approval of approvals) {
646
+ if (approval.findingFingerprint !== finding.fingerprint) continue;
647
+ if (new Date(approval.expiresAt).getTime() < now) continue;
648
+ return {
649
+ ...finding,
650
+ status: "approved",
651
+ approvedBy: approval.approvedBy,
652
+ approvedReason: approval.reason
653
+ };
654
+ }
655
+ for (const suppression of suppressions) {
656
+ if (suppression.ruleId !== finding.ruleId) continue;
657
+ if (new Date(suppression.expiresAt).getTime() < now) continue;
658
+ if (!semanticTargetMatches(suppression.semanticTarget, finding)) continue;
659
+ if (suppression.requiresEvidence && suppression.requiresEvidence.length > 0) {
660
+ const allPresent = suppression.requiresEvidence.every(
661
+ (item) => session.events.some(
662
+ (e) => e.type === item.type && (item.name === "*" || e.name.includes(item.name))
663
+ )
664
+ );
665
+ if (!allPresent) {
666
+ continue;
667
+ }
668
+ }
669
+ return {
670
+ ...finding,
671
+ status: "suppressed",
672
+ suppressedBy: suppression.approvedBy
673
+ };
674
+ }
675
+ return { ...finding, status: "open" };
676
+ }
677
+ function semanticTargetMatches(target, finding) {
678
+ if (Object.keys(target).length === 0) return true;
679
+ const titleAndDesc = `${finding.title} ${finding.description}`.toLowerCase();
680
+ if (target.functionName && !titleAndDesc.includes(target.functionName.toLowerCase())) {
681
+ return false;
682
+ }
683
+ if (target.className && !titleAndDesc.includes(target.className.toLowerCase())) {
684
+ return false;
685
+ }
686
+ if (target.routePathPattern && !titleAndDesc.includes(target.routePathPattern.toLowerCase())) {
687
+ return false;
688
+ }
689
+ if (target.role && !titleAndDesc.includes(target.role)) {
690
+ return false;
691
+ }
692
+ return true;
693
+ }
694
+
695
+ // src/analyse.ts
696
+ var import_node_crypto4 = require("crypto");
697
+ var ANALYSE_RULES = {
698
+ SENSITIVE_DATA_IN_RESPONSE: "security.sensitive_data.in_response",
699
+ N_PLUS_ONE_QUERY: "reliability.n_plus_one_query",
700
+ DUPLICATE_SIDE_EFFECTS: "reliability.duplicate_side_effects",
701
+ MISSING_TRANSACTION: "reliability.missing_transaction"
702
+ };
703
+ var SENSITIVE_FIELD_NAMES = /* @__PURE__ */ new Set([
704
+ // Credentials
705
+ "password",
706
+ "passwd",
707
+ "pass",
708
+ "secret",
709
+ "credentials",
710
+ // API keys / tokens
711
+ "token",
712
+ "apikey",
713
+ "apisecret",
714
+ "privatekey",
715
+ "signingkey",
716
+ "clientsecret",
717
+ "clienttoken",
718
+ // Auth tokens
719
+ "authtoken",
720
+ "accesstoken",
721
+ "refreshtoken",
722
+ "sessiontoken",
723
+ "idtoken",
724
+ "bearer",
725
+ // Payment / PII
726
+ "creditcard",
727
+ "cardnumber",
728
+ "cardcvv",
729
+ "cvv",
730
+ "cvc",
731
+ "cvc2",
732
+ "ssn",
733
+ "socialsecurity"
734
+ ]);
735
+ var N_PLUS_ONE_THRESHOLD = 5;
736
+ var WRITE_OPERATIONS = /* @__PURE__ */ new Set(["write", "insert", "update", "delete", "upsert", "create"]);
737
+ function analyseTraceFindings(session) {
738
+ return [
739
+ ...detectSensitiveDataInResponse(session),
740
+ ...detectNPlusOneQuery(session),
741
+ ...detectDuplicateSideEffects(session),
742
+ ...detectMissingTransaction(session)
743
+ ];
744
+ }
745
+ function detectSensitiveDataInResponse(session) {
746
+ const findings = [];
747
+ const seen = /* @__PURE__ */ new Set();
748
+ for (const event of session.events) {
749
+ if (event.type !== "http_response") continue;
750
+ if (!event.output || typeof event.output !== "object" || Array.isArray(event.output)) continue;
751
+ const fields = collectKeys(event.output);
752
+ for (const field of fields) {
753
+ if (!isSensitiveFieldName(field)) continue;
754
+ const fingerprint = fp(ANALYSE_RULES.SENSITIVE_DATA_IN_RESPONSE, session.traceId, field);
755
+ if (seen.has(fingerprint)) continue;
756
+ seen.add(fingerprint);
757
+ findings.push({
758
+ id: `find_${fingerprint}`,
759
+ fingerprint,
760
+ ruleId: ANALYSE_RULES.SENSITIVE_DATA_IN_RESPONSE,
761
+ severity: "high",
762
+ category: "security_sensitive_data",
763
+ title: `Sensitive field in HTTP response: "${field}"`,
764
+ description: `The field "${field}" appears in the HTTP response payload and may expose sensitive data (password, token, secret, PII). Leaking such fields in API responses is a security risk and may violate data-protection requirements.`,
765
+ evidence: [{ traceId: session.traceId, eventIds: event.eventId ? [event.eventId] : [] }],
766
+ recommendation: `Remove "${field}" from the response or redact its value before sending. Use an API resource / serializer layer to explicitly whitelist the fields your API should expose (e.g. Laravel API Resources, a JSON:API transformer).`
767
+ });
768
+ }
769
+ }
770
+ return findings;
771
+ }
772
+ function detectNPlusOneQuery(session) {
773
+ const findings = [];
774
+ const groups = /* @__PURE__ */ new Map();
775
+ for (const event of session.events) {
776
+ if (event.type !== "db_query" || !event.resource) continue;
777
+ const { type: resourceType, operation } = event.resource;
778
+ const key = `${resourceType}\0${operation}`;
779
+ let entry = groups.get(key);
780
+ if (!entry) {
781
+ entry = { count: 0, eventIds: [], resourceType, operation };
782
+ groups.set(key, entry);
783
+ }
784
+ entry.count++;
785
+ if (event.eventId) entry.eventIds.push(event.eventId);
786
+ }
787
+ for (const entry of groups.values()) {
788
+ if (entry.count < N_PLUS_ONE_THRESHOLD) continue;
789
+ const fingerprint = fp(
790
+ ANALYSE_RULES.N_PLUS_ONE_QUERY,
791
+ session.traceId,
792
+ `${entry.resourceType}\0${entry.operation}`
793
+ );
794
+ findings.push({
795
+ id: `find_${fingerprint}`,
796
+ fingerprint,
797
+ ruleId: ANALYSE_RULES.N_PLUS_ONE_QUERY,
798
+ severity: "medium",
799
+ category: "performance",
800
+ title: `Possible N+1 query: ${entry.resourceType}.${entry.operation} \xD7 ${entry.count}`,
801
+ description: `The "${entry.operation}" operation on "${entry.resourceType}" was executed ${entry.count} times in a single request. This pattern commonly indicates an N+1 problem where child records are fetched individually in a loop rather than in a single batched query.`,
802
+ evidence: [{ traceId: session.traceId, eventIds: entry.eventIds.slice(0, 10) }],
803
+ recommendation: `Use eager loading to batch-fetch related records (e.g. Eloquent \`with()\`, TypeORM \`relations\`, Sequelize \`include\`). Add a query counter assertion to your test suite to prevent regressions.`
804
+ });
805
+ }
806
+ return findings;
807
+ }
808
+ function detectDuplicateSideEffects(session) {
809
+ const findings = [];
810
+ const queueGroups = /* @__PURE__ */ new Map();
811
+ const outboundGroups = /* @__PURE__ */ new Map();
812
+ for (const event of session.events) {
813
+ if (event.type === "queue_event" && event.name) {
814
+ addToGroup(queueGroups, event.name, event.eventId);
815
+ }
816
+ if (event.type === "external_http_call") {
817
+ const method = String(
818
+ event.metadata?.["method"] ?? ""
819
+ ).toUpperCase();
820
+ if (!["POST", "PUT", "PATCH", "DELETE"].includes(method)) continue;
821
+ const url = String(
822
+ event.metadata?.["url"] ?? event.name ?? ""
823
+ );
824
+ addToGroup(outboundGroups, `${method}:${url}`, event.eventId);
825
+ }
826
+ }
827
+ for (const [name, entry] of queueGroups) {
828
+ if (entry.count < 2) continue;
829
+ const fingerprint = fp(ANALYSE_RULES.DUPLICATE_SIDE_EFFECTS, session.traceId, `queue:${name}`);
830
+ findings.push({
831
+ id: `find_${fingerprint}`,
832
+ fingerprint,
833
+ ruleId: ANALYSE_RULES.DUPLICATE_SIDE_EFFECTS,
834
+ severity: "medium",
835
+ category: "data_integrity",
836
+ title: `Duplicate queue dispatch: "${name}" \xD7 ${entry.count}`,
837
+ description: `The job/event "${name}" was dispatched ${entry.count} times within a single request. Unless this is intentional, consumers may process the same work multiple times (e.g. duplicate emails, double charges, redundant notifications).`,
838
+ evidence: [{ traceId: session.traceId, eventIds: entry.eventIds }],
839
+ recommendation: `Verify the dispatch is not inside a loop or on multiple code paths. Consider idempotency keys or a deduplication layer in the queue consumer.`
840
+ });
841
+ }
842
+ for (const [key, entry] of outboundGroups) {
843
+ if (entry.count < 2) continue;
844
+ const colonIdx = key.indexOf(":");
845
+ const method = key.slice(0, colonIdx);
846
+ const url = key.slice(colonIdx + 1);
847
+ const fingerprint = fp(ANALYSE_RULES.DUPLICATE_SIDE_EFFECTS, session.traceId, key);
848
+ findings.push({
849
+ id: `find_${fingerprint}`,
850
+ fingerprint,
851
+ ruleId: ANALYSE_RULES.DUPLICATE_SIDE_EFFECTS,
852
+ severity: "medium",
853
+ category: "data_integrity",
854
+ title: `Duplicate outbound ${method}: "${url}" \xD7 ${entry.count}`,
855
+ description: `An outbound ${method} request to "${url}" was made ${entry.count} times in a single request. This may cause unintended duplicate writes or side effects on the remote service.`,
856
+ evidence: [{ traceId: session.traceId, eventIds: entry.eventIds }],
857
+ recommendation: `Check whether the call appears in a loop or on multiple branches. If idempotency is required, pass an idempotency key in the request headers.`
858
+ });
859
+ }
860
+ return findings;
861
+ }
862
+ function detectMissingTransaction(session) {
863
+ const hasTransaction = session.events.some(
864
+ (e) => e.type === "transaction_start" || e.type === "transaction_commit" || e.type === "transaction_rollback"
865
+ );
866
+ if (hasTransaction) return [];
867
+ const writeEvents = session.events.filter(
868
+ (e) => e.type === "db_query" && e.resource != null && WRITE_OPERATIONS.has(e.resource.operation)
869
+ );
870
+ const tables = new Set(writeEvents.map((e) => e.resource.type));
871
+ if (tables.size < 2) return [];
872
+ const sortedTables = [...tables].sort();
873
+ const fingerprint = fp(
874
+ ANALYSE_RULES.MISSING_TRANSACTION,
875
+ session.traceId,
876
+ sortedTables.join(",")
877
+ );
878
+ const tableList = sortedTables.join(", ");
879
+ return [{
880
+ id: `find_${fingerprint}`,
881
+ fingerprint,
882
+ ruleId: ANALYSE_RULES.MISSING_TRANSACTION,
883
+ severity: "medium",
884
+ category: "data_integrity",
885
+ title: `Multi-table write without transaction: ${tableList}`,
886
+ description: `Write operations were performed on multiple tables (${tableList}) within a single request but no transaction boundary was observed. If an error occurs part-way through, the database may be left in an inconsistent state.`,
887
+ evidence: [{
888
+ traceId: session.traceId,
889
+ eventIds: writeEvents.map((e) => e.eventId).filter((id) => id != null)
890
+ }],
891
+ recommendation: `Wrap the multi-table writes in a database transaction. Laravel: \`DB::transaction(fn() => ...)\`. Express/TypeORM: \`dataSource.transaction(async (em) => ...)\`.`
892
+ }];
893
+ }
894
+ function fp(...parts) {
895
+ return (0, import_node_crypto4.createHash)("sha256").update(parts.join("\0")).digest("hex").slice(0, 16);
896
+ }
897
+ function normaliseName(name) {
898
+ return name.toLowerCase().replace(/[-_]/g, "");
899
+ }
900
+ function isSensitiveFieldName(field) {
901
+ return SENSITIVE_FIELD_NAMES.has(normaliseName(field));
902
+ }
903
+ function collectKeys(obj, depth = 0) {
904
+ const keys = [];
905
+ for (const [key, value] of Object.entries(obj)) {
906
+ keys.push(key);
907
+ if (depth < 2 && value !== null && typeof value === "object" && !Array.isArray(value)) {
908
+ keys.push(...collectKeys(value, depth + 1));
909
+ }
910
+ }
911
+ return keys;
912
+ }
913
+ function addToGroup(map, key, eventId) {
914
+ let entry = map.get(key);
915
+ if (!entry) {
916
+ entry = { count: 0, eventIds: [] };
917
+ map.set(key, entry);
918
+ }
919
+ entry.count++;
920
+ if (eventId) entry.eventIds.push(eventId);
921
+ }
922
+ // Annotate the CommonJS export names for ESM import in node:
923
+ 0 && (module.exports = {
924
+ ANALYSE_RULES,
925
+ analyseTraceFindings,
926
+ classifyRole,
927
+ computeFingerprint,
928
+ deriveTestId,
929
+ diffBaseline,
930
+ diffToFindings,
931
+ evaluateFindings,
932
+ eventToSignature,
933
+ extractShape,
934
+ sessionToBaseline,
935
+ signatureToIdentityHash,
936
+ traceSessionToGraph
937
+ });