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 +1 -1
- package/scanners/ikanban-aiscan.py +65 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ikanban",
|
|
3
|
-
"version": "0.4.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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)
|