@kinosec/cli 0.10.2 → 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,13 +8436,10 @@ ${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
8439
+ var VALID_SCAN_MODES = ["full", "quick", "htb", "defcon", "exploit-only"];
8438
8440
  function validateScanMode(mode) {
8439
- if (mode === "quick") {
8440
- console.error("Quick mode is temporarily unavailable. Please use --mode full (the default).");
8441
- process.exit(2);
8442
- }
8443
- if (mode !== "full") {
8444
- console.error(`Invalid mode "${mode}". Must be: full`);
8441
+ if (!VALID_SCAN_MODES.includes(mode)) {
8442
+ console.error(`Invalid mode "${mode}". Must be one of: ${VALID_SCAN_MODES.join(", ")}.`);
8445
8443
  process.exit(2);
8446
8444
  }
8447
8445
  }
@@ -12564,16 +12562,228 @@ function parseTarget(input) {
12564
12562
  };
12565
12563
  }
12566
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
+
12567
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
+ }
12568
12768
  function registerPentestCommand(program3) {
12569
- 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").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", `
12570
12770
  Examples:
12571
12771
  $ kinosec pentest -t delikado.org
12572
12772
  $ kinosec pentest --profile abc123
12573
12773
  $ kinosec pentest -t internal.corp --deployment on-prem
12574
12774
  $ kinosec pentest -t example.com --include api.example.com,app.example.com
12575
12775
  $ kinosec pentest -t example.com --exclude staging.example.com
12576
- $ 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) => {
12577
12787
  if (!options.target && !options.profile) {
12578
12788
  console.error("Provide --target <domain> or --profile <id>.");
12579
12789
  process.exit(2);
@@ -12586,6 +12796,16 @@ Examples:
12586
12796
  process.exit(2);
12587
12797
  }
12588
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
+ }
12589
12809
  const scopeClear = options.scope === "clear";
12590
12810
  if (options.scope && options.scope !== "clear") {
12591
12811
  console.error("Invalid --scope value. Must be: clear");
@@ -12604,6 +12824,15 @@ Examples:
12604
12824
  const opts = program3.opts();
12605
12825
  const { token, apiUrl } = resolveToken(opts);
12606
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
+ }
12607
12836
  let targetDisplay;
12608
12837
  let params;
12609
12838
  if (options.profile) {
@@ -12642,6 +12871,12 @@ Examples:
12642
12871
  asset_id: asset.id,
12643
12872
  scan_mode: options.mode
12644
12873
  };
12874
+ if (options.mode === "defcon") {
12875
+ assetParams.flag_format = options.flag ?? "HTB";
12876
+ if (options.context) assetParams.ctf_context = options.context;
12877
+ } else if (options.flag || options.context) {
12878
+ console.error("Warning: --flag/--context apply only to --mode defcon; ignoring.");
12879
+ }
12645
12880
  if (options.deployment !== "cloud") {
12646
12881
  assetParams.deployment_type = options.deployment;
12647
12882
  }
@@ -12681,8 +12916,47 @@ Examples:
12681
12916
  }
12682
12917
  displayScope(asset);
12683
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
+ }
12684
12934
  await confirmAuthorization(targetDisplay, Boolean(options.yes));
12685
- 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
+ }
12686
12960
  const isOnPrem = result.deployment_type === "on_prem";
12687
12961
  if (opts.json || opts.output === "json") {
12688
12962
  printJson(result);
@@ -13790,7 +14064,7 @@ Examples:
13790
14064
 
13791
14065
  // src/cli.ts
13792
14066
  var program2 = new Command();
13793
- program2.name("kinosec").description("KinoSec CLI \u2014 run pentests from your terminal").version("0.10.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");
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");
13794
14068
  registerAuthCommand(program2);
13795
14069
  registerMeCommand(program2);
13796
14070
  registerCreditsCommand(program2);
@@ -13799,7 +14073,7 @@ registerAssetCommand(program2);
13799
14073
  registerFindingCommand(program2);
13800
14074
  registerReportCommand(program2);
13801
14075
  registerTokenCommand(program2);
13802
- program2.parseAsync(process.argv).then(() => checkForUpdate("0.10.2")).catch(handleError);
14076
+ program2.parseAsync(process.argv).then(() => checkForUpdate("0.11.1")).catch(handleError);
13803
14077
  /*! Bundled license information:
13804
14078
 
13805
14079
  ky/distribution/index.js: