@knowttl/falcon-axi 1.0.0-rc.2 → 1.0.0-rc.3

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.
@@ -62,6 +62,8 @@ export async function main(argv, bin) {
62
62
  .map((name) => ` --${name}`)
63
63
  .join("");
64
64
  const result = help.map((item) => item.replace(/`(falcon-axi .*)`/s, (_, command) => `\`${command}${suffix}\``));
65
+ if (help.some((item) => item.includes("<original-filter>")))
66
+ result.push("Replace <original-filter> with your original --filter value when continuing; CID filters are not echoed");
65
67
  if (memberCid && help.some((item) => /`falcon-axi /.test(item)))
66
68
  result.push("Supply the same tenant selection used for this invocation when following these suggestions");
67
69
  return result;
@@ -17,15 +17,32 @@ export class CliError extends Error {
17
17
  export function object(value) {
18
18
  return value !== null && typeof value === "object" && !Array.isArray(value);
19
19
  }
20
- // Preserve numeric lexemes outside Number's exact integer domain before rounding.
20
+ // Preserve numeric lexemes outside Number's exact integer domain, judged from the
21
+ // original text because JSON.parse has already rounded the value it hands us.
21
22
  export function parseResponse(text) {
22
23
  return JSON.parse(text, (_key, value, context) => {
23
- if (typeof value !== "number" ||
24
- Math.abs(value) <= Number.MAX_SAFE_INTEGER ||
25
- !context?.source)
24
+ if (typeof value !== "number" || !context?.source)
26
25
  return value;
27
- return /^-?\d+$/.test(context.source)
28
- ? BigInt(context.source)
29
- : context.source;
26
+ const { source } = context;
27
+ if (/^-?\d+$/.test(source)) {
28
+ const integer = BigInt(source);
29
+ return (integer < 0n ? -integer : integer) >
30
+ BigInt(Number.MAX_SAFE_INTEGER)
31
+ ? integer
32
+ : value;
33
+ }
34
+ const [mantissa, exponent = "0"] = source.replace(/^-/, "").split(/[eE]/);
35
+ const [whole, fraction = ""] = mantissa.split(".");
36
+ const coefficient = whole + fraction;
37
+ const significant = coefficient.replace(/^0+/, "");
38
+ if (!significant)
39
+ return value;
40
+ const position = BigInt(whole.length - (coefficient.length - significant.length)) +
41
+ BigInt(exponent);
42
+ const digits = significant.replace(/0+$/, "");
43
+ return position > 16n ||
44
+ (position === 16n && digits.padEnd(16, "0") > "9007199254740991")
45
+ ? source
46
+ : value;
30
47
  });
31
48
  }
@@ -63,3 +63,10 @@ export class Cursor {
63
63
  }
64
64
  }
65
65
  }
66
+ // Serializes a cursor query context exactly as Python's json.dumps did: ASCII-only
67
+ // output with either compact or default separators, so existing tokens stay valid.
68
+ export function pythonJson(value, compact = false) {
69
+ if (Array.isArray(value))
70
+ return `[${value.map((item) => pythonJson(item, compact)).join(compact ? "," : ", ")}]`;
71
+ return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`);
72
+ }
@@ -1,4 +1,5 @@
1
1
  import { CliError, object } from "./core.js";
2
+ import { recoveryHelp } from "./metadata.js";
2
3
  import { responseFailure } from "./auth.js";
3
4
  import { requestRead } from "./transport/https.js";
4
5
  import { malformed } from "./response.js";
@@ -36,13 +37,14 @@ export class Domain {
36
37
  async response(operation, subject, query = {}, body, missing = false) {
37
38
  const response = await requestRead(operation, this.session.base, this.session.token, query, body, this.allowUnknown, this.signal);
38
39
  if (response.status === 404 && !missing) {
39
- const noun = this.invocation.command.name.startsWith("identity sensor ")
40
- ? "identity sensor"
41
- : this.invocation.command.name.split(" ")[0];
42
- throw new CliError("NOT_FOUND", noun === "host-group"
40
+ const { command, flags } = this.invocation;
41
+ const hostGroup = command.name.startsWith("host-group ");
42
+ throw new CliError("NOT_FOUND", hostGroup
43
43
  ? "no host group matched that identifier"
44
44
  : `no ${subject} matched that identifier`, [
45
- `Run \`falcon-axi ${noun} list${noun === "policy" ? ` --type ${this.invocation.flags.get("--type")}` : ""}\`${noun === "host-group" ? "" : " to see current identifiers"}`,
45
+ hostGroup
46
+ ? "Run `falcon-axi host-group list`"
47
+ : recoveryHelp(command.name, flags),
46
48
  ]);
47
49
  }
48
50
  if (response.status === 403 &&
@@ -1,4 +1,5 @@
1
1
  import { CliError, object } from "./core.js";
2
+ import { pythonJson } from "./cursor.js";
2
3
  import { Domain, text } from "./domain.js";
3
4
  import { Raw } from "./render.js";
4
5
  import { seconds } from "./search.js";
@@ -208,12 +209,12 @@ export async function identity(domain) {
208
209
  const type = flags.get("--type")?.toLowerCase();
209
210
  const fingerprint = timeline
210
211
  ? [entityId(id).toLowerCase(), since.toLowerCase(), chosen.join(",")].join("|")
211
- : JSON.stringify([
212
+ : pythonJson([
212
213
  type ?? null,
213
214
  flags.get("--name") ?? null,
214
215
  flags.get("--email") ?? null,
215
216
  flags.get("--domain") ?? null,
216
- ]);
217
+ ], true);
217
218
  const context = domain.cursor("api_preempt_proxy_post_graphql", fingerprint, "token");
218
219
  const variables = { first: limit };
219
220
  let start = new Date(Date.now() - seconds(since) * 1000)
@@ -1,4 +1,6 @@
1
1
  import { CliError, object } from "./core.js";
2
+ import { pythonJson } from "./cursor.js";
3
+ import { recoveryHelp } from "./metadata.js";
2
4
  import { Domain, count, more, project, text } from "./domain.js";
3
5
  import { Raw, redact, truncate } from "./render.js";
4
6
  import { operation } from "./transport/operations.js";
@@ -330,7 +332,7 @@ export async function inspection(domain, filter) {
330
332
  ]
331
333
  : ["classification", "data-policy"].includes(noun)
332
334
  ? [`Run \`falcon-axi ${noun} list --help\` to find identifiers`]
333
- : []);
335
+ : [recoveryHelp(command.name, flags)]);
334
336
  const record = records[0];
335
337
  if (recon || collection)
336
338
  return detail(domain, recon ? "record" : collectionNoun.replaceAll(" ", "_"), record, true, recon ? "for untruncated content" : "for untruncated detail");
@@ -378,9 +380,9 @@ export async function inspection(domain, filter) {
378
380
  filter = sandboxFilter(flags);
379
381
  const sort = noun === "indicator" ? "_marker.asc" : flags.get("--sort");
380
382
  const contextFilter = data
381
- ? JSON.stringify([filter ?? null, flags.get("--platform") ?? null])
383
+ ? pythonJson([filter ?? null, flags.get("--platform") ?? null], true)
382
384
  : noun === "policy"
383
- ? JSON.stringify([filter ?? null, members ? id : null])
385
+ ? pythonJson([filter ?? null, members ? id : null], true)
384
386
  : filter;
385
387
  const context = domain.cursor(op, contextFilter, noun === "indicator" ? "marker" : (collection?.model ?? "offset"), sort ?? null);
386
388
  const position = supplied
@@ -1,4 +1,5 @@
1
1
  import { CliError, object } from "./core.js";
2
+ import { pythonJson } from "./cursor.js";
2
3
  import { Domain, count, ids, more, project, quote, text } from "./domain.js";
3
4
  import { Raw, redact, truncate } from "./render.js";
4
5
  export const applicationFields = [
@@ -167,7 +168,7 @@ export async function inventory(domain, filter) {
167
168
  if (noun === "host-group") {
168
169
  const members = name.endsWith("members");
169
170
  const sort = members ? null : "name.asc";
170
- const context = domain.cursor(operation, `[${JSON.stringify(id ? [id] : [])}, ${JSON.stringify(filter ?? null)}]`.replace(/[\u007f-\uffff]/g, (character) => `\\u${character.charCodeAt(0).toString(16).padStart(4, "0")}`), "offset", sort);
171
+ const context = domain.cursor(operation, pythonJson([id ? [id] : [], filter ?? null]), "offset", sort);
171
172
  const position = flags.has("--cursor")
172
173
  ? Number(context.decode(flags.get("--cursor")))
173
174
  : 0;
@@ -1,10 +1,11 @@
1
1
  import { CliError, object } from "./core.js";
2
- import { Cursor } from "./cursor.js";
2
+ import { Cursor, pythonJson } from "./cursor.js";
3
3
  import { Raw, redact, truncate } from "./render.js";
4
4
  import { requestRead } from "./transport/https.js";
5
5
  import { responseFailure } from "./auth.js";
6
6
  import { malformed } from "./response.js";
7
7
  import { text, quote, project } from "./domain.js";
8
+ import { recoveryHelp } from "./metadata.js";
8
9
  const invalid = (message, details = {}) => {
9
10
  throw new CliError("UPSTREAM_ERROR", message, [], details);
10
11
  };
@@ -94,21 +95,18 @@ export async function investigate(invocation, credential, session, filter, allow
94
95
  ]);
95
96
  if (name.startsWith("ioc "))
96
97
  throw new CliError("NOT_FOUND", "no IOC record matched that identifier", ["Run `falcon-axi ioc list`"]);
97
- const command = operation === "QueryGetNetworkAddressHistoryV1"
98
- ? "host list"
99
- : name.startsWith("file-change ")
100
- ? "file-change list"
101
- : name.startsWith("rtr session ")
102
- ? "rtr session list"
103
- : name.startsWith("rtr audit ")
104
- ? "rtr audit list"
105
- : "detection list";
106
98
  throw new CliError("NOT_FOUND", `no ${subject} matched that identifier`, [
107
- `Run \`falcon-axi ${command}\` to see current identifiers`,
99
+ recoveryHelp(name === "home" ? "detection list" : name, flags),
108
100
  ]);
109
101
  }
110
102
  if (response.status !== 200)
111
103
  responseFailure(response, subject, false, false, operation);
104
+ if ((name === "host logins" || name === "host network") &&
105
+ Array.isArray(envelope.errors) &&
106
+ envelope.errors.length)
107
+ invalid(operation === "QueryDevicesByFilter"
108
+ ? "Falcon returned a partial hostname lookup; history was not read"
109
+ : `Falcon returned partial host ${name === "host logins" ? "login" : "network"} history`, { partial: true });
112
110
  const rows = Array.isArray(response.body)
113
111
  ? response.body
114
112
  : envelope.resources;
@@ -272,8 +270,6 @@ export async function investigate(invocation, credential, session, filter, allow
272
270
  if (more && offset < wall) {
273
271
  value.continuation_cursor = context.encode(offset);
274
272
  help.push(`Run \`${suggestion} --cursor ${String(value.continuation_cursor)}\` for the next page`);
275
- if (filter && /\b(?:cid|member_cid)\s*:/i.test(filter))
276
- help.push("Replace <original-filter> with your original --filter value when continuing; CID filters are not echoed");
277
273
  }
278
274
  else if (more)
279
275
  help.push(wallHelp[0], hosts
@@ -662,7 +658,7 @@ export async function investigate(invocation, credential, session, filter, allow
662
658
  const type = id.slice(0, id.indexOf(":"));
663
659
  const indicator = id.slice(id.indexOf(":") + 1);
664
660
  const host = flags.get("--host");
665
- const context = cursor(operation, JSON.stringify([type, indicator, host ?? null]).replaceAll(",", ", "));
661
+ const context = cursor(operation, pythonJson([type, indicator, host ?? null]));
666
662
  const offset = flags.has("--cursor")
667
663
  ? context.decode(flags.get("--cursor"))
668
664
  : 0;
@@ -1,5 +1,6 @@
1
1
  import { CliError, object } from "./core.js";
2
2
  import { Domain, project } from "./domain.js";
3
+ import { recoveryHelp } from "./metadata.js";
3
4
  import { Raw } from "./render.js";
4
5
  import { searchBody } from "./transport/search-authority.js";
5
6
  import { requestException, requestRead } from "./transport/https.js";
@@ -37,7 +38,15 @@ export async function malquery(domain) {
37
38
  "Check `falcon-axi malquery search --help` for the supported literal pattern grammar",
38
39
  ]);
39
40
  if (response.status === 404)
40
- throw new CliError("NOT_FOUND", `no ${metadata ? "MalQuery metadata" : status || submit ? "MalQuery search" : "MalQuery quota"} matched that identifier`, ["Run `falcon-axi malquery quota` to see current identifiers"]);
41
+ throw new CliError("NOT_FOUND", `no ${metadata ? "MalQuery metadata" : status || submit ? "MalQuery search" : "MalQuery quota"} matched that identifier`, metadata || status || submit
42
+ ? [
43
+ recoveryHelp(metadata
44
+ ? "malquery show"
45
+ : status
46
+ ? "malquery status"
47
+ : "malquery search", new Map()),
48
+ ]
49
+ : []);
41
50
  if (response.status !== 200)
42
51
  responseFailure(response, metadata
43
52
  ? "MalQuery metadata"
@@ -550,6 +550,35 @@ export const boolFlags = [
550
550
  "--no-member-cid",
551
551
  ];
552
552
  export const commandNames = commands.map((command) => command.name);
553
+ // Recovery guidance for an identifier or collection that Falcon did not find.
554
+ // Commands whose list needs a selector the failed command may not carry point
555
+ // to that list's help instead of an invalid invocation.
556
+ export const recoveryHelp = (name, flags, kind) => {
557
+ if (name.startsWith("process "))
558
+ return "Run `falcon-axi ioc processes <type:value> --host <device_id>` to find process identifiers";
559
+ if (name === "malquery search")
560
+ return "Run `falcon-axi malquery search --help` for the supported literal pattern grammar";
561
+ if (name === "malquery status")
562
+ return "Run `falcon-axi malquery search --help` to start a search and obtain a request identifier";
563
+ if (name === "malquery show")
564
+ return "Run `falcon-axi malquery show --help` for the SHA-256 argument it expects";
565
+ const words = name.split(" ");
566
+ const collection = name === "rtr run"
567
+ ? "host list"
568
+ : words.at(-1) === "list"
569
+ ? name
570
+ : words.length === 3
571
+ ? `${words[0]} ${words[1]} list`
572
+ : `${words[0]} list`;
573
+ const selector = { exclusion: "type", policy: "type", "data-policy": "platform" }[words[0]];
574
+ const value = selector ? flags.get(`--${selector}`) : undefined;
575
+ const command = value !== undefined && /^[A-Za-z0-9_-]+$/.test(value)
576
+ ? `${collection} --${selector} ${value}`
577
+ : selector || ["sandbox", "serverless", "vuln"].includes(words[0])
578
+ ? `${collection} --help`
579
+ : collection;
580
+ return `Run \`falcon-axi ${command}\` to ${command.endsWith(" --help") ? "see the selectors it requires" : kind === "policy" ? "find policy ids" : `see current ${kind ? `${kind} ` : ""}identifiers`}`;
581
+ };
553
582
  for (const flag of [
554
583
  ...valueFlags,
555
584
  ...boolFlags,
@@ -1,5 +1,5 @@
1
1
  import { CliError } from "./core.js";
2
- import { commands, valueFlags, boolFlags } from "./metadata.js";
2
+ import { commands, valueFlags, boolFlags, recoveryHelp } from "./metadata.js";
3
3
  import { applicationFields } from "./inventory.js";
4
4
  import { shieldFilters } from "./exposure.js";
5
5
  import { validateInspection } from "./inspection.js";
@@ -98,21 +98,21 @@ export function parse(argv) {
98
98
  if (command.positional &&
99
99
  (hostname ? positionals.length !== 0 : positionals.length !== 1))
100
100
  throw new CliError("VALIDATION_ERROR", `${command.name} requires exactly one ${command.positional}`, [
101
- command.name === "policy members"
102
- ? "Run `falcon-axi policy list --type prevention` to find policy ids"
103
- : command.name === "cve show"
104
- ? "A CVE identifier matches CVE-<year>-<number>, for example CVE-2021-44228"
105
- : command.name === "vuln show"
106
- ? "Run `falcon-axi vuln list --status open` to find Spotlight record identifiers"
107
- : command.name.startsWith("search ")
108
- ? "A search identifier comes from `falcon-axi search start --query '<cql>'`"
109
- : ["identity show", "identity timeline"].includes(command.name)
110
- ? "Run `falcon-axi identity list` to see current identity ids"
111
- : command.name === "identity activity"
112
- ? "Run `falcon-axi detection show <id>` for an identity detection's account_id"
113
- : command.name === "identity sensor show"
114
- ? "Run `falcon-axi identity sensor list` to see current sensor identifiers"
115
- : `Run \`falcon-axi ${command.name.startsWith("host ") ? "host" : command.name.startsWith("detection ") ? "detection" : command.name.split(" ")[0]} list\` to see current ${command.name.startsWith("host ") ? "device" : command.name.split(" ")[0]} identifiers`,
101
+ command.name === "cve show"
102
+ ? "A CVE identifier matches CVE-<year>-<number>, for example CVE-2021-44228"
103
+ : command.name === "vuln show"
104
+ ? "Run `falcon-axi vuln list --status open` to find Spotlight record identifiers"
105
+ : command.name.startsWith("search ")
106
+ ? "A search identifier comes from `falcon-axi search start --query '<cql>'`"
107
+ : ["identity show", "identity timeline"].includes(command.name)
108
+ ? "Run `falcon-axi identity list` to see current identity ids"
109
+ : command.name === "identity activity"
110
+ ? "Run `falcon-axi detection show <id>` for an identity detection's account_id"
111
+ : command.name === "identity sensor show"
112
+ ? "Run `falcon-axi identity sensor list` to see current sensor identifiers"
113
+ : recoveryHelp(command.name, flags, command.name.startsWith("host ")
114
+ ? "device"
115
+ : command.name.split(" ")[0]),
116
116
  ]);
117
117
  if (!command.positional && positionals.length)
118
118
  throw new CliError("VALIDATION_ERROR", `\`${command.name}\` accepts no positional arguments`, valid);
@@ -1 +1 @@
1
- export const VERSION = "1.0.0-rc.2"; // x-release-please-version
1
+ export const VERSION = "1.0.0-rc.3"; // x-release-please-version
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "parser": {
10
10
  "path": ".typescript-build/src/parser.js",
11
- "sha256": "245e0b7b86311cb7876ef94dc3eab0550529501d14e25073516fad1c028c5552"
11
+ "sha256": "1a348bb4a13e0034d5919b218e02ddf7ed0036b51d78d06b488bda4fcb78f8eb"
12
12
  },
13
13
  "skill": {
14
14
  "path": "skills/falcon-axi/SKILL.md",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowttl/falcon-axi",
3
- "version": "1.0.0-rc.2",
3
+ "version": "1.0.0-rc.3",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "MIT",