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 +26 -0
- package/README.md +23 -0
- package/bin/ikanban.js +5 -2
- package/package.json +3 -3
- package/scanners/ikanban-aiscan.py +141 -2
- package/scanners/ikanban-dastscan.py +450 -0
- package/scanners/ikanban-infrascan.py +511 -0
- package/scanners/ikanban-secscan.py +132 -4
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(
|
|
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
|
+
"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": "
|
|
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.
|
|
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))
|