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
|
@@ -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
|
|
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
|
|
462
|
-
|
|
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":
|
|
471
|
-
"location_file":
|
|
467
|
+
"title": scrub_text(f.title)[:300],
|
|
468
|
+
"location_file": f.location or None,
|
|
472
469
|
"location_lines": f.line,
|
|
473
|
-
"remediation":
|
|
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(
|
|
555
|
-
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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
|
|
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
|
-
|
|
736
|
-
|
|
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",
|
|
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
|
-
|
|
911
|
+
show_report(md, LAST_RUN["detail"], args.print_report)
|
|
846
912
|
sys.stdout.flush()
|
|
847
|
-
print(
|
|
913
|
+
print(scan_files_note(work), file=sys.stderr)
|
|
848
914
|
|
|
849
915
|
if args.publish:
|
|
850
|
-
|
|
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
|
|
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
|
|
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
|
|
36
|
-
(VIBE_API_TOKEN)
|
|
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
|
-
|
|
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)
|
|
796
|
-
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
|
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
|
-
|
|
1090
|
-
|
|
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
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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)",
|