ikanban 0.4.6 → 0.4.8
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
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ikanban",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.8",
|
|
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"],
|
|
@@ -49,7 +49,7 @@ import urllib.request
|
|
|
49
49
|
from datetime import datetime, timezone
|
|
50
50
|
from pathlib import Path
|
|
51
51
|
|
|
52
|
-
CLOUDSCAN_VERSION = "1.
|
|
52
|
+
CLOUDSCAN_VERSION = "1.1.0"
|
|
53
53
|
|
|
54
54
|
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
55
55
|
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
@@ -210,6 +210,13 @@ def check_id(row: dict) -> str:
|
|
|
210
210
|
return str((row.get("metadata") or {}).get("event_code") or "")
|
|
211
211
|
|
|
212
212
|
|
|
213
|
+
def fail_title(check_title: str) -> str:
|
|
214
|
+
"""Prowler's check title names the passing state ("S3 bucket is not publicly accessible"), so the
|
|
215
|
+
published title says it fails. Built from the check title only: Prowler's status_detail / message
|
|
216
|
+
is raw scanner text that can quote resource content, so it stays in the local Prowler output."""
|
|
217
|
+
return f"Fails: {check_title}"
|
|
218
|
+
|
|
219
|
+
|
|
213
220
|
def parse_rows(rows: list, region: str) -> tuple[list, int]:
|
|
214
221
|
"""(failed findings, how many RDS resources the run saw). Only plain fields are kept:
|
|
215
222
|
check id, title, resource ARN, region, severity, Prowler's remediation text."""
|
|
@@ -230,7 +237,7 @@ def parse_rows(rows: list, region: str) -> tuple[list, int]:
|
|
|
230
237
|
findings.append({
|
|
231
238
|
"check": cid, "severity": sev, "resource": uid,
|
|
232
239
|
"region": str(res.get("region") or (r.get("cloud") or {}).get("region") or region),
|
|
233
|
-
"title": str(info.get("title") or cid),
|
|
240
|
+
"title": fail_title(str(info.get("title") or cid)),
|
|
234
241
|
"remediation": str(fix),
|
|
235
242
|
})
|
|
236
243
|
return findings, len(rds)
|
|
@@ -28,7 +28,9 @@ Plan 11 H14: --openapi FILE_OR_URL runs ZAP's API scan instead, from an OpenAPI
|
|
|
28
28
|
-e ZAP_AUTH_HEADER_SITE] ghcr.io/zaproxy/zaproxy:stable \
|
|
29
29
|
zap-api-scan.py -t SPEC -f openapi -J zap.json -T MAX_MINUTES [-S]
|
|
30
30
|
(zaproxy.org/docs/docker/api-scan: it imports the definition and runs an active scan against
|
|
31
|
-
the URLs found; -S is safe mode, a baseline (passive) scan; exit codes as above
|
|
31
|
+
the URLs found; -S is safe mode, a baseline (passive) scan; exit codes as above; -T bounds only
|
|
32
|
+
ZAP's start and passive scan, so the active scan gets -z "-config scanner.maxScanDurationInMins=N",
|
|
33
|
+
and --rate N paces requests with the Network add-on's rate limit rule, see zap_config.) The findings
|
|
32
34
|
file as kind "api". The spec is a URL on the registered target's own origin (downloaded here,
|
|
33
35
|
no redirect, at most 5 MB; ZAP never gets a URL) or a local JSON file. Every servers entry must be
|
|
34
36
|
that origin or a path starting with '/' (no templates or variables); a checked copy with each server
|
|
@@ -47,16 +49,18 @@ import hashlib
|
|
|
47
49
|
import json
|
|
48
50
|
import os
|
|
49
51
|
import re
|
|
52
|
+
import shlex
|
|
50
53
|
import shutil
|
|
51
54
|
import subprocess
|
|
52
55
|
import sys
|
|
56
|
+
import time
|
|
53
57
|
import urllib.error
|
|
54
58
|
import urllib.parse
|
|
55
59
|
import urllib.request
|
|
56
60
|
from datetime import datetime, timezone
|
|
57
61
|
from pathlib import Path
|
|
58
62
|
|
|
59
|
-
DASTSCAN_VERSION = "1.
|
|
63
|
+
DASTSCAN_VERSION = "1.4.0"
|
|
60
64
|
|
|
61
65
|
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
62
66
|
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
@@ -69,6 +73,11 @@ MAX_URL = 2000
|
|
|
69
73
|
# Plan 11 H14 (owner): an active scan of production is refused; --passive may read it.
|
|
70
74
|
PRODUCTION_HOSTS = ("api.i-kanban.com",)
|
|
71
75
|
MAX_SPEC_BYTES = 5 * 1024 * 1024
|
|
76
|
+
# The scanner waits this long past --max-minutes for ZAP to write its report, then stops it.
|
|
77
|
+
GRACE_MINUTES = 3
|
|
78
|
+
# Requests per second for an active API scan unless --rate says otherwise (the dev API
|
|
79
|
+
# answered 429 643 times without a limit). Passive scans send few requests: no limit.
|
|
80
|
+
DEFAULT_API_RATE = 10
|
|
72
81
|
|
|
73
82
|
|
|
74
83
|
def check_url(url: str) -> str | None:
|
|
@@ -252,7 +261,8 @@ def findings_payload(url: str, findings: list, kind: str = "dast") -> list:
|
|
|
252
261
|
} for f in findings]
|
|
253
262
|
|
|
254
263
|
|
|
255
|
-
def build_report(url: str, findings: list, skipped: list, ran: list, mode: str = "ZAP baseline, passive"
|
|
264
|
+
def build_report(url: str, findings: list, skipped: list, ran: list, mode: str = "ZAP baseline, passive",
|
|
265
|
+
incomplete: str = "") -> tuple:
|
|
256
266
|
counts = {s: 0 for s in SEVERITIES}
|
|
257
267
|
for f in findings:
|
|
258
268
|
counts[f["severity"]] += 1
|
|
@@ -268,6 +278,8 @@ def build_report(url: str, findings: list, skipped: list, ran: list, mode: str =
|
|
|
268
278
|
L.append(f"_ran: {', '.join(ran)}_")
|
|
269
279
|
for s in skipped:
|
|
270
280
|
L.append(f"- ⚠️ **SKIPPED** — {s}")
|
|
281
|
+
if incomplete:
|
|
282
|
+
L.append(f"- ⚠️ **INCOMPLETE** — {incomplete}")
|
|
271
283
|
if ran and not findings:
|
|
272
284
|
L.append("- ✅ no findings")
|
|
273
285
|
for f in sorted(findings, key=lambda x: SEVERITY_RANK[x["severity"]]):
|
|
@@ -280,11 +292,30 @@ def build_report(url: str, findings: list, skipped: list, ran: list, mode: str =
|
|
|
280
292
|
return "\n".join(L), counts, total
|
|
281
293
|
|
|
282
294
|
|
|
295
|
+
def zap_config(host: str, active_api: bool, max_minutes: int, rate: int) -> list:
|
|
296
|
+
"""ZAP -config options (-z). zaproxy.org/docs/docker/api-scan: -T bounds only ZAP's start and the
|
|
297
|
+
passive scan, so an active scan is capped by scanner.maxScanDurationInMins (ScannerParam.java),
|
|
298
|
+
2 minutes under --max-minutes to leave time for the start and the report. The pace is the Network
|
|
299
|
+
add-on's rate limit (Options → Network → Rate Limit; keys from RateLimitOptions.java): one rule
|
|
300
|
+
for the target host, `rate` requests per second."""
|
|
301
|
+
opts = []
|
|
302
|
+
if active_api:
|
|
303
|
+
opts += ["-config", f"scanner.maxScanDurationInMins={max(1, max_minutes - 2)}"]
|
|
304
|
+
if rate:
|
|
305
|
+
rule = "network.ratelimit.rules.rule(0)."
|
|
306
|
+
opts += ["-config", rule + "description=ikanban-dastscan", "-config", rule + "enabled=true",
|
|
307
|
+
"-config", rule + "regex=false", "-config", rule + f"matchStr={host}",
|
|
308
|
+
"-config", rule + f"reqsPerSec={rate}", "-config", rule + "groupBy=RULE"]
|
|
309
|
+
return opts
|
|
310
|
+
|
|
311
|
+
|
|
283
312
|
def run_zap(url: str, minutes: int, max_minutes: int, work: Path,
|
|
284
|
-
spec_file: Path | None = None, passive: bool = True) -> tuple[dict | None, str]:
|
|
285
|
-
"""(report, "")
|
|
286
|
-
|
|
287
|
-
|
|
313
|
+
spec_file: Path | None = None, passive: bool = True, rate: int = 0) -> tuple[dict | None, str]:
|
|
314
|
+
"""(report, "") for a complete run; (report, why) for a partial report (ZAP hit the time limit
|
|
315
|
+
or failed after writing it: filed as an incomplete run); (None, why) when there is no report.
|
|
316
|
+
ZAP writes zap.json into <work>/zap/. With spec_file: the API scan (active unless passive), the
|
|
317
|
+
sign-in header from this shell's ZAP_AUTH_HEADER_VALUE passed by name only (`-e NAME`), its value
|
|
318
|
+
in the process environment."""
|
|
288
319
|
if not shutil.which("docker"):
|
|
289
320
|
return None, DOCKER_INSTALL
|
|
290
321
|
# The ZAP image writes as its own user, so it gets its own zap/ subfolder it may write to;
|
|
@@ -292,7 +323,10 @@ def run_zap(url: str, minutes: int, max_minutes: int, work: Path,
|
|
|
292
323
|
zap_dir = work / "zap"
|
|
293
324
|
zap_dir.mkdir(exist_ok=True)
|
|
294
325
|
os.chmod(zap_dir, 0o777)
|
|
295
|
-
|
|
326
|
+
# A name, so a run past the limit can be stopped: killing `docker run` leaves the container going.
|
|
327
|
+
name = f"ikanban-dastscan-{os.getpid()}-{int(time.time())}"
|
|
328
|
+
cmd, env = ["docker", "run", "--rm", "--name", name, "-v", f"{zap_dir}:/zap/wrk/:rw"], None
|
|
329
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
296
330
|
if spec_file is not None:
|
|
297
331
|
# The checked copy, read-only; -O pins every request to the registered host
|
|
298
332
|
# (zap-api-scan.py: "-O the hostname to override in the (remote) OpenAPI spec").
|
|
@@ -303,7 +337,7 @@ def run_zap(url: str, minutes: int, max_minutes: int, work: Path,
|
|
|
303
337
|
# Only what ZAP needs, plus PATH for docker itself: never VIBE_API_TOKEN or other keys.
|
|
304
338
|
env = {k: v for k, v in os.environ.items() if k in ("PATH", "HOME", "DOCKER_HOST", "DOCKER_CONFIG",
|
|
305
339
|
"DOCKER_CONTEXT", "ZAP_AUTH_HEADER")}
|
|
306
|
-
env.update(ZAP_AUTH_HEADER_VALUE=auth, ZAP_AUTH_HEADER_SITE=
|
|
340
|
+
env.update(ZAP_AUTH_HEADER_VALUE=auth, ZAP_AUTH_HEADER_SITE=host)
|
|
307
341
|
cmd += ["-e", "ZAP_AUTH_HEADER_VALUE", "-e", "ZAP_AUTH_HEADER_SITE"]
|
|
308
342
|
if "ZAP_AUTH_HEADER" in env:
|
|
309
343
|
cmd += ["-e", "ZAP_AUTH_HEADER"]
|
|
@@ -311,20 +345,38 @@ def run_zap(url: str, minutes: int, max_minutes: int, work: Path,
|
|
|
311
345
|
"-J", "zap.json", "-T", str(max_minutes)] + (["-S"] if passive else [])
|
|
312
346
|
else:
|
|
313
347
|
cmd += [ZAP_IMAGE, "zap-baseline.py", "-t", url, "-J", "zap.json", "-m", str(minutes), "-T", str(max_minutes)]
|
|
348
|
+
opts = zap_config(host, spec_file is not None and not passive, max_minutes, rate)
|
|
349
|
+
if opts:
|
|
350
|
+
cmd += ["-z", shlex.join(opts)] # zap_common.add_zap_options splits it with shlex
|
|
351
|
+
limit = max_minutes + GRACE_MINUTES
|
|
314
352
|
try:
|
|
315
|
-
p = subprocess.run(cmd, capture_output=True, text=True, timeout=
|
|
353
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=limit * 60, env=env)
|
|
354
|
+
code, tail = p.returncode, (p.stderr or p.stdout or "").strip()[-300:]
|
|
316
355
|
except FileNotFoundError:
|
|
317
356
|
return None, DOCKER_INSTALL
|
|
318
357
|
except subprocess.TimeoutExpired:
|
|
319
|
-
|
|
358
|
+
code, tail = None, ""
|
|
359
|
+
try:
|
|
360
|
+
subprocess.run(["docker", "stop", "-t", "30", name], capture_output=True, text=True, timeout=120)
|
|
361
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
362
|
+
pass
|
|
320
363
|
out = zap_dir / "zap.json"
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
364
|
+
report = None
|
|
365
|
+
if out.exists():
|
|
366
|
+
try:
|
|
367
|
+
report = json.loads(out.read_text() or "{}")
|
|
368
|
+
except ValueError:
|
|
369
|
+
report = None
|
|
370
|
+
if report is not None and code in (0, 1, 2):
|
|
371
|
+
return report, ""
|
|
372
|
+
if report is not None:
|
|
373
|
+
how = f"ZAP was stopped at the {limit}-minute limit" if code is None else f"ZAP exited {code}"
|
|
374
|
+
return report, f"{how}; its report is partial, so this run is filed as incomplete"
|
|
375
|
+
if code is None:
|
|
376
|
+
return None, f"ZAP did not finish within {limit} minutes and wrote no report"
|
|
377
|
+
if code in (0, 1, 2) and out.exists():
|
|
327
378
|
return None, "ZAP wrote no JSON report"
|
|
379
|
+
return None, f"ZAP failed (exit {code}): {tail or 'no report'}"
|
|
328
380
|
|
|
329
381
|
|
|
330
382
|
# ── copied from ikanban-secscan.py: key-host pinning and the API call ──────────────────
|
|
@@ -521,9 +573,12 @@ class Pipeline:
|
|
|
521
573
|
if not ok:
|
|
522
574
|
self._off(data or "this iKanban server has no Pipelines")
|
|
523
575
|
|
|
524
|
-
def start(self):
|
|
576
|
+
def start(self, url=""):
|
|
577
|
+
# Plan 11 H14: the URL as the running event's detail, so iKASecure's DAST targets list
|
|
578
|
+
# shows "Scanning…" on that target.
|
|
525
579
|
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
|
|
526
|
-
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"
|
|
580
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
581
|
+
"detail": url[:300] or None})
|
|
527
582
|
|
|
528
583
|
def log(self, line):
|
|
529
584
|
"""One progress line, sent as it happens (masked like the report). A failure only turns Pipelines off."""
|
|
@@ -553,9 +608,10 @@ def run_main(stage, main_fn, argv=None):
|
|
|
553
608
|
pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
|
|
554
609
|
pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
|
|
555
610
|
pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
|
|
611
|
+
pre.add_argument("--url", default="")
|
|
556
612
|
known, _ = pre.parse_known_args(raw)
|
|
557
613
|
pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
|
|
558
|
-
pl.start()
|
|
614
|
+
pl.start(known.url.strip())
|
|
559
615
|
global PIPE
|
|
560
616
|
PIPE = pl if pl.on else None
|
|
561
617
|
try:
|
|
@@ -581,7 +637,13 @@ def main(argv=None):
|
|
|
581
637
|
ap.add_argument("--passive", action="store_true",
|
|
582
638
|
help="With --openapi: no attacks (ZAP -S); the only way to scan the production iKanban API")
|
|
583
639
|
ap.add_argument("--minutes", type=int, default=1, help="Spider minutes (ZAP -m, default 1)")
|
|
584
|
-
ap.add_argument("--max-minutes", type=int, default=10,
|
|
640
|
+
ap.add_argument("--max-minutes", type=int, default=10,
|
|
641
|
+
help="Most minutes ZAP may take (default 10): ZAP -T, and for an active API scan "
|
|
642
|
+
"scanner.maxScanDurationInMins = this minus 2. The scanner waits 3 minutes more for "
|
|
643
|
+
"ZAP's report, then stops it; a late report files as an incomplete run")
|
|
644
|
+
ap.add_argument("--rate", type=int, default=None, metavar="N",
|
|
645
|
+
help="Most requests per second ZAP sends to the target (ZAP's Network rate limit). "
|
|
646
|
+
f"Default {DEFAULT_API_RATE} for an active API scan, no limit otherwise; 0 = no limit")
|
|
585
647
|
ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
|
|
586
648
|
ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
|
|
587
649
|
help="Team UUID (or IKANBAN_TEAM_ID); always needed, to check the URL is registered")
|
|
@@ -624,6 +686,9 @@ def main(argv=None):
|
|
|
624
686
|
print("error: dastscan needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID) "
|
|
625
687
|
"to check the URL is a registered target", file=sys.stderr)
|
|
626
688
|
return 2
|
|
689
|
+
if args.rate is not None and not 0 <= args.rate <= 1000:
|
|
690
|
+
print("error: --rate from 0 (no limit) to 1000 requests per second", file=sys.stderr)
|
|
691
|
+
return 2
|
|
627
692
|
if args.passive and not args.openapi:
|
|
628
693
|
print("error: --passive goes with --openapi (the baseline scan is always passive)", file=sys.stderr)
|
|
629
694
|
return 2
|
|
@@ -650,27 +715,32 @@ def main(argv=None):
|
|
|
650
715
|
write_private(spec_file, json.dumps(doc))
|
|
651
716
|
os.chmod(spec_file, 0o644) # ZAP reads it as its own user; the run folder above stays 0700
|
|
652
717
|
mode = "ZAP API scan, " + ("passive" if args.passive else "active")
|
|
718
|
+
rate = args.rate if args.rate is not None else (0 if args.passive else DEFAULT_API_RATE)
|
|
653
719
|
progress(f"dastscan {DASTSCAN_VERSION} · {mode} on {url}: at most {args.max_minutes} min …")
|
|
654
|
-
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work, spec_file, args.passive)
|
|
720
|
+
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work, spec_file, args.passive, rate)
|
|
655
721
|
ran = [f"zap-api-scan.py -f openapi -O {urllib.parse.urlsplit(url).netloc}"
|
|
656
722
|
+ (" -S" if args.passive else "")] if report is not None else []
|
|
657
723
|
else:
|
|
658
724
|
mode = "ZAP baseline, passive"
|
|
659
725
|
progress(f"dastscan {DASTSCAN_VERSION} · ZAP baseline on {url}: spider {args.minutes} min, "
|
|
660
726
|
f"at most {args.max_minutes} min …")
|
|
661
|
-
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work)
|
|
727
|
+
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work, None, True, args.rate or 0)
|
|
662
728
|
ran = [f"zap-baseline.py -t {url} -m {args.minutes}"] if report is not None else []
|
|
729
|
+
# A report with a reason is partial: filed, but as an incomplete run (no control reads Enforced).
|
|
730
|
+
incomplete = skipped_why if report is not None else ""
|
|
731
|
+
skipped_why = "" if report is not None else skipped_why
|
|
663
732
|
findings = parse_zap(report) if report is not None else []
|
|
664
|
-
progress(f"{mode.split(',')[0]} on {url} … " + (
|
|
665
|
-
|
|
666
|
-
md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran, mode)
|
|
733
|
+
progress(f"{mode.split(',')[0]} on {url} … " + ("SKIPPED (the reason is in the report)" if report is None else
|
|
734
|
+
f"done, {len(findings)} alerts" + (" (partial)" if incomplete else "")))
|
|
735
|
+
md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran, mode, incomplete)
|
|
667
736
|
LAST_RUN.update(report=md, detail=summary_detail(counts, total, [] if ran else [skipped_why]),
|
|
668
737
|
skipped=not ran)
|
|
669
738
|
write_private(work / "report.md", md)
|
|
670
739
|
if args.out:
|
|
671
740
|
Path(args.out).write_text(md)
|
|
672
741
|
if args.json:
|
|
673
|
-
print(json.dumps({"url": url, "total": total, "counts": counts, "skipped": bool(skipped_why)
|
|
742
|
+
print(json.dumps({"url": url, "total": total, "counts": counts, "skipped": bool(skipped_why),
|
|
743
|
+
"complete": report is not None and not incomplete}, indent=2))
|
|
674
744
|
else:
|
|
675
745
|
print(md)
|
|
676
746
|
sys.stdout.flush()
|
|
@@ -688,7 +758,7 @@ def main(argv=None):
|
|
|
688
758
|
print("ZAP did not run: nothing filed or closed", file=sys.stderr)
|
|
689
759
|
else:
|
|
690
760
|
ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token,
|
|
691
|
-
{"findings": findings_payload(url, findings, kind), "scope": url, "complete":
|
|
761
|
+
{"findings": findings_payload(url, findings, kind), "scope": url, "complete": not incomplete,
|
|
692
762
|
"kind": kind})
|
|
693
763
|
if not ok:
|
|
694
764
|
print(f"filing {kind} findings FAILED: {data or 'this iKanban server does not take them yet'}",
|