ikanban 0.3.4 → 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/README.md +19 -0
- package/bin/ikanban.js +5 -2
- package/package.json +1 -1
- 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/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
|
|
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,6 +1,6 @@
|
|
|
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
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.
|
|
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))
|