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.
- package/README.md +37 -0
- package/bin/ikanban.js +5 -2
- package/package.json +1 -1
- package/scanners/ikanban-aiscan.py +279 -68
- package/scanners/ikanban-dastscan.py +491 -0
- package/scanners/ikanban-infrascan.py +790 -0
- package/scanners/ikanban-secscan.py +173 -4
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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))
|