ikanban 0.4.3 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikanban",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "iKanban CLI: AI and security scans of your own repo, with results filed into iKanban",
5
5
  "bin": { "ikanban": "bin/ikanban.js" },
6
6
  "files": ["bin", "lib", "hooks", "scanners", "README.md", "LICENSE.md"],
@@ -57,7 +57,7 @@ PROMPTFOO_VERSION = "0.123.1"
57
57
  # so this string is the release identity. Bump it when publishing a release —
58
58
  # a customer pinning a version is pinning this value, and re-uploading different
59
59
  # bytes under a version someone already fetched defeats the sha256 they pinned.
60
- AISCAN_VERSION = "0.6.0"
60
+ AISCAN_VERSION = "0.6.1"
61
61
 
62
62
  # The ten categories, their labels, and their plugins -- the forward map, copied
63
63
  # from promptfoo's OWASP_LLM_TOP_10_MAPPING (src/redteam/constants/frameworks.ts).
@@ -356,9 +356,15 @@ def build_config(target, spec, plugins, num_tests, base_url, token, workspace_id
356
356
  }
357
357
 
358
358
 
359
- def run_promptfoo(args, cwd, allow_remote):
359
+ def run_promptfoo(args, cwd, allow_remote, openai=None):
360
360
  cmd = ["npx", "-y", f"promptfoo@{PROMPTFOO_VERSION}"] + args
361
361
  env = dict(os.environ)
362
+ # G22: openai_env's names; None removes a name from promptfoo's environment.
363
+ for name, value in (openai or {}).items():
364
+ if value is None:
365
+ env.pop(name, None)
366
+ else:
367
+ env[name] = value
362
368
  if not allow_remote:
363
369
  # Pinned here rather than left to the caller's shell. promptfoo decides
364
370
  # remote-vs-local by looking for OPENAI_API_KEY (shouldGenerateRemote in
@@ -403,7 +409,7 @@ def config_secrets(node) -> set:
403
409
  return {x for x in out if len(x) >= 8}
404
410
 
405
411
 
406
- def scan_category(label, target, cfg, workdir, allow_remote):
412
+ def scan_category(label, target, cfg, workdir, allow_remote, openai=None):
407
413
  """generate + eval one category. Returns (findings, plugins_that_ran, errors).
408
414
 
409
415
  Plan 11 H12-T10 review: promptfoo's config and cases hold the iKanban token, so they live
@@ -412,12 +418,12 @@ def scan_category(label, target, cfg, workdir, allow_remote):
412
418
  replaced, are copied into the scan's work folder."""
413
419
  private = Path(tempfile.mkdtemp(prefix="ikanban-aiscan-")) # mkdtemp creates it 0700
414
420
  try:
415
- return _scan_category(label, target, cfg, workdir, allow_remote, private)
421
+ return _scan_category(label, target, cfg, workdir, allow_remote, private, openai)
416
422
  finally:
417
423
  shutil.rmtree(private, ignore_errors=True)
418
424
 
419
425
 
420
- def _scan_category(label, target, cfg, workdir, allow_remote, private):
426
+ def _scan_category(label, target, cfg, workdir, allow_remote, private, openai=None):
421
427
  cfg_path = private / "config.yaml"
422
428
  cases_path = private / "cases.yaml"
423
429
  out_path = private / "results.json"
@@ -428,7 +434,7 @@ def _scan_category(label, target, cfg, workdir, allow_remote, private):
428
434
  cfg_path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
429
435
 
430
436
  if not run_promptfoo(
431
- ["redteam", "generate", "-c", str(cfg_path), "-o", str(cases_path)], private, allow_remote
437
+ ["redteam", "generate", "-c", str(cfg_path), "-o", str(cases_path)], private, allow_remote, openai
432
438
  ):
433
439
  return None
434
440
  if not cases_path.exists():
@@ -438,7 +444,7 @@ def _scan_category(label, target, cfg, workdir, allow_remote, private):
438
444
  # tests failed, i.e. attacks got through, i.e. exactly the findings we came
439
445
  # for. The results file decides, not the exit code.
440
446
  run_promptfoo(
441
- ["redteam", "eval", "-c", str(cases_path), "-o", str(out_path), "--no-cache"], private, allow_remote
447
+ ["redteam", "eval", "-c", str(cases_path), "-o", str(out_path), "--no-cache"], private, allow_remote, openai
442
448
  )
443
449
  if not out_path.exists():
444
450
  log(" promptfoo wrote no results file — the run genuinely failed")
@@ -446,7 +452,9 @@ def _scan_category(label, target, cfg, workdir, allow_remote, private):
446
452
 
447
453
  raw = out_path.read_text(encoding="utf-8")
448
454
  redacted = raw
449
- for secret in sorted(config_secrets(cfg), key=len, reverse=True):
455
+ # G22: the iKanban key promptfoo sends to the relay is hidden like the config's own.
456
+ relay_key = (openai or {}).get("OPENAI_API_KEY")
457
+ for secret in sorted(config_secrets(cfg) | ({relay_key} if relay_key else set()), key=len, reverse=True):
450
458
  redacted = redacted.replace(secret, "[hidden]")
451
459
  write_private(workdir / f"results-{re.sub(r'[^A-Za-z0-9_-]+', '_', label)}.json", redacted)
452
460
  doc = json.loads(raw)
@@ -701,6 +709,43 @@ def publish(findings, run_id, base_url, token, team_id, coverage, plugin_results
701
709
  return False, 0, 0
702
710
 
703
711
 
712
+ # G22 (owner): the attacks are made with the team's OpenAI key kept in iKanban. The key
713
+ # stays on the server: promptfoo's OpenAI base URL is iKanban's relay, which adds the key and
714
+ # forwards only the OpenAI calls promptfoo makes. promptfoo 0.123.1 reads OPENAI_API_HOST
715
+ # first, then OPENAI_API_BASE_URL, then OPENAI_BASE_URL (OpenAiGenericProvider.getApiUrl).
716
+ NO_OPENAI_KEY = ("AI endpoints skipped: no OpenAI key. Add the team's OpenAI key in iKanban "
717
+ "(Settings → AI Provider Keys), or set OPENAI_API_KEY in the shell that runs the scan.")
718
+ NO_AI_PLAN = "AI endpoints skipped: the team's plan has no AI findings (Pro+)."
719
+
720
+
721
+ def openai_env(environ, base_url, token, team):
722
+ """(promptfoo env changes, None) to scan, or (None, the SKIPPED line). A shell key wins."""
723
+ if environ.get("OPENAI_API_KEY"):
724
+ return {}, None
725
+ if not (token and team):
726
+ return None, NO_OPENAI_KEY
727
+ relay = f"{ikanban_api(base_url)}/api/teams/{urllib.parse.quote(team, safe='')}/security/ai-relay"
728
+ req = urllib.request.Request(relay, headers={"Authorization": f"Bearer {token}"})
729
+ try:
730
+ with _OPENER.open(req, timeout=30) as resp:
731
+ body = json.loads(resp.read().decode())
732
+ except urllib.error.HTTPError as exc:
733
+ if exc.code == 402:
734
+ return None, NO_AI_PLAN
735
+ return None, f"AI endpoints skipped: iKanban answered {exc.code} for the team's OpenAI key."
736
+ except (urllib.error.URLError, ValueError) as exc:
737
+ return None, f"AI endpoints skipped: could not reach iKanban for the team's OpenAI key ({type(exc).__name__})."
738
+ if not body.get("data", body).get("openai_key"):
739
+ return None, NO_OPENAI_KEY
740
+ return {
741
+ "OPENAI_API_HOST": None,
742
+ "OPENAI_API_BASE_URL": f"{relay}/v1",
743
+ "OPENAI_BASE_URL": f"{relay}/v1",
744
+ # The iKanban key, sent only to the relay above (iKanban's API, T3).
745
+ "OPENAI_API_KEY": token,
746
+ }, None
747
+
748
+
704
749
  # ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
705
750
  # <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
706
751
  # holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
@@ -909,7 +954,8 @@ def main(argv=None):
909
954
  ap.add_argument("--allow-remote-generation", action="store_true",
910
955
  help="Let promptfoo generate attacks on its hosted service. Off by default: "
911
956
  "it needs an email registered with promptfoo, and it sends the purpose "
912
- "string off this machine. Local generation needs OPENAI_API_KEY instead.")
957
+ "string off this machine. Local generation uses OPENAI_API_KEY from the shell, "
958
+ "else the team's OpenAI key in iKanban through its relay.")
913
959
  ap.add_argument("--dry-run", action="store_true", help="Print the payload, POST nothing")
914
960
  ap.add_argument("--allow-outside-cwd", action="store_true",
915
961
  help="Permit --config, --html and --out to point outside the working "
@@ -980,6 +1026,15 @@ def main(argv=None):
980
1026
  log(f"error: unknown collection {args.plugins!r}")
981
1027
  return 2
982
1028
 
1029
+ openai = None
1030
+ if not args.allow_remote_generation:
1031
+ openai, why = openai_env(os.environ, args.base_url, args.token, args.team)
1032
+ if openai is None:
1033
+ log(why)
1034
+ progress(why)
1035
+ LAST_RUN.update(report=f"# AI endpoint scan — {args.target}\n\n{why}", detail=why[:300], skipped=True)
1036
+ return 0
1037
+
983
1038
  # MEASURED 2026-09-20, promptfoo 0.123.1: of the MCP plugin set, only `rbac`
984
1039
  # generates locally. `mcp` itself, `bfla` and `bola` all require promptfoo's
985
1040
  # hosted generator and produce zero tests without it -- so a local-only MCP
@@ -1018,7 +1073,7 @@ def main(argv=None):
1018
1073
  progress(f"{label} started · {len(plugins)} plugins, {args.num_tests} tests each")
1019
1074
  cfg = build_config(args.target, spec, plugins, args.num_tests,
1020
1075
  args.base_url, args.token, args.workspace_id)
1021
- got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation)
1076
+ got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation, openai)
1022
1077
  if got is None:
1023
1078
  progress(f"{label} finished · did not run")
1024
1079
  failed.append(label)