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 +16 -1
- package/package.json +1 -1
- package/scanners/ikanban-aiscan.py +88 -10
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
|
+
"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.
|
|
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
|
-
|
|
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
|
|
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)
|