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.
@@ -5,7 +5,7 @@ Infrastructure tab (plan 11 H14): AWS infrastructure audit, Database network iso
5
5
  Database security review.
6
6
 
7
7
  npx ikanban cloudscan --aws-profile NAME --region ap-southeast-1 --account 123456789012
8
- npx ikanban cloudscan ... --publish --team TEAM # file the report and findings
8
+ npx ikanban cloudscan ... --publish --team TEAM # file the findings (the report stays here)
9
9
 
10
10
  It runs Prowler (Apache-2.0, https://github.com/prowler-cloud/prowler) from its pinned image in
11
11
  Docker, which must already be running:
@@ -256,15 +256,16 @@ def section(kind: str, findings: list, checks) -> tuple[list, bool]:
256
256
 
257
257
 
258
258
  def findings_payload(kind: str, scope: str, findings: list) -> list:
259
- return [{
259
+ """The findings --publish files; this run's credentials cut, then scrub_text (plan 5 X2)."""
260
+ return [scrub_finding({
260
261
  "finding_key": finding_key(kind, scope, f),
261
262
  "owasp_category": CATEGORY[kind],
262
263
  "severity": f["severity"].capitalize(),
263
264
  "title": scrub_report(f"{f['title']} ({f['check']})")[0][:300],
264
- "location_file": f["resource"][:500] or None,
265
+ "location_file": scrub_report(f["resource"][:500])[0] or None,
265
266
  "location_lines": None,
266
267
  "remediation": scrub_report(f"Region {f['region']}. Check {f['check']}. {f['remediation']}")[0][:2000],
267
- } for f in findings]
268
+ }) for f in findings]
268
269
 
269
270
 
270
271
  def build_report(scope: str, findings: list, skipped: list, ran: list, rds: int, notes: list) -> tuple:
@@ -355,9 +356,21 @@ def add_work_dir_arg(ap):
355
356
  ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
356
357
  help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
357
358
  "<repository>/.iKanban/scans)")
359
+ ap.add_argument("--print-report", action="store_true",
360
+ help="Print the full report on stdout (under the ikanban CLI stdout otherwise shows the "
361
+ "counts and `ikanban scans show <id>`)")
358
362
 
359
363
 
360
364
  def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
365
+ # Plan 5 X3: the ikanban CLI hands over a private temp folder (IKANBAN_SCAN_DIR) and the scan id
366
+ # (IKANBAN_SCAN_ID). That folder is this run's folder; the CLI seals it into ~/.ikanban/scans.
367
+ cli_dir = os.environ.get("IKANBAN_SCAN_DIR", "")
368
+ if override and cli_dir and os.environ.get("IKANBAN_SCAN_ID") and (
369
+ Path(override).expanduser().resolve() == Path(cli_dir).expanduser().resolve()):
370
+ run_dir = Path(cli_dir).expanduser().resolve()
371
+ run_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
372
+ os.chmod(run_dir, 0o700)
373
+ return run_dir
361
374
  scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
362
375
  scans.mkdir(parents=True, exist_ok=True)
363
376
  ignore = scans / ".gitignore"
@@ -383,14 +396,72 @@ def slug(text: str) -> str:
383
396
  return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
384
397
 
385
398
 
399
+ # ── scrub_text: identical in every scanner (test_scrub_text.py compares the copies) ──────────
400
+ # Plan 5 X2-T1: every text field of every finding passes through scrub_text before it leaves this
401
+ # machine. A secret keeps its first 4 characters; a home folder becomes ~. The server scrubs again.
402
+ _SCRUB_CUT = (
403
+ re.compile(r"-{5}BEGIN [A-Z ]*PRIVATE KEY-{5}.*?(?:-{5}END [A-Z ]*PRIVATE KEY-{5}|\Z)", re.S),
404
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
405
+ re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"),
406
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b"),
407
+ re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
408
+ re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
409
+ re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b"),
410
+ re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}\b"),
411
+ re.compile(r"\bwhsec_[A-Za-z0-9]{16,}\b"),
412
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
413
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
414
+ re.compile(r"\bvk_[A-Za-z0-9_-]{8,}"),
415
+ )
416
+ _SCRUB_ASSIGNED = re.compile(
417
+ r"(?i)(\b(?:aws_secret_access_key|aws_session_token|client_secret|secret_key|private_key|api[_-]?key|"
418
+ r"access[_-]?token|auth[_-]?token|token|password|passwd|pwd|secret)\b[\"']?\s*[:=]\s*[\"']?)([^\s\"',;&]+)")
419
+ _SCRUB_BEARER = re.compile(r"(?i)(\b(?:bearer|basic)\s+)([A-Za-z0-9._~+/=-]{12,})")
420
+ _SCRUB_URL_CREDS = re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)([^\s@/]+)(@)")
421
+ _SCRUB_HOME = re.compile(r"(?<![\w.-])(?:[A-Za-z]:\\[Uu]sers\\|/Users/|/home/)[^\\/\s\"'<>]+")
422
+
423
+
424
+ def scrub_text(text) -> str:
425
+ """Secrets, iKanban keys, tokens, AWS and GitHub keys, private key blocks and home folders
426
+ cut from one text. None becomes ""."""
427
+ if text is None:
428
+ return ""
429
+ text = str(text)
430
+ for rx in _SCRUB_CUT:
431
+ text = rx.sub(lambda m: m.group(0)[:4] + "…[hidden]", text)
432
+ text = _SCRUB_ASSIGNED.sub(
433
+ lambda m: m.group(1) + "[hidden]" if len(m.group(2)) >= 8 or any(c.isdigit() for c in m.group(2))
434
+ else m.group(0), text)
435
+ text = _SCRUB_BEARER.sub(lambda m: m.group(1) + "[hidden]", text)
436
+ text = _SCRUB_URL_CREDS.sub(lambda m: m.group(1) + "[hidden]" + m.group(3), text)
437
+ home = str(Path.home())
438
+ if home and home not in ("/", "\\") and home in text:
439
+ text = text.replace(home, "~")
440
+ return _SCRUB_HOME.sub("~", text)
441
+
442
+
443
+ def scrub_finding(value):
444
+ """scrub_text on every string in a finding, through nested dicts and lists; other values stay."""
445
+ if isinstance(value, str):
446
+ return scrub_text(value)
447
+ if isinstance(value, dict):
448
+ return {k: scrub_finding(v) for k, v in value.items()}
449
+ if isinstance(value, list):
450
+ return [scrub_finding(v) for v in value]
451
+ return value
452
+
453
+
454
+ # ── end scrub_text ──
455
+
456
+
386
457
  # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
387
458
  # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
388
459
  # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
389
- # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
460
+ # then that line as the log (plan 5: never the report). A plan without Pipelines (402) or any error never fails the scan.
390
461
  _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)
391
462
  # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
392
463
  PIPE_RUN = "security-scan"
393
- PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
464
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per log line
394
465
  LAST_RUN = {"report": "", "detail": "", "skipped": False}
395
466
 
396
467
 
@@ -410,39 +481,40 @@ def summary_detail(counts: dict, total: int, skipped: list) -> str:
410
481
 
411
482
 
412
483
  def mask_report(text: str) -> str:
413
- """The report is masked already; mask again before it leaves for Pipelines."""
484
+ """scrub_text before a line leaves for Pipelines, plus a scanner's own rules (scrub_report)."""
485
+ text = scrub_text(text)
414
486
  scrub = globals().get("scrub_report")
415
- if scrub:
416
- return scrub(text)[0]
417
- text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
418
- home = str(Path.home())
419
- return text.replace(home, "~") if home and home != "/" else text
420
-
421
-
422
- def log_chunks(text: str) -> list:
423
- """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
424
- chunks, cur = [], ""
425
- for line in text.splitlines(keepends=True):
426
- while len(line.encode()) > PIPE_CHUNK:
427
- head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
428
- chunks.append(head)
429
- line = line[len(head):]
430
- if len((cur + line).encode()) > PIPE_CHUNK:
431
- chunks.append(cur)
432
- cur = ""
433
- cur += line
434
- if cur:
435
- chunks.append(cur)
436
- return chunks
487
+ return scrub(text)[0] if scrub else text
488
+
489
+
490
+ def show_report(md: str, summary: str, print_report: bool = False) -> None:
491
+ """Plan 5 X3: under the ikanban CLI (IKANBAN_SCAN_ID set) stdout gets the counts and where the
492
+ full report is stored; the body only with --print-report. A plain python run prints the body."""
493
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
494
+ if scan_id and not print_report:
495
+ print(summary)
496
+ print(f"Full report: ikanban scans show {scan_id}")
497
+ else:
498
+ print(md)
499
+
500
+
501
+ def scan_files_note(work: Path) -> str:
502
+ """Where this run's files are: the encrypted store under the CLI, else the run folder."""
503
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
504
+ return f"scan files: sealed into ~/.ikanban/scans as {scan_id}" if scan_id else f"scan files: {work}"
505
+
506
+
437
507
 
438
508
  # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
439
- # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
509
+ # scan ran, so each step also sends one line as it starts and ends; the one-line summary follows at the end.
440
510
  # Progress lines are fixed status text: never raw tool output or an error body (it can carry
441
511
  # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
442
512
  PIPE = None
443
513
 
444
514
 
445
515
  def progress(text: str) -> None:
516
+ if os.environ.get("IKANBAN_SCAN_ID") and "--json" not in sys.argv:
517
+ print(text, flush=True) # Plan 5: under the CLI stdout shows progress, not the report
446
518
  if PIPE:
447
519
  PIPE.log(text)
448
520
 
@@ -500,8 +572,10 @@ class Pipeline:
500
572
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
501
573
  "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
502
574
  "detail": detail[:300] or None})
503
- for chunk in log_chunks(mask_report(LAST_RUN["report"])):
504
- self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
575
+ # Plan 5 X1-T2: the summary only; the report stays on the machine that ran the scan.
576
+ summary = f"{self.stage} {status}" + (f": {detail[:300]}" if detail else "")
577
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
578
+ "text": mask_report(summary) + "\n"})
505
579
 
506
580
 
507
581
  def run_main(stage, main_fn, argv=None):
@@ -538,7 +612,7 @@ def main(argv=None):
538
612
  ap.add_argument("--account", required=True, help="The 12-digit account id the profile must belong to")
539
613
  ap.add_argument("--services", nargs="+", default=SERVICES,
540
614
  help=f"Prowler services to check (default: {' '.join(SERVICES)})")
541
- ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
615
+ ap.add_argument("--publish", action="store_true", help="File the findings into iKanban (the full report stays on this machine)")
542
616
  ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
543
617
  help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
544
618
  ap.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
@@ -604,17 +678,12 @@ def main(argv=None):
604
678
  print(json.dumps({"scope": scope, "total": total, "counts": counts, "rds_resources": rds,
605
679
  "skipped": skipped}, indent=2))
606
680
  else:
607
- print(md)
681
+ show_report(md, LAST_RUN["detail"], args.print_report)
608
682
  sys.stdout.flush()
609
- print(f"scan files: {work}", file=sys.stderr)
683
+ print(scan_files_note(work), file=sys.stderr)
610
684
 
611
685
  if args.publish:
612
- ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
613
- {"team_id": args.team, "title": f"Cloud account audit — {scope} — "
614
- f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC", "content": md})
615
- print(f"\npublished: {(info or {}).get('id', '')}" if ok else f"\npublish FAILED: {info}", file=sys.stderr)
616
- if not ok:
617
- return 2
686
+ # Plan 5 X1: findings only; the report stays on this machine.
618
687
  if not ran:
619
688
  print("Prowler did not run: nothing filed or closed", file=sys.stderr)
620
689
  for kind, keep, whole in sections if ran else []: