zeuslock-dlp-cli 0.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.
- package/README.md +570 -0
- package/bin/zeuslock.js +9 -0
- package/package.json +33 -0
- package/src/cli.js +78 -0
- package/src/commands/agents.js +211 -0
- package/src/commands/anonymize.js +83 -0
- package/src/commands/auth.js +337 -0
- package/src/commands/deploy.js +515 -0
- package/src/commands/extensions.js +73 -0
- package/src/commands/hook.js +221 -0
- package/src/commands/incidents.js +436 -0
- package/src/commands/keys.js +211 -0
- package/src/commands/mcp.js +322 -0
- package/src/commands/rules.js +432 -0
- package/src/commands/scan.js +178 -0
- package/src/commands/shadow-ai.js +255 -0
- package/src/commands/siem.js +241 -0
- package/src/commands/status.js +170 -0
- package/src/commands/tokens.js +293 -0
- package/src/commands/users.js +255 -0
- package/src/commands/whoami.js +43 -0
- package/src/lib/api-client.js +308 -0
- package/src/lib/api-key-store.js +84 -0
- package/src/lib/auth-store.js +123 -0
- package/src/lib/cli-token.js +22 -0
- package/src/lib/command-token.js +15 -0
- package/src/lib/config.js +27 -0
- package/src/lib/dlp-scan.js +146 -0
- package/src/lib/package-info.js +11 -0
- package/src/lib/prompt.js +55 -0
- package/src/lib/siem-cursors.js +64 -0
- package/src/lib/table.js +30 -0
- package/src/lib/time.js +33 -0
- package/src/lib/version.js +24 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
import { requestAuthenticatedJson } from "../lib/api-client.js";
|
|
4
|
+
import { dash, formatTable } from "../lib/table.js";
|
|
5
|
+
|
|
6
|
+
const DAYS = [7, 30, 90];
|
|
7
|
+
const SOURCES = ["all", "endpoint", "browser", "both"];
|
|
8
|
+
const STATUSES = ["all", "sanctioned", "shadow"];
|
|
9
|
+
const CATEGORIES = ["all", "chatbot", "coding_assistant", "image_gen", "agent", "other"];
|
|
10
|
+
const DISABLED_MESSAGE = "Shadow AI is not enabled for this organization.";
|
|
11
|
+
|
|
12
|
+
export function createShadowAiCommand({
|
|
13
|
+
stdout = process.stdout,
|
|
14
|
+
env = process.env
|
|
15
|
+
} = {}) {
|
|
16
|
+
const command = new Command("shadow-ai")
|
|
17
|
+
.description("Review Shadow AI tool usage");
|
|
18
|
+
|
|
19
|
+
command.addCommand(createStatsCommand({ stdout, env }));
|
|
20
|
+
command.addCommand(createToolsCommand({ stdout, env }));
|
|
21
|
+
command.addCommand(createUsersCommand({ stdout, env }));
|
|
22
|
+
|
|
23
|
+
return command;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createStatsCommand({ stdout, env }) {
|
|
27
|
+
return new Command("stats")
|
|
28
|
+
.description("Show Shadow AI detection statistics")
|
|
29
|
+
.option("--days <days>", "history window: 7, 30, or 90", "30")
|
|
30
|
+
.option("--source <source>", "source filter: all, endpoint, browser, or both", "all")
|
|
31
|
+
.option("--status <status>", "policy status filter: all, sanctioned, or shadow", "all")
|
|
32
|
+
.option("--category <category>", "category filter: all, chatbot, coding_assistant, image_gen, agent, or other", "all")
|
|
33
|
+
.option("--json", "print machine-readable output")
|
|
34
|
+
.action(async (options) => {
|
|
35
|
+
const filters = parseSharedFilters(options);
|
|
36
|
+
if (!await isShadowAiEnabled(env)) {
|
|
37
|
+
writeDisabled(stdout, options, filters);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const stats = await requestAuthenticatedJson(`/api/shadow-ai/stats?${buildShadowQuery(filters)}`, { env });
|
|
42
|
+
const output = { enabled: true, filters, stats };
|
|
43
|
+
|
|
44
|
+
if (options.json) {
|
|
45
|
+
stdout.write(`${JSON.stringify(output)}\n`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
stdout.write(formatShadowStats(stats, filters.days));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function createToolsCommand({ stdout, env }) {
|
|
54
|
+
return new Command("tools")
|
|
55
|
+
.description("List detected Shadow AI tools")
|
|
56
|
+
.option("--days <days>", "history window: 7, 30, or 90", "30")
|
|
57
|
+
.option("--source <source>", "source filter: all, endpoint, browser, or both", "all")
|
|
58
|
+
.option("--status <status>", "policy status filter: all, sanctioned, or shadow", "all")
|
|
59
|
+
.option("--category <category>", "category filter: all, chatbot, coding_assistant, image_gen, agent, or other", "all")
|
|
60
|
+
.option("--json", "print machine-readable output")
|
|
61
|
+
.action(async (options) => {
|
|
62
|
+
const filters = parseSharedFilters(options);
|
|
63
|
+
if (!await isShadowAiEnabled(env)) {
|
|
64
|
+
writeDisabled(stdout, options, filters);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const data = await requestAuthenticatedJson(`/api/shadow-ai/tools?${buildShadowQuery(filters)}`, { env });
|
|
69
|
+
const tools = normalizeTools(data);
|
|
70
|
+
const output = {
|
|
71
|
+
enabled: true,
|
|
72
|
+
total: typeof data?.total === "number" ? data.total : tools.length,
|
|
73
|
+
period_days: typeof data?.period_days === "number" ? data.period_days : filters.days,
|
|
74
|
+
filters,
|
|
75
|
+
tools
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (options.json) {
|
|
79
|
+
stdout.write(`${JSON.stringify(output)}\n`);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
stdout.write(`Shadow AI tools: ${output.total} detected (${output.period_days} days)\n`);
|
|
84
|
+
if (!tools.length) {
|
|
85
|
+
stdout.write("No Shadow AI tools found.\n");
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
stdout.write("\n");
|
|
90
|
+
stdout.write(`${formatTable(tools, [
|
|
91
|
+
{ header: "TOOL", value: (tool) => dash(tool.tool_name || tool.tool_key) },
|
|
92
|
+
{ header: "CATEGORY", value: (tool) => dash(tool.category) },
|
|
93
|
+
{ header: "POLICY", value: (tool) => dash(tool.policy_status) },
|
|
94
|
+
{ header: "SOURCE", value: (tool) => dash(tool.detection_source) },
|
|
95
|
+
{ header: "USERS", value: (tool) => numberOrZero(tool.unique_users) },
|
|
96
|
+
{ header: "CONNECTIONS", value: (tool) => numberOrZero(tool.connections) },
|
|
97
|
+
{ header: "RISK", value: (tool) => numberOrZero(tool.risk_score) }
|
|
98
|
+
])}\n`);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createUsersCommand({ stdout, env }) {
|
|
103
|
+
return new Command("users")
|
|
104
|
+
.description("List users with Shadow AI tool usage")
|
|
105
|
+
.option("--days <days>", "history window: 7, 30, or 90", "30")
|
|
106
|
+
.option("--json", "print machine-readable output")
|
|
107
|
+
.action(async (options) => {
|
|
108
|
+
const days = parseAllowedInt(options.days, DAYS, "--days");
|
|
109
|
+
const filters = { days };
|
|
110
|
+
if (!await isShadowAiEnabled(env)) {
|
|
111
|
+
writeDisabled(stdout, options, filters);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const data = await requestAuthenticatedJson(`/api/shadow-ai/users?days=${days}`, { env });
|
|
116
|
+
const users = normalizeUsers(data);
|
|
117
|
+
const output = {
|
|
118
|
+
enabled: true,
|
|
119
|
+
total: typeof data?.total === "number" ? data.total : users.length,
|
|
120
|
+
period_days: typeof data?.period_days === "number" ? data.period_days : days,
|
|
121
|
+
filters,
|
|
122
|
+
users
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
if (options.json) {
|
|
126
|
+
stdout.write(`${JSON.stringify(output)}\n`);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
stdout.write(`Shadow AI users: ${output.total} users (${output.period_days} days)\n`);
|
|
131
|
+
if (!users.length) {
|
|
132
|
+
stdout.write("No Shadow AI users found.\n");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
stdout.write("\n");
|
|
137
|
+
stdout.write(`${formatTable(users, [
|
|
138
|
+
{ header: "USER", value: (user) => dash(user.username) },
|
|
139
|
+
{ header: "DISTINCT TOOLS", value: (user) => numberOrZero(user.distinct_tools) },
|
|
140
|
+
{ header: "MOST USED", value: (user) => dash(user.most_used_tool) }
|
|
141
|
+
])}\n`);
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function isShadowAiEnabled(env) {
|
|
146
|
+
const org = await requestAuthenticatedJson("/api/org", { env });
|
|
147
|
+
return Boolean(org?.settings?.shadow_ai_enabled);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function writeDisabled(stdout, options, filters) {
|
|
151
|
+
if (options.json) {
|
|
152
|
+
stdout.write(`${JSON.stringify({
|
|
153
|
+
enabled: false,
|
|
154
|
+
message: DISABLED_MESSAGE,
|
|
155
|
+
filters
|
|
156
|
+
})}\n`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
stdout.write(`${DISABLED_MESSAGE}\n`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseSharedFilters(options) {
|
|
163
|
+
return {
|
|
164
|
+
days: parseAllowedInt(options.days, DAYS, "--days"),
|
|
165
|
+
source: parseOptionalChoice(options.source, SOURCES, "--source") || "all",
|
|
166
|
+
status: parseOptionalChoice(options.status, STATUSES, "--status") || "all",
|
|
167
|
+
category: parseOptionalChoice(options.category, CATEGORIES, "--category") || "all"
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function buildShadowQuery(filters) {
|
|
172
|
+
const params = new URLSearchParams();
|
|
173
|
+
params.set("days", String(filters.days));
|
|
174
|
+
if (filters.source !== "all") {
|
|
175
|
+
params.set("source", filters.source);
|
|
176
|
+
}
|
|
177
|
+
if (filters.status !== "all") {
|
|
178
|
+
params.set("status", filters.status);
|
|
179
|
+
}
|
|
180
|
+
if (filters.category !== "all") {
|
|
181
|
+
params.set("category", filters.category);
|
|
182
|
+
}
|
|
183
|
+
return params.toString();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseAllowedInt(value, allowed, optionName) {
|
|
187
|
+
const number = Number(value);
|
|
188
|
+
if (!Number.isInteger(number) || !allowed.includes(number)) {
|
|
189
|
+
throw new Error(`Invalid ${optionName}. Allowed values: ${allowed.join(", ")}.`);
|
|
190
|
+
}
|
|
191
|
+
return number;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseOptionalChoice(value, allowed, optionName) {
|
|
195
|
+
if (value === undefined || value === null || value === "") {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
const normalized = String(value).trim().toLowerCase();
|
|
199
|
+
if (!allowed.includes(normalized)) {
|
|
200
|
+
throw new Error(`Invalid ${optionName}. Allowed values: ${allowed.join(", ")}.`);
|
|
201
|
+
}
|
|
202
|
+
return normalized;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeTools(data) {
|
|
206
|
+
const tools = Array.isArray(data) ? data : (data?.tools || []);
|
|
207
|
+
return tools.map((tool) => ({
|
|
208
|
+
tool_key: tool.tool_key || "",
|
|
209
|
+
tool_name: tool.tool_name || "",
|
|
210
|
+
category: tool.category || "",
|
|
211
|
+
vendor: tool.vendor || "",
|
|
212
|
+
destination_domain: tool.destination_domain || "",
|
|
213
|
+
policy_status: tool.policy_status || "",
|
|
214
|
+
detection_source: tool.detection_source || "",
|
|
215
|
+
unique_users: numberOrZero(tool.unique_users),
|
|
216
|
+
connections: numberOrZero(tool.connections),
|
|
217
|
+
risk_score: numberOrZero(tool.risk_score)
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function normalizeUsers(data) {
|
|
222
|
+
const users = Array.isArray(data) ? data : (data?.users || []);
|
|
223
|
+
return users.map((user) => ({
|
|
224
|
+
username: user.username || "",
|
|
225
|
+
distinct_tools: numberOrZero(user.distinct_tools),
|
|
226
|
+
shadow_tools: numberOrZero(user.shadow_tools),
|
|
227
|
+
most_used_tool: user.most_used_tool || "",
|
|
228
|
+
connections: numberOrZero(user.connections)
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatShadowStats(stats, days) {
|
|
233
|
+
const evolution = stats?.evolution && typeof stats.evolution === "object" ? stats.evolution : {};
|
|
234
|
+
return [
|
|
235
|
+
`Shadow AI stats (${stats?.period_days || days} days)`,
|
|
236
|
+
`Tools detected: ${numberOrZero(stats?.total_tools_detected)}${formatDelta(evolution.total_tools_detected)}`,
|
|
237
|
+
`Connections: ${numberOrZero(stats?.total_connections)}${formatDelta(evolution.total_connections)}`,
|
|
238
|
+
`Shadow tools: ${numberOrZero(stats?.shadow_tools)}${formatDelta(evolution.shadow_tools)}`,
|
|
239
|
+
`Shadow users: ${numberOrZero(stats?.shadow_users)}${formatDelta(evolution.shadow_users)}`,
|
|
240
|
+
`High-risk tools: ${numberOrZero(stats?.high_risk_tools)}${formatDelta(evolution.high_risk_tools)}`,
|
|
241
|
+
""
|
|
242
|
+
].join("\n");
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function formatDelta(value) {
|
|
246
|
+
const delta = Number(value || 0);
|
|
247
|
+
if (!delta) {
|
|
248
|
+
return "";
|
|
249
|
+
}
|
|
250
|
+
return ` (${delta > 0 ? "+" : ""}${delta})`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function numberOrZero(value) {
|
|
254
|
+
return Number.isFinite(Number(value)) ? Number(value) : 0;
|
|
255
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { Command, Option } from "commander";
|
|
3
|
+
|
|
4
|
+
import { requestApiKeyJson, resolveApiUrlForRequest } from "../lib/api-client.js";
|
|
5
|
+
import { buildSiemCursorKey, readSiemCursor, writeSiemCursor } from "../lib/siem-cursors.js";
|
|
6
|
+
|
|
7
|
+
const SEVERITIES = ["unknown", "low", "medium", "high", "critical"];
|
|
8
|
+
const OUTPUT_FORMATS = ["jsonl", "json"];
|
|
9
|
+
const MAX_LIMIT = 500;
|
|
10
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
export function createSiemCommand({
|
|
13
|
+
stdout = process.stdout,
|
|
14
|
+
env = process.env
|
|
15
|
+
} = {}) {
|
|
16
|
+
const command = new Command("siem")
|
|
17
|
+
.description("Pull SIEM OCSF events");
|
|
18
|
+
|
|
19
|
+
command.addCommand(createSiemPullCommand({ stdout, env }));
|
|
20
|
+
command.addCommand(createSiemTailCommand({ stdout, env }));
|
|
21
|
+
|
|
22
|
+
return command;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createSiemPullCommand({ stdout, env }) {
|
|
26
|
+
return new Command("pull")
|
|
27
|
+
.description("Retrieve one page of SIEM events")
|
|
28
|
+
.option("--api-key <key>", "organization API key; defaults to ZEUSLOCK_API_KEY")
|
|
29
|
+
.option("--since <cursor>", "backend cursor returned as next_cursor")
|
|
30
|
+
.option("--from <time>", "start time as ISO date/time or epoch")
|
|
31
|
+
.option("--to <time>", "end time as ISO date/time or epoch")
|
|
32
|
+
.option("--days <days>", "look back this many days; mutually exclusive with --from")
|
|
33
|
+
.option("--category <category>", "event category filter; current dashboard events use dlp")
|
|
34
|
+
.option("--severity <level>", "minimum severity: unknown, low, medium, high, or critical")
|
|
35
|
+
.option("--limit <count>", `page size: 1-${MAX_LIMIT}; backend default is 100`)
|
|
36
|
+
.option("--format <format>", "output format: jsonl or json", "jsonl")
|
|
37
|
+
.action(async (options) => {
|
|
38
|
+
const apiUrl = await resolveApiUrlForRequest(env);
|
|
39
|
+
const filters = parsePullFilters(options);
|
|
40
|
+
const data = await fetchSiemEvents({
|
|
41
|
+
apiUrl,
|
|
42
|
+
apiKey: options.apiKey,
|
|
43
|
+
env,
|
|
44
|
+
filters
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
writeSiemEvents(stdout, data, {
|
|
48
|
+
format: parseChoice(options.format, OUTPUT_FORMATS, "--format"),
|
|
49
|
+
filters: buildFilterMetadata(filters)
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createSiemTailCommand({ stdout, env }) {
|
|
55
|
+
return new Command("tail")
|
|
56
|
+
.description("Continuously poll SIEM events with a persistent cursor")
|
|
57
|
+
.option("--api-key <key>", "organization API key; defaults to ZEUSLOCK_API_KEY")
|
|
58
|
+
.option("--since <cursor>", "start from this backend cursor instead of the saved cursor")
|
|
59
|
+
.option("--category <category>", "event category filter; current dashboard events use dlp")
|
|
60
|
+
.option("--severity <level>", "minimum severity: unknown, low, medium, high, or critical")
|
|
61
|
+
.option("--limit <count>", `page size per poll: 1-${MAX_LIMIT}; backend default is 100`)
|
|
62
|
+
.option("--interval <seconds>", "poll interval in seconds", "5")
|
|
63
|
+
.option("--cursor-name <name>", "local cursor namespace", "default")
|
|
64
|
+
.option("--reset-cursor", "ignore the saved cursor for this run")
|
|
65
|
+
.addOption(new Option("--max-polls <count>", "stop after this many polling cycles").hideHelp())
|
|
66
|
+
.action(async (options) => {
|
|
67
|
+
const apiUrl = await resolveApiUrlForRequest(env);
|
|
68
|
+
const interval = parsePositiveNumber(options.interval, "--interval");
|
|
69
|
+
const maxPolls = options.maxPolls ? parsePositiveInt(options.maxPolls, "--max-polls") : null;
|
|
70
|
+
const baseFilters = parseTailFilters(options);
|
|
71
|
+
const cursorKey = buildSiemCursorKey({
|
|
72
|
+
apiUrl,
|
|
73
|
+
cursorName: options.cursorName || "default",
|
|
74
|
+
category: baseFilters.category,
|
|
75
|
+
severity: baseFilters.severity
|
|
76
|
+
});
|
|
77
|
+
let cursor = options.since || (
|
|
78
|
+
options.resetCursor ? null : await readSiemCursor(cursorKey, env)
|
|
79
|
+
);
|
|
80
|
+
let polls = 0;
|
|
81
|
+
|
|
82
|
+
for (;;) {
|
|
83
|
+
const filters = { ...baseFilters, since: cursor };
|
|
84
|
+
const data = await fetchSiemEvents({
|
|
85
|
+
apiUrl,
|
|
86
|
+
apiKey: options.apiKey,
|
|
87
|
+
env,
|
|
88
|
+
filters
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
writeSiemEvents(stdout, data, {
|
|
92
|
+
format: "jsonl",
|
|
93
|
+
filters: buildFilterMetadata(filters)
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (data?.next_cursor) {
|
|
97
|
+
cursor = data.next_cursor;
|
|
98
|
+
await writeSiemCursor(cursorKey, {
|
|
99
|
+
cursor,
|
|
100
|
+
apiUrl,
|
|
101
|
+
cursorName: options.cursorName || "default",
|
|
102
|
+
category: baseFilters.category || null,
|
|
103
|
+
severity: baseFilters.severity || null
|
|
104
|
+
}, env);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
polls += 1;
|
|
108
|
+
if (maxPolls !== null && polls >= maxPolls) {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
await sleep(interval * 1000);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function fetchSiemEvents({ apiUrl, apiKey, env, filters }) {
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
addParam(params, "since", filters.since);
|
|
120
|
+
addParam(params, "category", filters.category);
|
|
121
|
+
addParam(params, "severity", filters.severity);
|
|
122
|
+
addParam(params, "start_time", filters.startTime);
|
|
123
|
+
addParam(params, "end_time", filters.endTime);
|
|
124
|
+
addParam(params, "limit", filters.limit);
|
|
125
|
+
|
|
126
|
+
const query = params.toString();
|
|
127
|
+
return requestApiKeyJson(`/api/v1/siem/events${query ? `?${query}` : ""}`, {
|
|
128
|
+
apiUrl,
|
|
129
|
+
apiKey,
|
|
130
|
+
env
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parsePullFilters(options) {
|
|
135
|
+
if (options.days && options.from) {
|
|
136
|
+
throw new Error("--days and --from are mutually exclusive.");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
since: normalizeOptionalString(options.since),
|
|
141
|
+
category: normalizeOptionalString(options.category)?.toLowerCase() || null,
|
|
142
|
+
severity: parseOptionalChoice(options.severity, SEVERITIES, "--severity"),
|
|
143
|
+
startTime: options.days ? daysToStartTime(options.days) : normalizeOptionalString(options.from),
|
|
144
|
+
endTime: normalizeOptionalString(options.to),
|
|
145
|
+
limit: options.limit ? parseLimit(options.limit) : null
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseTailFilters(options) {
|
|
150
|
+
return {
|
|
151
|
+
since: null,
|
|
152
|
+
category: normalizeOptionalString(options.category)?.toLowerCase() || null,
|
|
153
|
+
severity: parseOptionalChoice(options.severity, SEVERITIES, "--severity"),
|
|
154
|
+
startTime: null,
|
|
155
|
+
endTime: null,
|
|
156
|
+
limit: options.limit ? parseLimit(options.limit) : null
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function writeSiemEvents(stdout, data, { format, filters }) {
|
|
161
|
+
const events = Array.isArray(data?.events) ? data.events : [];
|
|
162
|
+
if (format === "json") {
|
|
163
|
+
stdout.write(`${JSON.stringify({
|
|
164
|
+
events,
|
|
165
|
+
next_cursor: data?.next_cursor || null,
|
|
166
|
+
filters
|
|
167
|
+
})}\n`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
for (const event of events) {
|
|
172
|
+
stdout.write(`${JSON.stringify(event)}\n`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildFilterMetadata(filters) {
|
|
177
|
+
return {
|
|
178
|
+
since: filters.since || null,
|
|
179
|
+
category: filters.category || null,
|
|
180
|
+
severity: filters.severity || null,
|
|
181
|
+
start_time: filters.startTime || null,
|
|
182
|
+
end_time: filters.endTime || null,
|
|
183
|
+
limit: filters.limit || null
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function daysToStartTime(value) {
|
|
188
|
+
const days = parsePositiveInt(value, "--days");
|
|
189
|
+
return new Date(Date.now() - days * DAY_MS).toISOString();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseLimit(value) {
|
|
193
|
+
const limit = parsePositiveInt(value, "--limit");
|
|
194
|
+
if (limit > MAX_LIMIT) {
|
|
195
|
+
throw new Error(`Invalid --limit. Allowed values: 1-${MAX_LIMIT}.`);
|
|
196
|
+
}
|
|
197
|
+
return limit;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parsePositiveInt(value, optionName) {
|
|
201
|
+
const number = Number(value);
|
|
202
|
+
if (!Number.isInteger(number) || number < 1) {
|
|
203
|
+
throw new Error(`Invalid ${optionName}. Value must be a positive integer.`);
|
|
204
|
+
}
|
|
205
|
+
return number;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function parsePositiveNumber(value, optionName) {
|
|
209
|
+
const number = Number(value);
|
|
210
|
+
if (!Number.isFinite(number) || number <= 0) {
|
|
211
|
+
throw new Error(`Invalid ${optionName}. Value must be a positive number.`);
|
|
212
|
+
}
|
|
213
|
+
return number;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function parseOptionalChoice(value, allowed, optionName) {
|
|
217
|
+
const normalized = normalizeOptionalString(value)?.toLowerCase();
|
|
218
|
+
if (!normalized) {
|
|
219
|
+
return null;
|
|
220
|
+
}
|
|
221
|
+
return parseChoice(normalized, allowed, optionName);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function parseChoice(value, allowed, optionName) {
|
|
225
|
+
const normalized = String(value || "").trim().toLowerCase();
|
|
226
|
+
if (!allowed.includes(normalized)) {
|
|
227
|
+
throw new Error(`Invalid ${optionName}. Allowed values: ${allowed.join(", ")}.`);
|
|
228
|
+
}
|
|
229
|
+
return normalized;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function normalizeOptionalString(value) {
|
|
233
|
+
const normalized = String(value || "").trim();
|
|
234
|
+
return normalized || null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function addParam(params, key, value) {
|
|
238
|
+
if (value !== null && value !== undefined && value !== "") {
|
|
239
|
+
params.set(key, String(value));
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
|
|
3
|
+
import { requestApiKeyJson, requestJson, resolveApiUrlForRequest } from "../lib/api-client.js";
|
|
4
|
+
import { readApiKeyStore } from "../lib/api-key-store.js";
|
|
5
|
+
import { readAuthStore } from "../lib/auth-store.js";
|
|
6
|
+
import { readEnvConfig } from "../lib/config.js";
|
|
7
|
+
import { getPackageInfo } from "../lib/package-info.js";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_STATUS_TIMEOUT_MS = 1500;
|
|
10
|
+
|
|
11
|
+
export function createStatusCommand({ stdout = process.stdout, env = process.env } = {}) {
|
|
12
|
+
return new Command("status")
|
|
13
|
+
.description("Print local CLI configuration status")
|
|
14
|
+
.option("--api-key <key>", "organization API key for license validation; defaults to ZEUSLOCK_API_KEY")
|
|
15
|
+
.option("--require-license", "return a failing exit code unless backend health and license validation pass")
|
|
16
|
+
.option("--json", "print machine-readable output")
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
const config = readEnvConfig(env);
|
|
19
|
+
const auth = await readAuthStore(env);
|
|
20
|
+
const storedApiKey = await readApiKeyStore(env);
|
|
21
|
+
const packageInfo = getPackageInfo();
|
|
22
|
+
const apiUrl = await resolveApiUrlForRequest(env);
|
|
23
|
+
const apiKey = resolveApiKey(options.apiKey, env, storedApiKey);
|
|
24
|
+
const [health, readiness, license] = await Promise.all([
|
|
25
|
+
checkBackendEndpoint(apiUrl, "/healthz", "ok", env),
|
|
26
|
+
checkBackendEndpoint(apiUrl, "/readyz", "ready", env),
|
|
27
|
+
checkLicense({ apiUrl, apiKey, env, required: Boolean(options.requireLicense) })
|
|
28
|
+
]);
|
|
29
|
+
const status = {
|
|
30
|
+
name: packageInfo.name,
|
|
31
|
+
version: packageInfo.version,
|
|
32
|
+
node: process.version,
|
|
33
|
+
apiUrl,
|
|
34
|
+
authenticated: config.hasApiToken || Boolean(auth?.accessToken),
|
|
35
|
+
apiKeyConfigured: Boolean(apiKey),
|
|
36
|
+
backend: {
|
|
37
|
+
health,
|
|
38
|
+
readiness
|
|
39
|
+
},
|
|
40
|
+
license
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (options.json) {
|
|
44
|
+
stdout.write(`${JSON.stringify(status)}\n`);
|
|
45
|
+
} else {
|
|
46
|
+
stdout.write(`ZeusLock CLI ${status.version}\n`);
|
|
47
|
+
stdout.write(`Node: ${status.node}\n`);
|
|
48
|
+
stdout.write(`API URL: ${status.apiUrl ?? "not configured"}\n`);
|
|
49
|
+
stdout.write(`Auth token: ${status.authenticated ? "configured" : "not configured"}\n`);
|
|
50
|
+
stdout.write(`Org API key: ${status.apiKeyConfigured ? "configured" : "not configured"}\n`);
|
|
51
|
+
stdout.write(`Backend health: ${formatBackendCheck(health)}\n`);
|
|
52
|
+
stdout.write(`Backend readiness: ${formatBackendCheck(readiness)}\n`);
|
|
53
|
+
stdout.write(`License: ${formatLicenseCheck(license)}\n`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (options.requireLicense && shouldFailRequiredStatus(status)) {
|
|
57
|
+
process.exitCode = 1;
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function checkBackendEndpoint(apiUrl, endpoint, expectedStatus, env) {
|
|
63
|
+
try {
|
|
64
|
+
const data = await requestJson(apiUrl, endpoint, {
|
|
65
|
+
signal: buildTimeoutSignal(env)
|
|
66
|
+
});
|
|
67
|
+
const status = data?.status || expectedStatus;
|
|
68
|
+
const ok = status === expectedStatus;
|
|
69
|
+
return {
|
|
70
|
+
ok,
|
|
71
|
+
status,
|
|
72
|
+
error: ok ? null : `Expected status ${expectedStatus}`
|
|
73
|
+
};
|
|
74
|
+
} catch (error) {
|
|
75
|
+
return {
|
|
76
|
+
ok: false,
|
|
77
|
+
status: "error",
|
|
78
|
+
httpStatus: error?.status ?? null,
|
|
79
|
+
error: error?.message || "Backend check failed"
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function checkLicense({ apiUrl, apiKey, env, required }) {
|
|
85
|
+
if (!apiKey) {
|
|
86
|
+
return {
|
|
87
|
+
status: required ? "missing_api_key" : "skipped",
|
|
88
|
+
valid: false,
|
|
89
|
+
checked: false,
|
|
90
|
+
error: required ? "API key is required for license validation." : null
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const data = await requestApiKeyJson("/api/v1/validate-license", {
|
|
96
|
+
apiUrl,
|
|
97
|
+
apiKey,
|
|
98
|
+
env,
|
|
99
|
+
method: "POST",
|
|
100
|
+
signal: buildTimeoutSignal(env),
|
|
101
|
+
body: {
|
|
102
|
+
source: "desktop_agent"
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
const valid = data?.valid === true || data?.ok === true;
|
|
106
|
+
return {
|
|
107
|
+
status: valid ? "valid" : "invalid",
|
|
108
|
+
valid,
|
|
109
|
+
checked: true,
|
|
110
|
+
organization: data?.organization || null,
|
|
111
|
+
features: Array.isArray(data?.features) ? data.features : [],
|
|
112
|
+
limits: data?.limits && typeof data.limits === "object" ? data.limits : {},
|
|
113
|
+
error: data?.error || null
|
|
114
|
+
};
|
|
115
|
+
} catch (error) {
|
|
116
|
+
return {
|
|
117
|
+
status: "error",
|
|
118
|
+
valid: false,
|
|
119
|
+
checked: true,
|
|
120
|
+
error: error?.message || "License validation failed"
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function buildTimeoutSignal(env) {
|
|
126
|
+
const timeoutMs = Number(env.ZEUSLOCK_STATUS_TIMEOUT_MS) || DEFAULT_STATUS_TIMEOUT_MS;
|
|
127
|
+
if (typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function") {
|
|
128
|
+
return AbortSignal.timeout(timeoutMs);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
setTimeout(() => controller.abort(), timeoutMs).unref?.();
|
|
133
|
+
return controller.signal;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveApiKey(optionValue, env, storedApiKey) {
|
|
137
|
+
return String(optionValue || env.ZEUSLOCK_API_KEY || storedApiKey?.apiKey || "").trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function formatBackendCheck(check) {
|
|
141
|
+
if (check.ok) {
|
|
142
|
+
return check.status;
|
|
143
|
+
}
|
|
144
|
+
return `error (${check.error})`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function formatLicenseCheck(license) {
|
|
148
|
+
if (license.status === "skipped") {
|
|
149
|
+
return "skipped (API key not configured)";
|
|
150
|
+
}
|
|
151
|
+
if (license.status === "missing_api_key") {
|
|
152
|
+
return "missing API key";
|
|
153
|
+
}
|
|
154
|
+
if (license.status === "valid") {
|
|
155
|
+
const org = license.organization?.name || license.organization?.org_id;
|
|
156
|
+
return org ? `valid (${org})` : "valid";
|
|
157
|
+
}
|
|
158
|
+
if (license.status === "invalid") {
|
|
159
|
+
return `invalid${license.error ? ` (${license.error})` : ""}`;
|
|
160
|
+
}
|
|
161
|
+
return `error (${license.error || "license validation failed"})`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function shouldFailRequiredStatus(status) {
|
|
165
|
+
return (
|
|
166
|
+
status.backend.health.ok !== true ||
|
|
167
|
+
status.backend.readiness.ok !== true ||
|
|
168
|
+
status.license.valid !== true
|
|
169
|
+
);
|
|
170
|
+
}
|