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