ikanban 0.4.9 → 0.5.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 +59 -10
- 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/roadmap-sync.js +5 -2
- package/hooks/trace.js +384 -0
- package/lib/ikanban-safe.js +26 -3
- package/lib/plancheck.js +231 -0
- package/lib/roadmap.js +135 -28
- 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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
|
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
|
-
|
|
504
|
-
|
|
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
|
|
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
|
-
|
|
681
|
+
show_report(md, LAST_RUN["detail"], args.print_report)
|
|
608
682
|
sys.stdout.flush()
|
|
609
|
-
print(
|
|
683
|
+
print(scan_files_note(work), file=sys.stderr)
|
|
610
684
|
|
|
611
685
|
if args.publish:
|
|
612
|
-
|
|
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 []:
|