@nebulacomponents/citable 1.18.2 → 1.19.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/CHANGELOG.md +46 -0
- package/dist/universal/.agents/skills/citable/SKILL.md +1 -1
- package/dist/universal/.agents/skills/citable/VERSION +1 -1
- package/dist/universal/.agents/skills/citable/manifest.json +9 -6
- package/dist/universal/.agents/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.agents/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.agents/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.agents/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.claude/agents/citable/manifest.json +2 -2
- package/dist/universal/.claude/skills/citable/SKILL.md +1 -1
- package/dist/universal/.claude/skills/citable/VERSION +1 -1
- package/dist/universal/.claude/skills/citable/manifest.json +9 -6
- package/dist/universal/.claude/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.claude/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.claude/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.claude/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.cursor/skills/citable/SKILL.md +1 -1
- package/dist/universal/.cursor/skills/citable/VERSION +1 -1
- package/dist/universal/.cursor/skills/citable/manifest.json +9 -6
- package/dist/universal/.cursor/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.cursor/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.cursor/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.cursor/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.gemini/skills/citable/SKILL.md +1 -1
- package/dist/universal/.gemini/skills/citable/VERSION +1 -1
- package/dist/universal/.gemini/skills/citable/manifest.json +9 -6
- package/dist/universal/.gemini/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.gemini/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.gemini/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.gemini/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.github/skills/citable/SKILL.md +1 -1
- package/dist/universal/.github/skills/citable/VERSION +1 -1
- package/dist/universal/.github/skills/citable/manifest.json +9 -6
- package/dist/universal/.github/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.github/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.github/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.github/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.kiro/skills/citable/SKILL.md +1 -1
- package/dist/universal/.kiro/skills/citable/VERSION +1 -1
- package/dist/universal/.kiro/skills/citable/manifest.json +9 -6
- package/dist/universal/.kiro/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.kiro/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.kiro/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.kiro/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.opencode/skills/citable/SKILL.md +1 -1
- package/dist/universal/.opencode/skills/citable/VERSION +1 -1
- package/dist/universal/.opencode/skills/citable/manifest.json +9 -6
- package/dist/universal/.opencode/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.opencode/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.opencode/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.opencode/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.pi/agent/skills/citable/SKILL.md +1 -1
- package/dist/universal/.pi/agent/skills/citable/VERSION +1 -1
- package/dist/universal/.pi/agent/skills/citable/manifest.json +9 -6
- package/dist/universal/.pi/agent/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.pi/agent/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.pi/agent/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.qoder/skills/citable/SKILL.md +1 -1
- package/dist/universal/.qoder/skills/citable/VERSION +1 -1
- package/dist/universal/.qoder/skills/citable/manifest.json +9 -6
- package/dist/universal/.qoder/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.qoder/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.qoder/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.qoder/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.rovodev/skills/citable/SKILL.md +1 -1
- package/dist/universal/.rovodev/skills/citable/VERSION +1 -1
- package/dist/universal/.rovodev/skills/citable/manifest.json +9 -6
- package/dist/universal/.rovodev/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.rovodev/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.rovodev/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.rovodev/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.trae/skills/citable/SKILL.md +1 -1
- package/dist/universal/.trae/skills/citable/VERSION +1 -1
- package/dist/universal/.trae/skills/citable/manifest.json +9 -6
- package/dist/universal/.trae/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.trae/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.trae/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.trae/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/.trae-cn/skills/citable/SKILL.md +1 -1
- package/dist/universal/.trae-cn/skills/citable/VERSION +1 -1
- package/dist/universal/.trae-cn/skills/citable/manifest.json +9 -6
- package/dist/universal/.trae-cn/skills/citable/schemas/browser-evidence-plan.schema.json +186 -3
- package/dist/universal/.trae-cn/skills/citable/schemas/browser-network-events.schema.json +142 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/browser-runtime-events.schema.json +112 -0
- package/dist/universal/.trae-cn/skills/citable/schemas/browser-state-observations.schema.json +97 -0
- package/dist/universal/manifest.json +112 -76
- package/package.json +1 -1
- package/schemas/browser-evidence-plan.schema.json +186 -3
- package/schemas/browser-network-events.schema.json +142 -0
- package/schemas/browser-runtime-events.schema.json +112 -0
- package/schemas/browser-state-observations.schema.json +97 -0
- package/skill/SKILL.md +1 -1
- package/src/observations/browser/correlation.js +83 -0
- package/src/observations/browser/networkCollector.js +413 -0
- package/src/observations/browser/runtimeEventCollector.js +337 -0
- package/src/observations/browser/sanitizer.js +322 -0
- package/src/observations/browser/stateCollector.js +386 -0
- package/src/observations/browserJourney.js +131 -3
- package/src/reporting/executiveCroReport.js +1 -1
- package/src/reporting/executiveSearchReport.js +1 -1
- package/src/sow/generateSow.js +1 -1
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { safeJsonClone, isSensitiveKey, isSensitiveValue } from './sanitizer.js';
|
|
2
|
+
|
|
3
|
+
export class RuntimeEventCollector {
|
|
4
|
+
constructor(options = {}) {
|
|
5
|
+
const {
|
|
6
|
+
runtimeEventPolicy = {},
|
|
7
|
+
correlationTracker,
|
|
8
|
+
planId,
|
|
9
|
+
profileId,
|
|
10
|
+
} = options;
|
|
11
|
+
|
|
12
|
+
this.policy = runtimeEventPolicy;
|
|
13
|
+
this.enabled = Boolean(this.policy.enabled);
|
|
14
|
+
this.planId = planId || 'plan-unspecified';
|
|
15
|
+
this.profileId = profileId || 'profile-unspecified';
|
|
16
|
+
this.tracker = correlationTracker;
|
|
17
|
+
|
|
18
|
+
this.sources = this.policy.sources || [];
|
|
19
|
+
this.allowedEventNames = this.policy.allowed_event_names ? new Set(this.policy.allowed_event_names) : null;
|
|
20
|
+
this.allowedFields = this.policy.allowed_fields || null;
|
|
21
|
+
this.hashFields = this.policy.hash_fields || null;
|
|
22
|
+
this.redactFields = this.policy.redact_fields || null;
|
|
23
|
+
this.maxEvents = Math.min(this.policy.max_events || 200, 1000);
|
|
24
|
+
this.maxPayloadDepth = Math.min(this.policy.max_payload_depth || 5, 10);
|
|
25
|
+
this.maxFieldSize = Math.min(this.policy.max_field_size || 1024, 4096);
|
|
26
|
+
|
|
27
|
+
this.events = [];
|
|
28
|
+
this.eventCountsByName = {};
|
|
29
|
+
this.eventCountsBySource = {};
|
|
30
|
+
this.summary = {
|
|
31
|
+
total_events: 0,
|
|
32
|
+
filtered_events: 0,
|
|
33
|
+
by_source: {},
|
|
34
|
+
by_event_name: {},
|
|
35
|
+
collector_status: 'complete',
|
|
36
|
+
truncated: false,
|
|
37
|
+
truncation_reason: null,
|
|
38
|
+
collector_health: {
|
|
39
|
+
status: 'active',
|
|
40
|
+
queue_replaced: false,
|
|
41
|
+
push_replaced: false,
|
|
42
|
+
polling_fallback_used: false,
|
|
43
|
+
supported_contexts: ['top_level_main_frame'],
|
|
44
|
+
unsupported_contexts: ['cross_origin_iframes', 'web_workers', 'service_workers'],
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Generates initialization script to be added to browser context or page.
|
|
51
|
+
*/
|
|
52
|
+
getInitScript() {
|
|
53
|
+
if (!this.enabled || !this.sources.length) return null;
|
|
54
|
+
const sourcesConfig = JSON.stringify(this.sources);
|
|
55
|
+
|
|
56
|
+
return `
|
|
57
|
+
(function() {
|
|
58
|
+
window.__citable_events = window.__citable_events || [];
|
|
59
|
+
window.__citable_health = window.__citable_health || {
|
|
60
|
+
queue_replaced: false,
|
|
61
|
+
push_replaced: false,
|
|
62
|
+
polling_fallback_used: false,
|
|
63
|
+
};
|
|
64
|
+
const sources = ${sourcesConfig};
|
|
65
|
+
|
|
66
|
+
function safeExtract(obj, depth, seen) {
|
|
67
|
+
if (depth <= 0) return '[DEPTH_LIMIT]';
|
|
68
|
+
if (obj === null || obj === undefined) return obj;
|
|
69
|
+
const t = typeof obj;
|
|
70
|
+
if (t === 'number' || t === 'boolean' || t === 'string') return obj;
|
|
71
|
+
if (t !== 'object') return String(obj);
|
|
72
|
+
if (seen.has(obj)) return '[CIRCULAR]';
|
|
73
|
+
seen.add(obj);
|
|
74
|
+
|
|
75
|
+
if (Array.isArray(obj)) {
|
|
76
|
+
return obj.map(function(item) { return safeExtract(item, depth - 1, seen); });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const res = {};
|
|
80
|
+
const keys = Object.keys(obj);
|
|
81
|
+
for (var i = 0; i < keys.length; i++) {
|
|
82
|
+
var k = keys[i];
|
|
83
|
+
if (k === '__proto__' || k === 'constructor' || k === 'prototype') continue;
|
|
84
|
+
try {
|
|
85
|
+
res[k] = safeExtract(obj[k], depth - 1, seen);
|
|
86
|
+
} catch (err) {
|
|
87
|
+
res[k] = '[GETTER_ERROR]';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return res;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
for (var i = 0; i < sources.length; i++) {
|
|
94
|
+
var src = sources[i];
|
|
95
|
+
if (src.type === 'global_array' && src.global_name) {
|
|
96
|
+
var propName = src.global_name.replace(/^window\\./, '');
|
|
97
|
+
|
|
98
|
+
(function(sourceConfig, prop) {
|
|
99
|
+
var currentArray = window[prop];
|
|
100
|
+
if (!Array.isArray(currentArray)) {
|
|
101
|
+
currentArray = [];
|
|
102
|
+
window[prop] = currentArray;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
for (var j = 0; j < currentArray.length; j++) {
|
|
106
|
+
try {
|
|
107
|
+
window.__citable_events.push({
|
|
108
|
+
source_id: sourceConfig.source_id,
|
|
109
|
+
payload: safeExtract(currentArray[j], 5, new WeakSet()),
|
|
110
|
+
ts: Date.now()
|
|
111
|
+
});
|
|
112
|
+
} catch (e) {}
|
|
113
|
+
}
|
|
114
|
+
currentArray.__citable_drained_count = currentArray.length;
|
|
115
|
+
|
|
116
|
+
function wrapPush(arr) {
|
|
117
|
+
var origPush = arr.push;
|
|
118
|
+
arr.push = function(...items) {
|
|
119
|
+
for (var k = 0; k < items.length; k++) {
|
|
120
|
+
try {
|
|
121
|
+
window.__citable_events.push({
|
|
122
|
+
source_id: sourceConfig.source_id,
|
|
123
|
+
payload: safeExtract(items[k], 5, new WeakSet()),
|
|
124
|
+
ts: Date.now()
|
|
125
|
+
});
|
|
126
|
+
} catch (e) {}
|
|
127
|
+
}
|
|
128
|
+
if (typeof origPush === 'function') {
|
|
129
|
+
return origPush.apply(this, items);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
wrapPush(currentArray);
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
var storedArray = currentArray;
|
|
138
|
+
Object.defineProperty(window, prop, {
|
|
139
|
+
configurable: true,
|
|
140
|
+
enumerable: true,
|
|
141
|
+
get: function() { return storedArray; },
|
|
142
|
+
set: function(newVal) {
|
|
143
|
+
window.__citable_health.queue_replaced = true;
|
|
144
|
+
storedArray = newVal;
|
|
145
|
+
if (Array.isArray(newVal)) {
|
|
146
|
+
wrapPush(newVal);
|
|
147
|
+
for (var m = 0; m < newVal.length; m++) {
|
|
148
|
+
try {
|
|
149
|
+
window.__citable_events.push({
|
|
150
|
+
source_id: sourceConfig.source_id,
|
|
151
|
+
payload: safeExtract(newVal[m], 5, new WeakSet()),
|
|
152
|
+
ts: Date.now()
|
|
153
|
+
});
|
|
154
|
+
} catch (e) {}
|
|
155
|
+
}
|
|
156
|
+
newVal.__citable_drained_count = newVal.length;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
} catch (e) {}
|
|
161
|
+
})(src, propName);
|
|
162
|
+
|
|
163
|
+
} else if (src.type === 'window_event' && src.event_name) {
|
|
164
|
+
(function(sourceConfig) {
|
|
165
|
+
window.addEventListener(sourceConfig.event_name, function(e) {
|
|
166
|
+
try {
|
|
167
|
+
window.__citable_events.push({
|
|
168
|
+
source_id: sourceConfig.source_id,
|
|
169
|
+
payload: {
|
|
170
|
+
type: e.type,
|
|
171
|
+
detail: safeExtract(e.detail, 5, new WeakSet()) || null,
|
|
172
|
+
},
|
|
173
|
+
ts: Date.now()
|
|
174
|
+
});
|
|
175
|
+
} catch (err) {}
|
|
176
|
+
}, true);
|
|
177
|
+
})(src);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async installOnPage(page) {
|
|
185
|
+
if (!this.enabled || !this.sources.length) return;
|
|
186
|
+
const script = this.getInitScript();
|
|
187
|
+
if (!script) return;
|
|
188
|
+
try {
|
|
189
|
+
await page.evaluate(script);
|
|
190
|
+
} catch {
|
|
191
|
+
// Ignored if page is not ready yet
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async drainEvents(page) {
|
|
196
|
+
if (!this.enabled || !this.sources.length) return;
|
|
197
|
+
|
|
198
|
+
let drainResult = null;
|
|
199
|
+
try {
|
|
200
|
+
drainResult = await page.evaluate((sources) => {
|
|
201
|
+
const events = window.__citable_events ? window.__citable_events.splice(0) : [];
|
|
202
|
+
const health = window.__citable_health || {
|
|
203
|
+
queue_replaced: false,
|
|
204
|
+
push_replaced: false,
|
|
205
|
+
polling_fallback_used: false,
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// Fallback polling for arrays where push was replaced or unhooked
|
|
209
|
+
for (const src of sources) {
|
|
210
|
+
if (src.type === 'global_array' && src.global_name) {
|
|
211
|
+
const prop = src.global_name.replace(/^window\./, '');
|
|
212
|
+
const arr = window[prop];
|
|
213
|
+
if (Array.isArray(arr)) {
|
|
214
|
+
const startIdx = arr.__citable_drained_count || 0;
|
|
215
|
+
if (arr.length > startIdx) {
|
|
216
|
+
health.polling_fallback_used = true;
|
|
217
|
+
for (let i = startIdx; i < arr.length; i++) {
|
|
218
|
+
events.push({
|
|
219
|
+
source_id: src.source_id,
|
|
220
|
+
payload: arr[i],
|
|
221
|
+
ts: Date.now(),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
arr.__citable_drained_count = arr.length;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return { events, health };
|
|
231
|
+
}, this.sources);
|
|
232
|
+
} catch {
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const eventsList = Array.isArray(drainResult)
|
|
237
|
+
? drainResult
|
|
238
|
+
: (drainResult && Array.isArray(drainResult.events) ? drainResult.events : []);
|
|
239
|
+
|
|
240
|
+
if (!eventsList || eventsList.length === 0) return;
|
|
241
|
+
|
|
242
|
+
if (drainResult && !Array.isArray(drainResult) && drainResult.health) {
|
|
243
|
+
if (drainResult.health.queue_replaced) this.summary.collector_health.queue_replaced = true;
|
|
244
|
+
if (drainResult.health.push_replaced) this.summary.collector_health.push_replaced = true;
|
|
245
|
+
if (drainResult.health.polling_fallback_used) this.summary.collector_health.polling_fallback_used = true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const raw of eventsList) {
|
|
249
|
+
if (this.events.length >= this.maxEvents) {
|
|
250
|
+
this.summary.truncated = true;
|
|
251
|
+
this.summary.truncation_reason = 'max_events_exceeded';
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const sourceId = raw.source_id || 'unknown';
|
|
256
|
+
const sourceConfig = this.sources.find((s) => s.source_id === sourceId) || {};
|
|
257
|
+
const payload = raw.payload;
|
|
258
|
+
|
|
259
|
+
// Extract event name
|
|
260
|
+
let eventName = 'unnamed_event';
|
|
261
|
+
if (typeof payload === 'object' && payload !== null) {
|
|
262
|
+
const keyName = sourceConfig.event_name_key || 'event';
|
|
263
|
+
if (typeof payload[keyName] === 'string') {
|
|
264
|
+
eventName = payload[keyName];
|
|
265
|
+
} else if (typeof payload.type === 'string') {
|
|
266
|
+
eventName = payload.type;
|
|
267
|
+
} else if (sourceConfig.event_name) {
|
|
268
|
+
eventName = sourceConfig.event_name;
|
|
269
|
+
}
|
|
270
|
+
} else if (typeof payload === 'string') {
|
|
271
|
+
eventName = payload;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Check allowed event names
|
|
275
|
+
const isAllowed = !this.allowedEventNames || this.allowedEventNames.has(eventName);
|
|
276
|
+
if (!isAllowed) {
|
|
277
|
+
this.summary.filtered_events++;
|
|
278
|
+
continue; // Filtered out
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
this.eventCountsByName[eventName] = (this.eventCountsByName[eventName] || 0) + 1;
|
|
282
|
+
this.eventCountsBySource[sourceId] = (this.eventCountsBySource[sourceId] || 0) + 1;
|
|
283
|
+
const occurrenceIndex = this.eventCountsByName[eventName];
|
|
284
|
+
|
|
285
|
+
const seq = this.tracker.nextSequence();
|
|
286
|
+
const timing = this.tracker.timing();
|
|
287
|
+
const correlation = this.tracker.getCorrelation();
|
|
288
|
+
|
|
289
|
+
// Sanitize payload
|
|
290
|
+
const stats = { redactedCount: 0, filteredFields: [] };
|
|
291
|
+
const sanitizedPayload = typeof payload === 'object' && payload !== null
|
|
292
|
+
? safeJsonClone(payload, {
|
|
293
|
+
maxDepth: this.maxPayloadDepth,
|
|
294
|
+
maxFieldSize: this.maxFieldSize,
|
|
295
|
+
allowedFields: this.allowedFields,
|
|
296
|
+
hashFields: this.hashFields,
|
|
297
|
+
redactFields: this.redactFields,
|
|
298
|
+
stats,
|
|
299
|
+
})
|
|
300
|
+
: { value: String(payload) };
|
|
301
|
+
|
|
302
|
+
const eventRecord = {
|
|
303
|
+
sequence: seq,
|
|
304
|
+
source_id: sourceId,
|
|
305
|
+
event_name: eventName,
|
|
306
|
+
occurrence_index: occurrenceIndex,
|
|
307
|
+
timing,
|
|
308
|
+
correlation,
|
|
309
|
+
payload: sanitizedPayload,
|
|
310
|
+
disposition: {
|
|
311
|
+
capture_status: stats.redactedCount > 0 ? 'redacted' : 'captured',
|
|
312
|
+
redacted_count: stats.redactedCount,
|
|
313
|
+
filtered_fields: stats.filteredFields,
|
|
314
|
+
},
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
this.events.push(eventRecord);
|
|
318
|
+
this.summary.total_events++;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
this.summary.by_source = { ...this.eventCountsBySource };
|
|
322
|
+
this.summary.by_event_name = { ...this.eventCountsByName };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
toArtifact() {
|
|
326
|
+
this.summary.collector_status = this.summary.truncated ? 'truncated' : 'complete';
|
|
327
|
+
return {
|
|
328
|
+
schema_version: 1,
|
|
329
|
+
plan_id: this.planId,
|
|
330
|
+
profile_id: this.profileId,
|
|
331
|
+
collected_at: this.tracker.timing().timestamp,
|
|
332
|
+
capture_policy: this.policy,
|
|
333
|
+
summary: this.summary,
|
|
334
|
+
events: this.events,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { sha256 } from '../../shared/io.js';
|
|
2
|
+
|
|
3
|
+
// Sensitive key patterns: headers, storage keys, query parameters, payload fields
|
|
4
|
+
export const SENSITIVE_KEY_PATTERN = /(?:^|[-_])(?:authorization|bearer|token|secret|apiKey|api[-_]?key|password|credential|access[-_]?token|refresh[-_]?token|jwt|session(?:[-_]?id)?|csrf|xsrf|cvv|cvc|ssn|credit[-_]?card|passwd|auth[-_]?token|oauth[-_]?token|auth[-_]?code|code)(?:$|[-_])|^(?:cookie|set[-_]?cookie)$/i;
|
|
5
|
+
|
|
6
|
+
// Sensitive value patterns: bearer tokens, common API keys, JWT tokens, card numbers
|
|
7
|
+
export const SENSITIVE_VALUE_PATTERN = /(?:bearer\s+[a-zA-Z0-9_\-.]{10,}|(?:ghp|gho|glpat|ya29|sk-[a-zA-Z0-9]{20,})|eyJ[a-zA-Z0-9_-]{10,}\.eyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}|\b(?:\d[ -]*?){13,16}\b)/i;
|
|
8
|
+
|
|
9
|
+
export const REDACTED_MARKER = '[REDACTED_SECRET]';
|
|
10
|
+
export const FILTERED_MARKER = '[FILTERED]';
|
|
11
|
+
export const POLICY_RESTRICTED_MARKER = '[POLICY_RESTRICTED]';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a field/header/cookie/param key is sensitive.
|
|
15
|
+
*/
|
|
16
|
+
export function isSensitiveKey(key) {
|
|
17
|
+
if (typeof key !== 'string') return false;
|
|
18
|
+
return SENSITIVE_KEY_PATTERN.test(key);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if a string value contains sensitive tokens or patterns.
|
|
23
|
+
*/
|
|
24
|
+
export function isSensitiveValue(value) {
|
|
25
|
+
if (typeof value !== 'string') return false;
|
|
26
|
+
return SENSITIVE_VALUE_PATTERN.test(value);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sanitize headers against allowlist and sensitive key/value rules.
|
|
31
|
+
* Even if a sensitive header is allowlisted, it MUST be redacted.
|
|
32
|
+
*/
|
|
33
|
+
export function sanitizeHeaders(headers, allowedNames = null) {
|
|
34
|
+
if (!headers || typeof headers !== 'object') {
|
|
35
|
+
return { headers: null, status: 'not_requested', redactedCount: 0, filteredCount: 0 };
|
|
36
|
+
}
|
|
37
|
+
const allowedSet = Array.isArray(allowedNames) ? new Set(allowedNames.map((n) => n.toLowerCase())) : null;
|
|
38
|
+
const result = {};
|
|
39
|
+
let redactedCount = 0;
|
|
40
|
+
let filteredCount = 0;
|
|
41
|
+
|
|
42
|
+
for (const [rawKey, rawVal] of Object.entries(headers)) {
|
|
43
|
+
const key = rawKey.toLowerCase();
|
|
44
|
+
// Sensitive keys are unconditionally redacted or omitted
|
|
45
|
+
if (isSensitiveKey(key)) {
|
|
46
|
+
if (allowedSet && allowedSet.has(key)) {
|
|
47
|
+
result[key] = REDACTED_MARKER;
|
|
48
|
+
redactedCount++;
|
|
49
|
+
} else {
|
|
50
|
+
filteredCount++;
|
|
51
|
+
}
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Non-sensitive keys: must be explicitly allowed if allowedSet is provided
|
|
56
|
+
if (allowedSet && !allowedSet.has(key)) {
|
|
57
|
+
filteredCount++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Value sanitization
|
|
62
|
+
const strVal = String(rawVal ?? '');
|
|
63
|
+
if (isSensitiveValue(strVal)) {
|
|
64
|
+
result[key] = REDACTED_MARKER;
|
|
65
|
+
redactedCount++;
|
|
66
|
+
} else {
|
|
67
|
+
result[key] = strVal;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const hasEntries = Object.keys(result).length > 0;
|
|
72
|
+
let status = 'captured';
|
|
73
|
+
if (!hasEntries) {
|
|
74
|
+
status = filteredCount > 0 ? 'filtered' : 'not_requested';
|
|
75
|
+
} else if (redactedCount > 0) {
|
|
76
|
+
status = 'redacted';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
headers: hasEntries ? result : null,
|
|
81
|
+
status,
|
|
82
|
+
redactedCount,
|
|
83
|
+
filteredCount,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Sanitize URL query parameters according to allowlist.
|
|
89
|
+
*/
|
|
90
|
+
export function sanitizeQueryParams(urlStr, allowedParams = null) {
|
|
91
|
+
if (!urlStr) return { query: null, status: 'none_present', redactedCount: 0, filteredFields: [] };
|
|
92
|
+
if (!allowedParams || !Array.isArray(allowedParams) || allowedParams.length === 0) {
|
|
93
|
+
return { query: null, status: 'not_requested', redactedCount: 0, filteredFields: [] };
|
|
94
|
+
}
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = new URL(urlStr);
|
|
98
|
+
} catch {
|
|
99
|
+
return { query: null, status: 'none_present', redactedCount: 0, filteredFields: [] };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const searchParams = parsed.searchParams;
|
|
103
|
+
if (!searchParams || Array.from(searchParams.keys()).length === 0) {
|
|
104
|
+
return { query: null, status: 'none_present', redactedCount: 0, filteredFields: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const allowedSet = new Set(allowedParams.map((p) => p.toLowerCase()));
|
|
108
|
+
const query = {};
|
|
109
|
+
let redactedCount = 0;
|
|
110
|
+
const filteredFields = [];
|
|
111
|
+
|
|
112
|
+
for (const [key, val] of searchParams.entries()) {
|
|
113
|
+
const lowerKey = key.toLowerCase();
|
|
114
|
+
if (isSensitiveKey(lowerKey)) {
|
|
115
|
+
if (allowedSet.has(lowerKey)) {
|
|
116
|
+
query[key] = REDACTED_MARKER;
|
|
117
|
+
redactedCount++;
|
|
118
|
+
} else {
|
|
119
|
+
filteredFields.push(key);
|
|
120
|
+
}
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!allowedSet.has(lowerKey)) {
|
|
125
|
+
filteredFields.push(key);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (isSensitiveValue(val)) {
|
|
130
|
+
query[key] = REDACTED_MARKER;
|
|
131
|
+
redactedCount++;
|
|
132
|
+
} else {
|
|
133
|
+
query[key] = val;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const hasEntries = Object.keys(query).length > 0;
|
|
138
|
+
let status = 'captured';
|
|
139
|
+
if (!hasEntries) {
|
|
140
|
+
status = filteredFields.length > 0 ? 'filtered' : 'none_present';
|
|
141
|
+
} else if (redactedCount > 0) {
|
|
142
|
+
status = 'redacted';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
query: hasEntries ? query : null,
|
|
147
|
+
status,
|
|
148
|
+
redactedCount,
|
|
149
|
+
filteredFields,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Safely deep-clone an object with cycle detection, depth limits, field allowlists,
|
|
155
|
+
* and sensitive-value redaction.
|
|
156
|
+
*/
|
|
157
|
+
export function safeJsonClone(value, options = {}) {
|
|
158
|
+
const {
|
|
159
|
+
maxDepth = 5,
|
|
160
|
+
maxBytes = 4096,
|
|
161
|
+
maxFieldSize = 1024,
|
|
162
|
+
allowedFields = null,
|
|
163
|
+
hashFields = null,
|
|
164
|
+
redactFields = null,
|
|
165
|
+
seen = new WeakSet(),
|
|
166
|
+
depth = 0,
|
|
167
|
+
stats = { redactedCount: 0, filteredFields: [] },
|
|
168
|
+
} = options;
|
|
169
|
+
|
|
170
|
+
if (value === null || value === undefined) return value;
|
|
171
|
+
if (typeof value === 'boolean' || typeof value === 'number') return value;
|
|
172
|
+
|
|
173
|
+
if (typeof value === 'string') {
|
|
174
|
+
if (isSensitiveValue(value)) {
|
|
175
|
+
stats.redactedCount++;
|
|
176
|
+
return REDACTED_MARKER;
|
|
177
|
+
}
|
|
178
|
+
if (value.length > maxFieldSize) {
|
|
179
|
+
return value.slice(0, maxFieldSize) + '...[TRUNCATED]';
|
|
180
|
+
}
|
|
181
|
+
return value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (depth >= maxDepth) return '[DEPTH_LIMIT]';
|
|
185
|
+
|
|
186
|
+
if (typeof value === 'object') {
|
|
187
|
+
if (seen.has(value)) return '[CIRCULAR]';
|
|
188
|
+
seen.add(value);
|
|
189
|
+
|
|
190
|
+
if (Array.isArray(value)) {
|
|
191
|
+
return value.map((item) => safeJsonClone(item, { ...options, depth: depth + 1, seen, stats }));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const copy = {};
|
|
195
|
+
const allowedSet = allowedFields ? new Set(allowedFields) : null;
|
|
196
|
+
const hashSet = hashFields ? new Set(hashFields) : null;
|
|
197
|
+
const redactSet = redactFields ? new Set(redactFields) : null;
|
|
198
|
+
|
|
199
|
+
for (const k of Object.getOwnPropertyNames(value)) {
|
|
200
|
+
// Check allowlist first: if not allowed, filter it out
|
|
201
|
+
if (allowedSet && !allowedSet.has(k)) {
|
|
202
|
+
stats.filteredFields.push(k);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// If allowed (or no allowlist specified), check if k is sensitive or in redactSet
|
|
207
|
+
if (isSensitiveKey(k) || (redactSet && redactSet.has(k))) {
|
|
208
|
+
copy[k] = REDACTED_MARKER;
|
|
209
|
+
stats.redactedCount++;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let v;
|
|
214
|
+
try {
|
|
215
|
+
v = value[k];
|
|
216
|
+
} catch {
|
|
217
|
+
v = '[GETTER_ERROR]';
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Check hash fields
|
|
221
|
+
if (hashSet && hashSet.has(k)) {
|
|
222
|
+
copy[k] = `sha256:${sha256(typeof v === 'string' ? v : JSON.stringify(v))}`;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
copy[k] = safeJsonClone(v, { ...options, depth: depth + 1, seen, stats });
|
|
227
|
+
}
|
|
228
|
+
return copy;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return String(value);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Sanitize request or response payloads (body).
|
|
236
|
+
*/
|
|
237
|
+
export function sanitizePayload(payload, options = {}) {
|
|
238
|
+
const {
|
|
239
|
+
allowedFields = null,
|
|
240
|
+
hashOnly = false,
|
|
241
|
+
maxBytes = 4096,
|
|
242
|
+
maxDepth = 5,
|
|
243
|
+
} = options;
|
|
244
|
+
|
|
245
|
+
if (payload == null || payload === '') {
|
|
246
|
+
return { sanitized: null, hash: null, bytes: 0, status: 'unavailable', redactedCount: 0, filteredFields: [] };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Handle binary buffers without unsafe UTF-8 string coercion
|
|
250
|
+
if (Buffer.isBuffer(payload)) {
|
|
251
|
+
const isBinary = payload.some((byte) => byte === 0);
|
|
252
|
+
if (isBinary) {
|
|
253
|
+
const hash = sha256(payload);
|
|
254
|
+
return {
|
|
255
|
+
sanitized: null,
|
|
256
|
+
hash,
|
|
257
|
+
bytes: payload.length,
|
|
258
|
+
status: hashOnly ? 'hash_only' : 'unavailable',
|
|
259
|
+
redactedCount: 0,
|
|
260
|
+
filteredFields: [],
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const rawString = typeof payload === 'string' ? payload : Buffer.isBuffer(payload) ? payload.toString('utf8') : JSON.stringify(payload);
|
|
266
|
+
const bytes = Buffer.byteLength(rawString, 'utf8');
|
|
267
|
+
const hash = sha256(rawString);
|
|
268
|
+
|
|
269
|
+
if (hashOnly) {
|
|
270
|
+
return { sanitized: null, hash, bytes, status: 'hash_only', redactedCount: 0, filteredFields: [] };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Try parsing JSON if allowedFields or structured redaction is requested
|
|
274
|
+
let parsedJson = null;
|
|
275
|
+
try {
|
|
276
|
+
parsedJson = JSON.parse(rawString);
|
|
277
|
+
} catch {
|
|
278
|
+
parsedJson = null;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const stats = { redactedCount: 0, filteredFields: [] };
|
|
282
|
+
|
|
283
|
+
if (parsedJson && typeof parsedJson === 'object') {
|
|
284
|
+
const cloned = safeJsonClone(parsedJson, {
|
|
285
|
+
maxDepth,
|
|
286
|
+
maxBytes,
|
|
287
|
+
allowedFields,
|
|
288
|
+
stats,
|
|
289
|
+
});
|
|
290
|
+
const hasAllowedEntries = cloned !== null && typeof cloned === 'object' && Object.keys(cloned).length > 0;
|
|
291
|
+
let status = 'captured';
|
|
292
|
+
if (!hasAllowedEntries && allowedFields && allowedFields.length > 0) {
|
|
293
|
+
status = 'filtered';
|
|
294
|
+
} else if (rawString.length > maxBytes) {
|
|
295
|
+
status = 'truncated';
|
|
296
|
+
}
|
|
297
|
+
return {
|
|
298
|
+
sanitized: cloned,
|
|
299
|
+
hash,
|
|
300
|
+
bytes,
|
|
301
|
+
status,
|
|
302
|
+
redactedCount: stats.redactedCount,
|
|
303
|
+
filteredFields: stats.filteredFields,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Raw text payload
|
|
308
|
+
if (isSensitiveValue(rawString)) {
|
|
309
|
+
return { sanitized: REDACTED_MARKER, hash, bytes, status: 'captured', redactedCount: 1, filteredFields: [] };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const isTruncated = rawString.length > maxBytes;
|
|
313
|
+
const truncated = isTruncated ? rawString.slice(0, maxBytes) + '...[TRUNCATED]' : rawString;
|
|
314
|
+
return {
|
|
315
|
+
sanitized: truncated,
|
|
316
|
+
hash,
|
|
317
|
+
bytes,
|
|
318
|
+
status: isTruncated ? 'truncated' : 'captured',
|
|
319
|
+
redactedCount: 0,
|
|
320
|
+
filteredFields: [],
|
|
321
|
+
};
|
|
322
|
+
}
|