ikanban 0.4.8 → 0.5.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.
@@ -250,7 +250,8 @@ def finding_key(url: str, rule: str, title: str, kind: str = "dast") -> str:
250
250
 
251
251
 
252
252
  def findings_payload(url: str, findings: list, kind: str = "dast") -> list:
253
- return [{
253
+ """The findings --publish files; every text field through scrub_text (plan 5 X2)."""
254
+ return [scrub_finding({
254
255
  "finding_key": finding_key(url, f["rule"], f["title"], kind),
255
256
  "owasp_category": CATEGORY,
256
257
  "severity": f["severity"].capitalize(),
@@ -258,7 +259,7 @@ def findings_payload(url: str, findings: list, kind: str = "dast") -> list:
258
259
  "location_file": f["location"][:500] or None,
259
260
  "location_lines": None,
260
261
  "remediation": " ".join(x for x in (f["detail"], f["remediation"]) if x),
261
- } for f in findings]
262
+ }) for f in findings]
262
263
 
263
264
 
264
265
  def build_report(url: str, findings: list, skipped: list, ran: list, mode: str = "ZAP baseline, passive",
@@ -445,9 +446,21 @@ def add_work_dir_arg(ap):
445
446
  ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
446
447
  help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
447
448
  "<repository>/.iKanban/scans)")
449
+ ap.add_argument("--print-report", action="store_true",
450
+ help="Print the full report on stdout (under the ikanban CLI stdout otherwise shows the "
451
+ "counts and `ikanban scans show <id>`)")
448
452
 
449
453
 
450
454
  def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
455
+ # Plan 5 X3: the ikanban CLI hands over a private temp folder (IKANBAN_SCAN_DIR) and the scan id
456
+ # (IKANBAN_SCAN_ID). That folder is this run's folder; the CLI seals it into ~/.ikanban/scans.
457
+ cli_dir = os.environ.get("IKANBAN_SCAN_DIR", "")
458
+ if override and cli_dir and os.environ.get("IKANBAN_SCAN_ID") and (
459
+ Path(override).expanduser().resolve() == Path(cli_dir).expanduser().resolve()):
460
+ run_dir = Path(cli_dir).expanduser().resolve()
461
+ run_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
462
+ os.chmod(run_dir, 0o700)
463
+ return run_dir
451
464
  scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
452
465
  scans.mkdir(parents=True, exist_ok=True)
453
466
  ignore = scans / ".gitignore"
@@ -473,14 +486,72 @@ def slug(text: str) -> str:
473
486
  return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
474
487
 
475
488
 
489
+ # ── scrub_text: identical in every scanner (test_scrub_text.py compares the copies) ──────────
490
+ # Plan 5 X2-T1: every text field of every finding passes through scrub_text before it leaves this
491
+ # machine. A secret keeps its first 4 characters; a home folder becomes ~. The server scrubs again.
492
+ _SCRUB_CUT = (
493
+ re.compile(r"-{5}BEGIN [A-Z ]*PRIVATE KEY-{5}.*?(?:-{5}END [A-Z ]*PRIVATE KEY-{5}|\Z)", re.S),
494
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
495
+ re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"),
496
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b"),
497
+ re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
498
+ re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
499
+ re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b"),
500
+ re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}\b"),
501
+ re.compile(r"\bwhsec_[A-Za-z0-9]{16,}\b"),
502
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
503
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
504
+ re.compile(r"\bvk_[A-Za-z0-9_-]{8,}"),
505
+ )
506
+ _SCRUB_ASSIGNED = re.compile(
507
+ r"(?i)(\b(?:aws_secret_access_key|aws_session_token|client_secret|secret_key|private_key|api[_-]?key|"
508
+ r"access[_-]?token|auth[_-]?token|token|password|passwd|pwd|secret)\b[\"']?\s*[:=]\s*[\"']?)([^\s\"',;&]+)")
509
+ _SCRUB_BEARER = re.compile(r"(?i)(\b(?:bearer|basic)\s+)([A-Za-z0-9._~+/=-]{12,})")
510
+ _SCRUB_URL_CREDS = re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)([^\s@/]+)(@)")
511
+ _SCRUB_HOME = re.compile(r"(?<![\w.-])(?:[A-Za-z]:\\[Uu]sers\\|/Users/|/home/)[^\\/\s\"'<>]+")
512
+
513
+
514
+ def scrub_text(text) -> str:
515
+ """Secrets, iKanban keys, tokens, AWS and GitHub keys, private key blocks and home folders
516
+ cut from one text. None becomes ""."""
517
+ if text is None:
518
+ return ""
519
+ text = str(text)
520
+ for rx in _SCRUB_CUT:
521
+ text = rx.sub(lambda m: m.group(0)[:4] + "…[hidden]", text)
522
+ text = _SCRUB_ASSIGNED.sub(
523
+ lambda m: m.group(1) + "[hidden]" if len(m.group(2)) >= 8 or any(c.isdigit() for c in m.group(2))
524
+ else m.group(0), text)
525
+ text = _SCRUB_BEARER.sub(lambda m: m.group(1) + "[hidden]", text)
526
+ text = _SCRUB_URL_CREDS.sub(lambda m: m.group(1) + "[hidden]" + m.group(3), text)
527
+ home = str(Path.home())
528
+ if home and home not in ("/", "\\") and home in text:
529
+ text = text.replace(home, "~")
530
+ return _SCRUB_HOME.sub("~", text)
531
+
532
+
533
+ def scrub_finding(value):
534
+ """scrub_text on every string in a finding, through nested dicts and lists; other values stay."""
535
+ if isinstance(value, str):
536
+ return scrub_text(value)
537
+ if isinstance(value, dict):
538
+ return {k: scrub_finding(v) for k, v in value.items()}
539
+ if isinstance(value, list):
540
+ return [scrub_finding(v) for v in value]
541
+ return value
542
+
543
+
544
+ # ── end scrub_text ──
545
+
546
+
476
547
  # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
477
548
  # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
478
549
  # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
479
- # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
550
+ # then that line as the log (plan 5: never the report). A plan without Pipelines (402) or any error never fails the scan.
480
551
  _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)
481
552
  # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
482
553
  PIPE_RUN = "security-scan"
483
- PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
554
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per log line
484
555
  LAST_RUN = {"report": "", "detail": "", "skipped": False}
485
556
 
486
557
 
@@ -500,39 +571,40 @@ def summary_detail(counts: dict, total: int, skipped: list) -> str:
500
571
 
501
572
 
502
573
  def mask_report(text: str) -> str:
503
- """The report is masked already; mask again before it leaves for Pipelines."""
574
+ """scrub_text before a line leaves for Pipelines, plus a scanner's own rules (scrub_report)."""
575
+ text = scrub_text(text)
504
576
  scrub = globals().get("scrub_report")
505
- if scrub:
506
- return scrub(text)[0]
507
- text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
508
- home = str(Path.home())
509
- return text.replace(home, "~") if home and home != "/" else text
510
-
511
-
512
- def log_chunks(text: str) -> list:
513
- """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
514
- chunks, cur = [], ""
515
- for line in text.splitlines(keepends=True):
516
- while len(line.encode()) > PIPE_CHUNK:
517
- head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
518
- chunks.append(head)
519
- line = line[len(head):]
520
- if len((cur + line).encode()) > PIPE_CHUNK:
521
- chunks.append(cur)
522
- cur = ""
523
- cur += line
524
- if cur:
525
- chunks.append(cur)
526
- return chunks
577
+ return scrub(text)[0] if scrub else text
578
+
579
+
580
+ def show_report(md: str, summary: str, print_report: bool = False) -> None:
581
+ """Plan 5 X3: under the ikanban CLI (IKANBAN_SCAN_ID set) stdout gets the counts and where the
582
+ full report is stored; the body only with --print-report. A plain python run prints the body."""
583
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
584
+ if scan_id and not print_report:
585
+ print(summary)
586
+ print(f"Full report: ikanban scans show {scan_id}")
587
+ else:
588
+ print(md)
589
+
590
+
591
+ def scan_files_note(work: Path) -> str:
592
+ """Where this run's files are: the encrypted store under the CLI, else the run folder."""
593
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
594
+ return f"scan files: sealed into ~/.ikanban/scans as {scan_id}" if scan_id else f"scan files: {work}"
595
+
596
+
527
597
 
528
598
  # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
529
- # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
599
+ # scan ran, so each step also sends one line as it starts and ends; the one-line summary follows at the end.
530
600
  # Progress lines are fixed status text: never raw tool output or an error body (it can carry
531
601
  # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
532
602
  PIPE = None
533
603
 
534
604
 
535
605
  def progress(text: str) -> None:
606
+ if os.environ.get("IKANBAN_SCAN_ID") and "--json" not in sys.argv:
607
+ print(text, flush=True) # Plan 5: under the CLI stdout shows progress, not the report
536
608
  if PIPE:
537
609
  PIPE.log(text)
538
610
 
@@ -593,8 +665,10 @@ class Pipeline:
593
665
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
594
666
  "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
595
667
  "detail": detail[:300] or None})
596
- for chunk in log_chunks(mask_report(LAST_RUN["report"])):
597
- self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
668
+ # Plan 5 X1-T2: the summary only; the report stays on the machine that ran the scan.
669
+ summary = f"{self.stage} {status}" + (f": {detail[:300]}" if detail else "")
670
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
671
+ "text": mask_report(summary) + "\n"})
598
672
 
599
673
 
600
674
  def run_main(stage, main_fn, argv=None):
@@ -644,7 +718,7 @@ def main(argv=None):
644
718
  ap.add_argument("--rate", type=int, default=None, metavar="N",
645
719
  help="Most requests per second ZAP sends to the target (ZAP's Network rate limit). "
646
720
  f"Default {DEFAULT_API_RATE} for an active API scan, no limit otherwise; 0 = no limit")
647
- ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
721
+ ap.add_argument("--publish", action="store_true", help="File the findings into iKanban (the full report stays on this machine)")
648
722
  ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
649
723
  help="Team UUID (or IKANBAN_TEAM_ID); always needed, to check the URL is registered")
650
724
  ap.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
@@ -742,18 +816,12 @@ def main(argv=None):
742
816
  print(json.dumps({"url": url, "total": total, "counts": counts, "skipped": bool(skipped_why),
743
817
  "complete": report is not None and not incomplete}, indent=2))
744
818
  else:
745
- print(md)
819
+ show_report(md, LAST_RUN["detail"], args.print_report)
746
820
  sys.stdout.flush()
747
- print(f"scan files: {work}", file=sys.stderr)
821
+ print(scan_files_note(work), file=sys.stderr)
748
822
 
749
823
  if args.publish:
750
- ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
751
- {"team_id": args.team, "title": f"{'API scan' if kind == 'api' else 'DAST scan'} — {url} — "
752
- f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC", "content": md})
753
- print(f"\npublished: {(info or {}).get('id', '')}" if ok else f"\npublish FAILED: {info}",
754
- file=sys.stderr)
755
- if not ok:
756
- return 2
824
+ # Plan 5 X1: findings only; the report stays on this machine.
757
825
  if report is None:
758
826
  print("ZAP did not run: nothing filed or closed", file=sys.stderr)
759
827
  else: