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.
@@ -0,0 +1,432 @@
1
+ import { access, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Command } from "commander";
4
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
5
+
6
+ import { requestAuthenticatedJson } from "../lib/api-client.js";
7
+
8
+ const SCOPES = ["policies", "default", "policy"];
9
+ const EXPORT_FORMATS = ["yaml", "json"];
10
+ const DIFF_FORMATS = ["text", "json"];
11
+ const DEFAULT_DIFF_FILES = [
12
+ "zeuslock.rules.yaml",
13
+ "zeuslock.rules.yml",
14
+ "zeuslock.rules.json"
15
+ ];
16
+ const SCHEMA_VERSION = "zeuslock.rules.v1";
17
+ const OMITTED_KEYS = new Set([
18
+ "created_at",
19
+ "updated_at",
20
+ "org_id",
21
+ "sk",
22
+ "created_by"
23
+ ]);
24
+
25
+ export function createRulesCommand({
26
+ stdout = process.stdout,
27
+ env = process.env
28
+ } = {}) {
29
+ const command = new Command("rules")
30
+ .description("Export and compare DLP policies as code");
31
+
32
+ command.addCommand(createRulesGetCommand({ stdout, env }));
33
+ command.addCommand(createRulesDiffCommand({ stdout, env }));
34
+
35
+ return command;
36
+ }
37
+
38
+ function createRulesGetCommand({ stdout, env }) {
39
+ return new Command("get")
40
+ .description("Export DLP rules as YAML or JSON")
41
+ .option("--scope <scope>", "export scope: policies, default, or policy", "policies")
42
+ .option("--policy-id <id>", "policy id; required with --scope policy")
43
+ .option("--format <format>", "export format: yaml or json", "yaml")
44
+ .option("--output <path>", "write export to a file instead of stdout")
45
+ .action(async (options) => {
46
+ const scope = parseChoice(options.scope, SCOPES, "--scope");
47
+ const format = parseChoice(options.format, EXPORT_FORMATS, "--format");
48
+ validateScopeOptions({ scope, policyId: options.policyId });
49
+
50
+ const document = await fetchRulesDocument({ scope, policyId: options.policyId, env });
51
+ const content = serializeDocument(document, format);
52
+
53
+ if (!options.output) {
54
+ stdout.write(content);
55
+ return;
56
+ }
57
+
58
+ const outputPath = path.resolve(options.output);
59
+ await writeFile(outputPath, content, "utf8");
60
+ stdout.write(`Exported ${scope} rules to ${outputPath}\n`);
61
+ });
62
+ }
63
+
64
+ function createRulesDiffCommand({ stdout, env }) {
65
+ return new Command("diff")
66
+ .description("Compare a local rules file against live DLP policy state")
67
+ .argument("[file]", "YAML or JSON file exported by `zeuslock rules get`")
68
+ .option("--scope <scope>", "diff scope: policies, default, or policy", "policies")
69
+ .option("--policy-id <id>", "policy id; required with --scope policy")
70
+ .option("--format <format>", "diff output format: text or json", "text")
71
+ .action(async (file, options) => {
72
+ const scope = parseChoice(options.scope, SCOPES, "--scope");
73
+ const format = parseChoice(options.format, DIFF_FORMATS, "--format");
74
+ validateScopeOptions({ scope, policyId: options.policyId });
75
+
76
+ const filePath = await resolveDiffFile(file);
77
+ const localDocument = normalizeDocumentForScope(parseDocumentFile(await readFile(filePath, "utf8"), filePath), scope);
78
+ const liveDocument = await fetchRulesDocument({ scope, policyId: options.policyId, env });
79
+ const differences = diffValues(localDocument, liveDocument);
80
+
81
+ if (format === "json") {
82
+ stdout.write(`${JSON.stringify({
83
+ different: differences.length > 0,
84
+ total: differences.length,
85
+ differences
86
+ })}\n`);
87
+ } else if (differences.length === 0) {
88
+ stdout.write("No differences found\n");
89
+ } else {
90
+ stdout.write(formatDiffText(differences));
91
+ }
92
+
93
+ if (differences.length > 0) {
94
+ process.exitCode = 1;
95
+ }
96
+ });
97
+ }
98
+
99
+ async function fetchRulesDocument({ scope, policyId, env }) {
100
+ if (scope === "default") {
101
+ const rules = await requestAuthenticatedJson("/api/rules", { env });
102
+ return normalizeDocumentForScope({ scope, rules }, scope);
103
+ }
104
+
105
+ if (scope === "policy") {
106
+ const policy = await requestAuthenticatedJson(`/api/policies/${encodeURIComponent(policyId)}`, { env });
107
+ return normalizeDocumentForScope({ scope, policy }, scope);
108
+ }
109
+
110
+ const policiesData = await requestAuthenticatedJson("/api/policies", { env });
111
+ const groupsData = await requestAuthenticatedJson("/api/groups", { env });
112
+ return normalizeDocumentForScope({
113
+ scope,
114
+ policies: Array.isArray(policiesData) ? policiesData : (policiesData.policies || []),
115
+ groups: Array.isArray(groupsData) ? groupsData : (groupsData.groups || [])
116
+ }, scope);
117
+ }
118
+
119
+ function normalizeDocumentForScope(document, scope) {
120
+ if (document?.scope && document.scope !== scope) {
121
+ throw new Error(`Rules file scope is ${document.scope}; expected ${scope}.`);
122
+ }
123
+
124
+ if (scope === "default") {
125
+ const rules = document?.rules || document || {};
126
+ return {
127
+ schema_version: SCHEMA_VERSION,
128
+ scope,
129
+ rules: normalizeRules(rules)
130
+ };
131
+ }
132
+
133
+ if (scope === "policy") {
134
+ const policy = document?.policy || document || {};
135
+ return {
136
+ schema_version: SCHEMA_VERSION,
137
+ scope,
138
+ policy: normalizePolicy(policy)
139
+ };
140
+ }
141
+
142
+ return {
143
+ schema_version: SCHEMA_VERSION,
144
+ scope,
145
+ policies: normalizePolicies(document?.policies || []),
146
+ groups: normalizeGroups(document?.groups || [])
147
+ };
148
+ }
149
+
150
+ function normalizePolicies(policies) {
151
+ return policies
152
+ .map(normalizePolicy)
153
+ .sort((a, b) =>
154
+ Number(Boolean(b.is_default)) - Number(Boolean(a.is_default)) ||
155
+ numberOr(a.priority, 0) - numberOr(b.priority, 0) ||
156
+ String(a.name || "").localeCompare(String(b.name || "")) ||
157
+ String(a.policy_id || "").localeCompare(String(b.policy_id || ""))
158
+ );
159
+ }
160
+
161
+ function normalizePolicy(policy = {}) {
162
+ const normalized = {
163
+ policy_id: stringOrEmpty(policy.policy_id),
164
+ name: stringOrEmpty(policy.name),
165
+ description: stringOrEmpty(policy.description),
166
+ status: stringOrEmpty(policy.status),
167
+ priority: numberOr(policy.priority, 0),
168
+ version: numberOr(policy.version, 1),
169
+ is_default: Boolean(policy.is_default),
170
+ rules: normalizeRules(policy.rules || {})
171
+ };
172
+
173
+ if (policy.template_id) {
174
+ normalized.template_id = String(policy.template_id);
175
+ }
176
+ if (policy.template_version !== undefined && policy.template_version !== null) {
177
+ normalized.template_version = numberOr(policy.template_version, policy.template_version);
178
+ }
179
+
180
+ return stableSortObject(normalized);
181
+ }
182
+
183
+ function normalizeGroups(groups) {
184
+ return groups
185
+ .map(normalizeGroup)
186
+ .sort((a, b) =>
187
+ Number(Boolean(b.is_default)) - Number(Boolean(a.is_default)) ||
188
+ numberOr(a.priority, 0) - numberOr(b.priority, 0) ||
189
+ String(a.name || "").localeCompare(String(b.name || "")) ||
190
+ String(a.group_id || "").localeCompare(String(b.group_id || ""))
191
+ );
192
+ }
193
+
194
+ function normalizeGroup(group = {}) {
195
+ return stableSortObject({
196
+ group_id: stringOrEmpty(group.group_id),
197
+ name: stringOrEmpty(group.name),
198
+ description: stringOrEmpty(group.description),
199
+ policy_id: stringOrEmpty(group.policy_id),
200
+ priority: numberOr(group.priority, 0),
201
+ is_default: Boolean(group.is_default),
202
+ matchers: normalizeMatchers(group.matchers || [])
203
+ });
204
+ }
205
+
206
+ function normalizeMatchers(matchers) {
207
+ return matchers
208
+ .map((matcher) => stableSortObject({
209
+ type: stringOrEmpty(matcher?.type),
210
+ value: normalizeLooseValue(matcher?.value),
211
+ display: stringOrEmpty(matcher?.display),
212
+ match_by: matcher?.match_by ?? null,
213
+ enabled: matcher?.enabled !== false
214
+ }))
215
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
216
+ }
217
+
218
+ function normalizeRules(rules = {}) {
219
+ const normalized = {};
220
+
221
+ if (rules.version !== undefined && rules.version !== null) {
222
+ normalized.version = numberOr(rules.version, rules.version);
223
+ }
224
+
225
+ normalized.modes = stableSortObject(rules.modes || {});
226
+ normalized.customRules = normalizeCustomRules(rules.customRules || []);
227
+
228
+ const extras = {};
229
+ for (const [key, value] of Object.entries(rules || {})) {
230
+ if (key === "version" || key === "modes" || key === "customRules" || OMITTED_KEYS.has(key)) {
231
+ continue;
232
+ }
233
+ extras[key] = normalizeLooseValue(value);
234
+ }
235
+
236
+ return stableSortObject({
237
+ ...normalized,
238
+ ...stableSortObject(extras)
239
+ });
240
+ }
241
+
242
+ function normalizeCustomRules(customRules) {
243
+ return customRules
244
+ .map((rule) => stableSortObject(normalizeLooseObject(rule || {})))
245
+ .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)));
246
+ }
247
+
248
+ function normalizeLooseValue(value) {
249
+ if (Array.isArray(value)) {
250
+ return value.map(normalizeLooseValue);
251
+ }
252
+ if (isPlainObject(value)) {
253
+ return stableSortObject(normalizeLooseObject(value));
254
+ }
255
+ return value;
256
+ }
257
+
258
+ function normalizeLooseObject(value) {
259
+ const out = {};
260
+ for (const [key, entry] of Object.entries(value || {})) {
261
+ if (!OMITTED_KEYS.has(key)) {
262
+ out[key] = normalizeLooseValue(entry);
263
+ }
264
+ }
265
+ return out;
266
+ }
267
+
268
+ function serializeDocument(document, format) {
269
+ if (format === "json") {
270
+ return `${JSON.stringify(document, null, 2)}\n`;
271
+ }
272
+ return stringifyYaml(document, { lineWidth: 0 });
273
+ }
274
+
275
+ function parseDocumentFile(raw, filePath) {
276
+ const extension = path.extname(filePath).toLowerCase();
277
+
278
+ try {
279
+ if (extension === ".json") {
280
+ return JSON.parse(raw);
281
+ }
282
+ if (extension === ".yaml" || extension === ".yml") {
283
+ return parseYaml(raw);
284
+ }
285
+ try {
286
+ return JSON.parse(raw);
287
+ } catch {
288
+ return parseYaml(raw);
289
+ }
290
+ } catch (error) {
291
+ throw new Error(`Failed to parse ${filePath}: ${error.message}`);
292
+ }
293
+ }
294
+
295
+ async function resolveDiffFile(file) {
296
+ if (file) {
297
+ return path.resolve(file);
298
+ }
299
+
300
+ for (const candidate of DEFAULT_DIFF_FILES) {
301
+ const resolved = path.resolve(candidate);
302
+ try {
303
+ await access(resolved);
304
+ return resolved;
305
+ } catch {
306
+ // Try the next conventional export filename.
307
+ }
308
+ }
309
+
310
+ throw new Error(`No rules file specified and none found: ${DEFAULT_DIFF_FILES.join(", ")}.`);
311
+ }
312
+
313
+ function diffValues(fileValue, liveValue, currentPath = "$") {
314
+ if (Object.is(fileValue, liveValue)) {
315
+ return [];
316
+ }
317
+
318
+ if (Array.isArray(fileValue) || Array.isArray(liveValue)) {
319
+ if (!Array.isArray(fileValue) || !Array.isArray(liveValue)) {
320
+ return [difference("changed", currentPath, fileValue, liveValue)];
321
+ }
322
+
323
+ const diffs = [];
324
+ const length = Math.max(fileValue.length, liveValue.length);
325
+ for (let index = 0; index < length; index += 1) {
326
+ if (index >= fileValue.length) {
327
+ diffs.push(difference("only_in_live", `${currentPath}[${index}]`, undefined, liveValue[index]));
328
+ } else if (index >= liveValue.length) {
329
+ diffs.push(difference("only_in_file", `${currentPath}[${index}]`, fileValue[index], undefined));
330
+ } else {
331
+ diffs.push(...diffValues(fileValue[index], liveValue[index], `${currentPath}[${index}]`));
332
+ }
333
+ }
334
+ return diffs;
335
+ }
336
+
337
+ if (isPlainObject(fileValue) || isPlainObject(liveValue)) {
338
+ if (!isPlainObject(fileValue) || !isPlainObject(liveValue)) {
339
+ return [difference("changed", currentPath, fileValue, liveValue)];
340
+ }
341
+
342
+ const keys = Array.from(new Set([
343
+ ...Object.keys(fileValue),
344
+ ...Object.keys(liveValue)
345
+ ])).sort();
346
+ const diffs = [];
347
+ for (const key of keys) {
348
+ const nextPath = `${currentPath}.${key}`;
349
+ if (!(key in liveValue)) {
350
+ diffs.push(difference("only_in_file", nextPath, fileValue[key], undefined));
351
+ } else if (!(key in fileValue)) {
352
+ diffs.push(difference("only_in_live", nextPath, undefined, liveValue[key]));
353
+ } else {
354
+ diffs.push(...diffValues(fileValue[key], liveValue[key], nextPath));
355
+ }
356
+ }
357
+ return diffs;
358
+ }
359
+
360
+ return [difference("changed", currentPath, fileValue, liveValue)];
361
+ }
362
+
363
+ function difference(type, diffPath, fileValue, liveValue) {
364
+ return {
365
+ type,
366
+ path: diffPath,
367
+ file: fileValue,
368
+ live: liveValue
369
+ };
370
+ }
371
+
372
+ function formatDiffText(differences) {
373
+ const lines = [`Found ${differences.length} difference${differences.length === 1 ? "" : "s"}`];
374
+ for (const diff of differences) {
375
+ lines.push(`${diff.type} ${diff.path}`);
376
+ if (diff.type !== "only_in_live") {
377
+ lines.push(` file: ${formatDiffValue(diff.file)}`);
378
+ }
379
+ if (diff.type !== "only_in_file") {
380
+ lines.push(` live: ${formatDiffValue(diff.live)}`);
381
+ }
382
+ }
383
+ return `${lines.join("\n")}\n`;
384
+ }
385
+
386
+ function formatDiffValue(value) {
387
+ if (value === undefined) {
388
+ return "<missing>";
389
+ }
390
+ return JSON.stringify(value);
391
+ }
392
+
393
+ function parseChoice(value, allowed, optionName) {
394
+ const normalized = String(value || "").trim().toLowerCase();
395
+ if (!allowed.includes(normalized)) {
396
+ throw new Error(`Invalid ${optionName}. Allowed values: ${allowed.join(", ")}.`);
397
+ }
398
+ return normalized;
399
+ }
400
+
401
+ function validateScopeOptions({ scope, policyId }) {
402
+ if (scope === "policy" && !String(policyId || "").trim()) {
403
+ throw new Error("--policy-id is required with --scope policy.");
404
+ }
405
+ if (scope !== "policy" && String(policyId || "").trim()) {
406
+ throw new Error("--policy-id can only be used with --scope policy.");
407
+ }
408
+ }
409
+
410
+ function stableSortObject(value) {
411
+ if (!isPlainObject(value)) {
412
+ return value;
413
+ }
414
+ const out = {};
415
+ for (const key of Object.keys(value).sort()) {
416
+ out[key] = normalizeLooseValue(value[key]);
417
+ }
418
+ return out;
419
+ }
420
+
421
+ function isPlainObject(value) {
422
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
423
+ }
424
+
425
+ function stringOrEmpty(value) {
426
+ return value === undefined || value === null ? "" : String(value);
427
+ }
428
+
429
+ function numberOr(value, fallback) {
430
+ const number = Number(value);
431
+ return Number.isFinite(number) ? number : fallback;
432
+ }
@@ -0,0 +1,178 @@
1
+ import { readdir, readFile, stat } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { Command } from "commander";
5
+
6
+ import {
7
+ analyzeDlpBatch,
8
+ formatDlpSummary,
9
+ parseFailOn,
10
+ shouldFailDecision,
11
+ summarizeDlpResponses
12
+ } from "../lib/dlp-scan.js";
13
+
14
+ const BATCH_SIZE = 5;
15
+
16
+ export function createScanCommand({
17
+ stdin = process.stdin,
18
+ stdout = process.stdout,
19
+ env = process.env
20
+ } = {}) {
21
+ return new Command("scan")
22
+ .description("Analyze files or stdin with the organization's DLP policy")
23
+ .argument("[path]", "file or directory to scan")
24
+ .option("--stdin", "read text from stdin")
25
+ .option("--api-key <key>", "organization API key; defaults to saved key or ZEUSLOCK_API_KEY")
26
+ .option("--fail-on <decision>", "exit nonzero on alert, block, or never", "block")
27
+ .option("--source <source>", "source label sent to backend", "cli")
28
+ .option("--platform <platform>", "platform label sent to backend", "cli")
29
+ .option("--hostname <hostname>", "hostname sent to backend")
30
+ .option("--path <path>", "destination path label sent to backend")
31
+ .option("--user-email <email>", "user email label sent to backend")
32
+ .option("--include-sensitive", "include backend anonymization maps in JSON output")
33
+ .option("--json", "print machine-readable output")
34
+ .action(async (inputPath, options) => {
35
+ validateInput(inputPath, options);
36
+ const failOn = parseFailOn(options.failOn);
37
+ const metadata = buildMetadata(options, inputPath);
38
+ let responses = [];
39
+ let scannedFiles = 0;
40
+ let scannedText = false;
41
+
42
+ if (options.stdin) {
43
+ const text = await readStream(stdin);
44
+ scannedText = true;
45
+ responses.push(await analyzeDlpBatch({
46
+ text,
47
+ metadata: {
48
+ ...metadata,
49
+ path: options.path || "/cli/stdin"
50
+ },
51
+ apiKey: options.apiKey,
52
+ env
53
+ }));
54
+ } else {
55
+ const files = await loadInputFiles(inputPath);
56
+ scannedFiles = files.length;
57
+ for (const batch of chunk(files, BATCH_SIZE)) {
58
+ responses.push(await analyzeDlpBatch({
59
+ files: batch,
60
+ metadata,
61
+ apiKey: options.apiKey,
62
+ env
63
+ }));
64
+ }
65
+ }
66
+
67
+ const summary = summarizeDlpResponses(responses, {
68
+ failOn,
69
+ includeSensitive: Boolean(options.includeSensitive)
70
+ });
71
+
72
+ if (options.json) {
73
+ stdout.write(`${JSON.stringify({
74
+ ...summary,
75
+ scanned: {
76
+ text: scannedText,
77
+ files: scannedFiles
78
+ }
79
+ })}\n`);
80
+ } else {
81
+ stdout.write(formatDlpSummary(summary, { scannedFiles, scannedText }));
82
+ }
83
+
84
+ if (summary.responses.some((response) => shouldFailDecision(response?.decision, failOn))) {
85
+ process.exitCode = 1;
86
+ }
87
+ });
88
+ }
89
+
90
+ function validateInput(inputPath, options) {
91
+ if (options.stdin && inputPath) {
92
+ throw new Error("Pass either --stdin or a path, not both.");
93
+ }
94
+ if (!options.stdin && !inputPath) {
95
+ throw new Error("A file, directory, or --stdin is required.");
96
+ }
97
+ }
98
+
99
+ function buildMetadata(options, inputPath) {
100
+ return {
101
+ source: options.source,
102
+ platform: options.platform,
103
+ hostname: options.hostname || os.hostname(),
104
+ path: options.path || defaultPathLabel(inputPath),
105
+ method: "CLI",
106
+ userEmail: options.userEmail || null
107
+ };
108
+ }
109
+
110
+ function defaultPathLabel(inputPath) {
111
+ if (!inputPath) {
112
+ return "/cli";
113
+ }
114
+ const resolved = path.resolve(inputPath);
115
+ return `/cli/${path.basename(resolved)}`;
116
+ }
117
+
118
+ async function loadInputFiles(inputPath) {
119
+ const resolved = path.resolve(inputPath);
120
+ const inputStat = await stat(resolved);
121
+ if (inputStat.isFile()) {
122
+ return [await loadFile(resolved)];
123
+ }
124
+ if (!inputStat.isDirectory()) {
125
+ throw new Error(`Unsupported input path: ${resolved}`);
126
+ }
127
+
128
+ const files = [];
129
+ for (const filePath of await collectDirectoryFiles(resolved)) {
130
+ files.push(await loadFile(filePath));
131
+ }
132
+ if (!files.length) {
133
+ throw new Error(`No files found in directory: ${resolved}`);
134
+ }
135
+ return files;
136
+ }
137
+
138
+ async function collectDirectoryFiles(dirPath) {
139
+ const entries = await readdir(dirPath, { withFileTypes: true });
140
+ const files = [];
141
+ for (const entry of entries) {
142
+ if (entry.name === ".git") {
143
+ continue;
144
+ }
145
+ const entryPath = path.join(dirPath, entry.name);
146
+ if (entry.isDirectory()) {
147
+ files.push(...await collectDirectoryFiles(entryPath));
148
+ } else if (entry.isFile()) {
149
+ files.push(entryPath);
150
+ }
151
+ }
152
+ return files.sort();
153
+ }
154
+
155
+ async function loadFile(filePath) {
156
+ return {
157
+ filename: path.basename(filePath),
158
+ path: filePath,
159
+ contentType: "application/octet-stream",
160
+ data: await readFile(filePath)
161
+ };
162
+ }
163
+
164
+ async function readStream(stream) {
165
+ const chunks = [];
166
+ for await (const chunk of stream) {
167
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
168
+ }
169
+ return Buffer.concat(chunks).toString("utf8");
170
+ }
171
+
172
+ function chunk(items, size) {
173
+ const batches = [];
174
+ for (let i = 0; i < items.length; i += size) {
175
+ batches.push(items.slice(i, i + size));
176
+ }
177
+ return batches;
178
+ }