@kinosec/cli 0.10.3 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -6732,10 +6732,11 @@ function parseApiError(body, status) {
6732
6732
  });
6733
6733
  }
6734
6734
  const b = body;
6735
+ const detail = typeof b.detail === "string" ? b.detail : typeof b.error === "string" ? b.error : "An unexpected error occurred";
6735
6736
  return new ApiError({
6736
6737
  status: typeof b.status === "number" ? b.status : status,
6737
6738
  code: typeof b.code === "string" ? b.code : "unknown_error",
6738
- detail: typeof b.detail === "string" ? b.detail : "An unexpected error occurred",
6739
+ detail,
6739
6740
  requestId: typeof b.request_id === "string" ? b.request_id : "unknown"
6740
6741
  });
6741
6742
  }
@@ -8435,7 +8436,7 @@ ${import_picocolors3.default.dim("use of its services.")}
8435
8436
  var import_picocolors4 = __toESM(require_picocolors(), 1);
8436
8437
 
8437
8438
  // src/lib/validate-mode.ts
8438
- var VALID_SCAN_MODES = ["full", "quick", "htb", "defcon"];
8439
+ var VALID_SCAN_MODES = ["full", "quick", "htb", "defcon", "exploit-only"];
8439
8440
  function validateScanMode(mode) {
8440
8441
  if (!VALID_SCAN_MODES.includes(mode)) {
8441
8442
  console.error(`Invalid mode "${mode}". Must be one of: ${VALID_SCAN_MODES.join(", ")}.`);
@@ -12561,16 +12562,228 @@ function parseTarget(input) {
12561
12562
  };
12562
12563
  }
12563
12564
 
12565
+ // src/lib/graybox.ts
12566
+ import { spawnSync } from "child_process";
12567
+ import { existsSync as existsSync3, mkdtempSync, readFileSync as readFileSync3, rmSync, statSync } from "fs";
12568
+ import { tmpdir } from "os";
12569
+ import { dirname as dirname3, join as join3 } from "path";
12570
+ import { fileURLToPath } from "url";
12571
+ var SOURCE_BYTES_WARN = 2e5;
12572
+ function findPython() {
12573
+ const candidates = [
12574
+ { cmd: "python3", args: [] },
12575
+ { cmd: "py", args: ["-3"] },
12576
+ { cmd: "python", args: [] }
12577
+ ];
12578
+ for (const interp of candidates) {
12579
+ try {
12580
+ const result = spawnSync(interp.cmd, [...interp.args, "--version"], { stdio: "ignore" });
12581
+ if (!result.error && result.status === 0) return interp;
12582
+ } catch {
12583
+ }
12584
+ }
12585
+ return null;
12586
+ }
12587
+ function resolveCtxpackScript() {
12588
+ const here = dirname3(fileURLToPath(import.meta.url));
12589
+ const candidates = [
12590
+ join3(here, "vendor", "ctxpack.py"),
12591
+ // bundled: dist/cli.js -> dist/vendor/
12592
+ join3(here, "..", "vendor", "ctxpack.py"),
12593
+ // published package root -> vendor/
12594
+ join3(here, "..", "..", "vendor", "ctxpack.py")
12595
+ // dev: src/lib -> packages/cli/vendor/
12596
+ ];
12597
+ for (const candidate of candidates) {
12598
+ if (existsSync3(candidate)) return candidate;
12599
+ }
12600
+ throw new Error(
12601
+ `Could not locate the vendored ctxpack packer (looked in: ${candidates.join(", ")}). This is a packaging bug \u2014 reinstall @kinosec/cli.`
12602
+ );
12603
+ }
12604
+ var PYTHON_MISSING_MESSAGE = 'Packing --source requires Python 3, but none of "python3", "py -3", or "python" was found on PATH.\nInstall Python 3 (https://www.python.org/downloads/) and re-run, or pass an already-packed bundle: --source <bundle.txt>.';
12605
+ async function packSource(source, options) {
12606
+ if (!existsSync3(source)) {
12607
+ throw new Error(`--source path not found: ${source}`);
12608
+ }
12609
+ const stats = statSync(source);
12610
+ if (stats.isFile() && source.toLowerCase().endsWith(".txt")) {
12611
+ const bundleText = readFileSync3(source, "utf8");
12612
+ return {
12613
+ bundleText,
12614
+ bytes: Buffer.byteLength(bundleText, "utf8"),
12615
+ mode: null,
12616
+ stack: sniffBundleStack(bundleText),
12617
+ manifest: null,
12618
+ stderr: "",
12619
+ premade: true
12620
+ };
12621
+ }
12622
+ const python = findPython();
12623
+ if (!python) {
12624
+ throw new Error(PYTHON_MISSING_MESSAGE);
12625
+ }
12626
+ const script = resolveCtxpackScript();
12627
+ const outDir = mkdtempSync(join3(tmpdir(), "kinosec-ctxpack-"));
12628
+ try {
12629
+ const args = [...python.args, script, source, "--budget", String(options.budget), "-o", outDir];
12630
+ if (options.zipPassword) {
12631
+ args.push("--zip-password", options.zipPassword);
12632
+ }
12633
+ const proc = spawnSync(python.cmd, args, { encoding: "utf8" });
12634
+ if (proc.error) {
12635
+ throw new Error(`Failed to run ctxpack via ${python.cmd}: ${proc.error.message}`);
12636
+ }
12637
+ if (proc.status !== 0) {
12638
+ const detail = (proc.stderr || "").trim();
12639
+ throw new Error(
12640
+ `ctxpack failed to pack --source (exit ${proc.status ?? "unknown"}).` + (detail ? `
12641
+ ${detail}` : "") + (looksLikeAesZip(detail) ? '\nHint: only classic ZipCrypto zips are supported (HTB handouts use "hackthebox"), not WinZip/AES-256. Re-create the archive with classic encryption, or extract it and pass the directory.' : "")
12642
+ );
12643
+ }
12644
+ const bundlePath = join3(outDir, "bundle.txt");
12645
+ if (!existsSync3(bundlePath)) {
12646
+ throw new Error(`ctxpack produced no bundle (${bundlePath} missing).`);
12647
+ }
12648
+ const bundleText = readFileSync3(bundlePath, "utf8");
12649
+ let manifest = null;
12650
+ try {
12651
+ manifest = JSON.parse(readFileSync3(join3(outDir, "manifest.json"), "utf8"));
12652
+ } catch {
12653
+ manifest = null;
12654
+ }
12655
+ const stack = manifest?.signals?.stack ? String(manifest.signals.stack) : null;
12656
+ const mode = manifest?.mode ? String(manifest.mode) : null;
12657
+ return {
12658
+ bundleText,
12659
+ bytes: Buffer.byteLength(bundleText, "utf8"),
12660
+ mode,
12661
+ stack,
12662
+ manifest,
12663
+ stderr: proc.stderr || "",
12664
+ premade: false
12665
+ };
12666
+ } finally {
12667
+ try {
12668
+ rmSync(outDir, { recursive: true, force: true });
12669
+ } catch {
12670
+ }
12671
+ }
12672
+ }
12673
+ function looksLikeAesZip(stderr) {
12674
+ const lc = stderr.toLowerCase();
12675
+ return lc.includes("compression type 99") || lc.includes("aes") || lc.includes("winzip");
12676
+ }
12677
+ function sniffBundleStack(text) {
12678
+ const lc = text.toLowerCase();
12679
+ if (/"next"|next\.config|\/_next\//.test(lc)) return "nextjs";
12680
+ if (/"express"/.test(lc)) return "express";
12681
+ if (/from flask|import flask/.test(lc)) return "flask";
12682
+ if (/<\?php/.test(lc)) return "php";
12683
+ return null;
12684
+ }
12685
+ function matchLiveStack(lowercased) {
12686
+ if (lowercased.includes("x-powered-by: next.js") || lowercased.includes("/_next/") || lowercased.includes("__next_data__")) {
12687
+ return "nextjs";
12688
+ }
12689
+ if (lowercased.includes("x-powered-by: express")) return "express";
12690
+ if (lowercased.includes("x-powered-by: php") || lowercased.includes("phpsessid")) return "php";
12691
+ if (lowercased.includes("server: werkzeug")) return "flask";
12692
+ if (lowercased.includes("csrftoken")) return "django";
12693
+ return null;
12694
+ }
12695
+ var COMPATIBLE_STACKS = /* @__PURE__ */ new Set([
12696
+ "nextjs/react",
12697
+ "react/nextjs",
12698
+ "nextjs/node",
12699
+ "node/nextjs",
12700
+ "express/node",
12701
+ "node/express"
12702
+ ]);
12703
+ function stacksConflict(bundleStack, liveStack) {
12704
+ if (!bundleStack || !liveStack) return false;
12705
+ if (bundleStack === liveStack) return false;
12706
+ if (COMPATIBLE_STACKS.has(`${bundleStack}/${liveStack}`)) return false;
12707
+ return true;
12708
+ }
12709
+ async function probeLiveStack(host, timeoutMs = 8e3) {
12710
+ for (const scheme of ["https", "http"]) {
12711
+ let res;
12712
+ const controller = new AbortController();
12713
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
12714
+ try {
12715
+ res = await fetch(`${scheme}://${host}/`, { signal: controller.signal, redirect: "follow" });
12716
+ } catch {
12717
+ continue;
12718
+ } finally {
12719
+ clearTimeout(timer);
12720
+ }
12721
+ const headerBlob = [...res.headers].map(([key, value]) => `${key}: ${value}`).join("\n");
12722
+ let body = "";
12723
+ try {
12724
+ body = (await res.text()).slice(0, 4096);
12725
+ } catch {
12726
+ }
12727
+ return matchLiveStack(`${headerBlob}
12728
+ ${body}`.toLowerCase());
12729
+ }
12730
+ return null;
12731
+ }
12732
+
12564
12733
  // src/commands/pentest.ts
12734
+ var DEFAULT_SOURCE_BUDGET = 4e4;
12735
+ function reportPackResult(result, budget) {
12736
+ const size = `${result.bytes.toLocaleString()} bytes`;
12737
+ if (result.premade) {
12738
+ console.error(`Graybox: using pre-made source bundle (${size}).`);
12739
+ } else {
12740
+ for (const line of result.stderr.split("\n")) {
12741
+ const trimmed = line.trim();
12742
+ if (trimmed.startsWith("ctxpack: mode=")) {
12743
+ console.error(`Graybox: ${trimmed.slice("ctxpack: ".length)} [${size}]`);
12744
+ }
12745
+ }
12746
+ }
12747
+ if (result.bytes > SOURCE_BYTES_WARN) {
12748
+ printWarning(
12749
+ `Source bundle is ${size} \u2014 that's a lot of prompt context. It will still be delivered (the server ships oversize source out-of-band), but large bundles raise per-scan cost and can dilute the agent's focus. Lower --budget (currently ${budget}) or trim --source to tighten it.`
12750
+ );
12751
+ }
12752
+ }
12753
+ function ensurePasteBundle(result) {
12754
+ if (result.premade || result.mode === "paste") return;
12755
+ if (result.mode === "retrieve") {
12756
+ printWarning(
12757
+ "ctxpack fell back to retrieve mode: the source is too large to paste within --budget, so the bundle is a path outline with no code \u2014 useless as graybox context."
12758
+ );
12759
+ console.error(
12760
+ "Not sending it. Raise --budget to re-pack the source as a full paste bundle, or trim --source to the exploit-relevant files (or run a normal black-box scan without --source)."
12761
+ );
12762
+ process.exit(1);
12763
+ }
12764
+ }
12765
+ function isUserDataTooLarge(err) {
12766
+ return err.status === 413 || /user-?data too large/i.test(err.detail);
12767
+ }
12565
12768
  function registerPentestCommand(program3) {
12566
- 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, or defcon", "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").addHelpText("after", `
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", `
12567
12770
  Examples:
12568
12771
  $ kinosec pentest -t delikado.org
12569
12772
  $ kinosec pentest --profile abc123
12570
12773
  $ kinosec pentest -t internal.corp --deployment on-prem
12571
12774
  $ kinosec pentest -t example.com --include api.example.com,app.example.com
12572
12775
  $ kinosec pentest -t example.com --exclude staging.example.com
12573
- $ kinosec pentest -t example.com --scope clear`).action(async (options) => {
12776
+ $ kinosec pentest -t example.com --scope clear
12777
+ $ kinosec pentest -t juice.example.com --mode exploit-only
12778
+ $ kinosec pentest -t target.htb --mode htb --source ./challenge.zip --zip-password hackthebox
12779
+
12780
+ 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) => {
12574
12787
  if (!options.target && !options.profile) {
12575
12788
  console.error("Provide --target <domain> or --profile <id>.");
12576
12789
  process.exit(2);
@@ -12583,6 +12796,16 @@ Examples:
12583
12796
  process.exit(2);
12584
12797
  }
12585
12798
  validateScanMode(options.mode);
12799
+ let sourceBudget = DEFAULT_SOURCE_BUDGET;
12800
+ if (options.source) {
12801
+ sourceBudget = parseInt(options.budget, 10);
12802
+ if (!Number.isInteger(sourceBudget) || sourceBudget <= 0) {
12803
+ console.error("Invalid --budget. Must be a positive integer (tokens).");
12804
+ process.exit(2);
12805
+ }
12806
+ } else if (options.zipPassword || options.force) {
12807
+ console.error("Warning: --zip-password/--force apply only with --source; ignoring.");
12808
+ }
12586
12809
  const scopeClear = options.scope === "clear";
12587
12810
  if (options.scope && options.scope !== "clear") {
12588
12811
  console.error("Invalid --scope value. Must be: clear");
@@ -12601,6 +12824,15 @@ Examples:
12601
12824
  const opts = program3.opts();
12602
12825
  const { token, apiUrl } = resolveToken(opts);
12603
12826
  const client = new KinoSecClient({ apiUrl, token });
12827
+ let packResult = null;
12828
+ if (options.source) {
12829
+ packResult = await packSource(options.source, {
12830
+ budget: sourceBudget,
12831
+ zipPassword: options.zipPassword
12832
+ });
12833
+ reportPackResult(packResult, sourceBudget);
12834
+ ensurePasteBundle(packResult);
12835
+ }
12604
12836
  let targetDisplay;
12605
12837
  let params;
12606
12838
  if (options.profile) {
@@ -12684,8 +12916,47 @@ Examples:
12684
12916
  }
12685
12917
  displayScope(asset);
12686
12918
  }
12919
+ if (packResult?.stack && !options.force) {
12920
+ const liveStack = await probeLiveStack(targetDisplay);
12921
+ if (liveStack && stacksConflict(packResult.stack, liveStack)) {
12922
+ printWarning(
12923
+ `Source handout looks like "${packResult.stack}", but ${targetDisplay} responds like "${liveStack}".`
12924
+ );
12925
+ printWarning("The source may belong to a different app; graybox context could mislead the pentest.");
12926
+ if (options.yes) {
12927
+ console.error("Proceeding anyway (--yes).");
12928
+ } else {
12929
+ console.error("Aborting. Re-run with --force to override, or fix --source.");
12930
+ process.exit(2);
12931
+ }
12932
+ }
12933
+ }
12687
12934
  await confirmAuthorization(targetDisplay, Boolean(options.yes));
12688
- const result = await client.scans.create(params);
12935
+ if (packResult) {
12936
+ params.source_context = packResult.bundleText;
12937
+ }
12938
+ let result;
12939
+ try {
12940
+ result = await client.scans.create(params);
12941
+ } catch (err) {
12942
+ if (err instanceof ApiError) {
12943
+ if (packResult && isUserDataTooLarge(err)) {
12944
+ printWarning(`Server rejected the scan: ${err.detail}`);
12945
+ console.error(
12946
+ `The graybox source made the provisioning payload too large. Re-pack with a smaller --budget (currently ${sourceBudget}) or trim --source, then retry.`
12947
+ );
12948
+ process.exit(1);
12949
+ }
12950
+ if (err.status === 403 && (options.mode === "htb" || options.mode === "defcon")) {
12951
+ printWarning(`The "${options.mode}" scan mode requires admin access (server returned 403).`);
12952
+ console.error(
12953
+ "Run with --mode full or --mode quick (which support --source for your own assets), or ask your KinoSec admin to enable CTF modes for your account."
12954
+ );
12955
+ process.exit(77);
12956
+ }
12957
+ }
12958
+ throw err;
12959
+ }
12689
12960
  const isOnPrem = result.deployment_type === "on_prem";
12690
12961
  if (opts.json || opts.output === "json") {
12691
12962
  printJson(result);
@@ -13793,7 +14064,7 @@ Examples:
13793
14064
 
13794
14065
  // src/cli.ts
13795
14066
  var program2 = new Command();
13796
- program2.name("kinosec").description("KinoSec CLI \u2014 run pentests from your terminal").version("0.10.3", "-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");
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");
13797
14068
  registerAuthCommand(program2);
13798
14069
  registerMeCommand(program2);
13799
14070
  registerCreditsCommand(program2);
@@ -13802,7 +14073,7 @@ registerAssetCommand(program2);
13802
14073
  registerFindingCommand(program2);
13803
14074
  registerReportCommand(program2);
13804
14075
  registerTokenCommand(program2);
13805
- program2.parseAsync(process.argv).then(() => checkForUpdate("0.10.3")).catch(handleError);
14076
+ program2.parseAsync(process.argv).then(() => checkForUpdate("0.11.1")).catch(handleError);
13806
14077
  /*! Bundled license information:
13807
14078
 
13808
14079
  ky/distribution/index.js: