deepdebug-local-agent 1.1.0 → 1.3.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/.github/workflows/npm-publish.yml +16 -9
- package/CHANGELOG.md +14 -0
- package/docs/dlp.md +78 -0
- package/docs/releasing.md +37 -43
- package/docs/ws-metadata-only.md +44 -0
- package/env.example +11 -0
- package/package.json +1 -1
- package/scripts/next-version.mjs +75 -0
- package/src/dlp/engine.js +111 -0
- package/src/dlp/index.js +137 -0
- package/src/dlp/rules.js +97 -0
- package/src/dlp/validators.js +123 -0
- package/src/mcp-http-server.js +5 -3
- package/src/server.js +49 -6
- package/src/siblings/incident-forwarder.js +3 -1
- package/src/utils/exec-policy.js +8 -1
- package/src/ws-channel/allowlist.js +143 -0
- package/src/ws-channel/config.js +22 -0
- package/src/ws-channel/connection-state.js +18 -0
- package/src/ws-channel/index.js +6 -0
- package/src/ws-channel/metadata-guard.js +78 -0
- package/src/ws-channel/policy-offline.js +40 -0
- package/src/ws-channel/transit-guard.js +55 -0
- package/test/dlp/engine.test.js +173 -0
- package/test/dlp/fixtures.js +126 -0
- package/test/dlp/index.test.js +166 -0
- package/test/next-version.test.js +48 -0
- package/test/ws-channel/allowlist.test.js +67 -0
- package/test/ws-channel/metadata-guard.test.js +104 -0
- package/test/ws-channel/policy-offline.test.js +99 -0
- package/test/ws-channel/tunnel-e2e.test.js +88 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* metadata-guard.js - the metadata-only outbound filter for the Local Agent WS
|
|
3
|
+
* channel (T4-10). A NEW filtering module: it opens no socket itself and
|
|
4
|
+
* returns null when the flag is off, so with the flag off nothing changes.
|
|
5
|
+
*
|
|
6
|
+
* Every outbound message passes: size limit -> rate limit -> allowlist
|
|
7
|
+
* validation. A violation is dropped, logged WITHOUT its content (type, field
|
|
8
|
+
* name, reason code, byte size) and, for command replies, answered with a
|
|
9
|
+
* metadata-only error so the control plane does not wait for a timeout.
|
|
10
|
+
*/
|
|
11
|
+
import { METADATA_COMMANDS, validateOutbound, projectResponse } from "./allowlist.js";
|
|
12
|
+
import { createTransitGuard } from "./transit-guard.js";
|
|
13
|
+
|
|
14
|
+
export function createWsMetadataGuard({ config, transit, logger = console, now = Date.now } = {}) {
|
|
15
|
+
if (!config || config.mode !== "enforce") return null; // flag off: no guard, no behaviour change
|
|
16
|
+
const guard = transit || createTransitGuard({ now });
|
|
17
|
+
const stats = { sent: 0, blocked: 0 };
|
|
18
|
+
|
|
19
|
+
// Content-free log line: only codes/names/sizes, never values.
|
|
20
|
+
const logBlocked = (info) => {
|
|
21
|
+
stats.blocked++;
|
|
22
|
+
logger.warn(`[ws-metadata] BLOCKED ${JSON.stringify(info)}`);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Serialises + checks one message. @returns {string|null} the frame to send, null if blocked. */
|
|
26
|
+
function frame(msg) {
|
|
27
|
+
const v = validateOutbound(msg);
|
|
28
|
+
if (!v.ok) { logBlocked({ reason: v.reason, type: v.type || null, field: v.field || null }); return null; }
|
|
29
|
+
const serialized = JSON.stringify(msg);
|
|
30
|
+
const size = guard.checkSize(serialized);
|
|
31
|
+
if (!size.ok) { logBlocked({ reason: size.reason, type: v.type }); return null; }
|
|
32
|
+
const rate = guard.checkRate();
|
|
33
|
+
if (!rate.ok) { logBlocked({ reason: rate.reason, type: v.type }); return null; }
|
|
34
|
+
stats.sent++;
|
|
35
|
+
return serialized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const errorFrame = (requestId, command, errorCode) =>
|
|
39
|
+
frame({ requestId, command, ok: false, errorCode });
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
mode: "enforce",
|
|
43
|
+
stats,
|
|
44
|
+
/** Spontaneous (non-reply) messages: register, hello, status, cost. */
|
|
45
|
+
frameMessage: frame,
|
|
46
|
+
/**
|
|
47
|
+
* Inbound command gate: legacy content commands never execute in
|
|
48
|
+
* metadata-only mode. @returns {{ok:true}|{ok:false,errorFrame:string|null}}
|
|
49
|
+
*/
|
|
50
|
+
admitCommand(requestId, command) {
|
|
51
|
+
if (!METADATA_COMMANDS.includes(command)) {
|
|
52
|
+
logBlocked({ reason: "command-not-allowed", type: "command", field: "command" });
|
|
53
|
+
return { ok: false, errorFrame: errorFrame(requestId, command, "blocked_metadata_only") };
|
|
54
|
+
}
|
|
55
|
+
const a = guard.admitRequest(requestId);
|
|
56
|
+
if (!a.ok) return { ok: false, errorFrame: errorFrame(requestId, command, "rate_limited") };
|
|
57
|
+
return { ok: true };
|
|
58
|
+
},
|
|
59
|
+
/** Reply to an admitted command: projected onto the allowlist, TTL-checked, validated. */
|
|
60
|
+
frameResponse(requestId, command, result) {
|
|
61
|
+
const t = guard.consumeRequest(requestId);
|
|
62
|
+
if (!t.ok) {
|
|
63
|
+
logBlocked({ reason: t.reason, type: "response" });
|
|
64
|
+
return null; // nothing to reply to / too late: drop silently (no queueing)
|
|
65
|
+
}
|
|
66
|
+
const p = projectResponse(requestId, command, result);
|
|
67
|
+
if (!p.ok) {
|
|
68
|
+
logBlocked({ reason: p.reason, type: "response", field: p.field || null });
|
|
69
|
+
return errorFrame(requestId, command, "blocked_metadata_only");
|
|
70
|
+
}
|
|
71
|
+
return frame(p.message) || errorFrame(requestId, command, "blocked_metadata_only");
|
|
72
|
+
},
|
|
73
|
+
/** Error reply for a request that never reached execution (e.g. bad signature). */
|
|
74
|
+
frameError(requestId, command, errorCode) {
|
|
75
|
+
return errorFrame(requestId, command, errorCode);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* policy-offline.js - D19: PolicyGate per action; offline = DENY (fail-closed).
|
|
3
|
+
*
|
|
4
|
+
* Actions that need a PolicyGate decision (a platform-authored tool command or
|
|
5
|
+
* a human-approval token) are denied while the agent has no live connection to
|
|
6
|
+
* the control plane. Purely local, allowlisted commands do not need PolicyGate
|
|
7
|
+
* and are unaffected. Active only when LOCAL_AGENT_WS_METADATA_ONLY=enforce.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** decideExec().via prefixes that mean "PolicyGate authorised this". */
|
|
11
|
+
const GATED_VIA_PREFIXES = Object.freeze(["platform-tool:", "human-approval:"]);
|
|
12
|
+
|
|
13
|
+
export const OFFLINE_DENY_REASON = "PolicyGate unreachable: no connection to the control plane (offline = DENY)";
|
|
14
|
+
|
|
15
|
+
export function requiresPolicyGate(via) {
|
|
16
|
+
return typeof via === "string" && GATED_VIA_PREFIXES.some((p) => via.startsWith(p));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Generic per-action decision.
|
|
21
|
+
* @returns {{decision:"ALLOW"|"DENY",reason?:string}}
|
|
22
|
+
*/
|
|
23
|
+
export function decideOffline({ gated, online }) {
|
|
24
|
+
if (gated && !online) return { decision: "DENY", reason: OFFLINE_DENY_REASON };
|
|
25
|
+
return { decision: "ALLOW" };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Builds the guard plugged into exec-policy (`cfg.offlineGuard`): receives a
|
|
30
|
+
* decideExec() result and downgrades PolicyGate-backed ALLOWs to DENY when offline.
|
|
31
|
+
*/
|
|
32
|
+
export function createOfflinePolicyGuard({ connection, onDeny = () => {} }) {
|
|
33
|
+
return function offlineGuard(decision) {
|
|
34
|
+
if (!decision || !decision.allow || !requiresPolicyGate(decision.via)) return decision;
|
|
35
|
+
const verdict = decideOffline({ gated: true, online: connection.isOnline() });
|
|
36
|
+
if (verdict.decision === "ALLOW") return decision;
|
|
37
|
+
onDeny({ via: decision.via.split(":")[0] });
|
|
38
|
+
return { allow: false, status: 403, reason: verdict.reason, via: "policy-gate-offline" };
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transit-guard.js - D17 controls for content/metadata in transit through the
|
|
3
|
+
* control plane: nothing is persisted or queued here, no payload is ever
|
|
4
|
+
* logged, replies are only sent for known requests inside a TTL, and size and
|
|
5
|
+
* rate are bounded.
|
|
6
|
+
*/
|
|
7
|
+
export const DEFAULT_LIMITS = Object.freeze({
|
|
8
|
+
maxMessageBytes: 4096,
|
|
9
|
+
requestTtlMs: 60_000,
|
|
10
|
+
maxInFlight: 256,
|
|
11
|
+
rateWindowMs: 10_000,
|
|
12
|
+
rateMax: 200,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
export function createTransitGuard({ now = Date.now, limits = {} } = {}) {
|
|
16
|
+
const lim = { ...DEFAULT_LIMITS, ...limits };
|
|
17
|
+
const inFlight = new Map(); // requestId -> expiresAt (in memory only; never persisted)
|
|
18
|
+
let windowStart = now();
|
|
19
|
+
let windowCount = 0;
|
|
20
|
+
|
|
21
|
+
const sweep = () => {
|
|
22
|
+
const t = now();
|
|
23
|
+
for (const [id, exp] of inFlight) if (exp <= t) inFlight.delete(id);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
limits: lim,
|
|
28
|
+
/** Registers an inbound request. @returns {{ok:true}|{ok:false,reason:string}} */
|
|
29
|
+
admitRequest(requestId) {
|
|
30
|
+
sweep();
|
|
31
|
+
if (inFlight.size >= lim.maxInFlight) return { ok: false, reason: "too-many-in-flight" };
|
|
32
|
+
inFlight.set(requestId, now() + lim.requestTtlMs);
|
|
33
|
+
return { ok: true };
|
|
34
|
+
},
|
|
35
|
+
/** A reply may only be sent once, to a known request, before its TTL. */
|
|
36
|
+
consumeRequest(requestId) {
|
|
37
|
+
const exp = inFlight.get(requestId);
|
|
38
|
+
if (exp === undefined) return { ok: false, reason: "unknown-request" };
|
|
39
|
+
inFlight.delete(requestId);
|
|
40
|
+
if (exp <= now()) return { ok: false, reason: "request-expired" };
|
|
41
|
+
return { ok: true };
|
|
42
|
+
},
|
|
43
|
+
checkSize(serialized) {
|
|
44
|
+
return Buffer.byteLength(serialized, "utf8") <= lim.maxMessageBytes ? { ok: true } : { ok: false, reason: "message-too-large" };
|
|
45
|
+
},
|
|
46
|
+
checkRate() {
|
|
47
|
+
const t = now();
|
|
48
|
+
if (t - windowStart >= lim.rateWindowMs) { windowStart = t; windowCount = 0; }
|
|
49
|
+
if (windowCount >= lim.rateMax) return { ok: false, reason: "rate-limited" };
|
|
50
|
+
windowCount++;
|
|
51
|
+
return { ok: true };
|
|
52
|
+
},
|
|
53
|
+
pending: () => inFlight.size,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { test, describe } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { maskSensitive, maskDeep, normalizeMode, rulesetHash } from '../../src/dlp/engine.js';
|
|
4
|
+
import { RULES, RULESET_VERSION } from '../../src/dlp/rules.js';
|
|
5
|
+
import { POSITIVES, NEGATIVES, validPtNif } from './fixtures.js';
|
|
6
|
+
|
|
7
|
+
describe('ruleset', () => {
|
|
8
|
+
test('has at least 30 distinct rules with unique ids and valid regexes', () => {
|
|
9
|
+
const ids = RULES.map((r) => r.id);
|
|
10
|
+
assert.ok(ids.length >= 30, `only ${ids.length} rules`);
|
|
11
|
+
assert.equal(new Set(ids).size, ids.length);
|
|
12
|
+
for (const r of RULES) assert.ok(r.re.flags.includes('g'), r.id);
|
|
13
|
+
});
|
|
14
|
+
test('hash is stable and changes with the rules', () => {
|
|
15
|
+
assert.equal(rulesetHash(), rulesetHash());
|
|
16
|
+
assert.match(rulesetHash(), /^[0-9a-f]{64}$/);
|
|
17
|
+
assert.notEqual(rulesetHash(RULES.slice(1)), rulesetHash());
|
|
18
|
+
assert.match(RULESET_VERSION, /^\d{4}\.\d{2}\.\d{2}-\d+$/);
|
|
19
|
+
});
|
|
20
|
+
test('every rule is exercised by the positive corpus', () => {
|
|
21
|
+
const covered = new Set(POSITIVES.map(([id]) => id));
|
|
22
|
+
for (const r of RULES) assert.ok(covered.has(r.id), `no positive fixture for ${r.id}`);
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('positives: zero leaks in enforce', () => {
|
|
27
|
+
for (const [ruleId, sample, secret] of POSITIVES) {
|
|
28
|
+
test(`${ruleId}: ${sample.slice(0, 24).replace(/\n/g, ' ')}`, () => {
|
|
29
|
+
const res = maskSensitive(sample, { mode: 'enforce' });
|
|
30
|
+
assert.ok(res.redactions[ruleId] >= 1, `expected ${ruleId}, got ${JSON.stringify(res.redactions)}`);
|
|
31
|
+
assert.match(res.text, new RegExp(`\\[REDACTED:${ruleId}\\]`));
|
|
32
|
+
if (secret) assert.ok(!res.text.includes(secret), 'secret still present');
|
|
33
|
+
assert.ok(!res.blocked);
|
|
34
|
+
// idempotent: masking masked text finds nothing new
|
|
35
|
+
assert.equal(maskSensitive(res.text, { mode: 'enforce' }).total, 0);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('false positives', () => {
|
|
41
|
+
for (const text of NEGATIVES) {
|
|
42
|
+
test(`unchanged: ${text.slice(0, 50)}`, () => {
|
|
43
|
+
const res = maskSensitive(text, { mode: 'enforce' });
|
|
44
|
+
assert.equal(res.text, text, JSON.stringify(res.redactions));
|
|
45
|
+
assert.equal(res.total, 0);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
test('false-positive rate on the negative corpus is 0 %', () => {
|
|
49
|
+
const hits = NEGATIVES.filter((t) => maskSensitive(t, { mode: 'enforce' }).total > 0);
|
|
50
|
+
assert.equal(hits.length, 0);
|
|
51
|
+
});
|
|
52
|
+
test('valid NIF only with context', () => {
|
|
53
|
+
const nif = validPtNif('12345678');
|
|
54
|
+
assert.equal(maskSensitive(`ref ${nif}`, { mode: 'enforce' }).text, `ref ${nif}`);
|
|
55
|
+
assert.match(maskSensitive(`NIF ${nif}`, { mode: 'enforce' }).text, /NIF \[REDACTED:pt_nif\]/);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('modes', () => {
|
|
60
|
+
const sample = POSITIVES.find(([id]) => id === 'github_token')[1];
|
|
61
|
+
test('off: untouched and nothing scanned', () => {
|
|
62
|
+
const res = maskSensitive(sample, { mode: 'off' });
|
|
63
|
+
assert.equal(res.text, sample);
|
|
64
|
+
assert.equal(res.total, 0);
|
|
65
|
+
});
|
|
66
|
+
test('unknown or missing mode behaves as off', () => {
|
|
67
|
+
assert.equal(normalizeMode('ENFORCE '), 'enforce');
|
|
68
|
+
assert.equal(normalizeMode('yes'), 'off');
|
|
69
|
+
assert.equal(normalizeMode(undefined), 'off');
|
|
70
|
+
assert.equal(maskSensitive(sample, {}).text, sample);
|
|
71
|
+
});
|
|
72
|
+
test('warn: text untouched, counts reported', () => {
|
|
73
|
+
const res = maskSensitive(sample, { mode: 'warn' });
|
|
74
|
+
assert.equal(res.text, sample);
|
|
75
|
+
assert.equal(res.redactions.github_token, 1);
|
|
76
|
+
});
|
|
77
|
+
test('enforce + blockClasses withholds the whole text', () => {
|
|
78
|
+
const res = maskSensitive(`before ${sample} after`, { mode: 'enforce', blockClasses: ['vcs-token'] });
|
|
79
|
+
assert.equal(res.text, '[BLOCKED:vcs-token]');
|
|
80
|
+
assert.equal(res.blocked, true);
|
|
81
|
+
const warn = maskSensitive(sample, { mode: 'warn', blockClasses: ['vcs-token'] });
|
|
82
|
+
assert.equal(warn.blocked, true);
|
|
83
|
+
assert.equal(warn.text, sample);
|
|
84
|
+
});
|
|
85
|
+
test('non-matching block class does not block', () => {
|
|
86
|
+
const res = maskSensitive(sample, { mode: 'enforce', blockClasses: ['private-key'] });
|
|
87
|
+
assert.equal(res.blocked, false);
|
|
88
|
+
assert.match(res.text, /REDACTED:github_token/);
|
|
89
|
+
});
|
|
90
|
+
test('enforce fails closed above maxBytes, warn does not alter', () => {
|
|
91
|
+
const big = 'a'.repeat(100);
|
|
92
|
+
assert.equal(maskSensitive(big, { mode: 'enforce', maxBytes: 50 }).text, '[REDACTED:dlp_oversize]');
|
|
93
|
+
assert.equal(maskSensitive(big, { mode: 'warn', maxBytes: 50 }).text, big);
|
|
94
|
+
});
|
|
95
|
+
test('non-string and empty input pass through', () => {
|
|
96
|
+
assert.equal(maskSensitive('', { mode: 'enforce' }).text, '');
|
|
97
|
+
assert.equal(maskSensitive(undefined, { mode: 'enforce' }).text, undefined);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe('report never contains the value', () => {
|
|
102
|
+
test('JSON of the report has no secret material', () => {
|
|
103
|
+
const [, sample] = POSITIVES.find(([id]) => id === 'stripe_key');
|
|
104
|
+
const res = maskSensitive(sample, { mode: 'enforce' });
|
|
105
|
+
const { text, ...report } = res;
|
|
106
|
+
assert.ok(!JSON.stringify(report).includes('live_'));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('multiple hits and context', () => {
|
|
111
|
+
test('masks several secrets and keeps surrounding text', () => {
|
|
112
|
+
const gh = POSITIVES.find(([id]) => id === 'github_token')[1];
|
|
113
|
+
const mail = 'maria@acme-corp.pt';
|
|
114
|
+
const res = maskSensitive(`a ${gh} b ${mail} c ${gh}`, { mode: 'enforce' });
|
|
115
|
+
assert.equal(res.text, 'a [REDACTED:github_token] b [REDACTED:email] c [REDACTED:github_token]');
|
|
116
|
+
assert.deepEqual(res.redactions, { github_token: 2, email: 1 });
|
|
117
|
+
});
|
|
118
|
+
test('assignment keeps the key name, masks only the value', () => {
|
|
119
|
+
const res = maskSensitive('DB_PASSWORD=Sup3rS3cretValue', { mode: 'enforce' });
|
|
120
|
+
assert.equal(res.text, 'DB_PASSWORD=[REDACTED:generic_secret_assignment]');
|
|
121
|
+
});
|
|
122
|
+
test('URL credentials keep scheme and host', () => {
|
|
123
|
+
const res = maskSensitive('https://deploy:hunter2hunter2@git.example.com/o/r.git', { mode: 'enforce' });
|
|
124
|
+
assert.equal(res.text, 'https://[REDACTED:url_credentials]@git.example.com/o/r.git');
|
|
125
|
+
});
|
|
126
|
+
test('multi-line stack trace with one secret', () => {
|
|
127
|
+
const gh = POSITIVES.find(([id]) => id === 'github_token')[1];
|
|
128
|
+
const trace = `Error: boom\n at a (x.js:1:1)\n token=${gh}\n at b (y.js:2:2)`;
|
|
129
|
+
const res = maskSensitive(trace, { mode: 'enforce' });
|
|
130
|
+
assert.ok(res.text.startsWith('Error: boom\n at a (x.js:1:1)\n'));
|
|
131
|
+
assert.ok(res.text.endsWith('\n at b (y.js:2:2)'));
|
|
132
|
+
assert.ok(!res.text.includes(gh));
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('performance guard', () => {
|
|
137
|
+
test('1 MB of pathological input is scanned in bounded time', () => {
|
|
138
|
+
for (const chunk of ['a'.repeat(1_000_000), 'a.'.repeat(500_000), '1 '.repeat(500_000), 'x@'.repeat(300_000)]) {
|
|
139
|
+
const t = Date.now();
|
|
140
|
+
maskSensitive(chunk, { mode: 'enforce' });
|
|
141
|
+
assert.ok(Date.now() - t < 5000, `slow: ${Date.now() - t} ms`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe('maskDeep', () => {
|
|
147
|
+
const gh = POSITIVES.find(([id]) => id === 'github_token')[1];
|
|
148
|
+
test('enforce masks nested strings, keeps keys and non-strings', () => {
|
|
149
|
+
const input = { a: gh, n: 5, ok: true, nested: [{ m: `x ${gh}` }, null], [gh]: 'k' };
|
|
150
|
+
const res = maskDeep(input, { mode: 'enforce' });
|
|
151
|
+
assert.equal(res.value.a, '[REDACTED:github_token]');
|
|
152
|
+
assert.equal(res.value.n, 5);
|
|
153
|
+
assert.equal(res.value.nested[0].m, 'x [REDACTED:github_token]');
|
|
154
|
+
assert.equal(res.value.nested[1], null);
|
|
155
|
+
assert.equal(res.total, 2);
|
|
156
|
+
assert.equal(input.a, gh, 'input not mutated');
|
|
157
|
+
});
|
|
158
|
+
test('warn returns the original value but reports counts; off returns it untouched', () => {
|
|
159
|
+
const input = { a: gh };
|
|
160
|
+
const warn = maskDeep(input, { mode: 'warn' });
|
|
161
|
+
assert.equal(warn.value, input);
|
|
162
|
+
assert.equal(warn.total, 1);
|
|
163
|
+
const off = maskDeep(input, { mode: 'off' });
|
|
164
|
+
assert.equal(off.value, input);
|
|
165
|
+
assert.equal(off.total, 0);
|
|
166
|
+
});
|
|
167
|
+
test('circular structures do not hang', () => {
|
|
168
|
+
const o = { s: gh };
|
|
169
|
+
o.self = o;
|
|
170
|
+
const res = maskDeep(o, { mode: 'enforce' });
|
|
171
|
+
assert.equal(res.value.self, '[circular]');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// Synthetic secrets only. Prefixes are split so no scanner (GitHub push protection,
|
|
2
|
+
// trufflehog, our own DLP) flags this file; they are joined at runtime.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
// Deterministic pseudo-random text (sha256 stream), so fixtures are stable but look random.
|
|
5
|
+
const rnd = (alphabet, n, seed) => {
|
|
6
|
+
let out = '';
|
|
7
|
+
for (let i = 0; out.length < n; i++) {
|
|
8
|
+
const h = createHash('sha256').update(`${seed}:${i}`).digest();
|
|
9
|
+
for (const byte of h) { out += alphabet[byte % alphabet.length]; if (out.length === n) break; }
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
};
|
|
13
|
+
const AZ = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
14
|
+
const AZ09 = AZ + '0123456789';
|
|
15
|
+
const ALNUM = AZ09 + 'abcdefghijklmnopqrstuvwxyz';
|
|
16
|
+
const HEX = '0123456789abcdef';
|
|
17
|
+
const B64U = ALNUM + '-_';
|
|
18
|
+
const B64 = ALNUM + '+/';
|
|
19
|
+
|
|
20
|
+
/** [ruleId, sample text containing exactly one secret, the secret substring] */
|
|
21
|
+
export const POSITIVES = [
|
|
22
|
+
['pem_private_key', `key:\n${'-----BEGIN RSA PRIV' + 'ATE KEY-----'}\nMIIEowIBAAKCAQEAsynthetic${rnd(B64, 60, 1)}\n${'-----END RSA PRIV' + 'ATE KEY-----'}\nafter`, 'MIIEowIBAAKCAQEAsynthetic'],
|
|
23
|
+
['pem_private_key', `${'-----BEGIN OPENSSH PRIV' + 'ATE KEY-----'}\nb3BlbnNzaC1rZXktdjEAAAAA${rnd(B64, 40, 2)}\n${'-----END OPENSSH PRIV' + 'ATE KEY-----'}`, 'b3BlbnNzaC1rZXktdjEAAAAA'],
|
|
24
|
+
['pem_private_key', `${'-----BEGIN PRIV' + 'ATE KEY-----'}\ntruncated${rnd(B64, 30, 3)}`, 'truncated'],
|
|
25
|
+
['aws_access_key_id', `id=${'AKI' + 'A'}${rnd(AZ09, 16, 4)} region=eu`, 'AKIA'],
|
|
26
|
+
['aws_access_key_id', `temp ${'AS' + 'IA'}${rnd(AZ09, 16, 5)}`, 'ASIA'],
|
|
27
|
+
['aws_secret_access_key', `aws_secret_access_key = "${rnd(B64, 40, 6)}"`, null],
|
|
28
|
+
['gcp_api_key', `url?key=${'AI' + 'za'}${rnd(B64U, 35, 7)}&x=1`, 'AIza'],
|
|
29
|
+
['gcp_oauth_token', `tok ${'ya' + '29.'}${rnd(B64U, 40, 8)}`, 'ya29.'],
|
|
30
|
+
['gcp_oauth_client_secret', `secret ${'GOC' + 'SPX-'}${rnd(B64U, 28, 9)}`, 'GOCSPX-'],
|
|
31
|
+
['azure_storage_account_key', `DefaultEndpointsProtocol=https;AccountName=acme;AccountKey=${rnd(B64, 86, 10)}==;EndpointSuffix=core.windows.net`, null],
|
|
32
|
+
['digitalocean_token', `${'do' + 'p_v1_'}${rnd(HEX, 64, 11)}`, 'dop_v1_'],
|
|
33
|
+
['github_token', `${'gh' + 'p_'}${rnd(ALNUM, 36, 12)}`, 'ghp_'],
|
|
34
|
+
['github_fine_grained_pat', `${'github' + '_pat_'}${rnd(ALNUM, 40, 13)}`, 'github_pat_'],
|
|
35
|
+
['gitlab_pat', `${'gl' + 'pat-'}${rnd(B64U, 22, 14)}`, 'glpat-'],
|
|
36
|
+
['npm_token', `//registry.npmjs.org/:_authToken=${'np' + 'm_'}${rnd(ALNUM, 36, 15)}`, null],
|
|
37
|
+
['pypi_token', `${'py' + 'pi-AgEIcHlwaS5vcmc'}${rnd(B64U, 60, 16)}`, 'pypi-'],
|
|
38
|
+
['stripe_key', `${'sk_' + 'live_'}${rnd(ALNUM, 24, 17)}`, 'sk_live_'],
|
|
39
|
+
['stripe_key', `${'rk_' + 'test_'}${rnd(ALNUM, 24, 18)}`, 'rk_test_'],
|
|
40
|
+
['stripe_webhook_secret', `${'wh' + 'sec_'}${rnd(ALNUM, 32, 19)}`, 'whsec_'],
|
|
41
|
+
['shopify_token', `${'shp' + 'at_'}${rnd(HEX, 32, 20)}`, 'shpat_'],
|
|
42
|
+
['slack_token', `${'xo' + 'xb-'}1234567890-${rnd(ALNUM, 24, 21)}`, 'xoxb-'],
|
|
43
|
+
['slack_webhook', `${'https://hooks.' + 'slack.com/services/'}T${rnd(AZ09, 8, 22)}/B${rnd(AZ09, 8, 23)}/${rnd(ALNUM, 24, 24)}`, 'hooks.slack.com'],
|
|
44
|
+
['discord_webhook', `${'https://discord' + '.com/api/webhooks/'}123456789012345678/${rnd(B64U, 40, 25)}`, 'discord.com/api'],
|
|
45
|
+
['sendgrid_key', `${'S' + 'G.'}${rnd(B64U, 22, 26)}.${rnd(B64U, 43, 27)}`, 'SG.'],
|
|
46
|
+
['twilio_api_key', `${'S' + 'K'}${rnd(HEX, 32, 28)}`, null],
|
|
47
|
+
['mailgun_key', `${'ke' + 'y-'}${rnd(HEX, 32, 29)}`, 'key-'],
|
|
48
|
+
['huggingface_token', `${'h' + 'f_'}${rnd(ALNUM, 36, 30)}`, 'hf_'],
|
|
49
|
+
['telegram_bot_token', `bot 123456789:${'A' + 'A'}${rnd(B64U, 33, 31)}`, null],
|
|
50
|
+
['anthropic_key', `${'sk-' + 'ant-api03-'}${rnd(B64U, 40, 32)}`, 'sk-ant-'],
|
|
51
|
+
['openai_key', `${'sk' + '-proj-'}${rnd(ALNUM, 40, 33)}9`, 'sk-proj-'],
|
|
52
|
+
['jwt', `token ${'ey' + 'J'}hbGciOiJIUzI1NiJ9.${'ey' + 'J'}zdWIiOiIxMjM0NTY3ODkwIn0.${rnd(B64U, 43, 34)}`, null],
|
|
53
|
+
['bearer_token', `Authorization: Bearer ${rnd(B64U, 32, 35)}`, null],
|
|
54
|
+
['basic_auth_header', `Authorization: Basic ${Buffer.from('svc:' + rnd(ALNUM, 12, 36)).toString('base64')}`, null],
|
|
55
|
+
['db_connection_string', `DATABASE_URL=${'postgres' + 'ql'}://app:${rnd(ALNUM, 14, 37)}@db.internal:5432/app`, null],
|
|
56
|
+
['db_connection_string', `${'mongodb+' + 'srv'}://user:${rnd(ALNUM, 12, 38)}@cluster0.example.mongodb.net/db`, null],
|
|
57
|
+
['db_connection_string', `${'red' + 'is'}://:${rnd(ALNUM, 16, 39)}@cache.internal:6379`, null],
|
|
58
|
+
['jdbc_password', `jdbc:postgresql://db:5432/app?user=app&password=${rnd(ALNUM, 12, 40)}&ssl=true`, null],
|
|
59
|
+
['ado_connection_password', `Server=tcp:db,1433;Database=app;User Id=sa;Password=${rnd(ALNUM, 12, 41)}1!;Encrypt=True`, null],
|
|
60
|
+
['url_credentials', `git clone https://deploy:${rnd(ALNUM, 20, 42)}@git.example.com/org/repo.git`, null],
|
|
61
|
+
['generic_secret_assignment', `api_key = "${rnd(ALNUM, 24, 43)}"`, null],
|
|
62
|
+
['generic_secret_assignment', `DB_PASSWORD: ${rnd(ALNUM, 10, 44)}#9`, null],
|
|
63
|
+
['generic_secret_assignment', `"client_secret": "${rnd(B64U, 30, 45)}"`, null],
|
|
64
|
+
['email', 'contact maria.silva+ops@acme-corp.pt for access', 'maria.silva+ops@acme-corp.pt'],
|
|
65
|
+
['email', 'user JOAO.Costa@sub.empresa.co.uk failed login', 'JOAO.Costa@sub.empresa.co.uk'],
|
|
66
|
+
['iban', 'IBAN PT50 0002 0123 1234 5678 9015 4 transfer', 'PT50 0002 0123 1234 5678 9015 4'],
|
|
67
|
+
['iban', 'conta GB82WEST12345698765432 ok', 'GB82WEST12345698765432'],
|
|
68
|
+
['credit_card', 'card 4111 1111 1111 1111 declined', '4111 1111 1111 1111'],
|
|
69
|
+
['credit_card', 'pan=5555555555554444;', '5555555555554444'],
|
|
70
|
+
['pt_nif', 'NIF: 123456789 do cliente', '123456789'],
|
|
71
|
+
['pt_nif', 'contribuinte n.º 500000000', '500000000'],
|
|
72
|
+
['phone_international', 'ligar +351 912 345 678 urgente', '+351 912 345 678'],
|
|
73
|
+
['phone_international', 'tel 00351-213-456-789.', '00351-213-456-789'],
|
|
74
|
+
['high_entropy_blob', `blob=${rnd(B64, 48, 46)}Zq9`, null],
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** Text that must NOT be altered (false-positive corpus: ordinary code, hashes, ids). */
|
|
78
|
+
export const NEGATIVES = [
|
|
79
|
+
'const password = getPassword();',
|
|
80
|
+
'password: string;',
|
|
81
|
+
'token = process.env.API_TOKEN',
|
|
82
|
+
'const secret = config.secret',
|
|
83
|
+
'api_key = "${API_KEY}"',
|
|
84
|
+
'client_secret: <your-client-secret>',
|
|
85
|
+
'password = ""',
|
|
86
|
+
'if (!token) throw new Error("token required")',
|
|
87
|
+
'authToken: undefined',
|
|
88
|
+
'import { sanitizedChildEnv } from "../utils/child-env.js";',
|
|
89
|
+
'commit 283f6c2a1b9e4d7c5f0a8b6e3d2c1f9a8b7c6d5e merged',
|
|
90
|
+
'sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
|
|
91
|
+
'id 3f2504e0-4f89-41d3-9a0c-0305e82c3301 created',
|
|
92
|
+
'integrity "sha512-Q7DGnIuEtPSmGZ6JGVkLqUIxhEbuPDtCXXXgEngqNBhwxXrvTjTU0wsyxXfT3qlJl+ZRHU6h3cPRV0vg3s2uHQ=="',
|
|
93
|
+
'/Users/dev/projects/very-long-directory-name/another-long-segment/file-name-here.js',
|
|
94
|
+
'veryLongCamelCaseIdentifierNameThatKeepsGoingAndGoingForeverAndEver',
|
|
95
|
+
'see logo@2x.png and icon@3x.webp',
|
|
96
|
+
'user@host:~$ ls',
|
|
97
|
+
'import x from "@types/node"; npm i @scope/pkg',
|
|
98
|
+
'mail admin@example.com for docs',
|
|
99
|
+
'version +1.2.3 build 20261028 pid 123456789 ts=1698480000',
|
|
100
|
+
'timestamp 2026-10-28T09:00:00+01:00',
|
|
101
|
+
'order 4111 1111 1111 1112 failed luhn',
|
|
102
|
+
'number 123456789012345 is a plain id',
|
|
103
|
+
'NIF: 123456780 invalido',
|
|
104
|
+
'IBAN PT50 0002 0123 1234 5678 9015 5 wrong check digit',
|
|
105
|
+
'https://github.com/VISIONHAMMER/deepdebug-local-agent/pull/52',
|
|
106
|
+
'postgresql://localhost:5432/app',
|
|
107
|
+
'jdbc:postgresql://db:5432/app?ssl=true',
|
|
108
|
+
'Bearer token missing',
|
|
109
|
+
'sk-learn is a library; sk-short',
|
|
110
|
+
'The quick brown fox jumps over the lazy dog 1234.',
|
|
111
|
+
'phone format example: +1 (see docs)',
|
|
112
|
+
'java.lang.NullPointerException at com.acme.Foo.bar(Foo.java:42)',
|
|
113
|
+
'ERROR 2026-10-28 10:00:01 retry 3/5 host=db-01 latency=233ms',
|
|
114
|
+
'secret_manager.get_secret_version(name)',
|
|
115
|
+
'git clone git@github.com:VISIONHAMMER/repo.git',
|
|
116
|
+
'url = `https://${user}:${token}@github.com/o/r.git`',
|
|
117
|
+
'gcloud secrets versions access latest --secret=pg-password-qa',
|
|
118
|
+
'password_hash VARCHAR(255) NOT NULL',
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
export function validPtNif(prefix8) {
|
|
122
|
+
let sum = 0;
|
|
123
|
+
for (let i = 0; i < 8; i++) sum += Number(prefix8[i]) * (9 - i);
|
|
124
|
+
const c = 11 - (sum % 11);
|
|
125
|
+
return prefix8 + String(c >= 10 ? 0 : c);
|
|
126
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { test, describe, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
protectEgress, protectEgressAsync, protectDeep, dlpConfigFromEnv, getDlpStats, resetDlpStats,
|
|
5
|
+
DENIED, rulesetHash, RULESET_VERSION,
|
|
6
|
+
} from '../../src/dlp/index.js';
|
|
7
|
+
import { IncidentForwarder } from '../../src/siblings/incident-forwarder.js';
|
|
8
|
+
import { POSITIVES } from './fixtures.js';
|
|
9
|
+
|
|
10
|
+
const GH = POSITIVES.find(([id]) => id === 'github_token')[1];
|
|
11
|
+
const KEY = POSITIVES.find(([id]) => id === 'pem_private_key')[1];
|
|
12
|
+
const env = (mode, extra = {}) => ({ LOCAL_AGENT_DLP_MODE: mode, ...extra });
|
|
13
|
+
|
|
14
|
+
beforeEach(() => resetDlpStats());
|
|
15
|
+
|
|
16
|
+
describe('config', () => {
|
|
17
|
+
test('default is off', () => {
|
|
18
|
+
assert.equal(dlpConfigFromEnv({}).mode, 'off');
|
|
19
|
+
assert.deepEqual(dlpConfigFromEnv({}).blockClasses, []);
|
|
20
|
+
});
|
|
21
|
+
test('parses mode, block classes (unknown ignored) and max bytes', () => {
|
|
22
|
+
const c = dlpConfigFromEnv(env('enforce', { LOCAL_AGENT_DLP_BLOCK_CLASSES: 'private-key, nope ,cloud-credential', LOCAL_AGENT_DLP_MAX_BYTES: '1000' }));
|
|
23
|
+
assert.equal(c.mode, 'enforce');
|
|
24
|
+
assert.deepEqual(c.blockClasses, ['private-key', 'cloud-credential']);
|
|
25
|
+
assert.equal(c.maxBytes, 1000);
|
|
26
|
+
assert.equal(dlpConfigFromEnv(env('enforce', { LOCAL_AGENT_DLP_MAX_BYTES: 'x' })).maxBytes, 2_000_000);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('protectEgress', () => {
|
|
31
|
+
test('off (default): identical output and no counters', () => {
|
|
32
|
+
const r = protectEgress(GH, { env: {} });
|
|
33
|
+
assert.equal(r.text, GH);
|
|
34
|
+
assert.equal(getDlpStats({}).scanned, 0);
|
|
35
|
+
});
|
|
36
|
+
test('warn: identical output, counters move', () => {
|
|
37
|
+
const r = protectEgress(GH, { env: env('warn') });
|
|
38
|
+
assert.equal(r.text, GH);
|
|
39
|
+
assert.equal(r.total, 1);
|
|
40
|
+
assert.equal(getDlpStats(env('warn')).dlp_redactions_total.github_token, 1);
|
|
41
|
+
});
|
|
42
|
+
test('enforce: redacted, stats by rule, local log has counts but not the value', () => {
|
|
43
|
+
const logs = [];
|
|
44
|
+
const r = protectEgress(`x ${GH}`, { env: env('enforce'), source: 'unit', log: (o, m) => logs.push([o, m]) });
|
|
45
|
+
assert.equal(r.text, 'x [REDACTED:github_token]');
|
|
46
|
+
assert.equal(logs.length, 1);
|
|
47
|
+
assert.deepEqual(logs[0][0].rules, { github_token: 1 });
|
|
48
|
+
assert.ok(!JSON.stringify(logs).includes(GH));
|
|
49
|
+
const stats = getDlpStats(env('enforce'));
|
|
50
|
+
assert.equal(stats.mode, 'enforce');
|
|
51
|
+
assert.equal(stats.ruleset_version, RULESET_VERSION);
|
|
52
|
+
assert.equal(stats.ruleset_hash, rulesetHash());
|
|
53
|
+
assert.deepEqual(stats.dlp_redactions_total, { github_token: 1 });
|
|
54
|
+
assert.ok(!JSON.stringify(stats).includes(GH));
|
|
55
|
+
});
|
|
56
|
+
test('clean text produces no log record', () => {
|
|
57
|
+
let called = 0;
|
|
58
|
+
protectEgress('hello world', { env: env('enforce'), log: () => { called++; } });
|
|
59
|
+
assert.equal(called, 0);
|
|
60
|
+
});
|
|
61
|
+
test('a throwing logger never breaks the data path', () => {
|
|
62
|
+
const r = protectEgress(GH, { env: env('enforce'), log: () => { throw new Error('boom'); } });
|
|
63
|
+
assert.equal(r.text, '[REDACTED:github_token]');
|
|
64
|
+
});
|
|
65
|
+
test('block class withholds everything and counts', () => {
|
|
66
|
+
const r = protectEgress(`a ${KEY} b`, { env: env('enforce', { LOCAL_AGENT_DLP_BLOCK_CLASSES: 'private-key' }) });
|
|
67
|
+
assert.equal(r.text, '[BLOCKED:private-key]');
|
|
68
|
+
assert.equal(r.blocked, true);
|
|
69
|
+
assert.equal(getDlpStats(env('enforce')).blocked, 1);
|
|
70
|
+
});
|
|
71
|
+
test('oversize fails closed in enforce', () => {
|
|
72
|
+
const r = protectEgress('a'.repeat(200), { env: env('enforce', { LOCAL_AGENT_DLP_MAX_BYTES: '100' }) });
|
|
73
|
+
assert.equal(r.text, '[REDACTED:dlp_oversize]');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
describe('protectEgressAsync (PolicyGate, D19)', () => {
|
|
78
|
+
test('enforce without policyDecision just masks', async () => {
|
|
79
|
+
const r = await protectEgressAsync(GH, { env: env('enforce') });
|
|
80
|
+
assert.equal(r.text, '[REDACTED:github_token]');
|
|
81
|
+
});
|
|
82
|
+
test('ALLOW lets masked content out', async () => {
|
|
83
|
+
const r = await protectEgressAsync(`x ${GH}`, { env: env('enforce'), policyDecision: async () => 'ALLOW' });
|
|
84
|
+
assert.equal(r.text, 'x [REDACTED:github_token]');
|
|
85
|
+
assert.equal(r.denied, false);
|
|
86
|
+
});
|
|
87
|
+
for (const [name, fn] of [
|
|
88
|
+
['DENY', async () => 'DENY'],
|
|
89
|
+
['unknown answer', async () => 'MAYBE'],
|
|
90
|
+
['undefined (no answer)', async () => undefined],
|
|
91
|
+
['offline (throws)', async () => { throw new Error('ECONNREFUSED'); }],
|
|
92
|
+
['sync throw', () => { throw new Error('x'); }],
|
|
93
|
+
]) {
|
|
94
|
+
test(`${name} = DENY`, async () => {
|
|
95
|
+
const r = await protectEgressAsync('clean text', { env: env('enforce'), policyDecision: fn });
|
|
96
|
+
assert.equal(r.text, DENIED);
|
|
97
|
+
assert.equal(r.denied, true);
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
test('timeout = DENY', async () => {
|
|
101
|
+
const r = await protectEgressAsync('clean', { env: env('enforce'), timeoutMs: 20, policyDecision: () => new Promise(() => {}) });
|
|
102
|
+
assert.equal(r.denied, true);
|
|
103
|
+
});
|
|
104
|
+
test('policyDecision is ignored when the flag is off or warn', async () => {
|
|
105
|
+
for (const m of ['off', 'warn']) {
|
|
106
|
+
const r = await protectEgressAsync('clean', { env: env(m), policyDecision: async () => 'DENY' });
|
|
107
|
+
assert.equal(r.text, 'clean');
|
|
108
|
+
assert.equal(r.denied, false);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('protectDeep', () => {
|
|
114
|
+
test('off returns the same reference', () => {
|
|
115
|
+
const v = { a: GH };
|
|
116
|
+
assert.equal(protectDeep(v, { env: {} }).value, v);
|
|
117
|
+
});
|
|
118
|
+
test('enforce masks nested payloads', () => {
|
|
119
|
+
const r = protectDeep({ a: [GH], b: { c: 'ok' } }, { env: env('enforce') });
|
|
120
|
+
assert.deepEqual(r.value, { a: ['[REDACTED:github_token]'], b: { c: 'ok' } });
|
|
121
|
+
assert.equal(getDlpStats(env('enforce')).dlp_redactions_total.github_token, 1);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('integration: incident forwarder (log excerpts leaving the network)', () => {
|
|
126
|
+
const NOOP_LOGGER = { info() {}, warn() {}, error() {}, debug() {} };
|
|
127
|
+
const evt = (message) => ({ adapter: 'sap', errorCode: 'E1', file: '/logs/a', message, source: 'svc', timestamp: '2026-10-28T10:00:00Z' });
|
|
128
|
+
const prev = process.env.LOCAL_AGENT_DLP_MODE;
|
|
129
|
+
const restore = () => { if (prev === undefined) delete process.env.LOCAL_AGENT_DLP_MODE; else process.env.LOCAL_AGENT_DLP_MODE = prev; };
|
|
130
|
+
|
|
131
|
+
async function sendOne(message) {
|
|
132
|
+
const bodies = [];
|
|
133
|
+
const f = new IncidentForwarder({
|
|
134
|
+
logger: NOOP_LOGGER,
|
|
135
|
+
suppressor: { shouldSuppress: () => false },
|
|
136
|
+
fetchImpl: async (_u, init) => { bodies.push(JSON.parse(init.body)); return { ok: true, status: 200 }; },
|
|
137
|
+
});
|
|
138
|
+
f.configure({ gatewayUrl: 'http://gw.invalid', apiKey: 'k', tenantId: 't' });
|
|
139
|
+
f.handleAdapterEvent(evt(message), 'sap');
|
|
140
|
+
await f.flush();
|
|
141
|
+
return bodies[0];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
test('flag off: payload is byte-identical to the original message', async () => {
|
|
145
|
+
delete process.env.LOCAL_AGENT_DLP_MODE;
|
|
146
|
+
try {
|
|
147
|
+
const body = await sendOne(`login failed token=${GH} user maria@acme-corp.pt`);
|
|
148
|
+
assert.equal(body.message, `login failed token=${GH} user maria@acme-corp.pt`);
|
|
149
|
+
} finally { restore(); }
|
|
150
|
+
});
|
|
151
|
+
test('enforce: secrets and e-mails are masked before the POST', async () => {
|
|
152
|
+
process.env.LOCAL_AGENT_DLP_MODE = 'enforce';
|
|
153
|
+
try {
|
|
154
|
+
const body = await sendOne(`login failed ${GH} user maria@acme-corp.pt`);
|
|
155
|
+
assert.equal(body.message, 'login failed [REDACTED:github_token] user [REDACTED:email]');
|
|
156
|
+
assert.equal(body.service, 'svc');
|
|
157
|
+
} finally { restore(); }
|
|
158
|
+
});
|
|
159
|
+
test('warn: payload unchanged', async () => {
|
|
160
|
+
process.env.LOCAL_AGENT_DLP_MODE = 'warn';
|
|
161
|
+
try {
|
|
162
|
+
const body = await sendOne(`x ${GH}`);
|
|
163
|
+
assert.equal(body.message, `x ${GH}`);
|
|
164
|
+
} finally { restore(); }
|
|
165
|
+
});
|
|
166
|
+
});
|