@kinosec/cli 0.11.1 → 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.
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) {
@@ -8435,28 +8439,111 @@ ${import_picocolors3.default.dim("use of its services.")}
8435
8439
  // src/commands/schedule.ts
8436
8440
  var import_picocolors4 = __toESM(require_picocolors(), 1);
8437
8441
 
8438
- // src/lib/validate-mode.ts
8439
- var VALID_SCAN_MODES = ["full", "quick", "htb", "defcon", "exploit-only"];
8440
- function validateScanMode(mode) {
8441
- if (!VALID_SCAN_MODES.includes(mode)) {
8442
- console.error(`Invalid mode "${mode}". Must be one of: ${VALID_SCAN_MODES.join(", ")}.`);
8443
- process.exit(2);
8442
+ // src/lib/validate-schedule.ts
8443
+ var SCHEDULE_PATTERNS = ["once", "daily", "weekly", "biweekly", "monthly"];
8444
+ var SCHEDULE_MODES = ["quick", "full"];
8445
+ var TIME_OF_DAY = /^([01]?\d|2[0-3]):[0-5]\d$/;
8446
+ var DIGITS = /^\d+$/;
8447
+ var ONCE_AT = /^(\d{4})-(\d{2})-(\d{2})T([01]\d|2[0-3]):[0-5]\d(:[0-5]\d(\.\d{3})?)?(Z|[+-]([01]\d|2[0-3]):[0-5]\d)$/;
8448
+ function scheduleInputError(flags, now) {
8449
+ const { pattern, mode, at, time, timezone, dayOfWeek, dayOfMonth } = flags;
8450
+ if (pattern !== void 0 && !SCHEDULE_PATTERNS.includes(pattern)) {
8451
+ return `Invalid --pattern "${pattern}". Must be one of: ${SCHEDULE_PATTERNS.join(", ")}.`;
8452
+ }
8453
+ if (mode !== void 0 && !SCHEDULE_MODES.includes(mode)) {
8454
+ return `Invalid --mode "${mode}". Schedules support: ${SCHEDULE_MODES.join(", ")}.`;
8455
+ }
8456
+ if (at !== void 0) {
8457
+ const atError = onceAtError(at, now);
8458
+ if (atError) return atError;
8459
+ }
8460
+ if (time !== void 0 && !TIME_OF_DAY.test(time)) {
8461
+ return `Invalid --time "${time}". Use 24-hour HH:MM, e.g. 09:00.`;
8462
+ }
8463
+ if (timezone !== void 0 && !isKnownTimezone(timezone)) {
8464
+ return `Invalid --timezone "${timezone}". Use an IANA name, e.g. America/New_York.`;
8465
+ }
8466
+ if (dayOfWeek !== void 0 && !isWholeNumberIn(dayOfWeek, 0, 6)) {
8467
+ return `Invalid --day-of-week "${dayOfWeek}". Use a whole number 0-6 (0=Sunday).`;
8444
8468
  }
8469
+ if (dayOfMonth !== void 0 && !isWholeNumberIn(dayOfMonth, 1, 31)) {
8470
+ return `Invalid --day-of-month "${dayOfMonth}". Use a whole number 1-31.`;
8471
+ }
8472
+ if (pattern === void 0 && at !== void 0) return "--at requires --pattern once.";
8473
+ if (pattern === void 0) return null;
8474
+ return patternFitError(pattern, flags);
8475
+ }
8476
+ function patternFitError(pattern, flags) {
8477
+ if (pattern === "once" && flags.at === void 0) {
8478
+ return "--at is required for once schedules: YYYY-MM-DDTHH:MM followed by Z or \xB1HH:MM.";
8479
+ }
8480
+ if (pattern === "once" && flags.time !== void 0) {
8481
+ return "--time does not apply to once schedules; put the time in --at.";
8482
+ }
8483
+ if (pattern !== "once" && flags.at !== void 0) {
8484
+ return "--at only applies to once schedules.";
8485
+ }
8486
+ const isWeekly = pattern === "weekly" || pattern === "biweekly";
8487
+ if (isWeekly && flags.dayOfWeek === void 0) {
8488
+ return `--day-of-week is required for ${pattern} schedules.`;
8489
+ }
8490
+ if (!isWeekly && flags.dayOfWeek !== void 0) {
8491
+ return "--day-of-week only applies to weekly and biweekly schedules.";
8492
+ }
8493
+ if (pattern === "monthly" && flags.dayOfMonth === void 0) {
8494
+ return "--day-of-month is required for monthly schedules.";
8495
+ }
8496
+ if (pattern !== "monthly" && flags.dayOfMonth !== void 0) {
8497
+ return "--day-of-month only applies to monthly schedules.";
8498
+ }
8499
+ return null;
8500
+ }
8501
+ function onceAtError(at, now) {
8502
+ const match = ONCE_AT.exec(at);
8503
+ const ms = Date.parse(at);
8504
+ if (!match || Number.isNaN(ms) || !isCalendarDate(Number(match[1]), Number(match[2]), Number(match[3]))) {
8505
+ return `Invalid --at "${at}". Use YYYY-MM-DDTHH:MM followed by Z or \xB1HH:MM, on a real date.`;
8506
+ }
8507
+ if (ms <= now.getTime()) return `--at "${at}" is in the past. Pick a future time.`;
8508
+ return null;
8509
+ }
8510
+ function isCalendarDate(year, month, day) {
8511
+ const date = new Date(Date.UTC(year, month - 1, day));
8512
+ return date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
8513
+ }
8514
+ function isKnownTimezone(timezone) {
8515
+ try {
8516
+ new Intl.DateTimeFormat("en-US", { timeZone: timezone });
8517
+ return true;
8518
+ } catch (error2) {
8519
+ if (error2 instanceof RangeError) return false;
8520
+ throw error2;
8521
+ }
8522
+ }
8523
+ function isWholeNumberIn(value, min, max) {
8524
+ if (!DIGITS.test(value)) return false;
8525
+ const n = Number(value);
8526
+ return n >= min && n <= max;
8445
8527
  }
8446
8528
 
8447
8529
  // src/commands/schedule.ts
8448
- var VALID_PATTERNS = ["once", "daily", "weekly", "biweekly", "monthly"];
8449
8530
  var DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
8450
8531
  function extractTime(scheduledAt) {
8451
8532
  if (!scheduledAt) return "\u2014";
8452
8533
  const d = new Date(scheduledAt);
8453
8534
  return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
8454
8535
  }
8536
+ function extractDate(scheduledAt) {
8537
+ if (!scheduledAt) return "\u2014";
8538
+ const d = new Date(scheduledAt);
8539
+ const pad = (n) => String(n).padStart(2, "0");
8540
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
8541
+ }
8455
8542
  function formatPattern(s) {
8456
8543
  const time = extractTime(s.scheduled_at);
8457
8544
  switch (s.recurrence_pattern) {
8458
8545
  case "once":
8459
- return `Once at ${time}`;
8546
+ return `Once on ${extractDate(s.scheduled_at)} at ${time}`;
8460
8547
  case "daily":
8461
8548
  return `Daily at ${time}`;
8462
8549
  case "weekly":
@@ -8469,6 +8556,22 @@ function formatPattern(s) {
8469
8556
  return s.recurrence_pattern;
8470
8557
  }
8471
8558
  }
8559
+ function modeTypedOnPentest(pentestCmd) {
8560
+ return pentestCmd.getOptionValueSource("mode") === "cli" ? String(pentestCmd.getOptionValue("mode")) : void 0;
8561
+ }
8562
+ function yesTypedOnPentest(pentestCmd) {
8563
+ return pentestCmd.getOptionValueSource("yes") === "cli";
8564
+ }
8565
+ function exitOnInputError(flags) {
8566
+ const error2 = scheduleInputError(flags, /* @__PURE__ */ new Date());
8567
+ if (!error2) return;
8568
+ console.error(error2);
8569
+ process.exit(2);
8570
+ }
8571
+ function scheduleMode(mode) {
8572
+ if (mode === "quick" || mode === "full") return mode;
8573
+ throw new Error(`Unchecked schedule mode "${mode}".`);
8574
+ }
8472
8575
  function registerScheduleCommand(pentestCmd) {
8473
8576
  const schedule = pentestCmd.command("schedule").description("Manage scheduled pentests");
8474
8577
  schedule.command("list").description("List scheduled pentests").option("--limit <n>", "Max results per page", "20").option("--cursor <cursor>", "Pagination cursor").option("--status <status>", "Filter: active, paused, or all", "all").addHelpText("after", `
@@ -8543,36 +8646,35 @@ Examples:
8543
8646
  handleError(error2);
8544
8647
  }
8545
8648
  });
8546
- schedule.command("create").description("Create a scheduled pentest").requiredOption("--asset <id>", "Asset ID to scan").requiredOption("--pattern <pattern>", "Recurrence: once, daily, weekly, biweekly, monthly").option("--name <name>", "Schedule name").option("--mode <mode>", "Scan mode (default: full)", "full").option("--time <HH:MM>", "Time of day (24h format)", "09:00").option("--day-of-week <0-6>", "Day of week (0=Sun..6=Sat) for weekly/biweekly").option("--day-of-month <1-31>", "Day of month for monthly").option("--timezone <tz>", "Timezone (e.g. America/New_York)", Intl.DateTimeFormat().resolvedOptions().timeZone).addHelpText("after", `
8649
+ schedule.command("create").description("Create a scheduled pentest").requiredOption("--asset <id>", "Asset ID to scan").requiredOption("--pattern <pattern>", "Recurrence: once, daily, weekly, biweekly, monthly").option("--name <name>", "Schedule name").option("--mode <mode>", "Scan mode: quick or full (default: full)", "full").option("--at <datetime>", "Run time for --pattern once: YYYY-MM-DDTHH:MM followed by Z or \xB1HH:MM").option("--time <HH:MM>", "Time of day, 24h, for recurring patterns (default: 09:00)").option("--day-of-week <0-6>", "Day of week (0=Sun..6=Sat) for weekly/biweekly").option("--day-of-month <1-31>", "Day of month for monthly").option("--timezone <tz>", "Timezone (e.g. America/New_York)", Intl.DateTimeFormat().resolvedOptions().timeZone).addHelpText("after", `
8547
8650
  Examples:
8548
8651
  $ kinosec pentest schedule create --asset abc123 --pattern weekly --day-of-week 1 --time 09:00
8549
8652
  $ kinosec pentest schedule create --asset abc123 --pattern daily --time 14:00
8550
- $ kinosec pentest schedule create --asset abc123 --pattern monthly --day-of-month 15 --time 03:00 --timezone UTC`).action(async (options) => {
8551
- if (!VALID_PATTERNS.includes(options.pattern)) {
8552
- console.error(`Invalid pattern "${options.pattern}". Must be: ${VALID_PATTERNS.join(", ")}`);
8553
- process.exit(2);
8554
- }
8555
- validateScanMode(options.mode);
8556
- if ((options.pattern === "weekly" || options.pattern === "biweekly") && options.dayOfWeek === void 0) {
8557
- console.error(`--day-of-week is required for ${options.pattern} schedules.`);
8558
- process.exit(2);
8559
- }
8560
- if (options.pattern === "monthly" && options.dayOfMonth === void 0) {
8561
- console.error("--day-of-month is required for monthly schedules.");
8562
- process.exit(2);
8563
- }
8653
+ $ kinosec pentest schedule create --asset abc123 --pattern monthly --day-of-month 15 --time 03:00 --timezone UTC
8654
+ $ kinosec pentest schedule create --asset abc123 --pattern once --at 2099-12-01T09:00+01:00`).action(async (options) => {
8655
+ const mode = modeTypedOnPentest(pentestCmd) ?? options.mode;
8656
+ exitOnInputError({
8657
+ pattern: options.pattern,
8658
+ mode,
8659
+ at: options.at,
8660
+ time: options.time,
8661
+ timezone: options.timezone,
8662
+ dayOfWeek: options.dayOfWeek,
8663
+ dayOfMonth: options.dayOfMonth
8664
+ });
8564
8665
  try {
8565
8666
  const opts = schedule.parent.parent.opts();
8566
8667
  const { token, apiUrl } = resolveToken(opts);
8567
8668
  const client = new KinoSecClient({ apiUrl, token });
8568
8669
  const params = {
8569
- asset_id: options.domain_or_ip,
8570
- name: options.name ?? `${options.pattern} ${options.mode} pentest`,
8571
- scan_mode: options.mode,
8670
+ asset_id: options.asset,
8671
+ name: options.name ?? `${options.pattern} ${mode} pentest`,
8672
+ scan_mode: scheduleMode(mode),
8572
8673
  recurrence_pattern: options.pattern,
8573
- time_of_day: options.time,
8574
8674
  timezone: options.timezone
8575
8675
  };
8676
+ if (options.pattern === "once") params.once_at = new Date(options.at).toISOString();
8677
+ else params.time_of_day = options.time ?? "09:00";
8576
8678
  if (options.dayOfWeek !== void 0) params.day_of_week = parseInt(options.dayOfWeek, 10);
8577
8679
  if (options.dayOfMonth !== void 0) params.day_of_month = parseInt(options.dayOfMonth, 10);
8578
8680
  const s = await client.schedules.create(params);
@@ -8590,17 +8692,26 @@ Manage: kinosec pentest schedule get ${s.id}`);
8590
8692
  handleError(error2);
8591
8693
  }
8592
8694
  });
8593
- schedule.command("update").description("Update a schedule").argument("<id>", "Schedule ID").option("--pattern <pattern>", "Recurrence: once, daily, weekly, biweekly, monthly").option("--name <name>", "Schedule name").option("--mode <mode>", "Scan mode").option("--time <HH:MM>", "Time of day (24h format)").option("--day-of-week <0-6>", "Day of week (0=Sun..6=Sat)").option("--day-of-month <1-31>", "Day of month").option("--timezone <tz>", "Timezone").addHelpText("after", `
8695
+ schedule.command("update").description("Update a schedule").argument("<id>", "Schedule ID").option("--pattern <pattern>", "Recurrence: once, daily, weekly, biweekly, monthly").option("--name <name>", "Schedule name").option("--mode <mode>", "Scan mode: quick or full").option("--at <datetime>", "Run time with --pattern once: ISO date and time with an offset").option("--time <HH:MM>", "Time of day (24h format)").option("--day-of-week <0-6>", "Day of week (0=Sun..6=Sat)").option("--day-of-month <1-31>", "Day of month").option("--timezone <tz>", "Timezone").addHelpText("after", `
8594
8696
  Examples:
8595
8697
  $ kinosec pentest schedule update abc123 --pattern daily --time 14:00
8596
- $ kinosec pentest schedule update abc123 --time 14:00`).action(async (id, options) => {
8698
+ $ kinosec pentest schedule update abc123 --time 14:00
8699
+ $ kinosec pentest schedule update abc123 --pattern once --at 2099-12-01T09:00Z`).action(async (id, options) => {
8700
+ const mode = modeTypedOnPentest(pentestCmd) ?? options.mode;
8701
+ exitOnInputError({
8702
+ pattern: options.pattern,
8703
+ mode,
8704
+ at: options.at,
8705
+ time: options.time,
8706
+ timezone: options.timezone,
8707
+ dayOfWeek: options.dayOfWeek,
8708
+ dayOfMonth: options.dayOfMonth
8709
+ });
8597
8710
  const params = {};
8598
8711
  if (options.pattern) params.recurrence_pattern = options.pattern;
8599
8712
  if (options.name) params.name = options.name;
8600
- if (options.mode) {
8601
- validateScanMode(options.mode);
8602
- params.scan_mode = options.mode;
8603
- }
8713
+ if (mode) params.scan_mode = scheduleMode(mode);
8714
+ if (options.at !== void 0) params.once_at = new Date(options.at).toISOString();
8604
8715
  if (options.time) params.time_of_day = options.time;
8605
8716
  if (options.dayOfWeek !== void 0) params.day_of_week = parseInt(options.dayOfWeek, 10);
8606
8717
  if (options.dayOfMonth !== void 0) params.day_of_month = parseInt(options.dayOfMonth, 10);
@@ -8630,7 +8741,7 @@ Examples:
8630
8741
  $ kinosec pentest schedule delete abc123
8631
8742
  $ kinosec pentest schedule delete abc123 --yes`).action(async (id, options) => {
8632
8743
  try {
8633
- await confirmDestructive(`Delete schedule ${id}?`, Boolean(options.yes));
8744
+ await confirmDestructive(`Delete schedule ${id}?`, Boolean(options.yes) || yesTypedOnPentest(pentestCmd));
8634
8745
  const opts = schedule.parent.parent.opts();
8635
8746
  const { token, apiUrl } = resolveToken(opts);
8636
8747
  const client = new KinoSecClient({ apiUrl, token });
@@ -11904,17 +12015,32 @@ function normalizeActivityHistory(raw) {
11904
12015
  return JSON.stringify(entry);
11905
12016
  });
11906
12017
  }
11907
- function filterActivityForTier(entries, showFullDetails) {
11908
- if (showFullDetails) return entries;
11909
- return entries.filter((line) => !/^\s{4,}/.test(line)).map((line) => {
11910
- if (/\] Running:/.test(line)) {
11911
- return line.replace(
11912
- /Running:.*/,
11913
- "Running: [upgrade to Security Pro for full details]"
11914
- );
11915
- }
11916
- return line;
11917
- });
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;
11918
12044
  }
11919
12045
 
11920
12046
  // src/lib/buzzwords.ts
@@ -12019,15 +12145,6 @@ function printActivityLines(entries, tty3) {
12019
12145
  console.log(`${prompt} ${text}`);
12020
12146
  }
12021
12147
  }
12022
- async function resolveShowFullDetails(client) {
12023
- try {
12024
- const me = await client.me.get();
12025
- const slug = me.plan.slug;
12026
- return slug === "security_pro" || slug === "enterprise";
12027
- } catch {
12028
- return false;
12029
- }
12030
- }
12031
12148
  async function watchPentest(client, profileId, options) {
12032
12149
  const { timeoutMs, intervalMs, useJson, showFindingsSummary } = options;
12033
12150
  const tty3 = isTTY();
@@ -12056,7 +12173,6 @@ async function watchPentest(client, profileId, options) {
12056
12173
  console.log("\nWatch cancelled. Pentest is still running.");
12057
12174
  process.exit(0);
12058
12175
  });
12059
- const showFullDetails = await resolveShowFullDetails(client);
12060
12176
  let consecutiveErrors = 0;
12061
12177
  const MAX_CONSECUTIVE_ERRORS = 2;
12062
12178
  let deferredExitCode = null;
@@ -12104,17 +12220,16 @@ ${icon2} Lost connection to pentest.`);
12104
12220
  JSON.stringify({ ...scanDetail, elapsed_seconds: elapsed }) + "\n"
12105
12221
  );
12106
12222
  } else {
12107
- const normalized = normalizeActivityHistory(scanDetail.activity_history);
12108
- const filtered = filterActivityForTier(normalized, showFullDetails);
12109
- if (printedCount > filtered.length) printedCount = 0;
12110
- const newEntries = filtered.slice(printedCount);
12223
+ const entries = normalizeActivityHistory(scanDetail.activity_history);
12224
+ if (printedCount > entries.length) printedCount = 0;
12225
+ const newEntries = entries.slice(printedCount);
12111
12226
  if (newEntries.length > 0) {
12112
12227
  if (spinner) spinner.stop();
12113
12228
  printActivityLines(newEntries, tty3);
12114
12229
  printedCount += newEntries.length;
12115
12230
  if (spinner) spinner = ora({ color: "green", isEnabled: true, discardStdin: false }).start();
12116
12231
  }
12117
- currentPhase = extractPhase(filtered);
12232
+ currentPhase = extractPhase(entries);
12118
12233
  updateSpinnerText();
12119
12234
  }
12120
12235
  const status = scanDetail.scan_status;
@@ -12151,7 +12266,7 @@ ${icon2} Pentest completed: ${domain} ${dur}`);
12151
12266
  }
12152
12267
  if (status === "Failed") {
12153
12268
  const icon2 = tty3 ? import_picocolors5.default.red(sym.fail) : sym.fail;
12154
- const reason = scanDetail.failure_reason || "unknown reason";
12269
+ const reason = failureSentence(scanDetail.failure_reason);
12155
12270
  console.log(`
12156
12271
  ${icon2} Pentest failed: ${reason}`);
12157
12272
  deferredExitCode = 1;
@@ -12189,8 +12304,8 @@ ${icon} Unexpected status: ${status}`);
12189
12304
  }
12190
12305
  async function printFindingsSummary(client, profileId, tty3) {
12191
12306
  try {
12192
- const reports = await client.reports.list({ limit: 20 });
12193
- 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];
12194
12309
  if (report?.severity_counts) {
12195
12310
  const label = tty3 ? import_picocolors5.default.dim("Findings:") : "Findings:";
12196
12311
  const summary = renderFindingsSummary(report.severity_counts);
@@ -12317,6 +12432,15 @@ Timeout: Scout did not connect within ${secs}s.`);
12317
12432
  }
12318
12433
  }
12319
12434
 
12435
+ // src/lib/validate-mode.ts
12436
+ var VALID_SCAN_MODES = ["full", "quick", "htb", "defcon", "exploit-only"];
12437
+ function validateScanMode(mode) {
12438
+ if (!VALID_SCAN_MODES.includes(mode)) {
12439
+ console.error(`Invalid mode "${mode}". Must be one of: ${VALID_SCAN_MODES.join(", ")}.`);
12440
+ process.exit(2);
12441
+ }
12442
+ }
12443
+
12320
12444
  // src/lib/scope.ts
12321
12445
  var import_picocolors7 = __toESM(require_picocolors(), 1);
12322
12446
 
@@ -12762,14 +12886,26 @@ function ensurePasteBundle(result) {
12762
12886
  process.exit(1);
12763
12887
  }
12764
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
+ }
12765
12900
  function isUserDataTooLarge(err) {
12766
12901
  return err.status === 413 || /user-?data too large/i.test(err.detail);
12767
12902
  }
12768
12903
  function registerPentestCommand(program3) {
12769
- 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", `
12770
12905
  Examples:
12771
12906
  $ kinosec pentest -t delikado.org
12772
12907
  $ kinosec pentest --profile abc123
12908
+ $ kinosec pentest --profile abc123 --mode quick
12773
12909
  $ kinosec pentest -t internal.corp --deployment on-prem
12774
12910
  $ kinosec pentest -t example.com --include api.example.com,app.example.com
12775
12911
  $ kinosec pentest -t example.com --exclude staging.example.com
@@ -12778,12 +12914,10 @@ Examples:
12778
12914
  $ kinosec pentest -t target.htb --mode htb --source ./challenge.zip --zip-password hackthebox
12779
12915
 
12780
12916
  Graybox (--source):
12781
- Packs the target's source (paste-mode only) and folds it into the agent as authoritative
12782
- context. Works with --mode full and quick for any user's own asset; htb/defcon additionally
12783
- require admin access. Large source is delivered out-of-band, so there's no size ceiling to
12784
- fight \u2014 --budget just controls how much source is packed (bigger bundles add context and cost).
12785
- The source is sent over HTTPS, used only on the ephemeral scan droplet, and is NOT stored
12786
- server-side (the training log records just a "[GRAYBOX] N bytes injected" marker, never the source).`).action(async (options) => {
12917
+ Packs the target's source and sends it with the pentest as extra context.
12918
+ Works with --mode full and quick on your own assets. --budget controls how much
12919
+ source is packed (bigger bundles add context and cost).
12920
+ The source is sent over HTTPS, used only while the pentest runs, and is NOT stored.`).action(async (options) => {
12787
12921
  if (!options.target && !options.profile) {
12788
12922
  console.error("Provide --target <domain> or --profile <id>.");
12789
12923
  process.exit(2);
@@ -12795,7 +12929,7 @@ Graybox (--source):
12795
12929
  console.error("Invalid deployment type. Must be: cloud or on-prem.");
12796
12930
  process.exit(2);
12797
12931
  }
12798
- validateScanMode(options.mode);
12932
+ if (options.mode !== void 0) validateScanMode(options.mode);
12799
12933
  let sourceBudget = DEFAULT_SOURCE_BUDGET;
12800
12934
  if (options.source) {
12801
12935
  sourceBudget = parseInt(options.budget, 10);
@@ -12838,7 +12972,11 @@ Graybox (--source):
12838
12972
  if (options.profile) {
12839
12973
  const detail = await client.scans.get(options.profile);
12840
12974
  targetDisplay = detail.domain_or_ip;
12841
- 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;
12842
12980
  } else {
12843
12981
  const parsed = parseTarget(options.target);
12844
12982
  for (const warning2 of parsed.warnings) {
@@ -12869,7 +13007,7 @@ Graybox (--source):
12869
13007
  targetDisplay = parsed.hostname;
12870
13008
  const assetParams = {
12871
13009
  asset_id: asset.id,
12872
- scan_mode: options.mode
13010
+ scan_mode: options.mode ?? "full"
12873
13011
  };
12874
13012
  if (options.mode === "defcon") {
12875
13013
  assetParams.flag_format = options.flag ?? "HTB";
@@ -13013,17 +13151,36 @@ ${divider}
13013
13151
  }
13014
13152
  });
13015
13153
  registerScheduleCommand(pentest);
13016
- 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", `
13017
13155
  Examples:
13018
13156
  $ kinosec pentest list
13019
- $ 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
+ }
13020
13175
  try {
13021
13176
  const opts = program3.opts();
13022
13177
  const { token, apiUrl } = resolveToken(opts);
13023
13178
  const client = new KinoSecClient({ apiUrl, token });
13024
13179
  const result = await client.scans.list({
13025
13180
  limit: parseInt(options.limit, 10),
13026
- cursor: options.cursor
13181
+ cursor: options.cursor,
13182
+ status: status === void 0 ? void 0 : STATUS_FILTERS[status],
13183
+ domain_or_ip: target
13027
13184
  });
13028
13185
  if (opts.json || opts.output === "json") {
13029
13186
  printJson(result);
@@ -13044,8 +13201,9 @@ Examples:
13044
13201
  ])
13045
13202
  );
13046
13203
  if (result.next_cursor) {
13204
+ const filters = `${status ? ` --status ${status}` : ""}${target ? ` --target ${target}` : ""}`;
13047
13205
  console.log(import_picocolors8.default.dim(`
13048
- More results available. Use --cursor ${result.next_cursor}`));
13206
+ More results available: kinosec pentest list${filters} --cursor ${result.next_cursor}`));
13049
13207
  }
13050
13208
  } catch (error2) {
13051
13209
  handleError(error2);
@@ -13078,6 +13236,10 @@ Examples:
13078
13236
  ["Updated", detail.updated_at]
13079
13237
  ]
13080
13238
  );
13239
+ if (detail.scan_status === "Failed") {
13240
+ console.log(`
13241
+ Failure: ${failureSentence(detail.failure_reason)}`);
13242
+ }
13081
13243
  } catch (error2) {
13082
13244
  handleError(error2);
13083
13245
  }
@@ -13453,13 +13615,206 @@ Examples:
13453
13615
  });
13454
13616
  }
13455
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
+
13456
13812
  // src/commands/asset.ts
13457
13813
  function registerAssetCommand(program3) {
13458
13814
  const asset = program3.command("asset").description("Manage assets");
13459
- asset.command("list").description("List assets").option("--limit <n>", "Max results per page", "20").option("--cursor <cursor>", "Pagination cursor").option("--search <term>", "Filter by asset value (substring match)").option("--status <status>", "Filter by status (verified, unverified)").option("--type <type>", "Filter by type (domain, subdomain, ip)").addHelpText("after", `
13815
+ asset.command("list").description("List assets").option("--limit <n>", "Max results per page", "20").option("--cursor <cursor>", "Pagination cursor").option("--status <status>", "Filter by status (verified, unverified)").option("--type <type>", "Filter by type (domain, subdomain, ip)").addHelpText("after", `
13460
13816
  Examples:
13461
13817
  $ kinosec asset list
13462
- $ kinosec asset list --search example.com
13463
13818
  $ kinosec asset list --type subdomain --status verified
13464
13819
  $ kinosec asset list --limit 50 --json`).action(async (options) => {
13465
13820
  try {
@@ -13469,7 +13824,6 @@ Examples:
13469
13824
  const result = await client.assets.list({
13470
13825
  limit: parseInt(options.limit, 10),
13471
13826
  cursor: options.cursor,
13472
- search: options.search,
13473
13827
  status: options.status,
13474
13828
  type: options.type
13475
13829
  });
@@ -13570,6 +13924,42 @@ Verify: kinosec asset verify ${result.id}`);
13570
13924
  handleError(error2);
13571
13925
  }
13572
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
+ });
13573
13963
  asset.command("delete").description("Delete an asset").argument("<id>", "Asset ID").option("-y, --yes", "Skip confirmation prompt").addHelpText("after", `
13574
13964
  Examples:
13575
13965
  $ kinosec asset delete abc123
@@ -13700,12 +14090,39 @@ function formatPoc(poc) {
13700
14090
  return json === "{}" || json === "[]" ? null : json;
13701
14091
  }
13702
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
+ }
13703
14109
  function registerFindingCommand(program3) {
13704
14110
  const finding = program3.command("finding").description("Manage findings");
13705
- 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", `
13706
14112
  Examples:
13707
- $ kinosec finding list abc123
14113
+ $ kinosec finding list a1b2c3d4-0000-0000-0000-000000000001
14114
+ $ kinosec finding list a1b2c3d4-0000-0000-0000-000000000001 --severity critical --status open
13708
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
+ }
13709
14126
  try {
13710
14127
  const opts = program3.opts();
13711
14128
  const { token, apiUrl } = resolveToken(opts);
@@ -13713,7 +14130,9 @@ Examples:
13713
14130
  const result = await client.findings.list({
13714
14131
  limit: parseInt(options.limit, 10),
13715
14132
  cursor: options.cursor,
13716
- scan: profileId
14133
+ scan_profile_id: profileId,
14134
+ impact_level: severity,
14135
+ status
13717
14136
  });
13718
14137
  if (opts.json || opts.output === "json") {
13719
14138
  printJson(result);
@@ -13733,8 +14152,9 @@ Examples:
13733
14152
  ])
13734
14153
  );
13735
14154
  if (result.next_cursor) {
14155
+ const scope = `${profileId ? ` ${profileId}` : ""}${severity ? ` --severity ${severity}` : ""}${status ? ` --status ${status}` : ""}`;
13736
14156
  console.log(import_picocolors11.default.dim(`
13737
- More results available. Use --cursor ${result.next_cursor}`));
14157
+ More results available: kinosec finding list${scope} --cursor ${result.next_cursor}`));
13738
14158
  }
13739
14159
  } catch (error2) {
13740
14160
  handleError(error2);
@@ -13822,69 +14242,163 @@ Examples:
13822
14242
 
13823
14243
  // src/commands/report.ts
13824
14244
  var import_picocolors12 = __toESM(require_picocolors(), 1);
13825
- function renderMarkdown(data) {
13826
- const { scan, findings } = data;
13827
- const lines = [];
13828
- lines.push(`# Security Report: ${scan.domain_or_ip}`);
13829
- lines.push("");
13830
- lines.push(`**Report:** ${scan.report_name}`);
13831
- lines.push(`**Scan Mode:** ${scan.scan_mode}`);
13832
- lines.push(`**Completed:** ${scan.scan_completed_at}`);
13833
- lines.push("");
13834
- if (scan.severity_counts && Object.keys(scan.severity_counts).length > 0) {
13835
- lines.push("## Severity Summary");
13836
- lines.push("");
13837
- lines.push("| Severity | Count |");
13838
- lines.push("|----------|-------|");
13839
- for (const [severity, count] of Object.entries(scan.severity_counts)) {
13840
- lines.push(`| ${severity} | ${count} |`);
13841
- }
13842
- lines.push("");
13843
- }
13844
- if (findings.length > 0) {
13845
- lines.push("## Findings");
13846
- lines.push("");
13847
- for (let i = 0; i < findings.length; i++) {
13848
- const f = findings[i];
13849
- lines.push(`### ${i + 1}. ${f.finding_name} (${f.impact_level})`);
13850
- lines.push("");
13851
- if (f.finding_description) {
13852
- lines.push(`**Description:** ${f.finding_description}`);
13853
- lines.push("");
13854
- }
13855
- if (f.finding_solution) {
13856
- lines.push(`**Solution:** ${f.finding_solution}`);
13857
- lines.push("");
13858
- }
13859
- }
13860
- } else {
13861
- lines.push("## Findings");
13862
- lines.push("");
13863
- lines.push("No findings.");
13864
- 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;
13865
14375
  }
13866
- return lines.join("\n");
13867
14376
  }
13868
14377
  function registerReportCommand(program3) {
13869
14378
  const report = program3.command("report").description("Manage reports");
13870
- 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", `
13871
14380
  Examples:
13872
14381
  $ kinosec report list
13873
- $ 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);
13874
14385
  try {
13875
14386
  const opts = program3.opts();
13876
14387
  const { token, apiUrl } = resolveToken(opts);
13877
14388
  const client = new KinoSecClient({ apiUrl, token });
13878
14389
  const result = await client.reports.list({
13879
14390
  limit: parseInt(options.limit, 10),
13880
- cursor: options.cursor
14391
+ cursor: options.cursor,
14392
+ scan_profile_id: profileId
13881
14393
  });
13882
14394
  if (opts.json || opts.output === "json") {
13883
14395
  printJson(result);
13884
14396
  return;
13885
14397
  }
14398
+ const runState = profileId ? await pentestRunState(client, profileId) : "idle";
13886
14399
  if (result.items.length === 0) {
13887
- 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);
13888
14402
  return;
13889
14403
  }
13890
14404
  printTable(
@@ -13894,11 +14408,17 @@ Examples:
13894
14408
  truncate(r.domain_or_ip, 30),
13895
14409
  r.scan_mode,
13896
14410
  formatFindings(r.severity_counts),
13897
- r.scan_completed_at ? new Date(r.scan_completed_at).toLocaleDateString() : "(pending)"
14411
+ r.scan_completed_at ? new Date(r.scan_completed_at).toLocaleDateString() : "-"
13898
14412
  ])
13899
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}`);
13900
14419
  if (result.next_cursor) {
13901
- 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}` : `
13902
14422
  More results available. Use --cursor ${result.next_cursor}`));
13903
14423
  }
13904
14424
  } catch (error2) {
@@ -13926,7 +14446,7 @@ Examples:
13926
14446
  ["Target", detail.domain_or_ip],
13927
14447
  ["Profile", detail.scan_profile_name],
13928
14448
  ["Mode", detail.scan_mode],
13929
- ["Completed", detail.scan_completed_at ?? "(pending)"],
14449
+ ["Completed", detail.scan_completed_at ?? "-"],
13930
14450
  ["Created", detail.created_at]
13931
14451
  ]
13932
14452
  );
@@ -13936,6 +14456,8 @@ Examples:
13936
14456
  console.log(` ${severity}: ${count}`);
13937
14457
  }
13938
14458
  }
14459
+ console.log();
14460
+ for (const line of summaryLines(detail.ai_summary)) console.log(line);
13939
14461
  } catch (error2) {
13940
14462
  handleError(error2);
13941
14463
  }
@@ -13944,32 +14466,67 @@ Examples:
13944
14466
  Examples:
13945
14467
  $ kinosec report export abc123 --format json > report.json
13946
14468
  $ kinosec report export abc123 --format markdown > report.md`).action(async (id, options) => {
13947
- const validFormats = ["json", "markdown"];
13948
- if (!validFormats.includes(options.format)) {
13949
- console.error(`Invalid format "${options.format}". Valid: ${validFormats.join(", ")}`);
13950
- process.exit(2);
13951
- }
14469
+ exitOnBadFormat(options.format);
13952
14470
  try {
13953
14471
  const opts = program3.opts();
13954
14472
  const { token, apiUrl } = resolveToken(opts);
13955
14473
  const client = new KinoSecClient({ apiUrl, token });
13956
- const result = await client.reports.export({
13957
- scan_report_id: id,
13958
- format: "json"
13959
- });
13960
14474
  if (options.format === "markdown" && !(opts.json || opts.output === "json")) {
13961
- process.stdout.write(renderMarkdown(result));
13962
- } else {
13963
- printJson(result);
14475
+ process.stdout.write(await client.reports.exportMarkdown(id));
14476
+ return;
13964
14477
  }
14478
+ printJson(await client.reports.export({ scan_report_id: id, format: "json" }));
13965
14479
  } catch (error2) {
13966
14480
  handleError(error2);
13967
14481
  }
13968
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
+ });
14517
+ } catch (error2) {
14518
+ handleError(error2);
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);
14524
+ });
13969
14525
  }
13970
14526
 
13971
14527
  // src/commands/token.ts
13972
14528
  var import_picocolors13 = __toESM(require_picocolors(), 1);
14529
+ var WEB_APP_TOKENS_URL = "https://kinosec.ai/settings?tab=api-tokens";
13973
14530
  function registerTokenCommand(program3) {
13974
14531
  const token = program3.command("token").description("Manage API tokens");
13975
14532
  token.command("list").description("List API tokens").option("--limit <n>", "Max results per page", "20").option("--cursor <cursor>", "Pagination cursor").addHelpText("after", `
@@ -14015,8 +14572,8 @@ More results available. Use --cursor ${result.next_cursor}`));
14015
14572
  Examples:
14016
14573
  $ kinosec token create --name "CI" --scopes assets:read,scans:create
14017
14574
  $ kinosec token create --name "Full" --scopes assets:read,scans:read --expires 30`).action(async (options) => {
14575
+ const opts = program3.opts();
14018
14576
  try {
14019
- const opts = program3.opts();
14020
14577
  const { token: authToken, apiUrl } = resolveToken(opts);
14021
14578
  const client = new KinoSecClient({ apiUrl, token: authToken });
14022
14579
  const scopes = options.scopes.split(",").map((s) => s.trim());
@@ -14038,6 +14595,13 @@ Examples:
14038
14595
  console.log(` Scopes: ${result.scopes.join(", ")}`);
14039
14596
  console.log(` Expires: ${new Date(result.expires_at).toLocaleDateString()}`);
14040
14597
  } catch (error2) {
14598
+ const isJson = opts.json || opts.output === "json";
14599
+ if (error2 instanceof ApiError && error2.code === "pat_cannot_create_token" && !isJson) {
14600
+ printWarning("API tokens can't create other API tokens.");
14601
+ console.error("Create a new token in the KinoSec web app:");
14602
+ console.error(` ${WEB_APP_TOKENS_URL}`);
14603
+ console.error("");
14604
+ }
14041
14605
  handleError(error2);
14042
14606
  }
14043
14607
  });
@@ -14064,7 +14628,7 @@ Examples:
14064
14628
 
14065
14629
  // src/cli.ts
14066
14630
  var program2 = new Command();
14067
- program2.name("kinosec").description("KinoSec CLI \u2014 run pentests from your terminal").version("0.11.1", "-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");
14068
14632
  registerAuthCommand(program2);
14069
14633
  registerMeCommand(program2);
14070
14634
  registerCreditsCommand(program2);
@@ -14073,10 +14637,9 @@ registerAssetCommand(program2);
14073
14637
  registerFindingCommand(program2);
14074
14638
  registerReportCommand(program2);
14075
14639
  registerTokenCommand(program2);
14076
- program2.parseAsync(process.argv).then(() => checkForUpdate("0.11.1")).catch(handleError);
14640
+ program2.parseAsync(process.argv).then(() => checkForUpdate("0.12.0")).catch(handleError);
14077
14641
  /*! Bundled license information:
14078
14642
 
14079
14643
  ky/distribution/index.js:
14080
14644
  (*! MIT License © Sindre Sorhus *)
14081
14645
  */
14082
- //# sourceMappingURL=cli.js.map