@kinosec/cli 0.11.2 → 0.12.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.
Files changed (2) hide show
  1. package/dist/cli.js +543 -76
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -6944,6 +6944,10 @@ var ReportsResource = class {
6944
6944
  async export(params) {
6945
6945
  return this.api.post("v1/reports", { json: params }).json();
6946
6946
  }
6947
+ /** The API's own markdown rendering of a report (`text/markdown`), returned as is. */
6948
+ async exportMarkdown(scanReportId) {
6949
+ return this.api.post("v1/reports", { json: { scan_report_id: scanReportId, format: "markdown" } }).text();
6950
+ }
6947
6951
  };
6948
6952
  var TokensResource = class {
6949
6953
  constructor(api) {
@@ -12012,6 +12016,33 @@ function normalizeActivityHistory(raw) {
12012
12016
  });
12013
12017
  }
12014
12018
 
12019
+ // src/lib/failure-reason.ts
12020
+ var REASON_SENTENCES = {
12021
+ timeout: "The pentest stopped reporting progress for longer than we allow, so we shut it down to be safe. We could not see why it went quiet.",
12022
+ no_results: "The pentest ran but produced no results, so there was nothing to report.",
12023
+ worker_image_broken: "The pentest machine started with a broken image on our side. This is our fault, not yours.",
12024
+ worker_startup_failed: "The pentest worker could not start up, so testing never began.",
12025
+ scan_error: "The pentest engine hit an error while testing the target and could not continue.",
12026
+ ad_credentials_invalid: "The Active Directory credentials you supplied were rejected by the domain controller. Update them and try again.",
12027
+ no_dc_reachable: "No domain controller could be reached inside the scope you gave us. Check that the scope covers the domain controller's subnet and that the scout can route to it, then try again.",
12028
+ target_not_authorized: "We could not confirm you are authorized to test this target. Confirm authorization in your dashboard and try again.",
12029
+ mfa_required: "Starting this pentest requires two-factor authentication. Sign in with your second factor and try again.",
12030
+ egress_not_ready: "The network route we use to reach your target was not ready. Please try again in a few minutes.",
12031
+ launch_conflict: "Another launch of this same pentest was already in progress. Refresh your dashboard and try again.",
12032
+ launch_error: "The pentest could not be started. This is usually a temporary problem on our side.",
12033
+ scheduled_launch_error: "A scheduled pentest could not be started at its scheduled time.",
12034
+ schedule_cancelled: "The pentest was stopped because its schedule was cancelled while it was running."
12035
+ };
12036
+ var UNKNOWN_SENTENCE = "We could not determine the exact cause.";
12037
+ function isKnownReason(code) {
12038
+ return Object.hasOwn(REASON_SENTENCES, code);
12039
+ }
12040
+ function failureSentence(rawReason) {
12041
+ if (typeof rawReason !== "string") return UNKNOWN_SENTENCE;
12042
+ const code = rawReason.split(":")[0].trim().toLowerCase();
12043
+ return isKnownReason(code) ? REASON_SENTENCES[code] : UNKNOWN_SENTENCE;
12044
+ }
12045
+
12015
12046
  // src/lib/buzzwords.ts
12016
12047
  var SCAN_EXECUTION_PHRASES = [
12017
12048
  "DEEP SCAN",
@@ -12235,7 +12266,7 @@ ${icon2} Pentest completed: ${domain} ${dur}`);
12235
12266
  }
12236
12267
  if (status === "Failed") {
12237
12268
  const icon2 = tty3 ? import_picocolors5.default.red(sym.fail) : sym.fail;
12238
- const reason = scanDetail.failure_reason || "unknown reason";
12269
+ const reason = failureSentence(scanDetail.failure_reason);
12239
12270
  console.log(`
12240
12271
  ${icon2} Pentest failed: ${reason}`);
12241
12272
  deferredExitCode = 1;
@@ -12273,8 +12304,8 @@ ${icon} Unexpected status: ${status}`);
12273
12304
  }
12274
12305
  async function printFindingsSummary(client, profileId, tty3) {
12275
12306
  try {
12276
- const reports = await client.reports.list({ limit: 20 });
12277
- const report = reports.items.find((r) => r.scan_profile_id === profileId);
12307
+ const reports = await client.reports.list({ scan_profile_id: profileId, limit: 1 });
12308
+ const report = reports.items[0];
12278
12309
  if (report?.severity_counts) {
12279
12310
  const label = tty3 ? import_picocolors5.default.dim("Findings:") : "Findings:";
12280
12311
  const summary = renderFindingsSummary(report.severity_counts);
@@ -12855,14 +12886,26 @@ function ensurePasteBundle(result) {
12855
12886
  process.exit(1);
12856
12887
  }
12857
12888
  }
12889
+ var STATUS_FILTERS = {
12890
+ "not-started": "Not Yet Started",
12891
+ running: "Running...",
12892
+ completed: "Completed",
12893
+ failed: "Failed",
12894
+ stopping: "Stopping...",
12895
+ scheduled: "Scheduled"
12896
+ };
12897
+ function isStatusFilter(value) {
12898
+ return Object.hasOwn(STATUS_FILTERS, value);
12899
+ }
12858
12900
  function isUserDataTooLarge(err) {
12859
12901
  return err.status === 413 || /user-?data too large/i.test(err.detail);
12860
12902
  }
12861
12903
  function registerPentestCommand(program3) {
12862
- const pentest = program3.command("pentest").description("Run and manage pentests").option("-t, --target <domain>", "Target domain to pentest").option("--profile <id>", "Pentest profile ID (alternative to --target)").option("--mode <mode>", "Pentest mode: full, quick, htb, defcon, or exploit-only", "full").option("--flag <prefix>", "CTF flag prefix (defcon mode only, default HTB)").option("--context <text>", "CTF focus hint (defcon mode only)").option("-y, --yes", "Accept authorization terms without prompting").option("--no-watch", "Skip auto-watch after starting the pentest").option("--deployment <type>", "Deployment type: cloud or on-prem", "cloud").option("--include <hosts>", "Include only these subdomains (comma-separated)").option("--exclude <hosts>", "Exclude these subdomains (comma-separated)").option("--scope <action>", "Scope action: clear").option("--source <path>", "Graybox source handout: a directory, an archive (.zip/.tar/.tgz), or a pre-made bundle .txt. Its presence enables graybox").option("--budget <tokens>", "Token budget for packing --source (how much source to pack; larger = more context and cost)", String(DEFAULT_SOURCE_BUDGET)).option("--zip-password <pw>", "Password for an encrypted --source .zip (classic ZipCrypto only; HTB handouts use: hackthebox)").option("--force", "Proceed even when the --source stack looks unlike the live target").addHelpText("after", `
12904
+ const pentest = program3.command("pentest").description("Run and manage pentests").option("-t, --target <domain>", "Target domain to pentest").option("--profile <id>", "Pentest profile ID (alternative to --target)").option("--mode <mode>", "Pentest mode: full, quick, htb, defcon, or exploit-only (default: full; a --profile rerun keeps its saved mode)").option("--flag <prefix>", "CTF flag prefix (defcon mode only, default HTB)").option("--context <text>", "CTF focus hint (defcon mode only)").option("-y, --yes", "Accept authorization terms without prompting").option("--no-watch", "Skip auto-watch after starting the pentest").option("--deployment <type>", "Deployment type: cloud or on-prem", "cloud").option("--include <hosts>", "Include only these subdomains (comma-separated)").option("--exclude <hosts>", "Exclude these subdomains (comma-separated)").option("--scope <action>", "Scope action: clear").option("--source <path>", "Graybox source handout: a directory, an archive (.zip/.tar/.tgz), or a pre-made bundle .txt. Its presence enables graybox").option("--budget <tokens>", "Token budget for packing --source (how much source to pack; larger = more context and cost)", String(DEFAULT_SOURCE_BUDGET)).option("--zip-password <pw>", "Password for an encrypted --source .zip (classic ZipCrypto only; HTB handouts use: hackthebox)").option("--force", "Proceed even when the --source stack looks unlike the live target").addHelpText("after", `
12863
12905
  Examples:
12864
12906
  $ kinosec pentest -t delikado.org
12865
12907
  $ kinosec pentest --profile abc123
12908
+ $ kinosec pentest --profile abc123 --mode quick
12866
12909
  $ kinosec pentest -t internal.corp --deployment on-prem
12867
12910
  $ kinosec pentest -t example.com --include api.example.com,app.example.com
12868
12911
  $ kinosec pentest -t example.com --exclude staging.example.com
@@ -12886,7 +12929,7 @@ Graybox (--source):
12886
12929
  console.error("Invalid deployment type. Must be: cloud or on-prem.");
12887
12930
  process.exit(2);
12888
12931
  }
12889
- validateScanMode(options.mode);
12932
+ if (options.mode !== void 0) validateScanMode(options.mode);
12890
12933
  let sourceBudget = DEFAULT_SOURCE_BUDGET;
12891
12934
  if (options.source) {
12892
12935
  sourceBudget = parseInt(options.budget, 10);
@@ -12929,7 +12972,11 @@ Graybox (--source):
12929
12972
  if (options.profile) {
12930
12973
  const detail = await client.scans.get(options.profile);
12931
12974
  targetDisplay = detail.domain_or_ip;
12932
- params = { scan_profile_id: options.profile };
12975
+ const profileParams = { scan_profile_id: options.profile };
12976
+ if (options.mode !== void 0) {
12977
+ profileParams.scan_mode = options.mode;
12978
+ }
12979
+ params = profileParams;
12933
12980
  } else {
12934
12981
  const parsed = parseTarget(options.target);
12935
12982
  for (const warning2 of parsed.warnings) {
@@ -12960,7 +13007,7 @@ Graybox (--source):
12960
13007
  targetDisplay = parsed.hostname;
12961
13008
  const assetParams = {
12962
13009
  asset_id: asset.id,
12963
- scan_mode: options.mode
13010
+ scan_mode: options.mode ?? "full"
12964
13011
  };
12965
13012
  if (options.mode === "defcon") {
12966
13013
  assetParams.flag_format = options.flag ?? "HTB";
@@ -13104,17 +13151,36 @@ ${divider}
13104
13151
  }
13105
13152
  });
13106
13153
  registerScheduleCommand(pentest);
13107
- pentest.command("list").description("List pentests").option("--limit <n>", "Max results per page", "20").option("--cursor <cursor>", "Pagination cursor").addHelpText("after", `
13154
+ pentest.command("list").description("List pentests").option("--limit <n>", "Max results per page", "20").option("--cursor <cursor>", "Pagination cursor").option("--status <status>", `Only pentests with this status: ${Object.keys(STATUS_FILTERS).join(", ")}`).option("-t, --target <host>", "Only pentests of this exact host").addHelpText("after", `
13108
13155
  Examples:
13109
13156
  $ kinosec pentest list
13110
- $ kinosec pentest list --limit 5 --json`).action(async (options) => {
13157
+ $ kinosec pentest list --limit 5 --json
13158
+ $ kinosec pentest list --status failed
13159
+ $ kinosec pentest list -t example.com`).action(async (options) => {
13160
+ const status = options.status;
13161
+ if (status !== void 0 && !isStatusFilter(status)) {
13162
+ console.error(`Invalid --status "${status}". Must be one of: ${Object.keys(STATUS_FILTERS).join(", ")}.`);
13163
+ process.exit(2);
13164
+ }
13165
+ const typedTarget = pentest.opts().target ?? options.target;
13166
+ let target;
13167
+ if (typedTarget !== void 0) {
13168
+ try {
13169
+ target = parseTarget(typedTarget).hostname;
13170
+ } catch (error2) {
13171
+ console.error(error2 instanceof Error ? error2.message : String(error2));
13172
+ process.exit(2);
13173
+ }
13174
+ }
13111
13175
  try {
13112
13176
  const opts = program3.opts();
13113
13177
  const { token, apiUrl } = resolveToken(opts);
13114
13178
  const client = new KinoSecClient({ apiUrl, token });
13115
13179
  const result = await client.scans.list({
13116
13180
  limit: parseInt(options.limit, 10),
13117
- cursor: options.cursor
13181
+ cursor: options.cursor,
13182
+ status: status === void 0 ? void 0 : STATUS_FILTERS[status],
13183
+ domain_or_ip: target
13118
13184
  });
13119
13185
  if (opts.json || opts.output === "json") {
13120
13186
  printJson(result);
@@ -13135,8 +13201,9 @@ Examples:
13135
13201
  ])
13136
13202
  );
13137
13203
  if (result.next_cursor) {
13204
+ const filters = `${status ? ` --status ${status}` : ""}${target ? ` --target ${target}` : ""}`;
13138
13205
  console.log(import_picocolors8.default.dim(`
13139
- More results available. Use --cursor ${result.next_cursor}`));
13206
+ More results available: kinosec pentest list${filters} --cursor ${result.next_cursor}`));
13140
13207
  }
13141
13208
  } catch (error2) {
13142
13209
  handleError(error2);
@@ -13169,6 +13236,10 @@ Examples:
13169
13236
  ["Updated", detail.updated_at]
13170
13237
  ]
13171
13238
  );
13239
+ if (detail.scan_status === "Failed") {
13240
+ console.log(`
13241
+ Failure: ${failureSentence(detail.failure_reason)}`);
13242
+ }
13172
13243
  } catch (error2) {
13173
13244
  handleError(error2);
13174
13245
  }
@@ -13544,6 +13615,200 @@ Examples:
13544
13615
  });
13545
13616
  }
13546
13617
 
13618
+ // src/lib/asset-import.ts
13619
+ import { createReadStream } from "fs";
13620
+
13621
+ // src/lib/asset-value.ts
13622
+ var REASON_TEXT = {
13623
+ empty: "Empty",
13624
+ too_long: "Too long",
13625
+ non_ascii_host: "Non-ASCII characters are not supported",
13626
+ invalid_port: "Invalid port",
13627
+ invalid_ip: "Invalid IP address",
13628
+ malformed_host: "Not a valid host",
13629
+ apex_public_suffix: "Public suffix, add a full domain",
13630
+ network_range: "Network ranges aren't supported by asset import"
13631
+ };
13632
+ var MAX_HOST_LENGTH = 253;
13633
+ var MAX_LABEL_LENGTH = 63;
13634
+ var INVISIBLE_CHARS_RE = /[\u200B-\u200D\u2060\uFEFF\u00AD\u202A-\u202E\u2066-\u2069\u00A0]/g;
13635
+ var IPV4_STRICT_RE = /^(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)(?:\.(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]\d|\d)){3}$/;
13636
+ var HOSTNAME_RE2 = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$/;
13637
+ var RANGE_RE = /^\d{1,3}(?:\.\d{1,3}){3}\/\d/;
13638
+ function splitValues(text) {
13639
+ return text.split(/\r\n|\r|\n/).flatMap(
13640
+ (lineText, index) => lineText.split(/[,;\s]+/).filter((raw) => raw.length > 0).map((raw) => ({ raw, line: index + 1 }))
13641
+ );
13642
+ }
13643
+ function clean(raw) {
13644
+ return raw.replace(INVISIBLE_CHARS_RE, "").normalize("NFC").trim().replace(/^https?:\/\//i, "");
13645
+ }
13646
+ function normalize2(raw) {
13647
+ const withoutScheme = clean(raw);
13648
+ const slash = withoutScheme.indexOf("/");
13649
+ const host = slash === -1 ? withoutScheme : withoutScheme.slice(0, slash);
13650
+ return dropTrailingDots(host.toLowerCase()).trim();
13651
+ }
13652
+ function dropTrailingDots(text) {
13653
+ let end = text.length;
13654
+ while (end > 0 && text[end - 1] === ".") end--;
13655
+ return text.slice(0, end);
13656
+ }
13657
+ function invalid(reason) {
13658
+ return { valid: false, reason };
13659
+ }
13660
+ function isTooLong(value) {
13661
+ const host = value.replace(/:\d+$/, "");
13662
+ return host.length > MAX_HOST_LENGTH || host.split(".").some((label) => label.length > MAX_LABEL_LENGTH);
13663
+ }
13664
+ function checkValue(raw) {
13665
+ if (RANGE_RE.test(clean(raw))) return invalid("network_range");
13666
+ const value = normalize2(raw);
13667
+ if (value.length === 0) return invalid("empty");
13668
+ if (isTooLong(value)) return invalid("too_long");
13669
+ if (/[\u0080-\uFFFF]/.test(value)) return invalid("non_ascii_host");
13670
+ const colons = value.split(":").length - 1;
13671
+ if (colons >= 2) return invalid("invalid_ip");
13672
+ let host = value;
13673
+ if (colons === 1) {
13674
+ const at = value.lastIndexOf(":");
13675
+ const port = value.slice(at + 1);
13676
+ if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) return invalid("invalid_port");
13677
+ host = value.slice(0, at);
13678
+ }
13679
+ if (/^[\d.]+$/.test(host)) {
13680
+ return IPV4_STRICT_RE.test(host) ? { valid: true, value, type: "ip" } : invalid("invalid_ip");
13681
+ }
13682
+ if (!HOSTNAME_RE2.test(host)) return invalid("malformed_host");
13683
+ if (MULTI_LEVEL_TLDS.has(host) || isPublicSuffix(host)) return invalid("apex_public_suffix");
13684
+ return { valid: true, value, type: detectAssetType(value) };
13685
+ }
13686
+
13687
+ // src/lib/asset-import.ts
13688
+ var MAX_BYTES = 1048576;
13689
+ var MAX_VALUES = 1e4;
13690
+ var PAGE_SIZE = 100;
13691
+ function exitWithInputError(message) {
13692
+ console.error(message);
13693
+ process.exit(2);
13694
+ }
13695
+ function readFailure(error2) {
13696
+ if (error2 instanceof Error && "code" in error2) {
13697
+ if (error2.code === "ENOENT") return "no such file";
13698
+ if (error2.code === "EACCES") return "permission denied";
13699
+ if (error2.code === "EISDIR") return "it is a folder";
13700
+ }
13701
+ return error2 instanceof Error ? error2.message : String(error2);
13702
+ }
13703
+ async function readCapped(source) {
13704
+ const chunks = [];
13705
+ let size = 0;
13706
+ for await (const chunk of source) {
13707
+ const part = chunk;
13708
+ const buffer = Buffer.isBuffer(part) ? part : Buffer.from(String(part));
13709
+ chunks.push(buffer);
13710
+ size += buffer.length;
13711
+ if (size > MAX_BYTES) break;
13712
+ }
13713
+ return Buffer.concat(chunks);
13714
+ }
13715
+ function isUtf16(bytes) {
13716
+ return bytes.length >= 2 && (bytes[0] === 255 && bytes[1] === 254 || bytes[0] === 254 && bytes[1] === 255);
13717
+ }
13718
+ async function readImportValues(file) {
13719
+ const name = file === "-" ? "stdin" : file;
13720
+ let bytes;
13721
+ try {
13722
+ bytes = await readCapped(file === "-" ? process.stdin : createReadStream(file, { end: MAX_BYTES }));
13723
+ } catch (error2) {
13724
+ exitWithInputError(`Cannot read ${name}: ${readFailure(error2)}.`);
13725
+ }
13726
+ if (bytes.length > MAX_BYTES) exitWithInputError(`${name} is larger than 1 MB.`);
13727
+ if (isUtf16(bytes)) exitWithInputError(`${name} is UTF-16. Save it as UTF-8 and try again.`);
13728
+ const values = splitValues(new TextDecoder("utf-8").decode(bytes));
13729
+ if (values.length > MAX_VALUES) {
13730
+ exitWithInputError(`${name} has ${values.length.toLocaleString("en-US")} values. The limit is 10,000.`);
13731
+ }
13732
+ if (values.length === 0) exitWithInputError(`${name} has no values to import.`);
13733
+ return values;
13734
+ }
13735
+ async function orgAssetValues(client) {
13736
+ const values = /* @__PURE__ */ new Set();
13737
+ let cursor;
13738
+ do {
13739
+ const page = await client.assets.list({ limit: PAGE_SIZE, cursor });
13740
+ for (const item of page.items) values.add(item.asset_value);
13741
+ cursor = page.next_cursor ?? void 0;
13742
+ } while (cursor);
13743
+ return values;
13744
+ }
13745
+ function asError(error2) {
13746
+ return error2 instanceof Error ? error2 : new Error(String(error2));
13747
+ }
13748
+ function isAuthError(error2) {
13749
+ return error2 instanceof ApiError && (error2.status === 401 || error2.status === 403);
13750
+ }
13751
+ async function importOne(job) {
13752
+ const { client, input: { raw, line }, seen, existing } = job;
13753
+ const check = checkValue(raw);
13754
+ if (!check.valid) return { kind: "invalid", line, value: raw, reason: check.reason };
13755
+ const { value, type } = check;
13756
+ if (seen.has(value)) return { kind: "skipped", line, value, reason: "repeated" };
13757
+ seen.add(value);
13758
+ if (existing.has(value)) return { kind: "skipped", line, value, reason: "exists" };
13759
+ try {
13760
+ const asset = await client.assets.create({
13761
+ asset_type: type,
13762
+ asset_value: value,
13763
+ parent_domain: deriveParentDomain(value),
13764
+ verification_method: "dns"
13765
+ });
13766
+ return { kind: "created", line, value, id: asset.id };
13767
+ } catch (error2) {
13768
+ if (error2 instanceof ApiError && error2.code === "asset_already_exists") {
13769
+ return { kind: "skipped", line, value, reason: "exists" };
13770
+ }
13771
+ if (isAuthError(error2) || !(error2 instanceof ApiError)) throw error2;
13772
+ return { kind: "failed", line, value, error: asError(error2).message };
13773
+ }
13774
+ }
13775
+ function record(result, entry) {
13776
+ if (entry.kind === "created") result.created.push({ line: entry.line, value: entry.value, id: entry.id });
13777
+ if (entry.kind === "skipped") result.skipped.push({ line: entry.line, value: entry.value, reason: entry.reason });
13778
+ if (entry.kind === "invalid") result.invalid.push({ line: entry.line, value: entry.value, reason: entry.reason });
13779
+ if (entry.kind === "failed") result.failed.push({ line: entry.line, value: entry.value, error: entry.error });
13780
+ }
13781
+ async function importAssets(client, options) {
13782
+ const result = { created: [], skipped: [], invalid: [], failed: [] };
13783
+ const seen = /* @__PURE__ */ new Set();
13784
+ for (const input of options.values) {
13785
+ let entry;
13786
+ try {
13787
+ entry = await importOne({ client, input, seen, existing: options.existing });
13788
+ } catch (error2) {
13789
+ return { result, stopError: asError(error2) };
13790
+ }
13791
+ record(result, entry);
13792
+ options.onEntry?.(entry);
13793
+ }
13794
+ return { result, stopError: null };
13795
+ }
13796
+ function printable(text) {
13797
+ return text.replace(/\p{Cc}/gu, "?");
13798
+ }
13799
+ function entryLine(entry) {
13800
+ if (entry.kind === "created") return `Created ${entry.value}`;
13801
+ if (entry.kind === "invalid") {
13802
+ return `Invalid ${printable(entry.value)} (line ${entry.line}): ${REASON_TEXT[entry.reason]}`;
13803
+ }
13804
+ if (entry.kind === "failed") return `Failed ${entry.value}: ${entry.error}`;
13805
+ return entry.reason === "exists" ? `Skipped ${entry.value}: already exists` : `Skipped ${entry.value} (line ${entry.line}): repeated in file`;
13806
+ }
13807
+ function summaryLine(result) {
13808
+ const { created, skipped, invalid: invalid2, failed } = result;
13809
+ return `Created ${created.length}, skipped ${skipped.length}, invalid ${invalid2.length}, failed ${failed.length}.`;
13810
+ }
13811
+
13547
13812
  // src/commands/asset.ts
13548
13813
  function registerAssetCommand(program3) {
13549
13814
  const asset = program3.command("asset").description("Manage assets");
@@ -13659,6 +13924,42 @@ Verify: kinosec asset verify ${result.id}`);
13659
13924
  handleError(error2);
13660
13925
  }
13661
13926
  });
13927
+ asset.command("import").description("Add many assets from a text or CSV file").argument("<file>", "File to read, or - to read stdin").addHelpText("after", `
13928
+ Each value is a domain, subdomain or IPv4 address; its type is detected. A
13929
+ :port (example.com:8443) is kept as part of the value. Separate values with
13930
+ newlines, commas, semicolons or spaces. Network ranges are not supported.
13931
+ Limits: UTF-8, 1 MB and 10,000 values per file.
13932
+
13933
+ Values already in your organization are skipped. Assets are created one
13934
+ request at a time, so a long file takes a while. Safe to rerun: values added
13935
+ by an earlier run show as "already exists".
13936
+
13937
+ Token permissions needed: assets:read, assets:write.
13938
+
13939
+ Examples:
13940
+ $ kinosec asset import assets.txt
13941
+ $ cat assets.csv | kinosec asset import -`).action(async (file) => {
13942
+ const values = await readImportValues(file);
13943
+ const opts = program3.opts();
13944
+ const useJson = opts.json || opts.output === "json";
13945
+ let outcome;
13946
+ try {
13947
+ const { token, apiUrl } = resolveToken(opts);
13948
+ const client = new KinoSecClient({ apiUrl, token });
13949
+ const existing = await orgAssetValues(client);
13950
+ outcome = await importAssets(client, {
13951
+ values,
13952
+ existing,
13953
+ onEntry: useJson ? void 0 : (entry) => console.log(entryLine(entry))
13954
+ });
13955
+ } catch (error2) {
13956
+ handleError(error2);
13957
+ }
13958
+ if (useJson) printJson(outcome.result);
13959
+ else console.log(summaryLine(outcome.result));
13960
+ if (outcome.stopError) handleError(outcome.stopError);
13961
+ if (outcome.result.invalid.length > 0 || outcome.result.failed.length > 0) process.exit(1);
13962
+ });
13662
13963
  asset.command("delete").description("Delete an asset").argument("<id>", "Asset ID").option("-y, --yes", "Skip confirmation prompt").addHelpText("after", `
13663
13964
  Examples:
13664
13965
  $ kinosec asset delete abc123
@@ -13789,12 +14090,39 @@ function formatPoc(poc) {
13789
14090
  return json === "{}" || json === "[]" ? null : json;
13790
14091
  }
13791
14092
  var VALID_STATUSES = ["open", "fixed", "false_positive", "accepted_risk", "resolved"];
14093
+ var SEVERITY_FILTERS = ["critical", "high", "medium", "low", "info"];
14094
+ var STATUS_FILTERS2 = [
14095
+ "open",
14096
+ "fixed",
14097
+ "false_positive",
14098
+ "accepted_risk",
14099
+ "investigating",
14100
+ "wont_fix",
14101
+ "resolved"
14102
+ ];
14103
+ function isSeverityFilter(value) {
14104
+ return SEVERITY_FILTERS.some((level) => level === value);
14105
+ }
14106
+ function isStatusFilter2(value) {
14107
+ return STATUS_FILTERS2.some((status) => status === value);
14108
+ }
13792
14109
  function registerFindingCommand(program3) {
13793
14110
  const finding = program3.command("finding").description("Manage findings");
13794
- finding.command("list").description("List findings").argument("[profile-id]", "Pentest profile ID").option("--limit <n>", "Max results per page", "20").option("--cursor <cursor>", "Pagination cursor").addHelpText("after", `
14111
+ finding.command("list").description("List findings").argument("[profile-id]", "Pentest profile ID").option("--limit <n>", "Max results per page", "20").option("--cursor <cursor>", "Pagination cursor").option("--severity <level>", `Only findings of this severity: ${SEVERITY_FILTERS.join(", ")}`).option("--status <status>", `Only findings with this status: ${STATUS_FILTERS2.join(", ")}`).addHelpText("after", `
13795
14112
  Examples:
13796
14113
  $ kinosec finding list a1b2c3d4-0000-0000-0000-000000000001
14114
+ $ kinosec finding list a1b2c3d4-0000-0000-0000-000000000001 --severity critical --status open
13797
14115
  $ kinosec finding list --limit 50 --json`).action(async (profileId, options) => {
14116
+ const severity = options.severity;
14117
+ if (severity !== void 0 && !isSeverityFilter(severity)) {
14118
+ console.error(`Invalid --severity "${severity}". Must be one of: ${SEVERITY_FILTERS.join(", ")}.`);
14119
+ process.exit(2);
14120
+ }
14121
+ const status = options.status;
14122
+ if (status !== void 0 && !isStatusFilter2(status)) {
14123
+ console.error(`Invalid --status "${status}". Must be one of: ${STATUS_FILTERS2.join(", ")}.`);
14124
+ process.exit(2);
14125
+ }
13798
14126
  try {
13799
14127
  const opts = program3.opts();
13800
14128
  const { token, apiUrl } = resolveToken(opts);
@@ -13802,7 +14130,9 @@ Examples:
13802
14130
  const result = await client.findings.list({
13803
14131
  limit: parseInt(options.limit, 10),
13804
14132
  cursor: options.cursor,
13805
- scan_profile_id: profileId
14133
+ scan_profile_id: profileId,
14134
+ impact_level: severity,
14135
+ status
13806
14136
  });
13807
14137
  if (opts.json || opts.output === "json") {
13808
14138
  printJson(result);
@@ -13822,8 +14152,9 @@ Examples:
13822
14152
  ])
13823
14153
  );
13824
14154
  if (result.next_cursor) {
14155
+ const scope = `${profileId ? ` ${profileId}` : ""}${severity ? ` --severity ${severity}` : ""}${status ? ` --status ${status}` : ""}`;
13825
14156
  console.log(import_picocolors11.default.dim(`
13826
- More results available. Use --cursor ${result.next_cursor}`));
14157
+ More results available: kinosec finding list${scope} --cursor ${result.next_cursor}`));
13827
14158
  }
13828
14159
  } catch (error2) {
13829
14160
  handleError(error2);
@@ -13911,69 +14242,163 @@ Examples:
13911
14242
 
13912
14243
  // src/commands/report.ts
13913
14244
  var import_picocolors12 = __toESM(require_picocolors(), 1);
13914
- function renderMarkdown(data) {
13915
- const { scan, findings } = data;
13916
- const lines = [];
13917
- lines.push(`# Security Report: ${scan.domain_or_ip}`);
13918
- lines.push("");
13919
- lines.push(`**Report:** ${scan.report_name}`);
13920
- lines.push(`**Scan Mode:** ${scan.scan_mode}`);
13921
- lines.push(`**Completed:** ${scan.scan_completed_at}`);
13922
- lines.push("");
13923
- if (scan.severity_counts && Object.keys(scan.severity_counts).length > 0) {
13924
- lines.push("## Severity Summary");
13925
- lines.push("");
13926
- lines.push("| Severity | Count |");
13927
- lines.push("|----------|-------|");
13928
- for (const [severity, count] of Object.entries(scan.severity_counts)) {
13929
- lines.push(`| ${severity} | ${count} |`);
13930
- }
13931
- lines.push("");
13932
- }
13933
- if (findings.length > 0) {
13934
- lines.push("## Findings");
13935
- lines.push("");
13936
- for (let i = 0; i < findings.length; i++) {
13937
- const f = findings[i];
13938
- lines.push(`### ${i + 1}. ${f.finding_name} (${f.impact_level})`);
13939
- lines.push("");
13940
- if (f.finding_description) {
13941
- lines.push(`**Description:** ${f.finding_description}`);
13942
- lines.push("");
13943
- }
13944
- if (f.finding_solution) {
13945
- lines.push(`**Solution:** ${f.finding_solution}`);
13946
- lines.push("");
13947
- }
13948
- }
13949
- } else {
13950
- lines.push("## Findings");
13951
- lines.push("");
13952
- lines.push("No findings.");
13953
- lines.push("");
14245
+
14246
+ // src/lib/report-summary.ts
14247
+ var POSTURES = ["critical", "high", "medium", "low", "secure", "indeterminate"];
14248
+ var NOT_FINAL_FALLBACK = "Some findings from this scan could not be saved, so this report is not a complete assessment. Re-run the scan for accurate results \u2014 a clean result here does not mean the target is secure.";
14249
+ var PARTIAL_FALLBACK = "This scan did not fully complete, so some findings may be missing. The findings in this report are real \u2014 re-run the scan for complete coverage.";
14250
+ function indented(text) {
14251
+ return text.trim().split("\n").map((line) => ` ${line}`);
14252
+ }
14253
+ function postureLine(summary) {
14254
+ const posture = summary.security_posture;
14255
+ if (posture === void 0) return [];
14256
+ if (summary.incomplete && posture === "secure") return ["Security posture: indeterminate"];
14257
+ return [`Security posture: ${POSTURES.includes(posture) ? posture : "unknown"}`];
14258
+ }
14259
+ function incompleteLines(summary) {
14260
+ if (!summary.incomplete) return [];
14261
+ if (summary.security_posture === "indeterminate") {
14262
+ return ["Scan incomplete: results not final.", summary.incomplete_reason || NOT_FINAL_FALLBACK];
14263
+ }
14264
+ return ["Results may be partial.", summary.incomplete_reason || PARTIAL_FALLBACK];
14265
+ }
14266
+ function summaryLines(summary) {
14267
+ if (!summary) return ["No executive summary for this report."];
14268
+ const sections = [
14269
+ [...postureLine(summary), ...incompleteLines(summary)],
14270
+ summary.executive_summary ? ["Executive summary:", ...indented(summary.executive_summary)] : [],
14271
+ summary.key_concerns?.length ? ["Key concerns:", ...summary.key_concerns.map((c) => ` - ${c}`)] : [],
14272
+ summary.immediate_actions ? ["Immediate actions:", ...indented(summary.immediate_actions)] : []
14273
+ ].filter((section) => section.length > 0);
14274
+ if (sections.length === 0) return ["No executive summary for this report."];
14275
+ return sections.flatMap((section, i) => i === 0 ? section : ["", ...section]);
14276
+ }
14277
+
14278
+ // src/lib/uuid.ts
14279
+ var UUID_RE2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14280
+ function isUuid(value) {
14281
+ return UUID_RE2.test(value);
14282
+ }
14283
+
14284
+ // src/lib/export-reports.ts
14285
+ import { existsSync as existsSync4 } from "fs";
14286
+ import { mkdir, rename, writeFile } from "fs/promises";
14287
+ import { join as join4 } from "path";
14288
+ var EXPORT_FORMATS = ["json", "markdown"];
14289
+ var EXTENSIONS = { json: "json", markdown: "md" };
14290
+ var PAGE_SIZE2 = 100;
14291
+ function asError2(error2) {
14292
+ return error2 instanceof Error ? error2 : new Error(String(error2));
14293
+ }
14294
+ function isAuthError2(error2) {
14295
+ return error2 instanceof ApiError && (error2.status === 401 || error2.status === 403);
14296
+ }
14297
+ async function saveReport(client, id, format2, path) {
14298
+ const content = format2 === "markdown" ? await client.reports.exportMarkdown(id) : JSON.stringify(await client.reports.export({ scan_report_id: id, format: "json" }), null, 2) + "\n";
14299
+ const partial = `${path}.partial`;
14300
+ await writeFile(partial, content);
14301
+ await rename(partial, path);
14302
+ }
14303
+ async function exportOne(job) {
14304
+ const { client, options, id, isNewest } = job;
14305
+ if (!isUuid(id)) return { kind: "failed", id, error: "not a valid report ID" };
14306
+ const path = join4(options.outDir, `${id}.${EXTENSIONS[options.format]}`);
14307
+ if (options.skipNewest && isNewest) return { kind: "skipped", id, reason: "running" };
14308
+ if (!options.overwrite && existsSync4(path)) return { kind: "skipped", id, reason: "exists" };
14309
+ try {
14310
+ await saveReport(client, id, options.format, path);
14311
+ return { kind: "written", id, path };
14312
+ } catch (error2) {
14313
+ if (isAuthError2(error2)) throw error2;
14314
+ return { kind: "failed", id, error: asError2(error2).message };
14315
+ }
14316
+ }
14317
+ function record2(result, entry) {
14318
+ if (entry.kind === "written") result.written.push({ id: entry.id, path: entry.path });
14319
+ if (entry.kind === "skipped") result.skipped.push({ id: entry.id, reason: entry.reason });
14320
+ if (entry.kind === "failed") result.failed.push({ id: entry.id, error: entry.error });
14321
+ }
14322
+ async function exportReports(client, options) {
14323
+ const result = { written: [], skipped: [], failed: [] };
14324
+ try {
14325
+ await mkdir(options.outDir, { recursive: true });
14326
+ let cursor;
14327
+ let isNewest = true;
14328
+ do {
14329
+ const page = await client.reports.list({ scan_profile_id: options.profileId, limit: PAGE_SIZE2, cursor });
14330
+ for (const { id } of page.items) {
14331
+ const entry = await exportOne({ client, options, id, isNewest });
14332
+ isNewest = false;
14333
+ record2(result, entry);
14334
+ options.onEntry?.(entry);
14335
+ }
14336
+ cursor = page.next_cursor ?? void 0;
14337
+ } while (cursor);
14338
+ } catch (error2) {
14339
+ return { result, stopError: asError2(error2) };
14340
+ }
14341
+ return { result, stopError: null };
14342
+ }
14343
+ function entryLine2(entry) {
14344
+ if (entry.kind === "written") return `Wrote ${entry.path}`;
14345
+ if (entry.kind === "failed") return `Failed ${entry.id}: ${entry.error}`;
14346
+ return entry.reason === "exists" ? `Skipped ${entry.id}: already exists` : `Skipped ${entry.id}: pentest still running`;
14347
+ }
14348
+ function summaryLine2(result) {
14349
+ return `Exported ${result.written.length}, skipped ${result.skipped.length}, failed ${result.failed.length}.`;
14350
+ }
14351
+
14352
+ // src/commands/report.ts
14353
+ var RUNNING_STATUSES = ["Running...", "Stopping..."];
14354
+ var NO_SCANS_SCOPE_NOTE = "Can't tell whether this pentest is running: this token lacks scans:read.";
14355
+ function exitOnBadPentestId(value) {
14356
+ if (isUuid(value)) return;
14357
+ console.error(`Pentest ID must be a full UUID, got "${value}".`);
14358
+ process.exit(2);
14359
+ }
14360
+ function exitOnBadFormat(value) {
14361
+ if (EXPORT_FORMATS.some((f) => f === value)) return;
14362
+ console.error(`Invalid format "${value}". Valid: ${EXPORT_FORMATS.join(", ")}`);
14363
+ process.exit(2);
14364
+ }
14365
+ function isRunning(scanStatus) {
14366
+ return RUNNING_STATUSES.includes(scanStatus);
14367
+ }
14368
+ async function pentestRunState(client, profileId) {
14369
+ try {
14370
+ const scan = await client.scans.get(profileId);
14371
+ return isRunning(scan.scan_status) ? "running" : "idle";
14372
+ } catch (error2) {
14373
+ if (error2 instanceof ApiError && error2.status === 403) return "unknown";
14374
+ throw error2;
13954
14375
  }
13955
- return lines.join("\n");
13956
14376
  }
13957
14377
  function registerReportCommand(program3) {
13958
14378
  const report = program3.command("report").description("Manage reports");
13959
- report.command("list").description("List reports").option("--limit <n>", "Max results per page", "20").option("--cursor <cursor>", "Pagination cursor").addHelpText("after", `
14379
+ report.command("list").description("List reports").argument("[profile-id]", "Only this pentest's reports (pentest profile ID)").option("--limit <n>", "Max results per page", "20").option("--cursor <cursor>", "Pagination cursor").addHelpText("after", `
13960
14380
  Examples:
13961
14381
  $ kinosec report list
13962
- $ kinosec report list --limit 10 --json`).action(async (options) => {
14382
+ $ kinosec report list 550e8400-e29b-41d4-a716-446655440000
14383
+ $ kinosec report list --limit 10 --json`).action(async (profileId, options) => {
14384
+ if (profileId !== void 0) exitOnBadPentestId(profileId);
13963
14385
  try {
13964
14386
  const opts = program3.opts();
13965
14387
  const { token, apiUrl } = resolveToken(opts);
13966
14388
  const client = new KinoSecClient({ apiUrl, token });
13967
14389
  const result = await client.reports.list({
13968
14390
  limit: parseInt(options.limit, 10),
13969
- cursor: options.cursor
14391
+ cursor: options.cursor,
14392
+ scan_profile_id: profileId
13970
14393
  });
13971
14394
  if (opts.json || opts.output === "json") {
13972
14395
  printJson(result);
13973
14396
  return;
13974
14397
  }
14398
+ const runState = profileId ? await pentestRunState(client, profileId) : "idle";
13975
14399
  if (result.items.length === 0) {
13976
- console.log("No reports found.");
14400
+ console.log(runState === "running" ? "No reports yet. This pentest is still running." : "No reports found.");
14401
+ if (runState === "unknown") console.log(NO_SCANS_SCOPE_NOTE);
13977
14402
  return;
13978
14403
  }
13979
14404
  printTable(
@@ -13983,11 +14408,17 @@ Examples:
13983
14408
  truncate(r.domain_or_ip, 30),
13984
14409
  r.scan_mode,
13985
14410
  formatFindings(r.severity_counts),
13986
- r.scan_completed_at ? new Date(r.scan_completed_at).toLocaleDateString() : "(pending)"
14411
+ r.scan_completed_at ? new Date(r.scan_completed_at).toLocaleDateString() : "-"
13987
14412
  ])
13988
14413
  );
14414
+ if (runState === "running") {
14415
+ console.log("\nThis pentest is running. Its newest report may still be filling in.");
14416
+ }
14417
+ if (runState === "unknown") console.log(`
14418
+ ${NO_SCANS_SCOPE_NOTE}`);
13989
14419
  if (result.next_cursor) {
13990
- console.log(import_picocolors12.default.dim(`
14420
+ console.log(import_picocolors12.default.dim(profileId ? `
14421
+ More results available: kinosec report list ${profileId} --cursor ${result.next_cursor}` : `
13991
14422
  More results available. Use --cursor ${result.next_cursor}`));
13992
14423
  }
13993
14424
  } catch (error2) {
@@ -14015,7 +14446,7 @@ Examples:
14015
14446
  ["Target", detail.domain_or_ip],
14016
14447
  ["Profile", detail.scan_profile_name],
14017
14448
  ["Mode", detail.scan_mode],
14018
- ["Completed", detail.scan_completed_at ?? "(pending)"],
14449
+ ["Completed", detail.scan_completed_at ?? "-"],
14019
14450
  ["Created", detail.created_at]
14020
14451
  ]
14021
14452
  );
@@ -14025,6 +14456,8 @@ Examples:
14025
14456
  console.log(` ${severity}: ${count}`);
14026
14457
  }
14027
14458
  }
14459
+ console.log();
14460
+ for (const line of summaryLines(detail.ai_summary)) console.log(line);
14028
14461
  } catch (error2) {
14029
14462
  handleError(error2);
14030
14463
  }
@@ -14033,27 +14466,61 @@ Examples:
14033
14466
  Examples:
14034
14467
  $ kinosec report export abc123 --format json > report.json
14035
14468
  $ kinosec report export abc123 --format markdown > report.md`).action(async (id, options) => {
14036
- const validFormats = ["json", "markdown"];
14037
- if (!validFormats.includes(options.format)) {
14038
- console.error(`Invalid format "${options.format}". Valid: ${validFormats.join(", ")}`);
14039
- process.exit(2);
14040
- }
14469
+ exitOnBadFormat(options.format);
14041
14470
  try {
14042
14471
  const opts = program3.opts();
14043
14472
  const { token, apiUrl } = resolveToken(opts);
14044
14473
  const client = new KinoSecClient({ apiUrl, token });
14045
- const result = await client.reports.export({
14046
- scan_report_id: id,
14047
- format: "json"
14048
- });
14049
14474
  if (options.format === "markdown" && !(opts.json || opts.output === "json")) {
14050
- process.stdout.write(renderMarkdown(result));
14051
- } else {
14052
- printJson(result);
14475
+ process.stdout.write(await client.reports.exportMarkdown(id));
14476
+ return;
14053
14477
  }
14478
+ printJson(await client.reports.export({ scan_report_id: id, format: "json" }));
14479
+ } catch (error2) {
14480
+ handleError(error2);
14481
+ }
14482
+ });
14483
+ report.command("export-all").description("Save every report of a pentest to a folder").argument("<profile-id>", "Pentest profile ID").option("--out <dir>", "Folder to save the reports in (created if missing)").option("--format <format>", "File format: json or markdown", "json").option("--overwrite", "Replace report files already in the folder").addHelpText("after", `
14484
+ Files are named <report-id>.json or <report-id>.md. Reports already saved in
14485
+ the folder are skipped unless --overwrite is given; other files are never
14486
+ touched. While the pentest runs, its newest report is skipped: run again later.
14487
+ Just after a pentest completes, its newest report can take about a minute to
14488
+ finish. If you exported it then, run again with --overwrite.
14489
+
14490
+ Token permissions needed: reports:read, reports:export, scans:read.
14491
+
14492
+ Examples:
14493
+ $ kinosec report export-all 550e8400-e29b-41d4-a716-446655440000 --out ./reports
14494
+ $ kinosec report export-all 550e8400-e29b-41d4-a716-446655440000 --out ./reports --format markdown`).action(async (profileId, options) => {
14495
+ exitOnBadPentestId(profileId);
14496
+ const format2 = options.format;
14497
+ exitOnBadFormat(format2);
14498
+ if (typeof options.out !== "string" || options.out.trim() === "") {
14499
+ console.error("--out <dir> is required.");
14500
+ process.exit(2);
14501
+ }
14502
+ const opts = program3.opts();
14503
+ const useJson = opts.json || opts.output === "json";
14504
+ let outcome;
14505
+ try {
14506
+ const { token, apiUrl } = resolveToken(opts);
14507
+ const client = new KinoSecClient({ apiUrl, token });
14508
+ const scan = await client.scans.get(profileId);
14509
+ outcome = await exportReports(client, {
14510
+ profileId,
14511
+ format: format2,
14512
+ outDir: options.out,
14513
+ overwrite: options.overwrite === true,
14514
+ skipNewest: isRunning(scan.scan_status),
14515
+ onEntry: useJson ? void 0 : (entry) => console.log(entryLine2(entry))
14516
+ });
14054
14517
  } catch (error2) {
14055
14518
  handleError(error2);
14056
14519
  }
14520
+ if (useJson) printJson(outcome.result);
14521
+ else console.log(summaryLine2(outcome.result));
14522
+ if (outcome.stopError) handleError(outcome.stopError);
14523
+ if (outcome.result.failed.length > 0) process.exit(1);
14057
14524
  });
14058
14525
  }
14059
14526
 
@@ -14161,7 +14628,7 @@ Examples:
14161
14628
 
14162
14629
  // src/cli.ts
14163
14630
  var program2 = new Command();
14164
- program2.name("kinosec").description("KinoSec CLI \u2014 run pentests from your terminal").version("0.11.2", "-v, --version").option("--token <pat>", "API token (overrides KINOSEC_TOKEN)").option("--output <format>", "Output format: text or json", "text").option("--json", "Shorthand for --output json").option("--no-color", "Disable colors");
14631
+ program2.name("kinosec").description("KinoSec CLI \u2014 run pentests from your terminal").version("0.12.0", "-v, --version").option("--token <pat>", "API token (overrides KINOSEC_TOKEN)").option("--output <format>", "Output format: text or json", "text").option("--json", "Shorthand for --output json").option("--no-color", "Disable colors");
14165
14632
  registerAuthCommand(program2);
14166
14633
  registerMeCommand(program2);
14167
14634
  registerCreditsCommand(program2);
@@ -14170,7 +14637,7 @@ registerAssetCommand(program2);
14170
14637
  registerFindingCommand(program2);
14171
14638
  registerReportCommand(program2);
14172
14639
  registerTokenCommand(program2);
14173
- program2.parseAsync(process.argv).then(() => checkForUpdate("0.11.2")).catch(handleError);
14640
+ program2.parseAsync(process.argv).then(() => checkForUpdate("0.12.0")).catch(handleError);
14174
14641
  /*! Bundled license information:
14175
14642
 
14176
14643
  ky/distribution/index.js:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kinosec/cli",
3
- "version": "0.11.2",
3
+ "version": "0.12.0",
4
4
  "description": "Official KinoSec command-line interface",
5
5
  "type": "module",
6
6
  "bin": {