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.6",
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.0.0"
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.) The findings
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.3.0"
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") -> tuple:
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, "") or (None, why it did not run). ZAP writes zap.json into <work>/zap/.
286
- With spec_file: the API scan (active unless passive), the sign-in header from this shell's
287
- ZAP_AUTH_HEADER_VALUE passed by name only (`-e NAME`), its value in the process environment."""
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
- cmd, env = ["docker", "run", "--rm", "-v", f"{zap_dir}:/zap/wrk/:rw"], None
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=urllib.parse.urlsplit(url).hostname or "")
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=(max_minutes + 5) * 60, env=env)
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
- return None, f"ZAP did not finish within {max_minutes + 5} minutes"
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
- if p.returncode not in (0, 1, 2) or not out.exists():
322
- why = (p.stderr or p.stdout or "").strip()[-300:]
323
- return None, f"ZAP failed (exit {p.returncode}): {why or 'no report'}"
324
- try:
325
- return json.loads(out.read_text() or "{}"), ""
326
- except ValueError:
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, help="Most minutes ZAP may take (ZAP -T, 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} … " + (f"done, {len(findings)} alerts" if report is not None
665
- else "SKIPPED (the reason is in the report)"))
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)}, indent=2))
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": True,
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'}",