unbound-cli 1.16.19 → 1.16.21

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,236 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('fs');
4
+ const http = require('http');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawn } = require('child_process');
8
+
9
+ const {
10
+ buildDataSecurityQuery,
11
+ buildTerminalQuery,
12
+ dataSecurityRows,
13
+ maskValue,
14
+ redactDataSecurityLog,
15
+ resolveGuardrailIds,
16
+ resolveTraceId,
17
+ terminalRows,
18
+ } = require('../src/lib/flags');
19
+
20
+ const TRACE = '225c6413-26e4-4cd2-85a6-8c5ccfc3c5ef';
21
+
22
+ test('terminal query asks only for flagged runs when no --action is given', () => {
23
+ const q = buildTerminalQuery({});
24
+ assert.deepEqual(q.policy_actions, ['block', 'approval', 'warn', 'audit']);
25
+ assert.equal(q.page, 1);
26
+ assert.equal(q.time_window, 'THIRTY_DAYS');
27
+ });
28
+
29
+ test('terminal query narrows to the given outcomes, policies and trace', () => {
30
+ const q = buildTerminalQuery({ action: 'Block,warn', policy: '12,13', trace: TRACE.toUpperCase(), page: '2' });
31
+ assert.deepEqual(q.policy_actions, ['block', 'warn']);
32
+ assert.deepEqual(q.policy_ids, ['12', '13']);
33
+ assert.equal(q.search, TRACE);
34
+ assert.equal(q.page, 2);
35
+ });
36
+
37
+ test('terminal query rejects an unknown outcome, a bad page and a non-UUID trace', () => {
38
+ assert.throws(() => buildTerminalQuery({ action: 'nuke' }), /Invalid --action "nuke"/);
39
+ assert.throws(() => buildTerminalQuery({ page: '0' }), /Invalid --page/);
40
+ assert.throws(() => buildTerminalQuery({ trace: 'abc' }), /Invalid trace ID/);
41
+ });
42
+
43
+ test('data security query maps --findings to the backend values', () => {
44
+ assert.deepEqual(buildDataSecurityQuery({}).findings, ['real']);
45
+ assert.deepEqual(buildDataSecurityQuery({ findings: 'false-positive' }).findings, ['false_positive']);
46
+ assert.deepEqual(buildDataSecurityQuery({ findings: 'all' }).findings, ['real', 'false_positive']);
47
+ assert.throws(() => buildDataSecurityQuery({ findings: 'maybe' }), /Invalid --findings/);
48
+ const trace = '3f2a6c1e-8b7d-4e2f-9a1c-5d6e7f8a9b0c';
49
+ assert.deepEqual(buildDataSecurityQuery({ trace }).findings, ['real', 'false_positive']);
50
+ assert.deepEqual(buildDataSecurityQuery({ trace, findings: 'real' }).findings, ['real']);
51
+ });
52
+
53
+ test('guardrail names resolve to IDs case-insensitively, and an unknown name lists the choices', () => {
54
+ const available = [{ id: 4, name: 'PII' }, { id: 11, name: 'Secrets' }];
55
+ assert.deepEqual(resolveGuardrailIds('secrets,4', available), ['11', '4']);
56
+ assert.throws(() => resolveGuardrailIds('nope', available), /Available: PII, Secrets/);
57
+ });
58
+
59
+ test('maskValue keeps only the ends of a long value', () => {
60
+ assert.equal(maskValue('AKIAE2ETESTKEY0000001'), 'AK****01');
61
+ assert.equal(maskValue('short'), '****');
62
+ assert.equal(maskValue(''), '');
63
+ });
64
+
65
+ const LOG = {
66
+ id: 7,
67
+ trace_id: TRACE,
68
+ timestamp: '2026-09-24T19:17:53.790Z',
69
+ user_email: 'ada@example.com',
70
+ application_name: 'CLAUDE_CODE',
71
+ guardrails: ['Secrets'],
72
+ guardrail_actions: ['Block'],
73
+ sensitive_contents: ['AKIAE2ETESTKEY0000001'],
74
+ sensitive_content_details: [{
75
+ action_id: 1, action: 'Block', guardrail_name: 'Secrets', sensitive_content: 'AKIAE2ETESTKEY0000001', is_false_positive: false,
76
+ }],
77
+ prompt: { user_prompt: 'my key is AKIAE2ETESTKEY0000001' },
78
+ };
79
+
80
+ test('redacted data security JSON carries no detected value and no prompt', () => {
81
+ const text = JSON.stringify(redactDataSecurityLog(LOG));
82
+ assert.ok(!text.includes('AKIAE2ETESTKEY0000001'), text);
83
+ assert.ok(!('prompt' in redactDataSecurityLog(LOG)));
84
+ });
85
+
86
+ test('data security rows list one line per detection, masked unless revealed', () => {
87
+ const [row] = dataSecurityRows([LOG]);
88
+ assert.equal(row[0], '1');
89
+ assert.equal(row[1], '2026-09-24 19:17');
90
+ assert.equal(row[6], 'AK****01');
91
+ assert.equal(dataSecurityRows([LOG], { reveal: true })[0][6], 'AKIAE2ETESTKEY0000001');
92
+ });
93
+
94
+ test('terminal rows show policies with their action and the feedback as a label', () => {
95
+ const [row] = terminalRows([{
96
+ id: 554681,
97
+ created_at: '2026-09-24T19:17:53.790Z',
98
+ email: 'ada@example.com',
99
+ terminal_command: 'git status',
100
+ all_matched_policies: [{ name: 'Warn AWS', action: 'warn' }],
101
+ policy_action: 'warn',
102
+ risk_score: 5,
103
+ user_feedback: { feedback_type: 'reject' },
104
+ trace_id: TRACE,
105
+ }]);
106
+ assert.deepEqual(row, ['554681', '2026-09-24 19:17', 'ada@example.com', 'git status', 'Warn AWS (warn)',
107
+ 'warn', 5, 'False positive', TRACE]);
108
+ });
109
+
110
+ test('resolveTraceId lowercases and trims', () => {
111
+ assert.equal(resolveTraceId(` ${TRACE.toUpperCase()} `), TRACE);
112
+ });
113
+
114
+ // End to end through the real binary, against a fake backend that records requests.
115
+
116
+ function startServer(routes) {
117
+ const seen = [];
118
+ const server = http.createServer((req, res) => {
119
+ let body = '';
120
+ req.on('data', (c) => { body += c; });
121
+ req.on('end', () => {
122
+ const url = new URL(req.url, 'http://x');
123
+ seen.push({ method: req.method, path: url.pathname, query: url.searchParams, body: body ? JSON.parse(body) : null });
124
+ const [status, payload] = routes(req.method, url) || [404, { error: 'no route' }];
125
+ res.writeHead(status, { 'Content-Type': 'application/json' });
126
+ res.end(JSON.stringify(payload));
127
+ });
128
+ });
129
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ server, seen, port: server.address().port })));
130
+ }
131
+
132
+ function runCli(port, args) {
133
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'flags-test-'));
134
+ fs.mkdirSync(path.join(home, '.unbound'));
135
+ fs.writeFileSync(path.join(home, '.unbound', 'config.json'),
136
+ JSON.stringify({ api_key: 'test-key', base_url: `http://127.0.0.1:${port}` }));
137
+ return new Promise((resolve) => {
138
+ const child = spawn('node', [path.join(__dirname, '..', 'src', 'index.js'), ...args], {
139
+ env: { ...process.env, HOME: home, UNBOUND_API_URL: '', UNBOUND_TELEMETRY: '0', UNBOUND_NO_UPDATE_CHECK: '1' },
140
+ });
141
+ let stdout = '';
142
+ let stderr = '';
143
+ child.stdout.on('data', (d) => { stdout += d; });
144
+ child.stderr.on('data', (d) => { stderr += d; });
145
+ child.on('close', (code) => {
146
+ fs.rmSync(home, { recursive: true, force: true });
147
+ resolve({ code, stdout, stderr });
148
+ });
149
+ });
150
+ }
151
+
152
+ test('flags terminal mark-fp posts a reject feedback for the run', async () => {
153
+ const { server, seen, port } = await startServer((method, url) =>
154
+ (method === 'POST' && url.pathname === '/api/v1/analytics/feedback/'
155
+ ? [201, { status: 'success', action: 'created', feedback_id: 3 }] : null));
156
+ try {
157
+ const r = await runCli(port, ['flags', 'terminal', 'mark-fp', '554681', '--risk-score', '8', '--comment', 'build script']);
158
+ assert.equal(r.code, 0, r.stderr);
159
+ const post = seen.find((s) => s.method === 'POST');
160
+ assert.deepEqual(post.body, {
161
+ prompt_analytics_id: 554681, analytics_type: 'terminal_command', feedback_type: 'reject', risk_score: 8, comment: 'build script',
162
+ });
163
+ } finally {
164
+ server.close();
165
+ }
166
+ });
167
+
168
+ test('flags data-security mark-fp posts the detection ID', async () => {
169
+ const { server, seen, port } = await startServer((method, url) =>
170
+ (method === 'POST' && url.pathname === '/api/v1/analytics/guardrails/mark_false_positive/'
171
+ ? [200, { message: 'ok' }] : null));
172
+ try {
173
+ const r = await runCli(port, ['flags', 'data-security', 'mark-fp', '5521']);
174
+ assert.equal(r.code, 0, r.stderr);
175
+ assert.deepEqual(seen.find((s) => s.method === 'POST').body, { guardrail_action_id: 5521 });
176
+ } finally {
177
+ server.close();
178
+ }
179
+ });
180
+
181
+ test('trace keeps only exact log matches, masks values, and exits 1 when a source fails', async () => {
182
+ const { server, port } = await startServer((method, url) => {
183
+ if (url.pathname === '/api/v1/logs/prompts/') {
184
+ return [200, { prompts: [
185
+ { id: '1', traceId: TRACE, userPrompt: 'my key is AKIAE2ETESTKEY0000001' },
186
+ { id: '2', traceId: null, userPrompt: `mentions ${TRACE}` },
187
+ ] }];
188
+ }
189
+ if (url.pathname === '/api/v1/analytics/terminal_commands/') return [403, { error: 'Manager or admin access required' }];
190
+ if (url.pathname === '/api/v1/analytics/guardrails/') {
191
+ // A trace lookup must ask for false positives too.
192
+ const all = url.searchParams.getAll('findings').join() === 'real,false_positive';
193
+ return all ? [200, { logs: { data: [LOG] } }] : [400, { error: 'findings must be all' }];
194
+ }
195
+ return null;
196
+ });
197
+ try {
198
+ const r = await runCli(port, ['trace', TRACE, '--json']);
199
+ assert.equal(r.code, 1);
200
+ const out = JSON.parse(r.stdout);
201
+ assert.deepEqual(out.logs.rows.map((p) => p.id), ['1']);
202
+ assert.equal(out.terminal_runs.error, 'Manager or admin access required');
203
+ assert.equal(out.data_security.rows.length, 1);
204
+ assert.ok(!r.stdout.includes('AKIAE2ETESTKEY0000001'), 'values must be masked without --reveal');
205
+ } finally {
206
+ server.close();
207
+ }
208
+ });
209
+
210
+ test('trace reads every page of a source and hides log prompts in the table', async () => {
211
+ const { server, seen, port } = await startServer((method, url) => {
212
+ const page = url.searchParams.get('page');
213
+ if (url.pathname === '/api/v1/logs/prompts/') {
214
+ return [200, { prompts: [{ id: '1', traceId: TRACE, userPrompt: 'my key is AKIAE2ETESTKEY0000001' }], total_pages: 1 }];
215
+ }
216
+ if (url.pathname === '/api/v1/analytics/terminal_commands/') {
217
+ // A row from another trace stands in for an older backend that ignores search.
218
+ return [200, { terminal_commands: [{ id: Number(page), trace_id: TRACE }, { id: 99, trace_id: 'other' }], has_more: page === '1' }];
219
+ }
220
+ if (url.pathname === '/api/v1/analytics/guardrails/') return [200, { logs: { data: [], total_pages: 0 } }];
221
+ return null;
222
+ });
223
+ try {
224
+ const json = await runCli(port, ['trace', TRACE, '--json']);
225
+ assert.equal(json.code, 0, json.stderr);
226
+ assert.deepEqual(JSON.parse(json.stdout).terminal_runs.rows.map((row) => row.id), [1, 2]);
227
+ assert.deepEqual(seen.filter((s) => s.path === '/api/v1/analytics/terminal_commands/').map((s) => s.query.get('page')),
228
+ ['1', '2']);
229
+ const table = await runCli(port, ['trace', TRACE]);
230
+ assert.ok(!table.stdout.includes('AKIAE2ETESTKEY0000001'), table.stdout);
231
+ const revealed = await runCli(port, ['trace', TRACE, '--reveal', '--wide']);
232
+ assert.ok(revealed.stdout.includes('AKIAE2ETESTKEY0000001'), revealed.stdout);
233
+ } finally {
234
+ server.close();
235
+ }
236
+ });
@@ -27,6 +27,9 @@ function loadOnboard({ isRoot, setupOk, reportThrows }) {
27
27
  const setupReport = require('../src/setup-report');
28
28
 
29
29
  setup.hasRootPrivileges = () => isRoot;
30
+ // Detection and the Pi step are covered in tool-detect.test.js; here pi is absent,
31
+ // so the bundle is the only setup call.
32
+ require('../src/toolDetect').detectInstalledTools = (names) => ({ detected: [], missing: names });
30
33
  // Mirror runBatch's real behavior: on failure it sets exitCode and (best-effort)
31
34
  // reports, then returns { ok: false }. We model both the report call and the
32
35
  // exitCode side effect so the test exercises onboard's own handling.
@@ -57,6 +57,9 @@ async function runOnboard(isRoot, { setCron = false, discoveryKey = 'd', domain,
57
57
  const scheduled = require('../src/scheduled');
58
58
 
59
59
  setup.hasRootPrivileges = () => isRoot;
60
+ // Detection and the Pi step are covered in tool-detect.test.js; here pi is absent,
61
+ // so the bundle is the only setup call.
62
+ require('../src/toolDetect').detectInstalledTools = (names) => ({ detected: [], missing: names });
60
63
  setup.runMdmSetupAllBundle = async () => { calls.mdm++; return { ok: true, skipped: [] }; };
61
64
  setup.runSetupAllBundle = async () => { calls.user++; return { ok: true, skipped: [] }; };
62
65
  discover.runDiscoveryScan = async (o) => { calls.discovery++; calls.discoveryArgs = o; };