ikanban 0.4.7 → 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.7",
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.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") -> 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 ──────────────────
@@ -585,7 +637,13 @@ def main(argv=None):
585
637
  ap.add_argument("--passive", action="store_true",
586
638
  help="With --openapi: no attacks (ZAP -S); the only way to scan the production iKanban API")
587
639
  ap.add_argument("--minutes", type=int, default=1, help="Spider minutes (ZAP -m, default 1)")
588
- 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")
589
647
  ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
590
648
  ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
591
649
  help="Team UUID (or IKANBAN_TEAM_ID); always needed, to check the URL is registered")
@@ -628,6 +686,9 @@ def main(argv=None):
628
686
  print("error: dastscan needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID) "
629
687
  "to check the URL is a registered target", file=sys.stderr)
630
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
631
692
  if args.passive and not args.openapi:
632
693
  print("error: --passive goes with --openapi (the baseline scan is always passive)", file=sys.stderr)
633
694
  return 2
@@ -654,27 +715,32 @@ def main(argv=None):
654
715
  write_private(spec_file, json.dumps(doc))
655
716
  os.chmod(spec_file, 0o644) # ZAP reads it as its own user; the run folder above stays 0700
656
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)
657
719
  progress(f"dastscan {DASTSCAN_VERSION} · {mode} on {url}: at most {args.max_minutes} min …")
658
- 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)
659
721
  ran = [f"zap-api-scan.py -f openapi -O {urllib.parse.urlsplit(url).netloc}"
660
722
  + (" -S" if args.passive else "")] if report is not None else []
661
723
  else:
662
724
  mode = "ZAP baseline, passive"
663
725
  progress(f"dastscan {DASTSCAN_VERSION} · ZAP baseline on {url}: spider {args.minutes} min, "
664
726
  f"at most {args.max_minutes} min …")
665
- 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)
666
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
667
732
  findings = parse_zap(report) if report is not None else []
668
- progress(f"{mode.split(',')[0]} on {url} … " + (f"done, {len(findings)} alerts" if report is not None
669
- else "SKIPPED (the reason is in the report)"))
670
- 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)
671
736
  LAST_RUN.update(report=md, detail=summary_detail(counts, total, [] if ran else [skipped_why]),
672
737
  skipped=not ran)
673
738
  write_private(work / "report.md", md)
674
739
  if args.out:
675
740
  Path(args.out).write_text(md)
676
741
  if args.json:
677
- 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))
678
744
  else:
679
745
  print(md)
680
746
  sys.stdout.flush()
@@ -692,7 +758,7 @@ def main(argv=None):
692
758
  print("ZAP did not run: nothing filed or closed", file=sys.stderr)
693
759
  else:
694
760
  ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token,
695
- {"findings": findings_payload(url, findings, kind), "scope": url, "complete": True,
761
+ {"findings": findings_payload(url, findings, kind), "scope": url, "complete": not incomplete,
696
762
  "kind": kind})
697
763
  if not ok:
698
764
  print(f"filing {kind} findings FAILED: {data or 'this iKanban server does not take them yet'}",