ikanban 0.3.3 → 0.4.0

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/LICENSE.md ADDED
@@ -0,0 +1,26 @@
1
+ # iKanban CLI licence
2
+
3
+ Copyright © VerBodh Pte. Ltd., Singapore. All rights reserved.
4
+
5
+ The `ikanban` package (the command-line tool and the scanners it runs) is proprietary software.
6
+ It is not open source.
7
+
8
+ ## What you may do
9
+ - Install and run it to scan your own code, AI endpoints and reports, and to send the results to
10
+ your own iKanban account.
11
+ - Run it on your own machines and in your own CI.
12
+ - Run it without an iKanban account; publishing results needs one (the server checks your plan).
13
+
14
+ ## What you may not do
15
+ - Copy, change, or share the package or its source, except as needed to run it.
16
+ - Sell, rent, sublicense, or offer it as part of another product or service.
17
+ - Use it to build a product that competes with iKanban.
18
+ - Remove this notice.
19
+
20
+ ## No warranty
21
+ The package is provided "as is", without warranty of any kind. VerBodh Pte. Ltd. is not liable for
22
+ any claim or damage that comes from using it. A scan can miss a vulnerability; a clean report is not
23
+ proof that code is safe.
24
+
25
+ ## Questions
26
+ support@verbodh.com · https://i-kanban.com
package/README.md CHANGED
@@ -7,6 +7,9 @@ results into [iKanban](https://i-kanban.com).
7
7
  npx ikanban secscan # dependencies, committed secrets, risky file permissions
8
8
  npx ikanban aiscan --help # red-team scan of your AI app (OWASP LLM Top 10)
9
9
  npx ikanban secscan --publish # also file the report and findings into iKanban
10
+ npx ikanban infrascan --image app:1 # IaC misconfiguration and container image scan (Trivy)
11
+ npx ikanban dastscan --url URL # passive web scan of a registered URL (OWASP ZAP in Docker)
12
+ npx ikanban secscan --task IKA-123 # also show the run in that task's Pipelines
10
13
  npx ikanban roadmap sync plan.json # make the Roadmap plan match the plan file
11
14
  npx ikanban hooks install # sync plan files on every agent write and git push
12
15
  ```
@@ -23,10 +26,26 @@ exits 1 when the Roadmap differs. With no file it syncs every tracked plan file.
23
26
  agent hook for Claude Code, Gemini CLI and Copilot CLI (`.iKanban/hooks/roadmap-sync.js`).
24
27
  No CI is needed.
25
28
 
29
+ ## Infrastructure scans
30
+
31
+ `infrascan` runs [Trivy](https://github.com/aquasecurity/trivy) on the repository's IaC files
32
+ (`trivy fs --scanners misconfig`) and on each `--image` (`trivy image`). `dastscan` runs the
33
+ [OWASP ZAP](https://www.zaproxy.org/docs/docker/baseline-scan/) baseline scan in Docker: it reads
34
+ the site's responses and does not attack. It scans only a URL the team registered in iKASecure →
35
+ Infrastructure, so it always needs `--team` and a key. Findings show on the Infrastructure tab. A
36
+ missing Trivy or Docker is reported as SKIPPED with the install command, never as a pass.
37
+
38
+ ## Pipelines
39
+
40
+ Every scanner takes `--task IKA-123` (or `IKANBAN_TASK`) and `--task-team` (default `--team`). The
41
+ run then shows in that task's Pipelines view: running, then passed, failed or skipped with a
42
+ one-line summary, and the masked report as its log. A plan without Pipelines never fails the scan.
43
+
26
44
  ## What you need
27
45
 
28
46
  - Node.js 18 or newer.
29
47
  - Python 3.9 or newer. The scanners are Python; `ikanban` finds `python3`, `python` or `py -3`.
48
+ - `infrascan`: Trivy (macOS `brew install trivy`; other systems: a release binary). `dastscan`: Docker.
30
49
 
31
50
  ## Free and paid
32
51
 
@@ -38,3 +57,7 @@ iKanban checks your plan when you publish; a plan that does not include it gets
38
57
 
39
58
  Nothing, unless you pass `--publish`. With `--publish`, the report and its findings go to iKanban.
40
59
  Secrets found in your repo are masked in the report before anything is sent.
60
+
61
+ ## Licence
62
+
63
+ Proprietary: you may run it for your own code and your own iKanban account. See [LICENSE.md](LICENSE.md).
package/bin/ikanban.js CHANGED
@@ -10,10 +10,12 @@ const { version } = require("../package.json");
10
10
  const COMMANDS = {
11
11
  aiscan: { file: "ikanban-aiscan.py", about: "Red-team scan of your AI app (OWASP LLM Top 10)" },
12
12
  secscan: { file: "ikanban-secscan.py", about: "Dependency, secret and file-permission scan of a repo" },
13
+ infrascan: { file: "ikanban-infrascan.py", about: "Container image and IaC scan (needs Trivy)" },
14
+ dastscan: { file: "ikanban-dastscan.py", about: "Passive web scan of a registered URL (needs Docker)" },
13
15
  };
14
16
 
15
17
  function help() {
16
- const lines = Object.entries(COMMANDS).map(([n, c]) => ` ${n.padEnd(9)}${c.about}`);
18
+ const lines = Object.entries(COMMANDS).map(([n, c]) => ` ${n.padEnd(10)}${c.about}`);
17
19
  console.log(`ikanban ${version}
18
20
 
19
21
  Usage: ikanban <command> [options]
@@ -26,7 +28,8 @@ ${lines.join("\n")}
26
28
  pipeline Report a task's stage and stream its log to iKanban (any CI)
27
29
 
28
30
  Run "ikanban <command> --help" for its options. Add --publish to file the
29
- results into iKanban (needs an API token; the server checks your plan).
31
+ results into iKanban (needs an API token; the server checks your plan), and
32
+ --task IKA-123 to show the run in that task's Pipelines.
30
33
  Needs Python 3.9 or newer.`);
31
34
  }
32
35
 
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "ikanban",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
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
- "files": ["bin", "lib", "hooks", "scanners", "README.md"],
6
+ "files": ["bin", "lib", "hooks", "scanners", "README.md", "LICENSE.md"],
7
7
  "scripts": {
8
8
  "prepack": "node scripts/copy-scanners.js",
9
9
  "pretest": "node scripts/copy-scanners.js",
@@ -13,5 +13,5 @@
13
13
  "homepage": "https://i-kanban.com",
14
14
  "keywords": ["ikanban", "security", "scanner", "ai", "cli"],
15
15
  "author": "VerBodh <support@verbodh.com>",
16
- "license": "UNLICENSED"
16
+ "license": "SEE LICENSE IN LICENSE.md"
17
17
  }
@@ -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.3.2"
60
+ AISCAN_VERSION = "0.4.0"
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).
@@ -669,6 +669,132 @@ def publish(findings, run_id, base_url, token, team_id, coverage, plugin_results
669
669
  return False, 0, 0
670
670
 
671
671
 
672
+ # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
673
+ # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
674
+ # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
675
+ # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
676
+ _UUID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
677
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
678
+ LAST_RUN = {"report": "", "detail": "", "skipped": False}
679
+
680
+
681
+ def add_pipeline_args(ap):
682
+ ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
683
+ help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
684
+ "or IKANBAN_TASK)")
685
+ ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
686
+ help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
687
+
688
+
689
+ def summary_detail(counts: dict, total: int, skipped: list) -> str:
690
+ if skipped and not total and all(s for s in skipped):
691
+ return ("SKIPPED: " + "; ".join(skipped))[:300]
692
+ worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
693
+ return f"{total} findings" + (f": {worst}" if worst else "")
694
+
695
+
696
+ def mask_report(text: str) -> str:
697
+ """The report is masked already; mask again before it leaves for Pipelines."""
698
+ scrub = globals().get("scrub_report")
699
+ if scrub:
700
+ return scrub(text)[0]
701
+ text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
702
+ home = str(Path.home())
703
+ return text.replace(home, "~") if home and home != "/" else text
704
+
705
+
706
+ def log_chunks(text: str) -> list:
707
+ """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
708
+ chunks, cur = [], ""
709
+ for line in text.splitlines(keepends=True):
710
+ while len(line.encode()) > PIPE_CHUNK:
711
+ head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
712
+ chunks.append(head)
713
+ line = line[len(head):]
714
+ if len((cur + line).encode()) > PIPE_CHUNK:
715
+ chunks.append(cur)
716
+ cur = ""
717
+ cur += line
718
+ if cur:
719
+ chunks.append(cur)
720
+ return chunks
721
+
722
+
723
+ class Pipeline:
724
+ def __init__(self, stage, task, team, base_url, token):
725
+ self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
726
+ self.on = bool(task and team and token)
727
+ self.tid = None
728
+
729
+ def _off(self, why):
730
+ self.on = False
731
+ note = "the plan does not include Pipelines" if "402" in str(why) else why
732
+ print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
733
+
734
+ def _call(self, method, path, body=None):
735
+ try:
736
+ return api(method, path, self.base, self.token, body)
737
+ except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
738
+ return False, str(e)
739
+
740
+ def _post(self, path, body):
741
+ if not self.on:
742
+ return
743
+ if self.tid is None:
744
+ if _UUID.match(self.team):
745
+ self.tid = self.team
746
+ else:
747
+ ok, teams = self._call("GET", "/teams")
748
+ if not ok:
749
+ return self._off(teams)
750
+ found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
751
+ if not found:
752
+ return self._off(f"team {self.team} not found among your teams")
753
+ self.tid = found[0]
754
+ ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
755
+ if not ok:
756
+ self._off(data or "this iKanban server has no Pipelines")
757
+
758
+ def start(self):
759
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
760
+ "source": "laptop", "actor": f"ikanban-{self.stage}"})
761
+
762
+ def finish(self, code):
763
+ status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
764
+ detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
765
+ if code == 1 and not LAST_RUN["skipped"]:
766
+ detail = (detail + " (at or above --fail-on)").strip()
767
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
768
+ "source": "laptop", "actor": f"ikanban-{self.stage}",
769
+ "detail": detail[:300] or None})
770
+ for chunk in log_chunks(mask_report(LAST_RUN["report"])):
771
+ self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
772
+
773
+
774
+ def run_main(stage, main_fn, argv=None):
775
+ """main() as it is, wrapped in Pipelines events when --task is given."""
776
+ raw = sys.argv[1:] if argv is None else argv
777
+ if any(a in ("-h", "--help", "--version", "--list-apps") for a in raw):
778
+ return main_fn(argv)
779
+ pre = argparse.ArgumentParser(add_help=False)
780
+ pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
781
+ pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
782
+ pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
783
+ pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
784
+ pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
785
+ known, _ = pre.parse_known_args(raw)
786
+ pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
787
+ pl.start()
788
+ try:
789
+ code = main_fn(argv)
790
+ except SystemExit as e:
791
+ if not isinstance(e.code, int):
792
+ print(e.code, file=sys.stderr)
793
+ code = e.code if isinstance(e.code, int) else 2
794
+ pl.finish(code)
795
+ return code
796
+
797
+
672
798
  def main(argv=None):
673
799
  ap = argparse.ArgumentParser(prog="ikanban aiscan")
674
800
  # Lets someone who downloaded a copy tell which release it is, and verify it
@@ -701,6 +827,7 @@ def main(argv=None):
701
827
  help="Permit --config, --html and --out to point outside the working "
702
828
  "directory. Off by default so a mistyped path cannot overwrite "
703
829
  "something elsewhere; turn it on for CI writing to an artifacts dir.")
830
+ add_pipeline_args(ap)
704
831
  args = ap.parse_args(argv)
705
832
 
706
833
  # Resolve every caller-supplied path up front, before any scanning happens.
@@ -857,6 +984,18 @@ def main(argv=None):
857
984
  for label, missing in uncovered:
858
985
  log(f" partial — {label}: {len(missing)} plugin(s) unavailable locally")
859
986
 
987
+ # Plan 11 H12: the Pipelines log (no attack prompts or answers: titles and counts only).
988
+ counts = {s: sum(1 for f in findings if f["severity"] == s) for s in SEVERITIES}
989
+ LAST_RUN.update(
990
+ report="\n".join(
991
+ [f"# AI endpoint scan — {args.target}", "", f"{len(findings)} finding(s) across {scanned} categories tested"]
992
+ + [f"- [{f['severity']}] {f['llm_category']}: {f['title']}" for f in findings]
993
+ + [f"- not covered: {label}" for label in skipped]
994
+ + [f"- DID NOT RUN: {label}" for label in failed]),
995
+ detail=summary_detail(counts, len(findings), []) + (f"; {len(failed)} categories did not run" if failed else ""),
996
+ skipped=scanned == 0 and not findings,
997
+ )
998
+
860
999
  coverage = {
861
1000
  "target": args.target,
862
1001
  "categories_tested": scanned,
@@ -911,4 +1050,4 @@ def main(argv=None):
911
1050
 
912
1051
 
913
1052
  if __name__ == "__main__":
914
- sys.exit(main())
1053
+ sys.exit(run_main("aiscan", main))