@knowttl/checkpoint-axi 0.0.0 → 0.1.1

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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -3
  3. package/axi-credential.schema.json +35 -0
  4. package/bin/checkpoint-axi-hook.js +9 -0
  5. package/bin/checkpoint-axi.js +8 -0
  6. package/bin/version.js +3 -0
  7. package/dist/src/catalogue.d.ts +317 -0
  8. package/dist/src/catalogue.js +103 -0
  9. package/dist/src/cli.d.ts +9 -0
  10. package/dist/src/cli.js +22 -0
  11. package/dist/src/doctor.d.ts +3 -0
  12. package/dist/src/doctor.js +37 -0
  13. package/dist/src/downloads.d.ts +3 -0
  14. package/dist/src/downloads.js +52 -0
  15. package/dist/src/exceptions.d.ts +37 -0
  16. package/dist/src/exceptions.js +81 -0
  17. package/dist/src/local-commands.d.ts +3 -0
  18. package/dist/src/local-commands.js +123 -0
  19. package/dist/src/private-file.d.ts +10 -0
  20. package/dist/src/private-file.js +65 -0
  21. package/dist/src/profiles.d.ts +122 -0
  22. package/dist/src/profiles.js +137 -0
  23. package/dist/src/reads.d.ts +3 -0
  24. package/dist/src/reads.js +169 -0
  25. package/dist/src/redact.d.ts +6 -0
  26. package/dist/src/redact.js +25 -0
  27. package/dist/src/regions.d.ts +9 -0
  28. package/dist/src/regions.js +10 -0
  29. package/dist/src/transport.d.ts +86 -0
  30. package/dist/src/transport.js +306 -0
  31. package/dist/src/write-exceptions.d.ts +4 -0
  32. package/dist/src/write-exceptions.js +97 -0
  33. package/dist/src/write-sectools.d.ts +5 -0
  34. package/dist/src/write-sectools.js +107 -0
  35. package/dist/src/writes.d.ts +30 -0
  36. package/dist/src/writes.js +295 -0
  37. package/docs/agent-guide.md +108 -0
  38. package/docs/commands.md +489 -0
  39. package/docs/configuration.md +63 -0
  40. package/docs/design.md +165 -0
  41. package/docs/downloads.md +30 -0
  42. package/docs/reads.md +84 -0
  43. package/docs/writes.md +200 -0
  44. package/inventory/capabilities.json +217 -0
  45. package/inventory/specs.json +12 -0
  46. package/package.json +48 -5
  47. package/skills/checkpoint-axi/SKILL.md +102 -0
@@ -0,0 +1,306 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { randomUUID } from "node:crypto";
3
+ import { request } from "node:https";
4
+ import { setTimeout as delay } from "node:timers/promises";
5
+ import { REGIONS } from "./regions.js";
6
+ import { consumeWriteAuthorization } from "./writes.js";
7
+ export const READ_LIMITS = { requests: 10, rows: 1_000, milliseconds: 60_000, bytes: 8 * 1024 * 1024 };
8
+ export const DOWNLOAD_LIMIT = 50 * 1024 * 1024;
9
+ const failure = (code, message) => new AxiError(message, code, ["Check the selected region and credentials; run checkpoint-axi doctor"]);
10
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
11
+ // Object/array responseData inconsistency is documented on guide pages 35-37.
12
+ export function decodePage(value, unverified = false) {
13
+ // These exception families publish no response schema (guide pages 83-123).
14
+ if (unverified && (!object(value) || !Object.hasOwn(value, "responseEnvelope"))) {
15
+ return { records: Array.isArray(value) ? value : [value] };
16
+ }
17
+ if (!object(value) || !object(value.responseEnvelope))
18
+ throw failure("RESPONSE_INVALID", "Missing API response envelope");
19
+ const envelope = value.responseEnvelope;
20
+ if (envelope.responseCode !== 0 && envelope.responseCode !== "0" && envelope.responseCode !== 200 && envelope.responseCode !== "200") {
21
+ throw failure("API_ERROR", "API response envelope did not report success");
22
+ }
23
+ const data = value.responseData;
24
+ if ((!unverified && !Array.isArray(data) && !object(data)) || data === undefined)
25
+ throw failure("RESPONSE_INVALID", "Expected responseData");
26
+ if (envelope.scrollId !== undefined && envelope.scrollId !== null && typeof envelope.scrollId !== "string") {
27
+ throw failure("RESPONSE_INVALID", "Invalid API scrollId");
28
+ }
29
+ if (envelope.totalRecordsNumber !== undefined && (!Number.isSafeInteger(envelope.totalRecordsNumber) || Number(envelope.totalRecordsNumber) < 0)) {
30
+ throw failure("RESPONSE_INVALID", "Invalid API totalRecordsNumber");
31
+ }
32
+ return { records: Array.isArray(data) ? data : [data], scrollId: typeof envelope.scrollId === "string" && envelope.scrollId ? envelope.scrollId : undefined,
33
+ total: envelope.totalRecordsNumber };
34
+ }
35
+ // No field name or lifetime is commissioned yet (guide page 8).
36
+ function tokenFrom(value, redactor) {
37
+ const register = (child) => {
38
+ if (typeof child === "string")
39
+ redactor.add(child);
40
+ else if (Array.isArray(child))
41
+ child.forEach(register);
42
+ else if (object(child))
43
+ Object.values(child).forEach(register);
44
+ };
45
+ register(value);
46
+ const tokens = new Set();
47
+ let invalid = false;
48
+ if (object(value))
49
+ for (const container of [value, value.data, value.responseData]) {
50
+ if (!object(container))
51
+ continue;
52
+ for (const key of ["token", "access_token", "accessToken"]) {
53
+ const candidate = container[key];
54
+ if (candidate === undefined)
55
+ continue;
56
+ if (typeof candidate === "string" && candidate.length > 0 && /^[\x21-\x7E]+$/.test(candidate))
57
+ tokens.add(candidate);
58
+ else
59
+ invalid = true;
60
+ }
61
+ }
62
+ if (invalid || tokens.size !== 1)
63
+ throw failure("AUTH_SHAPE_UNVERIFIED", "Unverified auth token response shape; owner commissioning is required (API guide page 8 does not specify the shape)");
64
+ return [...tokens][0];
65
+ }
66
+ export function gateway(profile, credentials, redactor, dependencies = {}, mutation = false) {
67
+ const now = dependencies.now ?? Date.now;
68
+ const wait = dependencies.wait ?? delay;
69
+ const deadline = now() + READ_LIMITS.milliseconds;
70
+ let bytes = 0;
71
+ let requests = 0;
72
+ let token;
73
+ let refreshed = false;
74
+ const nextException = new Map();
75
+ redactor.add(credentials.clientSecret);
76
+ redactor.add(JSON.stringify({ clientId: credentials.clientId, accessKey: credentials.clientSecret }));
77
+ async function pause(milliseconds) {
78
+ if (!Number.isFinite(milliseconds) || milliseconds < 0 || now() + milliseconds >= deadline)
79
+ throw failure("TIME_BUDGET", "Read time budget exhausted");
80
+ await wait(milliseconds);
81
+ }
82
+ async function send(path, body, bearer, method = body === undefined ? "GET" : "POST", allowEmpty = false, binary = false, scopes) {
83
+ const remaining = deadline - now();
84
+ if (remaining <= 0)
85
+ throw failure("TIME_BUDGET", "Read time budget exhausted");
86
+ if (++requests > (mutation ? 44 : READ_LIMITS.requests))
87
+ throw failure("REQUEST_BUDGET", "Request budget exhausted");
88
+ const payload = body === undefined ? undefined : JSON.stringify(body);
89
+ if (bearer)
90
+ redactor.add(`Bearer ${bearer}`);
91
+ return new Promise((resolve, reject) => {
92
+ const req = request(new URL(path, REGIONS[profile.region]), {
93
+ method, agent: dependencies.agent,
94
+ headers: { "x-av-req-id": randomUUID(), accept: "application/json", ...(bearer ? { authorization: `Bearer ${bearer}` } : {}),
95
+ ...(scopes === undefined ? {} : { scopes }),
96
+ ...(payload === undefined ? {} : { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }) },
97
+ }, (response) => {
98
+ const chunks = [];
99
+ let received = 0;
100
+ response.on("data", (chunk) => {
101
+ received += chunk.length;
102
+ if (!binary)
103
+ bytes += chunk.length;
104
+ if (binary ? received > DOWNLOAD_LIMIT : bytes > READ_LIMITS.bytes)
105
+ req.destroy(failure("RESPONSE_TOO_LARGE", "Response byte budget exhausted"));
106
+ else
107
+ chunks.push(chunk);
108
+ });
109
+ response.on("error", () => {
110
+ clearTimeout(timer);
111
+ reject(failure("NETWORK_ERROR", "API response stream failed"));
112
+ });
113
+ response.on("end", () => {
114
+ clearTimeout(timer);
115
+ const status = response.statusCode ?? 0;
116
+ // Error bodies are untrusted and may echo credentials. Never render them.
117
+ if (status < 200 || status >= 300)
118
+ return resolve({ status, retryAfter: response.headers["retry-after"], value: undefined });
119
+ if (binary)
120
+ return resolve({ status, value: Buffer.concat(chunks), sha256: typeof response.headers["x-av-sha256"] === "string" ? response.headers["x-av-sha256"] : undefined });
121
+ const json = Buffer.concat(chunks).toString("utf8");
122
+ if (allowEmpty && status === 204 && !json)
123
+ return resolve({ status, value: null });
124
+ try {
125
+ resolve({ status, value: JSON.parse(json) });
126
+ }
127
+ catch {
128
+ reject(failure("RESPONSE_INVALID", "API returned invalid JSON"));
129
+ }
130
+ });
131
+ });
132
+ const timer = setTimeout(() => req.destroy(failure("TIME_BUDGET", "Read time budget exhausted")), remaining);
133
+ req.on("error", (error) => {
134
+ clearTimeout(timer);
135
+ reject(error instanceof AxiError ? error : failure("NETWORK_ERROR", "Cannot reach the regional API with verified TLS"));
136
+ });
137
+ req.end(payload);
138
+ });
139
+ }
140
+ async function authenticate() {
141
+ if (token)
142
+ return;
143
+ const response = await send("/auth/external", { clientId: credentials.clientId, accessKey: credentials.clientSecret });
144
+ if (response.status < 200 || response.status >= 300)
145
+ throw failure("AUTH_FAILED", "Regional API authentication failed");
146
+ token = tokenFrom(response.value, redactor);
147
+ }
148
+ async function spaceException(path) {
149
+ const group = /^\/exceptions\/(whitelist|blacklist)(\/|$)/.test(path) ? "phishing"
150
+ : /^\/(exceptions|sectools|sectool-exceptions)\//.test(path) ? "other" : undefined;
151
+ if (group) {
152
+ const remaining = (nextException.get(group) ?? 0) - now();
153
+ if (remaining > 0)
154
+ await pause(remaining);
155
+ nextException.set(group, now() + (group === "phishing" ? 1_000 : 100));
156
+ }
157
+ }
158
+ async function read(query) {
159
+ await authenticate();
160
+ for (let retries = 0;;) {
161
+ await spaceException(query.path);
162
+ const response = await send(`/app/hec-api/v1.0${query.path}`, query.body === undefined ? undefined : { requestData: query.body }, token);
163
+ if (response.status === 401 && !refreshed) {
164
+ refreshed = true;
165
+ token = undefined;
166
+ await authenticate();
167
+ continue;
168
+ }
169
+ if (response.status === 429 && retries++ < 2) {
170
+ const header = response.retryAfter;
171
+ const milliseconds = header === undefined ? 1_000 : /^\d+$/.test(header) ? Number(header) * 1_000 : Math.max(0, Date.parse(header) - now());
172
+ await pause(Number.isFinite(milliseconds) ? milliseconds : 1_000);
173
+ continue;
174
+ }
175
+ if (response.status === 401)
176
+ throw failure("AUTH_FAILED", "API rejected authentication after one re-exchange");
177
+ if (response.status === 429)
178
+ throw failure("RATE_LIMITED", "API rate limit remains after two read retries");
179
+ if (response.status < 200 || response.status >= 300)
180
+ throw failure("HTTP_ERROR", `Regional API returned HTTP ${response.status}`);
181
+ return decodePage(response.value, /^\/(sectools|sectool-exceptions)\//.test(query.path));
182
+ }
183
+ }
184
+ async function download(query) {
185
+ if (!profile.downloads.allow)
186
+ throw failure("DOWNLOADS_DISABLED", "Enable downloads.allow in the selected file profile");
187
+ const validId = (value) => /^[A-Za-z0-9_-]{1,256}$/.test(value);
188
+ if (!validId(query.entityId) || query.attachmentId !== undefined && !validId(query.attachmentId)
189
+ || query.all && query.attachmentId !== undefined || (query.all || query.attachmentId !== undefined) && query.original !== undefined
190
+ || query.scope !== undefined && !/^[\x21-\x7E]+$/.test(query.scope)
191
+ || profile.scope !== undefined && !/^[\x21-\x7E]+$/.test(profile.scope))
192
+ throw failure("VALIDATION_ERROR", "Invalid download selectors or scopes");
193
+ const attachment = query.all || query.attachmentId !== undefined;
194
+ if (!attachment && !profile.scope)
195
+ throw failure("DOWNLOAD_SCOPE_REQUIRED", "Email downloads require the profile scope for the scopes header");
196
+ const params = new URLSearchParams();
197
+ if (attachment && query.scope)
198
+ params.set("scope", query.scope);
199
+ if (!attachment)
200
+ params.set("original", String(query.original ?? false));
201
+ const path = `/app/hec-api/v1.0/download/entity/${encodeURIComponent(query.entityId)}${query.all ? "/attachments" : query.attachmentId ? `/attachment/${encodeURIComponent(query.attachmentId)}` : ""}${params.size ? `?${params}` : ""}`;
202
+ await authenticate();
203
+ for (let retries = 0;;) {
204
+ const response = await send(path, undefined, token, "GET", false, true, attachment ? undefined : profile.scope);
205
+ if (response.status === 401 && !refreshed) {
206
+ refreshed = true;
207
+ token = undefined;
208
+ await authenticate();
209
+ continue;
210
+ }
211
+ if (response.status === 429 && retries++ < 2) {
212
+ const header = response.retryAfter;
213
+ const milliseconds = header === undefined ? 1_000 : /^\d+$/.test(header) ? Number(header) * 1_000 : Math.max(0, Date.parse(header) - now());
214
+ await pause(Number.isFinite(milliseconds) ? milliseconds : 1_000);
215
+ continue;
216
+ }
217
+ if (response.status === 401)
218
+ throw failure("AUTH_FAILED", "API rejected authentication after one re-exchange");
219
+ if (response.status === 429)
220
+ throw failure("RATE_LIMITED", "API rate limit remains after two download retries");
221
+ if (response.status !== 200)
222
+ throw failure("HTTP_ERROR", `Regional API returned HTTP ${response.status}`);
223
+ if (!response.sha256 || !/^[a-fA-F0-9]{64}$/.test(response.sha256))
224
+ throw failure("DOWNLOAD_HASH_INVALID", "Download SHA-256 header is missing or invalid; commissioning may be required");
225
+ return { content: response.value, sha256: response.sha256.toLowerCase() };
226
+ }
227
+ }
228
+ // Closed entity-action route; mutations never enter the read retry loop.
229
+ async function emailAction(authorization, action, targets) {
230
+ if (!mutation || !consumeWriteAuthorization(authorization, `email.${action}`, targets, profile.name, profile.region)) {
231
+ throw failure("WRITES_DISABLED", "A one-use coordinator authorization is required");
232
+ }
233
+ await authenticate();
234
+ try {
235
+ const response = await send("/app/hec-api/v1.0/action/entity", { requestData: {
236
+ entityIds: targets, entityActionName: [action], entityActionParam: [""],
237
+ } }, token);
238
+ if (response.status < 200 || response.status >= 300)
239
+ throw new Error("Action response requires reconciliation");
240
+ return decodePage(response.value);
241
+ }
242
+ catch {
243
+ throw new AxiError("Email action outcome is unknown; never replay automatically", "OUTCOME_UNKNOWN", [
244
+ "Inspect the write journal and read back every target before deciding what to do next",
245
+ ]);
246
+ }
247
+ }
248
+ // Closed event-action route; authorization cannot be reused or supplied by argv.
249
+ async function eventAction(authorization, action, targets) {
250
+ if (!mutation || !consumeWriteAuthorization(authorization, `event.action.${action}`, targets, profile.name, profile.region)) {
251
+ throw failure("WRITES_DISABLED", "A one-use coordinator authorization is required");
252
+ }
253
+ await authenticate();
254
+ const response = await send("/app/hec-api/v1.0/action/event", { requestData: {
255
+ eventIds: targets, eventActionName: [action], eventActionParam: [""],
256
+ } }, token);
257
+ if (response.status < 200 || response.status >= 300)
258
+ throw failure("OUTCOME_UNKNOWN", "Event action outcome requires reconciliation; never replay automatically");
259
+ return decodePage(response.value);
260
+ }
261
+ // Guide pp.61-69, 75-82: closed C8 routes; deletion is a bodyless POST.
262
+ async function exceptionWrite(authorization, kind, action, target, checkScopeBeforeSend, data) {
263
+ const routes = { "phishing-allow": "whitelist", "phishing-block": "blacklist", "spam-allow": "spam_whitelist" };
264
+ if (!mutation || !Object.hasOwn(routes, kind) || !["create", "update", "remove"].includes(action)
265
+ || !consumeWriteAuthorization(authorization, `exception.${kind}.${action}`, [target], profile.name, profile.region)) {
266
+ throw failure("WRITES_DISABLED", "A one-use coordinator authorization is required");
267
+ }
268
+ await authenticate();
269
+ const path = `/exceptions/${routes[kind]}${action === "create" ? "" : action === "remove" ? `/delete/${encodeURIComponent(target)}` : `/${encodeURIComponent(target)}`}`;
270
+ await spaceException(path);
271
+ checkScopeBeforeSend();
272
+ const response = await send(`/app/hec-api/v1.0${path}`, action === "remove" ? undefined : { requestData: data }, token, action === "update" ? "PUT" : "POST");
273
+ if (response.status < 200 || response.status >= 300 || !object(response.value) || !object(response.value.responseEnvelope)
274
+ || ![0, "0", 200, "200", ...(action === "remove" ? [204, "204"] : [])].includes(response.value.responseEnvelope.responseCode)) {
275
+ throw failure("OUTCOME_UNKNOWN", "Exception write acknowledgement requires reconciliation; never replay automatically");
276
+ }
277
+ }
278
+ // Guide pp.84-99,102-127: closed routes; request bodies distinguish shared POSTs.
279
+ // Response schemas are unpublished; preserve JSON, interpreting only HTTP/envelope status.
280
+ async function sectoolsWrite(authorization, kind, action, targets, checkScopeBeforeSend, data, specific = false) {
281
+ const sectools = { malware: "checkpoint2", url: "avanan_url", dlp: "avanan_dlp" };
282
+ const sectool = Object.hasOwn(sectools, kind);
283
+ if (sectool && action !== "create" && action !== "update")
284
+ throw failure("ENDPOINT_UNVERIFIED", "Sectool deletes require commissioning; nothing was sent");
285
+ if (!mutation || !["anomaly", "click-time", "malware", "url", "dlp"].includes(kind) || !["create", "update", "remove", "remove-items"].includes(action)
286
+ || kind === "anomaly" && action !== "create" && action !== "remove"
287
+ || specific && (!sectool || action !== "create")
288
+ || action !== "remove-items" && targets.length !== 1
289
+ || !consumeWriteAuthorization(authorization, `exception.${kind}.${action}`, targets, profile.name, profile.region)) {
290
+ throw failure("WRITES_DISABLED", "A one-use coordinator authorization is required");
291
+ }
292
+ const path = sectool ? `/sectool-exceptions/${sectools[kind]}${specific ? "/exceptions" : ""}`
293
+ : kind === "anomaly" ? "/sectools/anomaly/exceptions"
294
+ : `/sectools/click_time_protection/exceptions/items${action === "update" || action === "remove" ? `/${encodeURIComponent(targets[0])}` : ""}`;
295
+ await authenticate();
296
+ await spaceException(path);
297
+ checkScopeBeforeSend();
298
+ const response = await send(`/app/hec-api/v1.0${path}`, kind === "click-time" && action === "remove" ? undefined : { requestData: data }, token, action === "update" ? "PUT" : "POST", true);
299
+ if (response.status < 200 || response.status >= 300 || object(response.value) && Object.hasOwn(response.value, "responseEnvelope")
300
+ && (!object(response.value.responseEnvelope) || ![0, "0", 200, "200", 204, "204"].includes(response.value.responseEnvelope.responseCode))) {
301
+ throw failure("OUTCOME_UNKNOWN", "Exception write status requires reconciliation; never replay automatically");
302
+ }
303
+ return response.value;
304
+ }
305
+ return { authenticate, read, download, emailAction, eventAction, exceptionWrite, sectoolsWrite };
306
+ }
@@ -0,0 +1,4 @@
1
+ import type { credentialStore } from "./profiles.js";
2
+ import { gateway } from "./transport.js";
3
+ import { type SectoolsAction } from "./write-sectools.js";
4
+ export declare function exceptionWriteCommand(name: `exception ${SectoolsAction}`, kind: string | undefined, id: string | undefined, flags: Map<string, string>, store: ReturnType<typeof credentialStore>, transport?: Parameters<typeof gateway>[3]): Promise<Record<string, unknown>>;
@@ -0,0 +1,97 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { createHash } from "node:crypto";
3
+ import { isAbsolute } from "node:path";
4
+ import { z } from "zod";
5
+ import { exceptionFamilies } from "./exceptions.js";
6
+ import { readPrivateFile } from "./private-file.js";
7
+ import { gateway, READ_LIMITS } from "./transport.js";
8
+ import { coordinateWrite } from "./writes.js";
9
+ import { sectoolsWriteCommand } from "./write-sectools.js";
10
+ // Request parameter tables, not the contradictory string-boolean samples: guide pp.62-66, 76-80.
11
+ const criteria = { attachmentMd5: z.string(), senderEmail: z.string(), senderName: z.string(), recipient: z.string(),
12
+ senderClientIp: z.string(), senderDomain: z.string(), senderIp: z.string(), linkDomains: z.string(), subject: z.string() };
13
+ const fields = { ...criteria, comment: z.string(), actionNeeded: z.string(), ignoringSpfCheck: z.boolean(),
14
+ subjectMatching: z.string(), linkDomainMatching: z.string(), senderNameMatching: z.string(),
15
+ senderDomainMatching: z.string(), senderEmailMatching: z.string(), recipientMatching: z.string() };
16
+ const payload = z.strictObject({ ...fields, entityId: z.string().regex(/^[A-Za-z0-9_-]+$/) }).partial();
17
+ const record = z.object({ entityId: z.string().regex(/^[A-Za-z0-9_-]+$/),
18
+ ...Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, value.nullish()])) }).passthrough();
19
+ const fail = (code, message) => { throw new AxiError(message, code, ["Run checkpoint-axi exception create --help; inspect the preview before execution"]); };
20
+ export async function exceptionWriteCommand(name, kind, id, flags, store, transport) {
21
+ const action = name.slice("exception ".length);
22
+ if (kind === "anomaly" || kind === "click-time" || kind === "malware" || kind === "url" || kind === "dlp" || action === "remove-items" || action === "clear") {
23
+ return sectoolsWriteCommand(action, kind ?? "", id, flags, store, transport);
24
+ }
25
+ if (flags.has("--specific"))
26
+ return fail("VALIDATION_ERROR", "--specific is only valid for malware, url or dlp create");
27
+ if (kind !== "phishing-allow" && kind !== "phishing-block" && kind !== "spam-allow") {
28
+ return fail("VALIDATION_ERROR", "Choose phishing-allow, phishing-block, spam-allow, anomaly or click-time for exception writes");
29
+ }
30
+ if (action !== "create" && (!id || !/^[A-Za-z0-9_-]{1,256}$/.test(id)))
31
+ return fail("VALIDATION_ERROR", "Supply one exception ID containing letters, digits, underscores or hyphens");
32
+ if (flags.has("--execute") && flags.has("--dry-run"))
33
+ return fail("VALIDATION_ERROR", "--execute cannot combine with --dry-run");
34
+ let data;
35
+ if (action !== "remove") {
36
+ const path = flags.get("--input");
37
+ if (!path || !isAbsolute(path))
38
+ return fail("VALIDATION_ERROR", "--input requires an absolute private JSON file path");
39
+ let value;
40
+ try {
41
+ value = JSON.parse(readPrivateFile(path, true, true));
42
+ }
43
+ catch (error) {
44
+ if (error instanceof AxiError)
45
+ throw error;
46
+ return fail("VALIDATION_ERROR", "Cannot read a valid private JSON input file");
47
+ }
48
+ const parsed = payload.safeParse(value);
49
+ if (!parsed.success || !Object.keys(criteria).some((key) => typeof parsed.data[key] === "string"
50
+ && String(parsed.data[key]).trim())) {
51
+ return fail("VALIDATION_ERROR", "Input must contain documented requestData fields and at least one nonempty matching criterion; ignoringSpfCheck is boolean");
52
+ }
53
+ data = parsed.data;
54
+ if (action === "update" && data.entityId !== undefined && data.entityId !== id)
55
+ return fail("VALIDATION_ERROR", "Input entityId must match the exception ID");
56
+ }
57
+ // Canonical payload identity binds a new rule's confirmation without putting its contents in the journal.
58
+ const canonical = data === undefined ? "" : JSON.stringify(Object.fromEntries(Object.entries(data).sort(([a], [b]) => a.localeCompare(b))));
59
+ const target = action === "create" ? `sha256:${createHash("sha256").update(canonical).digest("hex")}` : id;
60
+ const operation = `exception.${kind}.${action}`;
61
+ store.readOnly();
62
+ const profile = store.select(store.load(flags.get("--config"), true).config, flags.get("--profile"));
63
+ const checkScope = () => {
64
+ if (store.readOnly() || !profile.writes.allowWrites)
65
+ fail("WRITES_DISABLED", "Writes are disabled for the selected profile");
66
+ if (!profile.writes.operations.includes(operation))
67
+ fail("OPERATION_NOT_WRITABLE", "Operation is outside the selected profile's exact write scope");
68
+ const fresh = store.select(store.load(flags.get("--config"), true).config, flags.get("--profile"));
69
+ if (JSON.stringify(fresh) !== JSON.stringify(profile))
70
+ fail("CONFIG_CHANGED", "Profile changed during the write; nothing was sent");
71
+ };
72
+ checkScope();
73
+ const session = gateway(profile, store.credentials(profile, true), store.redactor, transport, true);
74
+ const path = exceptionFamilies[kind].path;
75
+ const normalize = (value) => value === "" ? undefined : value ?? undefined;
76
+ const same = (item) => Object.keys(fields).every((key) => normalize(item[key]) === normalize(data[key]))
77
+ && (data.entityId === undefined || item.entityId === data.entityId);
78
+ const readState = async () => {
79
+ const page = await session.read({ path: (action === "create" ? path : `${path}/${id}`) });
80
+ const parsed = z.array(record).safeParse(page.records);
81
+ if (!parsed.success || page.scrollId || page.total !== undefined && page.total > page.records.length
82
+ || page.records.length > (action === "create" ? READ_LIMITS.rows : 1)
83
+ || action !== "create" && parsed.data.some((item) => item.entityId !== id)
84
+ || new Set(parsed.data.map((item) => item.entityId)).size !== parsed.data.length) {
85
+ return fail("RESPONSE_INVALID", "Exception preflight returned incomplete, mismatched or malformed state");
86
+ }
87
+ if (action === "update" && !parsed.data.length)
88
+ return fail("NOT_FOUND", "Exception to update was not found");
89
+ const noop = action === "remove" ? parsed.data.length === 0 : parsed.data.some(same);
90
+ return { noop, state: { existing: action === "create" ? parsed.data.filter(same) : parsed.data, ...(data ? { requested: data } : {}) } };
91
+ };
92
+ return coordinateWrite({ operation, targets: [target], flags, store, scope: { profile: profile.name, region: profile.region }, checkScope, readState,
93
+ send: async (authorization, checkScopeBeforeSend) => {
94
+ await session.exceptionWrite(authorization, kind, action, target, checkScopeBeforeSend, data);
95
+ return { help: [`Run checkpoint-axi exception ${action === "create" ? `list ${kind}` : `show ${kind} ${id}`} to reconcile the accepted request; live behavior is uncommissioned`] };
96
+ } });
97
+ }
@@ -0,0 +1,5 @@
1
+ import type { credentialStore } from "./profiles.js";
2
+ import { gateway } from "./transport.js";
3
+ export type SectoolsAction = "create" | "update" | "remove" | "remove-items" | "clear";
4
+ export type SectoolKind = "malware" | "url" | "dlp";
5
+ export declare function sectoolsWriteCommand(action: SectoolsAction, kind: string, id: string | undefined, flags: Map<string, string>, store: ReturnType<typeof credentialStore>, transport?: Parameters<typeof gateway>[3]): Promise<Record<string, unknown>>;
@@ -0,0 +1,107 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { createHash } from "node:crypto";
3
+ import { isAbsolute } from "node:path";
4
+ import { z } from "zod";
5
+ import { readPrivateFile } from "./private-file.js";
6
+ import { gateway } from "./transport.js";
7
+ import { coordinateWrite } from "./writes.js";
8
+ import { exceptionFamilies } from "./exceptions.js";
9
+ const identifier = z.string().regex(/^[A-Za-z0-9_-]{1,256}$/);
10
+ const text = z.string().refine((value) => value.trim().length > 0);
11
+ // Request bodies only: guide pp.84,86,92-95,97-99 publishes no response schemas.
12
+ const anomalyInput = z.strictObject({ addedBy: z.string(), requestJson: z.strictObject({
13
+ "whitelist-option:new_country": identifier, "apply-to-past": z.enum(["Yes", "No"]),
14
+ "anomaly-comment": z.string(), event_id: identifier,
15
+ }) });
16
+ const clickInput = z.strictObject({ listItemName: text, listId: identifier, createdBy: z.string() });
17
+ const clickUpdate = clickInput.extend({ listItemId: identifier });
18
+ const condition = z.strictObject({ condition: text });
19
+ // Guide pp.103-106,113-117,123-127 supplies request examples, but no parameter tables.
20
+ const sectoolFields = { exception_type: text, exception_str: text, comment: z.string() };
21
+ const sectoolCreate = z.strictObject({ ...sectoolFields, entity_type: text, entity_id: text,
22
+ file_name: z.string(), created_by_email: z.string(), is_exclusive: z.boolean() });
23
+ const malwareCreate = sectoolCreate.extend({ exception_payload: condition });
24
+ const sectoolUpdate = z.strictObject({ ...sectoolFields, exception_payload: condition });
25
+ const fail = (code, message) => {
26
+ throw new AxiError(message, code, ["Run checkpoint-axi exception create --help; live response identities remain unverified until commissioning through Artium"]);
27
+ };
28
+ export async function sectoolsWriteCommand(action, kind, id, flags, store, transport) {
29
+ const sectool = kind === "malware" || kind === "url" || kind === "dlp";
30
+ if (sectool && (action === "remove" || action === "clear")) {
31
+ return fail("ENDPOINT_UNVERIFIED", "Malware, URL and DLP deletes duplicate create requests in the guide (pages 102-109, 113-119, 123-129); commissioning must establish the real requests");
32
+ }
33
+ if (sectool ? action !== "create" && action !== "update"
34
+ : kind !== "anomaly" && kind !== "click-time" || kind === "anomaly" && action !== "create" && action !== "remove") {
35
+ return fail("VALIDATION_ERROR", "Anomaly supports create/remove; click-time supports create/update/remove/remove-items/clear; malware/url/dlp support create/update");
36
+ }
37
+ if (flags.has("--specific") && (!sectool || action !== "create"))
38
+ return fail("VALIDATION_ERROR", "--specific is only valid for malware, url or dlp create");
39
+ if (sectool && id !== undefined)
40
+ return fail("VALIDATION_ERROR", "Malware, URL and DLP updates identify the exception in --input; omit the positional ID");
41
+ if (flags.has("--execute") && flags.has("--dry-run"))
42
+ return fail("VALIDATION_ERROR", "--execute cannot combine with --dry-run");
43
+ if (action === "clear")
44
+ return fail("ENDPOINT_UNVERIFIED", "Click-time clear requires a trustworthy pre-read count; the response schema is undocumented (guide pages 87-88, 96-97)");
45
+ if (action !== "remove-items" && flags.has("--ids"))
46
+ return fail("VALIDATION_ERROR", "--ids is only valid for click-time remove-items");
47
+ if (!sectool && (action === "update" || action === "remove") && !identifier.safeParse(id).success)
48
+ return fail("VALIDATION_ERROR", "Supply one exception ID containing letters, digits, underscores or hyphens");
49
+ let data;
50
+ if (action === "create" || action === "update") {
51
+ const path = flags.get("--input");
52
+ if (!path || !isAbsolute(path))
53
+ return fail("VALIDATION_ERROR", "--input requires an absolute private JSON file path");
54
+ let value;
55
+ try {
56
+ value = JSON.parse(readPrivateFile(path, true, true));
57
+ }
58
+ catch (error) {
59
+ if (error instanceof AxiError)
60
+ throw error;
61
+ return fail("VALIDATION_ERROR", "Cannot read a valid private JSON input file");
62
+ }
63
+ const parsed = (sectool ? action === "update" ? sectoolUpdate : kind === "malware" ? malwareCreate : sectoolCreate
64
+ : kind === "anomaly" ? anomalyInput : action === "update" ? clickUpdate : clickInput).safeParse(value);
65
+ if (!parsed.success)
66
+ return fail("VALIDATION_ERROR", "Input must contain only the documented requestData fields for this operation");
67
+ data = parsed.data;
68
+ if (sectool && !exceptionFamilies[kind].types.includes(String(data.exception_type)))
69
+ return fail("VALIDATION_ERROR", "Input exception_type must be a documented type for the selected kind");
70
+ if (!sectool && action === "update" && data.listItemId !== id)
71
+ return fail("VALIDATION_ERROR", "Input listItemId must match the positional item ID");
72
+ }
73
+ const targets = action === "remove-items" ? flags.get("--ids")?.split(",") ?? [] : action === "create" || sectool
74
+ ? [`sha256:${createHash("sha256").update(JSON.stringify(sectool ? { specific: flags.has("--specific"), data } : data)).digest("hex")}`] : [id];
75
+ if (!targets.length || targets.length > 20 || new Set(targets).size !== targets.length
76
+ || action === "remove-items" && targets.some((target) => !identifier.safeParse(target).success)) {
77
+ return fail("VALIDATION_ERROR", "Supply --ids with 1-20 unique comma-separated item IDs");
78
+ }
79
+ if (action === "remove-items")
80
+ data = { listItemIds: targets };
81
+ if (kind === "anomaly" && action === "remove")
82
+ data = { ruleId: id };
83
+ const operation = `exception.${kind}.${action}`;
84
+ store.readOnly();
85
+ const profile = store.select(store.load(flags.get("--config"), true).config, flags.get("--profile"));
86
+ const checkScope = () => {
87
+ if (store.readOnly() || !profile.writes.allowWrites)
88
+ fail("WRITES_DISABLED", "Writes are disabled for the selected profile");
89
+ if (!profile.writes.operations.includes(operation))
90
+ fail("OPERATION_NOT_WRITABLE", "Operation is outside the selected profile's exact write scope");
91
+ const fresh = store.select(store.load(flags.get("--config"), true).config, flags.get("--profile"));
92
+ if (JSON.stringify(fresh) !== JSON.stringify(profile))
93
+ fail("CONFIG_CHANGED", "Profile changed during the write; nothing was sent");
94
+ };
95
+ checkScope();
96
+ const session = gateway(profile, store.credentials(profile, true), store.redactor, transport, true);
97
+ // No existence/no-op inference is safe without documented response identities.
98
+ // Both coordinator checks retain explicit uncertainty instead of inventing a list preflight.
99
+ return coordinateWrite({ operation, targets, flags, store, scope: { profile: profile.name, region: profile.region }, checkScope,
100
+ readState: async () => ({ noop: false, state: { targetState: "unverified", responseSchema: "unverified",
101
+ reason: "Existence and no-op detection require live response identity commissioning", ...(data ? { requested: data } : {}) } }),
102
+ send: async (authorization, checkScopeBeforeSend) => ({
103
+ response: await session.sectoolsWrite(authorization, kind, action, targets, checkScopeBeforeSend, data, flags.has("--specific")),
104
+ responseSchema: "unverified",
105
+ help: [`Run checkpoint-axi exception list ${kind}${sectool ? ` --type ${String(data.exception_type)}` : kind === "click-time" ? " --items" : ""} to inspect unverified JSON and reconcile; never replay automatically`],
106
+ }) });
107
+ }
@@ -0,0 +1,30 @@
1
+ import { credentialStore } from "./profiles.js";
2
+ import { gateway } from "./transport.js";
3
+ type Store = ReturnType<typeof credentialStore>;
4
+ type Flags = Map<string, string>;
5
+ export type WriteAuthorization = Readonly<{
6
+ operation: string;
7
+ targets: readonly string[];
8
+ profile: string;
9
+ region: string;
10
+ }>;
11
+ export declare function consumeWriteAuthorization(authorization: WriteAuthorization, operation: string, targets: readonly string[], profile: string, region: string): boolean;
12
+ export declare function coordinateWrite(args: {
13
+ operation: string;
14
+ targets: readonly string[];
15
+ flags: Flags;
16
+ store: Store;
17
+ scope: {
18
+ profile: string;
19
+ region: string;
20
+ };
21
+ checkScope: () => void;
22
+ readState: () => Promise<{
23
+ noop: boolean;
24
+ state: unknown;
25
+ }>;
26
+ send: (authorization: WriteAuthorization, checkScopeBeforeSend: () => void) => Promise<Record<string, unknown>>;
27
+ }): Promise<Record<string, unknown>>;
28
+ export declare function eventWriteCommand(flags: Flags, store: Store, transport?: Parameters<typeof gateway>[3]): Promise<Record<string, unknown>>;
29
+ export declare function emailWriteCommand(name: "email quarantine" | "email restore", flags: Flags, store: Store, transport?: Parameters<typeof gateway>[3]): Promise<Record<string, unknown>>;
30
+ export {};