unbound-cli 1.14.0 → 1.15.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/package.json +1 -1
- package/src/api.js +96 -0
- package/src/commands/analytics.js +344 -0
- package/src/commands/download.js +245 -0
- package/src/index.js +14 -0
- package/src/lib/analytics-query.js +216 -0
- package/src/lib/downloads.js +343 -0
- package/src/output.js +75 -3
- package/test/analytics-columns.test.js +68 -0
- package/test/analytics-export-job.test.js +141 -0
- package/test/analytics-log-table.test.js +77 -0
- package/test/analytics-paging-footer.test.js +34 -0
- package/test/analytics-query.test.js +142 -0
- package/test/download-flag-collision.test.js +52 -0
- package/test/download-query.test.js +79 -0
- package/test/download-safety.test.js +201 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
const config = require('../config');
|
|
2
|
+
const output = require('../output');
|
|
3
|
+
const downloads = require('../lib/downloads');
|
|
4
|
+
|
|
5
|
+
const { KINDS, MAX_EXPORT_ROWS, RETENTION_HOURS } = downloads;
|
|
6
|
+
|
|
7
|
+
function requireLogin() {
|
|
8
|
+
if (!config.isLoggedIn()) {
|
|
9
|
+
throw new Error('Not logged in. Run `unbound login` first.');
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const KIND_LIST = Object.entries(KINDS)
|
|
14
|
+
.map(([kind, meta]) => ` ${kind.padEnd(23)} ${meta.summary}`)
|
|
15
|
+
.join('\n');
|
|
16
|
+
|
|
17
|
+
const AGENT_HELP = `
|
|
18
|
+
AGENTS / SCRIPTING
|
|
19
|
+
A CSV export is prepared by a background worker, so it is three steps. This
|
|
20
|
+
command does all three and blocks until the file is on disk:
|
|
21
|
+
|
|
22
|
+
1. POST starts a job and returns its ID
|
|
23
|
+
2. GET polls the job until it is completed
|
|
24
|
+
3. GET signs a short-lived URL and streams the file down
|
|
25
|
+
|
|
26
|
+
Exit code is 0 on success and 1 on any error. Diagnostics go to stderr, so
|
|
27
|
+
stdout carries only the CSV and is safe to pipe.
|
|
28
|
+
|
|
29
|
+
Check before you commit — this counts rows without starting anything:
|
|
30
|
+
|
|
31
|
+
$ unbound download start logs-export --check --last 7d --json
|
|
32
|
+
{"row_count":18204,"limit":25000,"exceeds_limit":false,"exact":true}
|
|
33
|
+
|
|
34
|
+
A selection over ${MAX_EXPORT_ROWS.toLocaleString()} rows is refused up front with the real count, so
|
|
35
|
+
narrow the range and retry rather than paging.
|
|
36
|
+
|
|
37
|
+
If you stop waiting, the job keeps running. Fetch it within ${RETENTION_HOURS} hours:
|
|
38
|
+
|
|
39
|
+
$ unbound download get <job-id> -o out.csv
|
|
40
|
+
|
|
41
|
+
Only 3 exports may be preparing at once per organization; a 4th returns an
|
|
42
|
+
error telling you to wait. Do not poll by re-running the export.
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const FILTER_HELP = `
|
|
46
|
+
TIME RANGE
|
|
47
|
+
Omit it and the server applies its own default window. --start/--end may be at
|
|
48
|
+
most 90 days apart, and cannot start earlier than 90 days ago.
|
|
49
|
+
|
|
50
|
+
FILTERS
|
|
51
|
+
Repeat --filter with the same key to pass a list; each is sent as its own
|
|
52
|
+
value, which is how the server reads list filters:
|
|
53
|
+
|
|
54
|
+
--filter tool_types=CLAUDE_CODE --filter tool_types=CURSOR
|
|
55
|
+
|
|
56
|
+
An unknown filter KEY is silently ignored, so a typo widens the export rather
|
|
57
|
+
than failing — 'tool_type=CURSOR' returns every tool. An invalid VALUE for a
|
|
58
|
+
known key IS rejected, with the supported values listed. Run --check first and
|
|
59
|
+
confirm the count moved.
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
/** `--last 7d` / `--last 24h` -> an absolute window the server understands. */
|
|
63
|
+
function resolveLast(value) {
|
|
64
|
+
const match = String(value).trim().match(/^(\d+)\s*([dh])$/i);
|
|
65
|
+
if (!match) {
|
|
66
|
+
throw new Error(`Invalid --last "${value}". Use a number followed by d or h, e.g. 7d or 24h.`);
|
|
67
|
+
}
|
|
68
|
+
const amount = Number(match[1]);
|
|
69
|
+
if (amount < 1) throw new Error('--last must be at least 1.');
|
|
70
|
+
const hours = match[2].toLowerCase() === 'd' ? amount * 24 : amount;
|
|
71
|
+
const end = new Date();
|
|
72
|
+
const start = new Date(end.getTime() - hours * 3600 * 1000);
|
|
73
|
+
return { start, end };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function buildQueryString(opts) {
|
|
77
|
+
const params = new URLSearchParams();
|
|
78
|
+
|
|
79
|
+
if (opts.last && (opts.start || opts.end)) {
|
|
80
|
+
throw new Error('Use --last or --start/--end, not both.');
|
|
81
|
+
}
|
|
82
|
+
if (Boolean(opts.start) !== Boolean(opts.end)) {
|
|
83
|
+
throw new Error('--start and --end must be used together.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (opts.last) {
|
|
87
|
+
const { start, end } = resolveLast(opts.last);
|
|
88
|
+
params.set('start_date', start.toISOString());
|
|
89
|
+
params.set('end_date', end.toISOString());
|
|
90
|
+
} else if (opts.start) {
|
|
91
|
+
params.set('start_date', opts.start);
|
|
92
|
+
params.set('end_date', opts.end);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (opts.search) params.set('search', opts.search);
|
|
96
|
+
|
|
97
|
+
// Repeated keys are appended, not replaced: every list filter on the server
|
|
98
|
+
// reads getlist(), so collapsing duplicates would silently drop all but one.
|
|
99
|
+
for (const entry of opts.filter || []) {
|
|
100
|
+
const index = String(entry).indexOf('=');
|
|
101
|
+
if (index <= 0) {
|
|
102
|
+
throw new Error(`Invalid --filter "${entry}". Use key=value, e.g. --filter tool_types=CURSOR.`);
|
|
103
|
+
}
|
|
104
|
+
params.append(entry.slice(0, index).trim(), entry.slice(index + 1));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return params.toString();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function runDownload(kind, opts) {
|
|
111
|
+
const query = buildQueryString(opts);
|
|
112
|
+
|
|
113
|
+
if (opts.check) {
|
|
114
|
+
const result = await downloads.checkCount(kind, query);
|
|
115
|
+
if (opts.json) {
|
|
116
|
+
output.json(result);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const rows = Number(result.row_count);
|
|
120
|
+
if (!Number.isFinite(rows)) {
|
|
121
|
+
output.info('This export does not report a row count up front.');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (result.exceeds_limit) {
|
|
125
|
+
output.warn(
|
|
126
|
+
`${rows.toLocaleString()} rows, over the ${Number(result.limit).toLocaleString()}-row limit. ` +
|
|
127
|
+
'Narrow the range or add filters before exporting.'
|
|
128
|
+
);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
output.info(`${rows.toLocaleString()} row(s) would be exported.`);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (opts.wait === false) {
|
|
136
|
+
const job = await downloads.startJob(kind, query);
|
|
137
|
+
if (opts.json) {
|
|
138
|
+
output.json(job);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
output.success(`Export started. ${downloads.fetchHint(job.id)}`);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await downloads.runExport(kind, query, { output: opts.output });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function register(program) {
|
|
149
|
+
// A group with no arguments and no options of its own. Commander binds a
|
|
150
|
+
// repeated flag on the parent, so `-o` declared on both here and on `get`
|
|
151
|
+
// would leave the subcommand's value undefined and silently write to stdout.
|
|
152
|
+
const group = program
|
|
153
|
+
.command('download')
|
|
154
|
+
.description('Export org data as CSV (Logs, Tasks, Users, Tool Use).')
|
|
155
|
+
.addHelpText('after', `
|
|
156
|
+
KINDS
|
|
157
|
+
${KIND_LIST}
|
|
158
|
+
|
|
159
|
+
Examples:
|
|
160
|
+
$ unbound download kinds
|
|
161
|
+
$ unbound download start logs-export --last 7d
|
|
162
|
+
$ unbound download start logs-export --check --last 90d --json
|
|
163
|
+
$ unbound download get 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6
|
|
164
|
+
|
|
165
|
+
Admin role required. Exports are kept for ${RETENTION_HOURS} hours.
|
|
166
|
+
`);
|
|
167
|
+
|
|
168
|
+
group
|
|
169
|
+
.command('kinds')
|
|
170
|
+
.description('List what can be exported, and the filters each kind accepts.')
|
|
171
|
+
.option('--json', 'Machine-readable output')
|
|
172
|
+
.action((opts) => {
|
|
173
|
+
if (opts.json) {
|
|
174
|
+
output.json(Object.entries(KINDS).map(([kind, meta]) => ({ kind, ...meta })));
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const [kind, meta] of Object.entries(KINDS)) {
|
|
178
|
+
console.log(`${kind}\n ${meta.summary}\n filters: ${meta.filters.join(', ')}\n`);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const command = group
|
|
183
|
+
.command('start <kind>')
|
|
184
|
+
.description('Start an export and wait for the file.')
|
|
185
|
+
.option('--last <window>', 'Relative range, e.g. 7d or 24h')
|
|
186
|
+
.option('--start <date>', 'Range start, YYYY-MM-DD (use with --end)')
|
|
187
|
+
.option('--end <date>', 'Range end, YYYY-MM-DD (use with --start)')
|
|
188
|
+
.option('--search <text>', 'Free-text filter (logs kinds only)')
|
|
189
|
+
.option('--filter <key=value>', 'Server-side filter, repeatable', (value, previous) => (previous || []).concat(value))
|
|
190
|
+
.option('-o, --output <file>', 'Write to a file instead of the server-chosen name')
|
|
191
|
+
.option('--check', 'Print how many rows would be exported, then exit')
|
|
192
|
+
.option('--json', 'Machine-readable output')
|
|
193
|
+
.option('--no-wait', 'Start the export and print its job ID instead of waiting')
|
|
194
|
+
.addHelpText('after', `
|
|
195
|
+
KINDS
|
|
196
|
+
${KIND_LIST}
|
|
197
|
+
|
|
198
|
+
Examples:
|
|
199
|
+
$ unbound download start logs-export --last 7d
|
|
200
|
+
$ unbound download start logs-export --last 24h --filter tool_types=CLAUDE_CODE -o today.csv
|
|
201
|
+
$ unbound download start logs-tasks-export --start 2026-07-01 --end 2026-07-31
|
|
202
|
+
$ unbound download start users-export --last 30d
|
|
203
|
+
$ unbound download start analytics-export --filter dataset=terminal-runs --last 7d
|
|
204
|
+
$ unbound download start logs-export --check --last 90d --json
|
|
205
|
+
|
|
206
|
+
Admin role required. Exports are kept for ${RETENTION_HOURS} hours.
|
|
207
|
+
${FILTER_HELP}${AGENT_HELP}`)
|
|
208
|
+
.action(async (kind, opts) => {
|
|
209
|
+
try {
|
|
210
|
+
requireLogin();
|
|
211
|
+
await runDownload(kind, opts);
|
|
212
|
+
} catch (err) {
|
|
213
|
+
output.error(downloads.describeError(err, `${kind} export`));
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
group
|
|
219
|
+
.command('get <job-id>')
|
|
220
|
+
.description('Download an export that was already started, by its job ID.')
|
|
221
|
+
.option('-o, --output <file>', 'Write to a file instead of the server-chosen name')
|
|
222
|
+
.addHelpText('after', `
|
|
223
|
+
Exports run in the background. If you interrupted the wait, or used --no-wait,
|
|
224
|
+
this fetches the finished file. If the job is still running, it waits for it.
|
|
225
|
+
|
|
226
|
+
Exports are kept for ${RETENTION_HOURS} hours.
|
|
227
|
+
|
|
228
|
+
Examples:
|
|
229
|
+
$ unbound download get 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6
|
|
230
|
+
$ unbound download get 4f2a1c9e-1b2c-4d5e-8f90-a1b2c3d4e5f6 -o july.csv
|
|
231
|
+
`)
|
|
232
|
+
.action(async (jobId, opts) => {
|
|
233
|
+
try {
|
|
234
|
+
requireLogin();
|
|
235
|
+
await downloads.fetchExisting(jobId, { output: opts.output });
|
|
236
|
+
} catch (err) {
|
|
237
|
+
output.error(downloads.describeError(err, 'export', { byJobId: true }));
|
|
238
|
+
process.exitCode = 1;
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
return command;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
module.exports = { register, buildQueryString, resolveLast };
|
package/src/index.js
CHANGED
|
@@ -155,6 +155,18 @@ TOOL CONNECTIONS
|
|
|
155
155
|
$ unbound tools connect <type> Connect a new tool
|
|
156
156
|
$ unbound tools approved List approved tool types
|
|
157
157
|
|
|
158
|
+
ANALYTICS (Admin/Manager; CSV export is Admin-only)
|
|
159
|
+
$ unbound analytics tool-use terminal Terminal commands agents ran
|
|
160
|
+
$ unbound analytics tool-use mcp-servers MCP servers seen (find --server values here)
|
|
161
|
+
$ unbound analytics tool-use mcp-actions --server Notion
|
|
162
|
+
MCP tool calls for one service
|
|
163
|
+
$ unbound analytics tool-use unsanctioned Blocked/warned unsanctioned MCP usage
|
|
164
|
+
|
|
165
|
+
Every dataset takes --format table|json|csv, -o <file>, --limit/--offset,
|
|
166
|
+
--time-window or --start/--end, and filters (--tool --user --risk --policy).
|
|
167
|
+
csv exports every matching row, not just the page.
|
|
168
|
+
See "unbound analytics tool-use --help" for the JSON contract used by agents.
|
|
169
|
+
|
|
158
170
|
CHAT (Admin/Manager)
|
|
159
171
|
$ unbound chat Interactive REPL (multi-turn, in-memory history)
|
|
160
172
|
$ unbound chat -m "show cost by provider for the last 30 days"
|
|
@@ -196,6 +208,8 @@ require('./commands/policy').register(program);
|
|
|
196
208
|
require('./commands/users').register(program);
|
|
197
209
|
require('./commands/user-groups').register(program);
|
|
198
210
|
require('./commands/tools').register(program);
|
|
211
|
+
require('./commands/analytics').register(program);
|
|
212
|
+
require('./commands/download').register(program);
|
|
199
213
|
require('./commands/setup').register(program);
|
|
200
214
|
require('./commands/discover').register(program);
|
|
201
215
|
require('./commands/onboard').register(program);
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flag parsing and query-string construction for `unbound analytics tool-use`.
|
|
3
|
+
* Pure functions — no network, no process state — so they are unit-testable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const TIME_WINDOWS = [
|
|
7
|
+
'ONE_DAY',
|
|
8
|
+
'THREE_DAYS',
|
|
9
|
+
'SEVEN_DAYS',
|
|
10
|
+
'FOURTEEN_DAYS',
|
|
11
|
+
'THIRTY_DAYS',
|
|
12
|
+
'THREE_MONTHS',
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
const FORMATS = ['table', 'json', 'csv'];
|
|
16
|
+
|
|
17
|
+
const RISK_LEVELS = ['Low', 'Medium', 'High'];
|
|
18
|
+
|
|
19
|
+
const INITIATORS = {
|
|
20
|
+
user: 'USER_INTENTIONAL',
|
|
21
|
+
agent: 'AGENT_INITIATED',
|
|
22
|
+
unknown: 'UNKNOWN',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// The backend rejects a custom range wider than this.
|
|
26
|
+
const MAX_RANGE_DAYS = 90;
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TIME_WINDOW = 'THIRTY_DAYS';
|
|
29
|
+
const DEFAULT_LIMIT = 20;
|
|
30
|
+
const MAX_LIMIT = 1000;
|
|
31
|
+
// Lower for the table: past roughly a screenful nobody reads the rows, they
|
|
32
|
+
// scroll past them. json is parsed rather than read, so it keeps the full cap.
|
|
33
|
+
const MAX_TABLE_LIMIT = 100;
|
|
34
|
+
|
|
35
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
36
|
+
|
|
37
|
+
function parseList(value) {
|
|
38
|
+
if (value === undefined || value === null) return [];
|
|
39
|
+
const items = Array.isArray(value) ? value : String(value).split(',');
|
|
40
|
+
return items.map((v) => String(v).trim()).filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function resolveFormat(opts) {
|
|
44
|
+
if (opts.json && opts.format && opts.format !== 'json') {
|
|
45
|
+
throw new Error(`Cannot combine --json with --format ${opts.format}.`);
|
|
46
|
+
}
|
|
47
|
+
const format = opts.json ? 'json' : (opts.format || 'table');
|
|
48
|
+
if (!FORMATS.includes(format)) {
|
|
49
|
+
throw new Error(`Invalid --format "${format}". Must be one of: ${FORMATS.join(', ')}.`);
|
|
50
|
+
}
|
|
51
|
+
return format;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveWindow(opts) {
|
|
55
|
+
const hasStart = Boolean(opts.start);
|
|
56
|
+
const hasEnd = Boolean(opts.end);
|
|
57
|
+
|
|
58
|
+
if (hasStart !== hasEnd) {
|
|
59
|
+
throw new Error('--start and --end must be used together.');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (hasStart) {
|
|
63
|
+
if (opts.timeWindow) {
|
|
64
|
+
throw new Error('Cannot combine --time-window with --start/--end. Pick one.');
|
|
65
|
+
}
|
|
66
|
+
for (const [flag, value] of [['--start', opts.start], ['--end', opts.end]]) {
|
|
67
|
+
if (!DATE_RE.test(value)) {
|
|
68
|
+
throw new Error(`Invalid ${flag} "${value}". Expected YYYY-MM-DD.`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const start = new Date(`${opts.start}T00:00:00Z`);
|
|
72
|
+
const end = new Date(`${opts.end}T00:00:00Z`);
|
|
73
|
+
// Round-tripped, not just checked for NaN: Date rolls an impossible day
|
|
74
|
+
// into the next month, so 2026-02-30 parses happily as March 2.
|
|
75
|
+
const isRealDate = (value, parsed) =>
|
|
76
|
+
!Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value;
|
|
77
|
+
if (!isRealDate(opts.start, start) || !isRealDate(opts.end, end)) {
|
|
78
|
+
throw new Error('--start and --end must be real calendar dates (YYYY-MM-DD).');
|
|
79
|
+
}
|
|
80
|
+
if (start > end) {
|
|
81
|
+
throw new Error('--start must be on or before --end.');
|
|
82
|
+
}
|
|
83
|
+
const spanDays = Math.round((end - start) / 86400000);
|
|
84
|
+
if (spanDays > MAX_RANGE_DAYS) {
|
|
85
|
+
throw new Error(`Date range is ${spanDays} days; the maximum is ${MAX_RANGE_DAYS}.`);
|
|
86
|
+
}
|
|
87
|
+
return { start_date: opts.start, end_date: opts.end };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const timeWindow = (opts.timeWindow || DEFAULT_TIME_WINDOW).toUpperCase().replace(/-/g, '_');
|
|
91
|
+
if (!TIME_WINDOWS.includes(timeWindow)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Invalid --time-window "${opts.timeWindow}". Must be one of: ${TIME_WINDOWS.join(', ')}.`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return { time_window: timeWindow };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function resolveLimit(opts, format = 'json') {
|
|
100
|
+
if (opts.limit === undefined) return DEFAULT_LIMIT;
|
|
101
|
+
const limit = Number(opts.limit);
|
|
102
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
103
|
+
throw new Error(`Invalid --limit "${opts.limit}". Must be a positive integer.`);
|
|
104
|
+
}
|
|
105
|
+
const max = format === 'table' ? MAX_TABLE_LIMIT : MAX_LIMIT;
|
|
106
|
+
if (limit > max) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`--limit cannot exceed ${max} for ${format} output. ` +
|
|
109
|
+
(format === 'table'
|
|
110
|
+
? `Use --format json for up to ${MAX_LIMIT}, or --format csv to export everything.`
|
|
111
|
+
: 'Use --format csv to export everything.')
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return limit;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveOffset(opts) {
|
|
118
|
+
if (opts.offset === undefined) return 0;
|
|
119
|
+
const offset = Number(opts.offset);
|
|
120
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
121
|
+
throw new Error(`Invalid --offset "${opts.offset}". Must be zero or a positive integer.`);
|
|
122
|
+
}
|
|
123
|
+
return offset;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function resolveRiskLevels(opts) {
|
|
127
|
+
const wanted = parseList(opts.risk);
|
|
128
|
+
return wanted.map((raw) => {
|
|
129
|
+
const match = RISK_LEVELS.find((level) => level.toLowerCase() === raw.toLowerCase());
|
|
130
|
+
if (!match) {
|
|
131
|
+
throw new Error(`Invalid --risk "${raw}". Must be one of: ${RISK_LEVELS.join(', ')}.`);
|
|
132
|
+
}
|
|
133
|
+
return match;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function resolveInitiators(opts) {
|
|
138
|
+
const wanted = parseList(opts.initiator);
|
|
139
|
+
return wanted.map((raw) => {
|
|
140
|
+
const mapped = INITIATORS[raw.toLowerCase()];
|
|
141
|
+
if (!mapped) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Invalid --initiator "${raw}". Must be one of: ${Object.keys(INITIATORS).join(', ')}.`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return mapped;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Turn parsed commander options into the export endpoint's query object.
|
|
152
|
+
* Repeated keys are represented as arrays; buildQueryString expands them.
|
|
153
|
+
*/
|
|
154
|
+
function buildQuery(opts, { format, extra = {} } = {}) {
|
|
155
|
+
const query = {
|
|
156
|
+
format: format === 'table' ? 'json' : format,
|
|
157
|
+
...resolveWindow(opts),
|
|
158
|
+
...extra,
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
if (query.format === 'json') {
|
|
162
|
+
// `format` is the caller's intent; query.format is what the server speaks,
|
|
163
|
+
// and the table asks the server for json. The cap follows the intent.
|
|
164
|
+
query.limit = resolveLimit(opts, format);
|
|
165
|
+
const offset = resolveOffset(opts);
|
|
166
|
+
if (offset) query.offset = offset;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const lists = {
|
|
170
|
+
tool_types: parseList(opts.tool).map((t) => t.toUpperCase().replace(/-/g, '_')),
|
|
171
|
+
user_ids: parseList(opts.user),
|
|
172
|
+
policy_ids: parseList(opts.policy),
|
|
173
|
+
command_families: parseList(opts.family),
|
|
174
|
+
server: parseList(opts.server),
|
|
175
|
+
risk_levels: resolveRiskLevels(opts),
|
|
176
|
+
intent_attribution: resolveInitiators(opts),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
for (const [key, values] of Object.entries(lists)) {
|
|
180
|
+
if (values.length) query[key] = values;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return query;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function buildQueryString(query) {
|
|
187
|
+
const params = new URLSearchParams();
|
|
188
|
+
for (const [key, value] of Object.entries(query)) {
|
|
189
|
+
if (value === undefined || value === null || value === '') continue;
|
|
190
|
+
if (Array.isArray(value)) {
|
|
191
|
+
for (const item of value) params.append(key, String(item));
|
|
192
|
+
} else {
|
|
193
|
+
params.append(key, String(value));
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return params.toString();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
module.exports = {
|
|
200
|
+
DEFAULT_LIMIT,
|
|
201
|
+
DEFAULT_TIME_WINDOW,
|
|
202
|
+
FORMATS,
|
|
203
|
+
INITIATORS,
|
|
204
|
+
MAX_LIMIT,
|
|
205
|
+
MAX_TABLE_LIMIT,
|
|
206
|
+
MAX_RANGE_DAYS,
|
|
207
|
+
RISK_LEVELS,
|
|
208
|
+
TIME_WINDOWS,
|
|
209
|
+
buildQuery,
|
|
210
|
+
buildQueryString,
|
|
211
|
+
parseList,
|
|
212
|
+
resolveFormat,
|
|
213
|
+
resolveLimit,
|
|
214
|
+
resolveOffset,
|
|
215
|
+
resolveWindow,
|
|
216
|
+
};
|