deepdebug-local-agent 1.1.0 → 1.2.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.
@@ -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
+ });
@@ -0,0 +1,48 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { classify, bumpFor, nextVersion } from '../scripts/next-version.mjs'
4
+
5
+ test('fix and other non-feature types are a patch', () => {
6
+ for (const s of ['fix: handle empty body', 'fix(ci): x', 'chore: tidy', 'docs: readme', 'ci: workflow', 'perf: faster', 'refactor: split', 'Update thing']) {
7
+ assert.equal(classify(s), 'patch', s)
8
+ }
9
+ })
10
+
11
+ test('feat is a minor', () => {
12
+ assert.equal(classify('feat: add DLP masking'), 'minor')
13
+ assert.equal(classify('feat(ws): metadata only channel'), 'minor')
14
+ })
15
+
16
+ test('breaking changes are a major, by "!" or by footer', () => {
17
+ assert.equal(classify('feat!: drop node 18'), 'major')
18
+ assert.equal(classify('fix(api)!: rename endpoint'), 'major')
19
+ assert.equal(classify('fix: x\n\nBREAKING CHANGE: signature enforce is mandatory'), 'major')
20
+ assert.equal(classify('chore: y\n\nBREAKING-CHANGE: z'), 'major')
21
+ })
22
+
23
+ test('auto-bump and merge commits are ignored', () => {
24
+ assert.equal(classify('ci: auto bump to 1.1.0 [skip ci]'), null)
25
+ assert.equal(classify('Merge pull request #55 from x/y'), null)
26
+ assert.equal(classify(''), null)
27
+ })
28
+
29
+ test('the highest level wins', () => {
30
+ assert.equal(bumpFor(['fix: a', 'fix: b']), 'patch')
31
+ assert.equal(bumpFor(['fix: a', 'feat: b', 'chore: c']), 'minor')
32
+ assert.equal(bumpFor(['feat: a', 'fix!: b', 'fix: c']), 'major')
33
+ assert.equal(bumpFor([]), 'patch')
34
+ assert.equal(bumpFor(['ci: auto bump to 1.2.0 [skip ci]']), 'patch')
35
+ })
36
+
37
+ test('a footer that is not at the start of a line is not breaking', () => {
38
+ assert.equal(classify('fix: mention BREAKING CHANGE: in prose'), 'patch')
39
+ })
40
+
41
+ test('nextVersion resets the lower levels', () => {
42
+ assert.equal(nextVersion('1.1.0', 'patch'), '1.1.1')
43
+ assert.equal(nextVersion('1.1.7', 'minor'), '1.2.0')
44
+ assert.equal(nextVersion('1.9.4', 'major'), '2.0.0')
45
+ assert.equal(nextVersion('0.0.1', 'minor'), '0.1.0')
46
+ assert.throws(() => nextVersion('1.1', 'patch'))
47
+ assert.throws(() => nextVersion('1.x.0', 'patch'))
48
+ })