ikanban 0.3.4 → 0.4.1

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.
@@ -64,7 +64,7 @@ from pathlib import Path
64
64
  # that npm no longer hosts the package. Starting at 1.0.0 rather than continuing
65
65
  # the npm package's 0.1.x line: that counter belonged to the wrapper, not to
66
66
  # this file, and reusing it would suggest a continuity that does not exist.
67
- SECSCAN_VERSION = "1.3.0"
67
+ SECSCAN_VERSION = "1.5.0"
68
68
 
69
69
  SEVERITIES = ["critical", "high", "medium", "low", "info"]
70
70
  SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
@@ -205,6 +205,8 @@ def iter_files(root: Path):
205
205
  d for d in dirnames
206
206
  if d not in SKIP_DIRS
207
207
  and not d.startswith(".git")
208
+ # Plan 11 H12-T10: never read our own scan output.
209
+ and not (d == "scans" and Path(dirpath).name == ".iKanban")
208
210
  and not _is_nested_repo(Path(dirpath) / d, root)
209
211
  ]
210
212
  for fn in filenames:
@@ -859,7 +861,168 @@ def publish(md: str, title: str, base_url: str, token: str, team_id: str,
859
861
  return False, str(e)
860
862
 
861
863
 
862
- def main():
864
+ # ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
865
+ # <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
866
+ # holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
867
+ def add_work_dir_arg(ap):
868
+ ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
869
+ help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
870
+ "<repository>/.iKanban/scans)")
871
+
872
+
873
+ def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
874
+ scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
875
+ scans.mkdir(parents=True, exist_ok=True)
876
+ ignore = scans / ".gitignore"
877
+ if not ignore.exists():
878
+ ignore.write_text("*\n")
879
+ run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
880
+ run_dir.mkdir(mode=0o700, exist_ok=True)
881
+ os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
882
+ return run_dir
883
+
884
+
885
+ def write_private(path: Path, text: str) -> None:
886
+ """Write a file (0600) without following a symlink planted at its name: write a new
887
+ temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
888
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
889
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
890
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
891
+ f.write(text)
892
+ os.replace(tmp, path)
893
+
894
+
895
+ def slug(text: str) -> str:
896
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
897
+
898
+
899
+ # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
900
+ # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
901
+ # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
902
+ # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
903
+ _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)
904
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
905
+ LAST_RUN = {"report": "", "detail": "", "skipped": False}
906
+
907
+
908
+ def add_pipeline_args(ap):
909
+ ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
910
+ help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
911
+ "or IKANBAN_TASK)")
912
+ ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
913
+ help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
914
+
915
+
916
+ def summary_detail(counts: dict, total: int, skipped: list) -> str:
917
+ if skipped and not total and all(s for s in skipped):
918
+ return ("SKIPPED: " + "; ".join(skipped))[:300]
919
+ worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
920
+ return f"{total} findings" + (f": {worst}" if worst else "")
921
+
922
+
923
+ def mask_report(text: str) -> str:
924
+ """The report is masked already; mask again before it leaves for Pipelines."""
925
+ scrub = globals().get("scrub_report")
926
+ if scrub:
927
+ return scrub(text)[0]
928
+ text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
929
+ home = str(Path.home())
930
+ return text.replace(home, "~") if home and home != "/" else text
931
+
932
+
933
+ def log_chunks(text: str) -> list:
934
+ """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
935
+ chunks, cur = [], ""
936
+ for line in text.splitlines(keepends=True):
937
+ while len(line.encode()) > PIPE_CHUNK:
938
+ head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
939
+ chunks.append(head)
940
+ line = line[len(head):]
941
+ if len((cur + line).encode()) > PIPE_CHUNK:
942
+ chunks.append(cur)
943
+ cur = ""
944
+ cur += line
945
+ if cur:
946
+ chunks.append(cur)
947
+ return chunks
948
+
949
+
950
+ class Pipeline:
951
+ def __init__(self, stage, task, team, base_url, token):
952
+ self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
953
+ self.on = bool(task and team and token)
954
+ self.tid = None
955
+
956
+ def _off(self, why):
957
+ self.on = False
958
+ note = "the plan does not include Pipelines" if "402" in str(why) else why
959
+ print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
960
+
961
+ def _call(self, method, path, body=None):
962
+ try:
963
+ return api(method, path, self.base, self.token, body)
964
+ except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
965
+ return False, str(e)
966
+
967
+ def _post(self, path, body):
968
+ if not self.on:
969
+ return
970
+ if self.tid is None:
971
+ if _UUID.match(self.team):
972
+ self.tid = self.team
973
+ else:
974
+ ok, teams = self._call("GET", "/teams")
975
+ if not ok:
976
+ return self._off(teams)
977
+ found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
978
+ if not found:
979
+ return self._off(f"team {self.team} not found among your teams")
980
+ self.tid = found[0]
981
+ ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
982
+ if not ok:
983
+ self._off(data or "this iKanban server has no Pipelines")
984
+
985
+ def start(self):
986
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
987
+ "source": "laptop", "actor": f"ikanban-{self.stage}"})
988
+
989
+ def finish(self, code):
990
+ status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
991
+ detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
992
+ if code == 1 and not LAST_RUN["skipped"]:
993
+ detail = (detail + " (at or above --fail-on)").strip()
994
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
995
+ "source": "laptop", "actor": f"ikanban-{self.stage}",
996
+ "detail": detail[:300] or None})
997
+ for chunk in log_chunks(mask_report(LAST_RUN["report"])):
998
+ self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
999
+
1000
+
1001
+ def run_main(stage, main_fn, argv=None):
1002
+ """main() as it is, wrapped in Pipelines events when --task is given."""
1003
+ raw = sys.argv[1:] if argv is None else argv
1004
+ if any(a in ("-h", "--help", "--version", "--list-apps") for a in raw):
1005
+ return main_fn(argv)
1006
+ pre = argparse.ArgumentParser(add_help=False)
1007
+ pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
1008
+ pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
1009
+ pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
1010
+ pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
1011
+ pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
1012
+ known, _ = pre.parse_known_args(raw)
1013
+ pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
1014
+ pl.start()
1015
+ try:
1016
+ code = main_fn(argv)
1017
+ except SystemExit as e:
1018
+ if not isinstance(e.code, int):
1019
+ print(e.code, file=sys.stderr)
1020
+ code = e.code if isinstance(e.code, int) else 2
1021
+ pl.finish(code)
1022
+ return code
1023
+
1024
+
1025
+ def main(argv=None):
863
1026
  ap = argparse.ArgumentParser(
864
1027
  description="Standalone security scan that reports into iKanban.")
865
1028
  # Lets someone who downloaded a copy tell which release it is, and check it
@@ -884,7 +1047,9 @@ def main():
884
1047
  help="Print the team's applications (the NAMEs --app takes) and exit")
885
1048
  ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
886
1049
  help="Exit 1 if any finding is at or above this severity")
887
- args = ap.parse_args()
1050
+ add_pipeline_args(ap)
1051
+ add_work_dir_arg(ap)
1052
+ args = ap.parse_args(argv)
888
1053
 
889
1054
  root = Path(args.path).expanduser().resolve()
890
1055
  if not root.is_dir():
@@ -922,6 +1087,9 @@ def main():
922
1087
  sections = [audit_dependencies(root), *(s for _, s in app_sections),
923
1088
  scan_secrets(root), scan_permissions(root)]
924
1089
  md, counts, total = build_report(root, sections)
1090
+ LAST_RUN.update(report=md, detail=summary_detail(counts, total, []), skipped=False)
1091
+ work = scan_work_dir("secscan", root, args.work_dir)
1092
+ write_private(work / "report.md", md)
925
1093
 
926
1094
  if args.out:
927
1095
  Path(args.out).write_text(md)
@@ -934,6 +1102,7 @@ def main():
934
1102
  # The lines below go to stderr; flush first so they follow the report when both are
935
1103
  # piped into one stream (an agent's `2>&1 | tail` would otherwise cut them off).
936
1104
  sys.stdout.flush()
1105
+ print(f"scan files: {work}", file=sys.stderr)
937
1106
 
938
1107
  if args.publish:
939
1108
  if not args.token or not args.team:
@@ -997,4 +1166,4 @@ def main():
997
1166
 
998
1167
 
999
1168
  if __name__ == "__main__":
1000
- sys.exit(main())
1169
+ sys.exit(run_main("secscan", main))