ikanban 0.4.9 → 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.
@@ -6,7 +6,7 @@ repository and images, reporting into iKanban's Infrastructure tab (plan 11 H12)
6
6
  npx ikanban infrascan # inventory + IaC scan of cwd
7
7
  npx ikanban infrascan --path . --path ../infra # infra in another folder or repo too
8
8
  npx ikanban infrascan --image myapp:latest # also scan a container image
9
- npx ikanban infrascan --publish --team TEAM # file the report and its findings
9
+ npx ikanban infrascan --publish --team TEAM # file the findings (the report stays here)
10
10
 
11
11
  Plan 11 H12-T10 (owner: assume messy development). It starts with an inventory of each path:
12
12
  what infrastructure it found, what Trivy scanned, and what it could not scan and why. Infra
@@ -458,20 +458,17 @@ def finding_key(kind: str, scope: str, rule: str, location: str, line: str | Non
458
458
  return "infrascan-" + hashlib.sha256(raw.encode()).hexdigest()[:16]
459
459
 
460
460
 
461
- def findings_payload(sec: Section, home: str | None = None) -> list:
462
- home = home if home is not None else str(Path.home())
463
-
464
- def clean(s):
465
- return s.replace(home, "~") if s and home and home != "/" else s
466
- return [{
461
+ def findings_payload(sec: Section) -> list:
462
+ """The findings --publish files; every text field through scrub_text (plan 5 X2)."""
463
+ return [scrub_finding({
467
464
  "finding_key": finding_key(sec.kind, sec.scope, f.rule, f.location, f.line),
468
465
  "owasp_category": CATEGORY[sec.kind],
469
466
  "severity": "Low" if f.severity == "info" else f.severity.capitalize(),
470
- "title": clean(f.title)[:300],
471
- "location_file": clean(f.location) or None,
467
+ "title": scrub_text(f.title)[:300],
468
+ "location_file": f.location or None,
472
469
  "location_lines": f.line,
473
- "remediation": clean(" ".join(x for x in (f.detail, f.remediation) if x)),
474
- } for f in (sec.filed if sec.filed is not None else sec.findings)]
470
+ "remediation": " ".join(x for x in (f.detail, f.remediation) if x),
471
+ }) for f in (sec.filed if sec.filed is not None else sec.findings)]
475
472
 
476
473
 
477
474
  # ── copied from ikanban-secscan.py: scope, key-host pinning, API ─────────────────────
@@ -551,13 +548,8 @@ def api(method: str, path: str, base_url: str, token: str, payload=None):
551
548
  return False, str(e)
552
549
 
553
550
 
554
- def publish(md: str, title: str, sections: list, args) -> int:
555
- ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
556
- {"team_id": args.team, "title": title, "content": md.replace(str(Path.home()), "~")})
557
- print(f"\npublished: {(info or {}).get('id', '') if ok else ''}" if ok else f"\npublish FAILED: {info}",
558
- file=sys.stderr)
559
- if not ok:
560
- return 2
551
+ def publish(sections: list, args) -> int:
552
+ """Plan 5 X1: findings only, one scan-findings call per section; the report stays here."""
561
553
  for sec in sections:
562
554
  nothing_found = any(x.startswith("No infrastructure-as-code files found") for x in sec.skipped)
563
555
  cut = any("more were left out" in n for n in sec.notes)
@@ -586,9 +578,21 @@ def add_work_dir_arg(ap):
586
578
  ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
587
579
  help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
588
580
  "<repository>/.iKanban/scans)")
581
+ ap.add_argument("--print-report", action="store_true",
582
+ help="Print the full report on stdout (under the ikanban CLI stdout otherwise shows the "
583
+ "counts and `ikanban scans show <id>`)")
589
584
 
590
585
 
591
586
  def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
587
+ # Plan 5 X3: the ikanban CLI hands over a private temp folder (IKANBAN_SCAN_DIR) and the scan id
588
+ # (IKANBAN_SCAN_ID). That folder is this run's folder; the CLI seals it into ~/.ikanban/scans.
589
+ cli_dir = os.environ.get("IKANBAN_SCAN_DIR", "")
590
+ if override and cli_dir and os.environ.get("IKANBAN_SCAN_ID") and (
591
+ Path(override).expanduser().resolve() == Path(cli_dir).expanduser().resolve()):
592
+ run_dir = Path(cli_dir).expanduser().resolve()
593
+ run_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
594
+ os.chmod(run_dir, 0o700)
595
+ return run_dir
592
596
  scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
593
597
  scans.mkdir(parents=True, exist_ok=True)
594
598
  ignore = scans / ".gitignore"
@@ -614,14 +618,72 @@ def slug(text: str) -> str:
614
618
  return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
615
619
 
616
620
 
621
+ # ── scrub_text: identical in every scanner (test_scrub_text.py compares the copies) ──────────
622
+ # Plan 5 X2-T1: every text field of every finding passes through scrub_text before it leaves this
623
+ # machine. A secret keeps its first 4 characters; a home folder becomes ~. The server scrubs again.
624
+ _SCRUB_CUT = (
625
+ re.compile(r"-{5}BEGIN [A-Z ]*PRIVATE KEY-{5}.*?(?:-{5}END [A-Z ]*PRIVATE KEY-{5}|\Z)", re.S),
626
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
627
+ re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"),
628
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b"),
629
+ re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
630
+ re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
631
+ re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b"),
632
+ re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}\b"),
633
+ re.compile(r"\bwhsec_[A-Za-z0-9]{16,}\b"),
634
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
635
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
636
+ re.compile(r"\bvk_[A-Za-z0-9_-]{8,}"),
637
+ )
638
+ _SCRUB_ASSIGNED = re.compile(
639
+ r"(?i)(\b(?:aws_secret_access_key|aws_session_token|client_secret|secret_key|private_key|api[_-]?key|"
640
+ r"access[_-]?token|auth[_-]?token|token|password|passwd|pwd|secret)\b[\"']?\s*[:=]\s*[\"']?)([^\s\"',;&]+)")
641
+ _SCRUB_BEARER = re.compile(r"(?i)(\b(?:bearer|basic)\s+)([A-Za-z0-9._~+/=-]{12,})")
642
+ _SCRUB_URL_CREDS = re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)([^\s@/]+)(@)")
643
+ _SCRUB_HOME = re.compile(r"(?<![\w.-])(?:[A-Za-z]:\\[Uu]sers\\|/Users/|/home/)[^\\/\s\"'<>]+")
644
+
645
+
646
+ def scrub_text(text) -> str:
647
+ """Secrets, iKanban keys, tokens, AWS and GitHub keys, private key blocks and home folders
648
+ cut from one text. None becomes ""."""
649
+ if text is None:
650
+ return ""
651
+ text = str(text)
652
+ for rx in _SCRUB_CUT:
653
+ text = rx.sub(lambda m: m.group(0)[:4] + "…[hidden]", text)
654
+ text = _SCRUB_ASSIGNED.sub(
655
+ lambda m: m.group(1) + "[hidden]" if len(m.group(2)) >= 8 or any(c.isdigit() for c in m.group(2))
656
+ else m.group(0), text)
657
+ text = _SCRUB_BEARER.sub(lambda m: m.group(1) + "[hidden]", text)
658
+ text = _SCRUB_URL_CREDS.sub(lambda m: m.group(1) + "[hidden]" + m.group(3), text)
659
+ home = str(Path.home())
660
+ if home and home not in ("/", "\\") and home in text:
661
+ text = text.replace(home, "~")
662
+ return _SCRUB_HOME.sub("~", text)
663
+
664
+
665
+ def scrub_finding(value):
666
+ """scrub_text on every string in a finding, through nested dicts and lists; other values stay."""
667
+ if isinstance(value, str):
668
+ return scrub_text(value)
669
+ if isinstance(value, dict):
670
+ return {k: scrub_finding(v) for k, v in value.items()}
671
+ if isinstance(value, list):
672
+ return [scrub_finding(v) for v in value]
673
+ return value
674
+
675
+
676
+ # ── end scrub_text ──
677
+
678
+
617
679
  # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
618
680
  # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
619
681
  # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
620
- # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
682
+ # then that line as the log (plan 5: never the report). A plan without Pipelines (402) or any error never fails the scan.
621
683
  _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)
622
684
  # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
623
685
  PIPE_RUN = "security-scan"
624
- PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
686
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per log line
625
687
  LAST_RUN = {"report": "", "detail": "", "skipped": False}
626
688
  INVENTORY: list = [] # (scope, table rows) per --path, for the report
627
689
 
@@ -642,39 +704,40 @@ def summary_detail(counts: dict, total: int, skipped: list) -> str:
642
704
 
643
705
 
644
706
  def mask_report(text: str) -> str:
645
- """The report is masked already; mask again before it leaves for Pipelines."""
707
+ """scrub_text before a line leaves for Pipelines, plus a scanner's own rules (scrub_report)."""
708
+ text = scrub_text(text)
646
709
  scrub = globals().get("scrub_report")
647
- if scrub:
648
- return scrub(text)[0]
649
- text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
650
- home = str(Path.home())
651
- return text.replace(home, "~") if home and home != "/" else text
652
-
653
-
654
- def log_chunks(text: str) -> list:
655
- """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
656
- chunks, cur = [], ""
657
- for line in text.splitlines(keepends=True):
658
- while len(line.encode()) > PIPE_CHUNK:
659
- head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
660
- chunks.append(head)
661
- line = line[len(head):]
662
- if len((cur + line).encode()) > PIPE_CHUNK:
663
- chunks.append(cur)
664
- cur = ""
665
- cur += line
666
- if cur:
667
- chunks.append(cur)
668
- return chunks
710
+ return scrub(text)[0] if scrub else text
711
+
712
+
713
+ def show_report(md: str, summary: str, print_report: bool = False) -> None:
714
+ """Plan 5 X3: under the ikanban CLI (IKANBAN_SCAN_ID set) stdout gets the counts and where the
715
+ full report is stored; the body only with --print-report. A plain python run prints the body."""
716
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
717
+ if scan_id and not print_report:
718
+ print(summary)
719
+ print(f"Full report: ikanban scans show {scan_id}")
720
+ else:
721
+ print(md)
722
+
723
+
724
+ def scan_files_note(work: Path) -> str:
725
+ """Where this run's files are: the encrypted store under the CLI, else the run folder."""
726
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
727
+ return f"scan files: sealed into ~/.ikanban/scans as {scan_id}" if scan_id else f"scan files: {work}"
728
+
729
+
669
730
 
670
731
  # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
671
- # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
732
+ # scan ran, so each step also sends one line as it starts and ends; the one-line summary follows at the end.
672
733
  # Progress lines are fixed status text: never raw tool output or an error body (it can carry
673
734
  # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
674
735
  PIPE = None
675
736
 
676
737
 
677
738
  def progress(text: str) -> None:
739
+ if os.environ.get("IKANBAN_SCAN_ID") and "--json" not in sys.argv:
740
+ print(text, flush=True) # Plan 5: under the CLI stdout shows progress, not the report
678
741
  if PIPE:
679
742
  PIPE.log(text)
680
743
 
@@ -732,8 +795,10 @@ class Pipeline:
732
795
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
733
796
  "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
734
797
  "detail": detail[:300] or None})
735
- for chunk in log_chunks(mask_report(LAST_RUN["report"])):
736
- self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
798
+ # Plan 5 X1-T2: the summary only; the report stays on the machine that ran the scan.
799
+ summary = f"{self.stage} {status}" + (f": {detail[:300]}" if detail else "")
800
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
801
+ "text": mask_report(summary) + "\n"})
737
802
 
738
803
 
739
804
  def run_main(stage, main_fn, argv=None):
@@ -772,7 +837,8 @@ def main(argv=None):
772
837
  ap.add_argument("--no-iac", action="store_true", help="Scan only the --image images")
773
838
  ap.add_argument("--no-compose-images", action="store_true",
774
839
  help="Do not scan the images that docker-compose files name")
775
- ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
840
+ ap.add_argument("--publish", action="store_true",
841
+ help="File the findings into iKanban (the full report stays on this machine)")
776
842
  ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
777
843
  help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
778
844
  ap.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
@@ -842,13 +908,12 @@ def main(argv=None):
842
908
  print(json.dumps({"paths": [str(r) for r in roots], "images": images, "total": total, "counts": counts,
843
909
  "skipped": [s for sec in sections for s in sec.skipped], "work_dir": str(work)}, indent=2))
844
910
  else:
845
- print(md)
911
+ show_report(md, LAST_RUN["detail"], args.print_report)
846
912
  sys.stdout.flush()
847
- print(f"scan files: {work}", file=sys.stderr)
913
+ print(scan_files_note(work), file=sys.stderr)
848
914
 
849
915
  if args.publish:
850
- title = f"Infrastructure scan — {root.name} — {datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC"
851
- code = publish(md, title, sections, args)
916
+ code = publish(sections, args)
852
917
  if code:
853
918
  return code
854
919
 
@@ -1,11 +1,11 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
3
  ikanban-secscan — a standalone security scan you run against YOUR OWN repo,
4
- which reports its findings into iKanban as a document.
4
+ which files its findings into iKanban. The full report stays on your machine.
5
5
 
6
6
  python3 ikanban-secscan.py # scan cwd, print report
7
7
  python3 ikanban-secscan.py --path ~/src/myapp # scan somewhere else
8
- python3 ikanban-secscan.py --publish # also file the report and its findings into iKanban
8
+ python3 ikanban-secscan.py --publish # also file the findings into iKanban (the report stays here)
9
9
  python3 ikanban-secscan.py --fail-on high # non-zero exit for CI
10
10
 
11
11
  WHAT IT ACTUALLY CHECKS (IKA-1640)
@@ -32,8 +32,8 @@ DESIGN NOTES
32
32
  - No AWS calls, no iKanban-repo assumptions, no network access unless you
33
33
  pass --publish. It is meant to run on a laptop against any repo.
34
34
  - Ecosystems are detected from lockfiles present; nothing is assumed.
35
- - Publishing reuses the existing documents API with a personal API key
36
- (VIBE_API_TOKEN). No new auth surface, per the ticket.
35
+ - Publishing sends findings only (plan 5 X1), with a personal API key
36
+ (VIBE_API_TOKEN); every text field passes through scrub_text first.
37
37
 
38
38
  Closest siblings considered: vibe-security/scripts/owasp-scan.py (source-pattern
39
39
  scanner) and run-security-audit.py (AWS posture). Both are iKanban-internal —
@@ -678,18 +678,6 @@ def build_report(root: Path, sections: list) -> tuple:
678
678
  IKANBAN_KEY = re.compile(r"\bvk_[A-Za-z0-9_-]{8,}")
679
679
 
680
680
 
681
- def repo_name(root: Path) -> str:
682
- """The repository's name from origin (an agent's run folder is named by its run id);
683
- else the folder name."""
684
- try:
685
- url = subprocess.run(["git", "-C", str(root), "remote", "get-url", "origin"],
686
- capture_output=True, text=True, timeout=10).stdout.strip()
687
- except (OSError, subprocess.SubprocessError):
688
- url = ""
689
- name = url.rstrip("/").rsplit("/", 1)[-1].rsplit(":", 1)[-1]
690
- return name[:-4] if name.endswith(".git") else (name or root.name)
691
-
692
-
693
681
  def normalize_remote(url: str) -> str:
694
682
  """owner/repo from any remote style (scp, https, ssh, a host alias), lower case;
695
683
  never the user or a token in the URL."""
@@ -766,7 +754,8 @@ def build_findings_payload(sections: list, home: str | None = None) -> tuple[lis
766
754
  def clean(value):
767
755
  if value is None:
768
756
  return None
769
- out, h = scrub_report(value, home)
757
+ _, h = scrub_report(value, home) # the counts; scrub_text first sees whole key blocks
758
+ out = scrub_report(scrub_text(value), home)[0]
770
759
  hidden["secrets"] += h["secrets"]
771
760
  hidden["paths"] += h["paths"]
772
761
  return out
@@ -780,7 +769,7 @@ def build_findings_payload(sections: list, home: str | None = None) -> tuple[lis
780
769
  path, _, line = f.location.rpartition(":")
781
770
  if not (path and line.isdigit()):
782
771
  path, line = f.location, None
783
- payload.append({
772
+ payload.append(scrub_finding({
784
773
  "finding_key": finding_key(f.rule, path, line),
785
774
  "owasp_category": category,
786
775
  "severity": "Low" if f.severity == "info" else f.severity.capitalize(),
@@ -788,13 +777,12 @@ def build_findings_payload(sections: list, home: str | None = None) -> tuple[lis
788
777
  "location_file": clean(path),
789
778
  "location_lines": line,
790
779
  "remediation": clean(f"{f.detail}. {advice}" if f.detail else advice),
791
- })
780
+ }))
792
781
  return payload, hidden
793
782
 
794
783
 
795
- # A server older than this scanner has no route for these (an empty 404 or 405); the
796
- # report is still filed, so that is a note, not a failure.
797
- OLD_SERVER = "this iKanban server does not take them yet; the report is filed"
784
+ # A server older than this scanner has no route for these (an empty 404 or 405): a note, not a failure.
785
+ OLD_SERVER = "this iKanban server does not take them yet"
798
786
 
799
787
 
800
788
  # Coding standard T3: the iKanban key goes only to iKanban's API (or this machine), and never
@@ -894,46 +882,6 @@ def parse_app_maps(values: list, root: Path) -> list:
894
882
  return maps
895
883
 
896
884
 
897
- def publish(md: str, title: str, base_url: str, token: str, team_id: str,
898
- folder_id: str | None):
899
- payload = {"team_id": team_id, "title": title, "content": md}
900
- if folder_id:
901
- payload["folder_id"] = folder_id
902
- # IKA-1672: /api/security/scan-reports, not /api/documents. Same payload —
903
- # the endpoint delegates to the document handler after checking that the
904
- # workspace has an active subscription. Scanning stays free; only filing the
905
- # report into iKanban is a paid feature.
906
- req = urllib.request.Request(
907
- f"{ikanban_api(base_url)}/api/security/scan-reports",
908
- data=json.dumps(payload).encode(),
909
- headers={"Authorization": f"Bearer {token}",
910
- "Content-Type": "application/json"},
911
- method="POST",
912
- )
913
- try:
914
- with _OPENER.open(req, timeout=30) as r:
915
- body = json.loads(r.read().decode() or "{}")
916
- doc = body.get("data", body)
917
- return True, doc.get("id", "")
918
- except urllib.error.HTTPError as e:
919
- detail = e.read().decode()[:300]
920
- if e.code == 402:
921
- # Not a failure of the scan — the report is still on stdout, and
922
- # --out writes it to a file. Say so instead of just echoing 402.
923
- # The backend's ErrorResponse serialises the text under "error",
924
- # not "message"; "message" is still checked so this keeps working
925
- # if that ever changes.
926
- try:
927
- body = json.loads(detail)
928
- detail = body.get("error") or body.get("message") or detail
929
- except (ValueError, AttributeError):
930
- pass
931
- return False, detail
932
- return False, f"HTTP {e.code}: {detail}"
933
- except Exception as e: # noqa: BLE001 — surface any transport failure verbatim
934
- return False, str(e)
935
-
936
-
937
885
  # ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
938
886
  # <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
939
887
  # holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
@@ -941,9 +889,21 @@ def add_work_dir_arg(ap):
941
889
  ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
942
890
  help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
943
891
  "<repository>/.iKanban/scans)")
892
+ ap.add_argument("--print-report", action="store_true",
893
+ help="Print the full report on stdout (under the ikanban CLI stdout otherwise shows the "
894
+ "counts and `ikanban scans show <id>`)")
944
895
 
945
896
 
946
897
  def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
898
+ # Plan 5 X3: the ikanban CLI hands over a private temp folder (IKANBAN_SCAN_DIR) and the scan id
899
+ # (IKANBAN_SCAN_ID). That folder is this run's folder; the CLI seals it into ~/.ikanban/scans.
900
+ cli_dir = os.environ.get("IKANBAN_SCAN_DIR", "")
901
+ if override and cli_dir and os.environ.get("IKANBAN_SCAN_ID") and (
902
+ Path(override).expanduser().resolve() == Path(cli_dir).expanduser().resolve()):
903
+ run_dir = Path(cli_dir).expanduser().resolve()
904
+ run_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
905
+ os.chmod(run_dir, 0o700)
906
+ return run_dir
947
907
  scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
948
908
  scans.mkdir(parents=True, exist_ok=True)
949
909
  ignore = scans / ".gitignore"
@@ -969,14 +929,72 @@ def slug(text: str) -> str:
969
929
  return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
970
930
 
971
931
 
932
+ # ── scrub_text: identical in every scanner (test_scrub_text.py compares the copies) ──────────
933
+ # Plan 5 X2-T1: every text field of every finding passes through scrub_text before it leaves this
934
+ # machine. A secret keeps its first 4 characters; a home folder becomes ~. The server scrubs again.
935
+ _SCRUB_CUT = (
936
+ re.compile(r"-{5}BEGIN [A-Z ]*PRIVATE KEY-{5}.*?(?:-{5}END [A-Z ]*PRIVATE KEY-{5}|\Z)", re.S),
937
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
938
+ re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"),
939
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b"),
940
+ re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
941
+ re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
942
+ re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b"),
943
+ re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}\b"),
944
+ re.compile(r"\bwhsec_[A-Za-z0-9]{16,}\b"),
945
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
946
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
947
+ re.compile(r"\bvk_[A-Za-z0-9_-]{8,}"),
948
+ )
949
+ _SCRUB_ASSIGNED = re.compile(
950
+ r"(?i)(\b(?:aws_secret_access_key|aws_session_token|client_secret|secret_key|private_key|api[_-]?key|"
951
+ r"access[_-]?token|auth[_-]?token|token|password|passwd|pwd|secret)\b[\"']?\s*[:=]\s*[\"']?)([^\s\"',;&]+)")
952
+ _SCRUB_BEARER = re.compile(r"(?i)(\b(?:bearer|basic)\s+)([A-Za-z0-9._~+/=-]{12,})")
953
+ _SCRUB_URL_CREDS = re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)([^\s@/]+)(@)")
954
+ _SCRUB_HOME = re.compile(r"(?<![\w.-])(?:[A-Za-z]:\\[Uu]sers\\|/Users/|/home/)[^\\/\s\"'<>]+")
955
+
956
+
957
+ def scrub_text(text) -> str:
958
+ """Secrets, iKanban keys, tokens, AWS and GitHub keys, private key blocks and home folders
959
+ cut from one text. None becomes ""."""
960
+ if text is None:
961
+ return ""
962
+ text = str(text)
963
+ for rx in _SCRUB_CUT:
964
+ text = rx.sub(lambda m: m.group(0)[:4] + "…[hidden]", text)
965
+ text = _SCRUB_ASSIGNED.sub(
966
+ lambda m: m.group(1) + "[hidden]" if len(m.group(2)) >= 8 or any(c.isdigit() for c in m.group(2))
967
+ else m.group(0), text)
968
+ text = _SCRUB_BEARER.sub(lambda m: m.group(1) + "[hidden]", text)
969
+ text = _SCRUB_URL_CREDS.sub(lambda m: m.group(1) + "[hidden]" + m.group(3), text)
970
+ home = str(Path.home())
971
+ if home and home not in ("/", "\\") and home in text:
972
+ text = text.replace(home, "~")
973
+ return _SCRUB_HOME.sub("~", text)
974
+
975
+
976
+ def scrub_finding(value):
977
+ """scrub_text on every string in a finding, through nested dicts and lists; other values stay."""
978
+ if isinstance(value, str):
979
+ return scrub_text(value)
980
+ if isinstance(value, dict):
981
+ return {k: scrub_finding(v) for k, v in value.items()}
982
+ if isinstance(value, list):
983
+ return [scrub_finding(v) for v in value]
984
+ return value
985
+
986
+
987
+ # ── end scrub_text ──
988
+
989
+
972
990
  # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
973
991
  # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
974
992
  # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
975
- # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
993
+ # then that line as the log (plan 5: never the report). A plan without Pipelines (402) or any error never fails the scan.
976
994
  _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)
977
995
  # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
978
996
  PIPE_RUN = "security-scan"
979
- PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
997
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per log line
980
998
  LAST_RUN = {"report": "", "detail": "", "skipped": False}
981
999
 
982
1000
 
@@ -996,39 +1014,40 @@ def summary_detail(counts: dict, total: int, skipped: list) -> str:
996
1014
 
997
1015
 
998
1016
  def mask_report(text: str) -> str:
999
- """The report is masked already; mask again before it leaves for Pipelines."""
1017
+ """scrub_text before a line leaves for Pipelines, plus a scanner's own rules (scrub_report)."""
1018
+ text = scrub_text(text)
1000
1019
  scrub = globals().get("scrub_report")
1001
- if scrub:
1002
- return scrub(text)[0]
1003
- text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
1004
- home = str(Path.home())
1005
- return text.replace(home, "~") if home and home != "/" else text
1006
-
1007
-
1008
- def log_chunks(text: str) -> list:
1009
- """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
1010
- chunks, cur = [], ""
1011
- for line in text.splitlines(keepends=True):
1012
- while len(line.encode()) > PIPE_CHUNK:
1013
- head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
1014
- chunks.append(head)
1015
- line = line[len(head):]
1016
- if len((cur + line).encode()) > PIPE_CHUNK:
1017
- chunks.append(cur)
1018
- cur = ""
1019
- cur += line
1020
- if cur:
1021
- chunks.append(cur)
1022
- return chunks
1020
+ return scrub(text)[0] if scrub else text
1021
+
1022
+
1023
+ def show_report(md: str, summary: str, print_report: bool = False) -> None:
1024
+ """Plan 5 X3: under the ikanban CLI (IKANBAN_SCAN_ID set) stdout gets the counts and where the
1025
+ full report is stored; the body only with --print-report. A plain python run prints the body."""
1026
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
1027
+ if scan_id and not print_report:
1028
+ print(summary)
1029
+ print(f"Full report: ikanban scans show {scan_id}")
1030
+ else:
1031
+ print(md)
1032
+
1033
+
1034
+ def scan_files_note(work: Path) -> str:
1035
+ """Where this run's files are: the encrypted store under the CLI, else the run folder."""
1036
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
1037
+ return f"scan files: sealed into ~/.ikanban/scans as {scan_id}" if scan_id else f"scan files: {work}"
1038
+
1039
+
1023
1040
 
1024
1041
  # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
1025
- # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
1042
+ # scan ran, so each step also sends one line as it starts and ends; the one-line summary follows at the end.
1026
1043
  # Progress lines are fixed status text: never raw tool output or an error body (it can carry
1027
1044
  # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
1028
1045
  PIPE = None
1029
1046
 
1030
1047
 
1031
1048
  def progress(text: str) -> None:
1049
+ if os.environ.get("IKANBAN_SCAN_ID") and "--json" not in sys.argv:
1050
+ print(text, flush=True) # Plan 5: under the CLI stdout shows progress, not the report
1032
1051
  if PIPE:
1033
1052
  PIPE.log(text)
1034
1053
 
@@ -1086,8 +1105,10 @@ class Pipeline:
1086
1105
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
1087
1106
  "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
1088
1107
  "detail": detail[:300] or None})
1089
- for chunk in log_chunks(mask_report(LAST_RUN["report"])):
1090
- self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
1108
+ # Plan 5 X1-T2: the summary only; the report stays on the machine that ran the scan.
1109
+ summary = f"{self.stage} {status}" + (f": {detail[:300]}" if detail else "")
1110
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
1111
+ "text": mask_report(summary) + "\n"})
1091
1112
 
1092
1113
 
1093
1114
  def run_main(stage, main_fn, argv=None):
@@ -1124,10 +1145,10 @@ def main(argv=None):
1124
1145
  ap.add_argument("--version", action="version", version=f"ikanban secscan {SECSCAN_VERSION}")
1125
1146
  ap.add_argument("--path", default=".", help="Repo to scan (default: cwd)")
1126
1147
  ap.add_argument("--publish", action="store_true",
1127
- help="File the report into iKanban as a document")
1148
+ help="File the findings into iKanban (the full report stays on this machine)")
1128
1149
  ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
1129
1150
  help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
1130
- ap.add_argument("--folder", default=None, help="Optional folder UUID")
1151
+ ap.add_argument("--folder", default=None, help=argparse.SUPPRESS) # reports are no longer filed
1131
1152
  ap.add_argument("--base-url", default=os.environ.get(
1132
1153
  "IKANBAN_BASE_URL", "https://api.i-kanban.com"))
1133
1154
  ap.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""),
@@ -1203,27 +1224,18 @@ def main(argv=None):
1203
1224
  print(json.dumps({"repo": str(root), "total": total, "counts": counts},
1204
1225
  indent=2))
1205
1226
  else:
1206
- print(md)
1227
+ show_report(md, LAST_RUN["detail"], args.print_report)
1207
1228
  # The lines below go to stderr; flush first so they follow the report when both are
1208
1229
  # piped into one stream (an agent's `2>&1 | tail` would otherwise cut them off).
1209
1230
  sys.stdout.flush()
1210
- print(f"scan files: {work}", file=sys.stderr)
1231
+ print(scan_files_note(work), file=sys.stderr)
1211
1232
 
1212
1233
  if args.publish:
1213
1234
  if not args.token or not args.team:
1214
1235
  print("\nerror: --publish needs --token (or VIBE_API_TOKEN) and "
1215
1236
  "--team (or IKANBAN_TEAM_ID)", file=sys.stderr)
1216
1237
  return 2
1217
- title = f"Security scan — {repo_name(root)} — {datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC"
1218
- sent, hidden = scrub_report(md)
1219
- if hidden["secrets"] or hidden["paths"]:
1220
- print(f"hidden before sending: {hidden['secrets']} secret(s), {hidden['paths']} home path(s)",
1221
- file=sys.stderr)
1222
- ok, info = publish(sent, title, args.base_url, args.token, args.team, args.folder)
1223
- print(f"\npublished: {info}" if ok else f"\npublish FAILED: {info}",
1224
- file=sys.stderr)
1225
- if not ok:
1226
- return 2
1238
+ # Plan 5 X1: findings only; the report stays on this machine.
1227
1239
  findings, hidden = build_findings_payload(sections)
1228
1240
  if hidden["secrets"] or hidden["paths"]:
1229
1241
  print(f"hidden in findings: {hidden['secrets']} secret(s), {hidden['paths']} home path(s)",