ikanban 0.4.3 → 0.4.5

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/lib/pipeline.js CHANGED
@@ -5,6 +5,8 @@
5
5
  // Pipelines view (secrets masked first), posts running → passed/failed, and exits
6
6
  // with the command's own exit code. iKanban being down never fails the command.
7
7
  // ikanban pipeline event --task IKA-1 --stage deploy --status passed [--url U] [--sha S] [--detail D]
8
+ // ikanban pipeline event --by-url --stage checks --status passed --url U
9
+ // the end of a CI run, to every task whose stage is still open on that run link
8
10
  // ikanban pipeline log --task IKA-1 --stage build < output.txt
9
11
  //
10
12
  // Team: --team or IKANBAN_TEAM (identifier like IKA, or its id). Key: VIBE_API_TOKEN.
@@ -31,6 +33,9 @@ function masker(values) {
31
33
  return (line) => values.reduce((s, v) => s.split(v).join("***"), line);
32
34
  }
33
35
 
36
+ /** Options that take no value. */
37
+ const FLAGS = ["--by-url"];
38
+
34
39
  function parse(args) {
35
40
  const opts = { _: [] };
36
41
  for (let i = 0; i < args.length; i++) {
@@ -40,6 +45,7 @@ function parse(args) {
40
45
  break;
41
46
  }
42
47
  if (!a.startsWith("--")) opts._.push(a);
48
+ else if (FLAGS.includes(a)) opts[a.slice(2)] = true;
43
49
  else opts[a.slice(2)] = args[++i];
44
50
  }
45
51
  return opts;
@@ -80,7 +86,7 @@ function connection(opts) {
80
86
  const [task, run] = t.split("@");
81
87
  return { task, run: run || opts.run || process.env.IKANBAN_RUN || undefined };
82
88
  });
83
- if (!tasks.length) throw new Error("set --task (for example IKA-123; several: IKA-1,IKA-2; with a run: IKA-1@pr-159)");
89
+ if (!tasks.length && !opts["by-url"]) throw new Error("set --task (for example IKA-123; several: IKA-1,IKA-2; with a run: IKA-1@pr-159)");
84
90
  if (!opts.stage) throw new Error("set --stage (for example build, deploy, smoke)");
85
91
  return {
86
92
  base: apiOrigin(opts["base-url"] || process.env.IKANBAN_BASE_URL || "https://api.i-kanban.com"),
@@ -192,8 +198,15 @@ async function run(args) {
192
198
  if (sub === "event") {
193
199
  const conn = connection(opts);
194
200
  if (!opts.status) throw new Error("set --status (queued, running, passed, failed, skipped, held, cancelled)");
201
+ if (opts["by-url"] && !opts.url) throw new Error("--by-url needs --url (the run's link)");
195
202
  const tid = await teamId(conn, conn.team);
196
203
  try {
204
+ if (opts["by-url"]) {
205
+ const body = { stage: opts.stage, status: opts.status, url: opts.url, commit_sha: opts.sha, detail: opts.detail, source: conn.source, actor: conn.actor };
206
+ const done = await api(`/api/teams/${tid}/pipeline-events/by-url`, { ...conn, method: "POST", body });
207
+ console.log(`${opts.stage} ${opts.status}: ${(done && done.tasks ? done.tasks.length : 0)} open task(s) on that link`);
208
+ return 0;
209
+ }
197
210
  for (const t of conn.tasks) {
198
211
  await postEvent(conn, tid, t, { stage: opts.stage, status: opts.status, url: opts.url, commit_sha: opts.sha, detail: opts.detail });
199
212
  }
@@ -221,6 +234,8 @@ async function run(args) {
221
234
  const HELP = `Usage:
222
235
  ikanban pipeline run --task IKA-1[@pr-159][,IKA-2] --stage build [--url U] [--sha S] [--mask NAME,...] -- <command> [args...]
223
236
  ikanban pipeline event --task IKA-1 --stage deploy --status passed [--url U] [--sha S] [--detail D]
237
+ ikanban pipeline event --by-url --stage checks --status passed --url U
238
+ (the end of a CI run, to every task whose stage is still open on that link)
224
239
  ikanban pipeline log --task IKA-1 --stage build < output.txt
225
240
  Every command also takes --run R (the run: a PR like pr-159, or a branch), --source S (where:
226
241
  github, gitlab, laptop, ci) and --actor A (who). One run per task: IKA-1@pr-159.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikanban",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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.2"
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)
@@ -655,6 +663,29 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
655
663
  _OPENER = urllib.request.build_opener(_NoRedirect)
656
664
 
657
665
 
666
+ def api(method: str, path: str, base_url: str, token: str, payload=None):
667
+ """(ok, data | error text | None for 404). Same helper as secscan's; the Pipelines code
668
+ below calls it (seen on dev: without it `--task` crashed with a NameError)."""
669
+ req = urllib.request.Request(
670
+ f"{ikanban_api(base_url)}/api{path}",
671
+ data=json.dumps(payload).encode() if payload is not None else None,
672
+ headers={"Authorization": f"Bearer {token}",
673
+ "Content-Type": "application/json"},
674
+ method=method,
675
+ )
676
+ try:
677
+ with _OPENER.open(req, timeout=30) as r:
678
+ body = json.loads(r.read().decode() or "{}")
679
+ return True, body.get("data", body)
680
+ except urllib.error.HTTPError as e:
681
+ detail = e.read().decode()[:300]
682
+ if e.code in (404, 405) and not detail.strip():
683
+ return False, None
684
+ return False, f"HTTP {e.code}: {detail}"
685
+ except Exception as e: # noqa: BLE001 — surface any transport failure verbatim
686
+ return False, str(e)
687
+
688
+
658
689
  def publish(findings, run_id, base_url, token, team_id, coverage, plugin_results):
659
690
  payload = {
660
691
  "scan_run_id": run_id,
@@ -701,6 +732,43 @@ def publish(findings, run_id, base_url, token, team_id, coverage, plugin_results
701
732
  return False, 0, 0
702
733
 
703
734
 
735
+ # G22 (owner): the attacks are made with the team's OpenAI key kept in iKanban. The key
736
+ # stays on the server: promptfoo's OpenAI base URL is iKanban's relay, which adds the key and
737
+ # forwards only the OpenAI calls promptfoo makes. promptfoo 0.123.1 reads OPENAI_API_HOST
738
+ # first, then OPENAI_API_BASE_URL, then OPENAI_BASE_URL (OpenAiGenericProvider.getApiUrl).
739
+ NO_OPENAI_KEY = ("AI endpoints skipped: no OpenAI key. Add the team's OpenAI key in iKanban "
740
+ "(Settings → AI Provider Keys), or set OPENAI_API_KEY in the shell that runs the scan.")
741
+ NO_AI_PLAN = "AI endpoints skipped: the team's plan has no AI findings (Pro+)."
742
+
743
+
744
+ def openai_env(environ, base_url, token, team):
745
+ """(promptfoo env changes, None) to scan, or (None, the SKIPPED line). A shell key wins."""
746
+ if environ.get("OPENAI_API_KEY"):
747
+ return {}, None
748
+ if not (token and team):
749
+ return None, NO_OPENAI_KEY
750
+ relay = f"{ikanban_api(base_url)}/api/teams/{urllib.parse.quote(team, safe='')}/security/ai-relay"
751
+ req = urllib.request.Request(relay, headers={"Authorization": f"Bearer {token}"})
752
+ try:
753
+ with _OPENER.open(req, timeout=30) as resp:
754
+ body = json.loads(resp.read().decode())
755
+ except urllib.error.HTTPError as exc:
756
+ if exc.code == 402:
757
+ return None, NO_AI_PLAN
758
+ return None, f"AI endpoints skipped: iKanban answered {exc.code} for the team's OpenAI key."
759
+ except (urllib.error.URLError, ValueError) as exc:
760
+ return None, f"AI endpoints skipped: could not reach iKanban for the team's OpenAI key ({type(exc).__name__})."
761
+ if not body.get("data", body).get("openai_key"):
762
+ return None, NO_OPENAI_KEY
763
+ return {
764
+ "OPENAI_API_HOST": None,
765
+ "OPENAI_API_BASE_URL": f"{relay}/v1",
766
+ "OPENAI_BASE_URL": f"{relay}/v1",
767
+ # The iKanban key, sent only to the relay above (iKanban's API, T3).
768
+ "OPENAI_API_KEY": token,
769
+ }, None
770
+
771
+
704
772
  # ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
705
773
  # <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
706
774
  # holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
@@ -909,7 +977,8 @@ def main(argv=None):
909
977
  ap.add_argument("--allow-remote-generation", action="store_true",
910
978
  help="Let promptfoo generate attacks on its hosted service. Off by default: "
911
979
  "it needs an email registered with promptfoo, and it sends the purpose "
912
- "string off this machine. Local generation needs OPENAI_API_KEY instead.")
980
+ "string off this machine. Local generation uses OPENAI_API_KEY from the shell, "
981
+ "else the team's OpenAI key in iKanban through its relay.")
913
982
  ap.add_argument("--dry-run", action="store_true", help="Print the payload, POST nothing")
914
983
  ap.add_argument("--allow-outside-cwd", action="store_true",
915
984
  help="Permit --config, --html and --out to point outside the working "
@@ -980,6 +1049,15 @@ def main(argv=None):
980
1049
  log(f"error: unknown collection {args.plugins!r}")
981
1050
  return 2
982
1051
 
1052
+ openai = None
1053
+ if not args.allow_remote_generation:
1054
+ openai, why = openai_env(os.environ, args.base_url, args.token, args.team)
1055
+ if openai is None:
1056
+ log(why)
1057
+ progress(why)
1058
+ LAST_RUN.update(report=f"# AI endpoint scan — {args.target}\n\n{why}", detail=why[:300], skipped=True)
1059
+ return 0
1060
+
983
1061
  # MEASURED 2026-09-20, promptfoo 0.123.1: of the MCP plugin set, only `rbac`
984
1062
  # generates locally. `mcp` itself, `bfla` and `bola` all require promptfoo's
985
1063
  # hosted generator and produce zero tests without it -- so a local-only MCP
@@ -1018,7 +1096,7 @@ def main(argv=None):
1018
1096
  progress(f"{label} started · {len(plugins)} plugins, {args.num_tests} tests each")
1019
1097
  cfg = build_config(args.target, spec, plugins, args.num_tests,
1020
1098
  args.base_url, args.token, args.workspace_id)
1021
- got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation)
1099
+ got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation, openai)
1022
1100
  if got is None:
1023
1101
  progress(f"{label} finished · did not run")
1024
1102
  failed.append(label)