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.
- package/README.md +57 -9
- package/bin/ikanban.js +49 -6
- package/hooks/deploy-check.js +221 -0
- package/hooks/ikanban-safe.js +26 -3
- package/hooks/plan-gate.js +206 -0
- package/hooks/trace.js +365 -0
- package/lib/ikanban-safe.js +26 -3
- package/lib/plancheck.js +221 -0
- package/lib/roadmap.js +73 -26
- package/lib/scanstore.js +403 -0
- package/lib/statuswords.js +13 -0
- package/package.json +3 -3
- package/scanners/ikanban-aiscan.py +121 -30
- package/scanners/ikanban-cloudscan.py +110 -41
- package/scanners/ikanban-controlscan.py +783 -0
- package/scanners/ikanban-dastscan.py +108 -40
- package/scanners/ikanban-import.py +597 -0
- package/scanners/ikanban-infrascan.py +116 -51
- package/scanners/ikanban-secscan.py +116 -104
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
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
|
|
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
|
-
|
|
597
|
-
|
|
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
|
|
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
|
-
|
|
819
|
+
show_report(md, LAST_RUN["detail"], args.print_report)
|
|
746
820
|
sys.stdout.flush()
|
|
747
|
-
print(
|
|
821
|
+
print(scan_files_note(work), file=sys.stderr)
|
|
748
822
|
|
|
749
823
|
if args.publish:
|
|
750
|
-
|
|
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:
|