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.
@@ -0,0 +1,783 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ikanban-controlscan — checks the security controls of YOUR OWN repository (plan 5 X4).
4
+
5
+ python3 ikanban-controlscan.py --path . # check, print the result
6
+ python3 ikanban-controlscan.py --publish --team TEAM_ID # also file it into iKanban
7
+
8
+ The 8 repository checks, each pass / warn / fail / skipped with the reason:
9
+
10
+ Supply chain & dependency scanning
11
+ ci-dependency-audit a CI workflow runs a dependency audit, or Dependabot or Renovate is configured
12
+ lockfiles-committed every package manifest has its lockfile in git
13
+ Code & secrets scanning
14
+ ci-secret-scanning a CI workflow runs gitleaks, trufflehog or detect-secrets (or GitHub secret
15
+ scanning with push protection is on, read through your own gh sign-in)
16
+ ci-sast a CI workflow runs CodeQL, Semgrep or another SAST tool
17
+ env-files-ignored no .env or private key file is tracked in git; .gitignore covers .env
18
+ Repository & access
19
+ branch-protection the default branch blocks force-push and deletion and needs a pull request
20
+ (GitHub API through your own `gh`; skipped without gh or its sign-in)
21
+ Security process
22
+ codeowners a CODEOWNERS file names owners
23
+ security-policy SECURITY.md says how to report a vulnerability
24
+
25
+ No network, except the GitHub API through the user's own `gh` (branch protection, secret scanning).
26
+ --publish sends one POST /api/teams/{team}/security/scan-findings per control (kind ctl-<key>):
27
+ a failed check files one Medium finding, a warning one Low finding, a pass none; a skipped check
28
+ is sent as an incomplete run, so nothing closes. Standard library only.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import argparse
34
+ import hashlib
35
+ import json
36
+ import os
37
+ import re
38
+ import shutil
39
+ import subprocess
40
+ import sys
41
+ import urllib.error
42
+ import urllib.parse
43
+ import urllib.request
44
+ from datetime import datetime, timezone
45
+ from pathlib import Path
46
+
47
+ CONTROLSCAN_VERSION = "1.0.0"
48
+ SEVERITIES = ["critical", "high", "medium", "low", "info"]
49
+ SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
50
+ SUPPLY, CODE, ACCESS, PROCESS = ("Supply chain & dependency scanning", "Code & secrets scanning",
51
+ "Repository & access", "Security process")
52
+
53
+ # The control list of plan 5 X4 (the server computes findings-lifecycle and ai-red-team-fresh).
54
+ CONTROLS = [
55
+ {"key": "ci-dependency-audit", "group": SUPPLY, "title": "CI audits dependencies",
56
+ "fix": "Add a dependency audit step to CI (npm audit, cargo audit, pip-audit, osv-scanner, ikanban secscan) "
57
+ "or configure Dependabot (.github/dependabot.yml) or Renovate."},
58
+ {"key": "lockfiles-committed", "group": SUPPLY, "title": "Lockfiles are committed",
59
+ "fix": "Commit the lockfile next to each manifest (package-lock.json, pnpm-lock.yaml, yarn.lock, Cargo.lock, "
60
+ "poetry.lock or uv.lock, Gemfile.lock, go.sum, composer.lock)."},
61
+ {"key": "ci-secret-scanning", "group": CODE, "title": "CI scans for secrets",
62
+ "fix": "Run gitleaks, trufflehog or detect-secrets in CI, or turn on GitHub secret scanning with push protection."},
63
+ {"key": "ci-sast", "group": CODE, "title": "CI runs static analysis (SAST)",
64
+ "fix": "Run CodeQL (github/codeql-action), Semgrep or another SAST tool in CI."},
65
+ {"key": "env-files-ignored", "group": CODE, "title": ".env and key files stay out of git",
66
+ "fix": "Remove the file from git (git rm --cached FILE), rotate what it held, and add .env and key "
67
+ "files to .gitignore."},
68
+ {"key": "branch-protection", "group": ACCESS, "title": "Default branch is protected",
69
+ "fix": "Protect the default branch: block force-push and deletion and require a pull request "
70
+ "(GitHub: Settings, Rules or Branches)."},
71
+ {"key": "codeowners", "group": ACCESS, "title": "CODEOWNERS names owners",
72
+ "fix": "Add a CODEOWNERS file (root, .github/ or docs/) with at least one line like `* @org/team`."},
73
+ {"key": "security-policy", "group": PROCESS, "title": "SECURITY.md says how to report a vulnerability",
74
+ "fix": "Add SECURITY.md with a contact: an email address or a private reporting link."},
75
+ ]
76
+ CONTROL = {c["key"]: c for c in CONTROLS}
77
+
78
+ CI_FILES = (".gitlab-ci.yml", "azure-pipelines.yml", "bitbucket-pipelines.yml", ".circleci/config.yml",
79
+ "Jenkinsfile", ".travis.yml", ".drone.yml", "buildspec.yml", "cloudbuild.yaml")
80
+ AUDIT_TOOLS = re.compile(r"(?i)\b(?:npm|pnpm|yarn|bun)\s+(?:npm\s+)?audit\b|\bcargo[- ](?:audit|deny)\b|\bpip-audit\b|"
81
+ r"\bsafety\s+(?:check|scan)\b|\bosv-scanner\b|\btrivy\s+(?:fs|repo)\b|\bsnyk\s+test\b|"
82
+ r"dependency-check|dependency-review-action|\bgovulncheck\b|\bbundle(?:r|-)?\s*audit\b|"
83
+ r"\bikanban\s+secscan\b|\bgrype\b")
84
+ SECRET_TOOLS = re.compile(r"(?i)\bgitleaks\b|\btrufflehog\b|\bdetect-secrets\b|\bggshield\b|\bsecretlint\b|"
85
+ r"\bikanban\s+secscan\b")
86
+ SAST_TOOLS = re.compile(r"(?i)codeql|\bsemgrep\b|\bsonar(?:cloud|qube|-scanner)?\b|\bsnyk\s+code\b|\bbandit\b|"
87
+ r"\bgosec\b|\bbrakeman\b|\bnjsscan\b|\bhorusec\b|checkmarx|\bbearer\b|\bopengrep\b")
88
+ LOCKS = {
89
+ "package.json": ("package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb", "bun.lock"),
90
+ "Cargo.toml": ("Cargo.lock",),
91
+ "pyproject.toml": ("poetry.lock", "uv.lock", "pdm.lock", "Pipfile.lock", "requirements.txt"),
92
+ "Pipfile": ("Pipfile.lock",),
93
+ "Gemfile": ("Gemfile.lock",),
94
+ "go.mod": ("go.sum",),
95
+ "composer.json": ("composer.lock",),
96
+ }
97
+ SECRET_FILE = re.compile(r"(?i)(?:^|/)(?:\.env(?:\.[^/]*)?|id_rsa|id_dsa|id_ecdsa|id_ed25519|[^/]*\.(?:pem|key|p12|pfx|jks))$")
98
+ SECRET_FILE_OK = re.compile(r"(?i)\.(?:example|sample|template|dist|defaults?)$|\.pub$")
99
+
100
+
101
+ def result(key: str, status: str, reason: str) -> dict:
102
+ c = CONTROL[key]
103
+ return {"key": key, "group": c["group"], "title": c["title"], "status": status, "reason": reason}
104
+
105
+
106
+ def git(root: Path, *args, timeout=20):
107
+ """(exit code, stdout); never raises."""
108
+ try:
109
+ p = subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, timeout=timeout)
110
+ return p.returncode, p.stdout
111
+ except (OSError, subprocess.SubprocessError):
112
+ return 127, ""
113
+
114
+
115
+ class Repo:
116
+ """What every check reads, gathered once: tracked files and the CI configuration text."""
117
+
118
+ def __init__(self, root: Path):
119
+ self.root = root
120
+ code, top = git(root, "rev-parse", "--show-toplevel")
121
+ self.is_git = code == 0 and bool(top.strip())
122
+ code, out = git(root, "ls-files", "-z") if self.is_git else (1, "")
123
+ self.tracked = sorted(f for f in out.split("\0") if f) if code == 0 else []
124
+ self.ci = {}
125
+ wf = root / ".github" / "workflows"
126
+ names = sorted(p for p in wf.glob("*.y*ml")) if wf.is_dir() else []
127
+ names += [root / n for n in CI_FILES if (root / n).is_file()]
128
+ for p in names:
129
+ try:
130
+ self.ci[p.relative_to(root).as_posix()] = p.read_text(encoding="utf-8", errors="replace")[:1_000_000]
131
+ except OSError:
132
+ continue
133
+
134
+ def ci_match(self, rx):
135
+ return sorted(name for name, text in self.ci.items() if rx.search(text))
136
+
137
+ def first_file(self, *rels):
138
+ for rel in rels:
139
+ p = self.root / rel
140
+ if p.is_file():
141
+ return p
142
+ return None
143
+
144
+
145
+ # ── the GitHub API, only through the user's own gh ──────────────────────────────
146
+ def gh_ready() -> bool:
147
+ """gh is installed and signed in."""
148
+ if not shutil.which("gh"):
149
+ return False
150
+ try:
151
+ return subprocess.run(["gh", "auth", "status"], capture_output=True, text=True, timeout=20).returncode == 0
152
+ except (OSError, subprocess.SubprocessError):
153
+ return False
154
+
155
+
156
+ def gh_json(path: str):
157
+ """(ok, data | error text) for `gh api <path>`; the path is built from checked parts only."""
158
+ try:
159
+ p = subprocess.run(["gh", "api", "-H", "Accept: application/vnd.github+json", path],
160
+ capture_output=True, text=True, timeout=30)
161
+ except (OSError, subprocess.SubprocessError) as e:
162
+ return False, str(e)
163
+ if p.returncode != 0:
164
+ return False, (p.stderr or p.stdout).strip()[:200]
165
+ try:
166
+ return True, json.loads(p.stdout or "null")
167
+ except ValueError:
168
+ return False, "gh api did not answer JSON"
169
+
170
+
171
+ GITHUB_SLUG = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
172
+
173
+
174
+ def github_slug(root: Path) -> str | None:
175
+ _, url = git(root, "remote", "get-url", "origin")
176
+ url = url.strip()
177
+ if not re.match(r"^(?:https://(?:[^@/]+@)?github\.com/|git@github\.com:|ssh://git@github\.com/)", url):
178
+ return None
179
+ slug = normalize_remote(url)
180
+ return slug if GITHUB_SLUG.match(slug) and ".." not in slug else None
181
+
182
+
183
+ # ── the checks ──────────────────────────────────────────────────────────────────
184
+ def check_dependency_audit(r: Repo) -> dict:
185
+ hits = r.ci_match(AUDIT_TOOLS)
186
+ if hits:
187
+ return result("ci-dependency-audit", "pass", f"dependency audit in {', '.join(hits)}")
188
+ bot = r.first_file(".github/dependabot.yml", ".github/dependabot.yaml", "renovate.json", "renovate.json5",
189
+ ".github/renovate.json", ".github/renovate.json5", ".renovaterc", ".renovaterc.json",
190
+ ".gitlab/renovate.json")
191
+ if bot:
192
+ return result("ci-dependency-audit", "pass", f"{bot.relative_to(r.root).as_posix()} is configured")
193
+ where = f"{len(r.ci)} CI file(s) read" if r.ci else "no CI workflow found"
194
+ return result("ci-dependency-audit", "fail", f"no dependency audit step in CI ({where}) and no Dependabot "
195
+ "or Renovate config")
196
+
197
+
198
+ def check_lockfiles(r: Repo) -> dict:
199
+ if not r.is_git:
200
+ return result("lockfiles-committed", "skipped", "not a git repository: cannot tell what is committed")
201
+ tracked = set(r.tracked)
202
+ manifests = [f for f in r.tracked if f.rsplit("/", 1)[-1] in LOCKS
203
+ and "node_modules/" not in f and "vendor/" not in f]
204
+ if not manifests:
205
+ return result("lockfiles-committed", "skipped", "no package manifest is tracked in git")
206
+ missing = []
207
+ for m in manifests:
208
+ folder, name = (m.rsplit("/", 1) if "/" in m else ("", m))
209
+ parts = folder.split("/") if folder else []
210
+ dirs = ["/".join(parts[:i]) for i in range(len(parts), -1, -1)] # the folder, then each parent
211
+ if not any(f"{d}/{lock}".lstrip("/") in tracked for d in dirs for lock in LOCKS[name]):
212
+ missing.append(m)
213
+ if missing:
214
+ more = f" and {len(missing) - 10} more" if len(missing) > 10 else ""
215
+ return result("lockfiles-committed", "fail", f"no committed lockfile for {', '.join(missing[:10])}{more}")
216
+ return result("lockfiles-committed", "pass", f"{len(manifests)} manifest(s), each with a committed lockfile")
217
+
218
+
219
+ def github_secret_scanning(slug: str):
220
+ """(status, reason) from the repository's security_and_analysis, or None when it cannot be read."""
221
+ ok, data = gh_json(f"repos/{slug}")
222
+ sa = (data or {}).get("security_and_analysis") if ok and isinstance(data, dict) else None
223
+ if not sa:
224
+ return None
225
+ on = (sa.get("secret_scanning") or {}).get("status") == "enabled"
226
+ push = (sa.get("secret_scanning_push_protection") or {}).get("status") == "enabled"
227
+ if on and push:
228
+ return "pass", "GitHub secret scanning with push protection is on"
229
+ if on:
230
+ return "warn", "GitHub secret scanning is on, but push protection is off"
231
+ return None
232
+
233
+
234
+ def check_secret_scanning(r: Repo) -> dict:
235
+ hits = r.ci_match(SECRET_TOOLS)
236
+ if hits:
237
+ return result("ci-secret-scanning", "pass", f"secret scanning in {', '.join(hits)}")
238
+ slug = github_slug(r.root) if r.is_git else None
239
+ if slug and gh_ready():
240
+ got = github_secret_scanning(slug)
241
+ if got:
242
+ return result("ci-secret-scanning", *got)
243
+ note = "" if slug and gh_ready() else "; GitHub secret scanning not read (needs a GitHub origin and gh signed in)"
244
+ return result("ci-secret-scanning", "fail", f"no CI step runs gitleaks, trufflehog or detect-secrets{note}")
245
+
246
+
247
+ def check_sast(r: Repo) -> dict:
248
+ hits = r.ci_match(SAST_TOOLS)
249
+ if hits:
250
+ return result("ci-sast", "pass", f"static analysis in {', '.join(hits)}")
251
+ where = f"{len(r.ci)} CI file(s) read" if r.ci else "no CI workflow found"
252
+ return result("ci-sast", "fail", f"no CodeQL, Semgrep or other SAST step in CI ({where})")
253
+
254
+
255
+ def check_env_files(r: Repo) -> dict:
256
+ if not r.is_git:
257
+ return result("env-files-ignored", "skipped", "not a git repository: cannot tell what is committed")
258
+ bad = [f for f in r.tracked if SECRET_FILE.search(f) and not SECRET_FILE_OK.search(f)]
259
+ if bad:
260
+ more = f" and {len(bad) - 10} more" if len(bad) > 10 else ""
261
+ return result("env-files-ignored", "fail", f"tracked in git: {', '.join(bad[:10])}{more}")
262
+ code, _ = git(r.root, "check-ignore", "-q", "--no-index", ".env")
263
+ if code != 0:
264
+ return result("env-files-ignored", "warn", "no .env or key file is tracked, but .gitignore does not cover .env")
265
+ return result("env-files-ignored", "pass", "no .env or key file is tracked; .gitignore covers .env")
266
+
267
+
268
+ def check_branch_protection(r: Repo) -> dict:
269
+ if not r.is_git:
270
+ return result("branch-protection", "skipped", "not a git repository")
271
+ slug = github_slug(r.root)
272
+ if not slug:
273
+ return result("branch-protection", "skipped", "origin is not a GitHub repository; check the host's "
274
+ "branch rules by hand")
275
+ if not gh_ready():
276
+ return result("branch-protection", "skipped", "needs the GitHub CLI (gh) installed and signed in "
277
+ "(gh auth login)")
278
+ ok, repo = gh_json(f"repos/{slug}")
279
+ branch = repo.get("default_branch") if ok and isinstance(repo, dict) else None
280
+ if not branch or not re.match(r"^[A-Za-z0-9._/-]+$", branch) or ".." in branch:
281
+ return result("branch-protection", "skipped", f"GitHub API: could not read {slug} ({repo if not ok else 'no default branch'})")
282
+ b = urllib.parse.quote(branch, safe="")
283
+ ok_rules, rules = gh_json(f"repos/{slug}/rules/branches/{b}")
284
+ types = {x.get("type") for x in rules if isinstance(x, dict)} if ok_rules and isinstance(rules, list) else set()
285
+ ok_prot, prot = gh_json(f"repos/{slug}/branches/{b}/protection")
286
+ prot = prot if ok_prot and isinstance(prot, dict) else {}
287
+ if not ok_rules and not ok_prot and "404" not in f"{rules}{prot}":
288
+ return result("branch-protection", "skipped", f"GitHub API: could not read the rules of {branch}")
289
+ no_force = "non_fast_forward" in types or (prot and not (prot.get("allow_force_pushes") or {}).get("enabled", True))
290
+ no_delete = "deletion" in types or (prot and not (prot.get("allow_deletions") or {}).get("enabled", True))
291
+ needs_pr = "pull_request" in types or bool(prot.get("required_pull_request_reviews"))
292
+ missing = [label for label, ok in (("force-push is not blocked", no_force), ("deletion is not blocked", no_delete),
293
+ ("no pull request is required", needs_pr)) if not ok]
294
+ if missing:
295
+ return result("branch-protection", "fail", f"{branch}: {'; '.join(missing)}")
296
+ return result("branch-protection", "pass", f"{branch} blocks force-push and deletion and needs a pull request")
297
+
298
+
299
+ OWNER = re.compile(r"(?:^|\s)(?:@[\w.-]+(?:/[\w.-]+)?|[\w.+-]+@[\w-]+\.[\w.-]+)")
300
+
301
+
302
+ def check_codeowners(r: Repo) -> dict:
303
+ f = r.first_file(".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS", ".gitlab/CODEOWNERS")
304
+ if not f:
305
+ return result("codeowners", "fail", "no CODEOWNERS file in the root, .github/, docs/ or .gitlab/")
306
+ rel = f.relative_to(r.root).as_posix()
307
+ lines = [ln for ln in f.read_text(encoding="utf-8", errors="replace").splitlines()
308
+ if ln.strip() and not ln.lstrip().startswith("#")]
309
+ if not any(OWNER.search(ln) for ln in lines):
310
+ return result("codeowners", "warn", f"{rel} names no owner (@user, @org/team or an email)")
311
+ return result("codeowners", "pass", f"{rel} names owners on {len(lines)} line(s)")
312
+
313
+
314
+ CONTACT = re.compile(r"(?i)[\w.+-]+@[\w-]+\.[\w.-]+|https?://\S+|security/advisories|private vulnerability report")
315
+
316
+
317
+ def check_security_policy(r: Repo) -> dict:
318
+ found = None
319
+ for folder in ("", ".github", "docs"):
320
+ d = r.root / folder if folder else r.root
321
+ if d.is_dir():
322
+ found = next((p for p in sorted(d.iterdir()) if p.is_file() and p.name.lower() == "security.md"), None)
323
+ if found:
324
+ break
325
+ if not found:
326
+ return result("security-policy", "fail", "no SECURITY.md in the root, .github/ or docs/")
327
+ rel = found.relative_to(r.root).as_posix()
328
+ if not CONTACT.search(found.read_text(encoding="utf-8", errors="replace")):
329
+ return result("security-policy", "warn", f"{rel} gives no contact (an email or a reporting link)")
330
+ return result("security-policy", "pass", f"{rel} says how to report a vulnerability")
331
+
332
+
333
+ CHECKS = [check_dependency_audit, check_lockfiles, check_secret_scanning, check_sast, check_env_files,
334
+ check_branch_protection, check_codeowners, check_security_policy]
335
+
336
+
337
+ def run_checks(root: Path) -> list:
338
+ r = Repo(root)
339
+ out = []
340
+ for check in CHECKS:
341
+ try:
342
+ out.append(check(r))
343
+ except Exception as e: # noqa: BLE001 — one broken check never hides the others
344
+ key = CONTROLS[len(out)]["key"]
345
+ out.append(result(key, "skipped", f"the check stopped: {type(e).__name__}"))
346
+ return out
347
+
348
+
349
+ # ── report and publish ──────────────────────────────────────────────────────────
350
+ MARK = {"pass": "✅ pass", "warn": "⚠️ warn", "fail": "❌ fail", "skipped": "⏭ skipped"}
351
+
352
+
353
+ def build_report(name: str, results: list) -> tuple:
354
+ counts = {s: sum(1 for x in results if x["status"] == s) for s in ("pass", "warn", "fail", "skipped")}
355
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
356
+ L = [f"# Repository controls — {name}", "", f"_{stamp} · `ikanban-controlscan`_", "",
357
+ "| Control | Group | Result | Why |", "|---|---|---|---|"]
358
+ L += [f"| {x['key']} | {x['group']} | {MARK[x['status']]} | {x['reason']} |" for x in results]
359
+ L += ["", f"**{counts['pass']} pass, {counts['warn']} warn, {counts['fail']} fail, {counts['skipped']} skipped.**", ""]
360
+ for x in results:
361
+ if x["status"] in ("fail", "warn"):
362
+ L.append(f"- **{x['key']}**: {CONTROL[x['key']]['fix']}")
363
+ L += ["", "A skipped check is a gap, not a pass: its control stays unchecked in iKanban."]
364
+ return "\n".join(L), counts
365
+
366
+
367
+ def finding_key(kind: str, scope: str) -> str:
368
+ return "controlscan-" + hashlib.sha256(f"{kind}|{scope}".encode()).hexdigest()[:16]
369
+
370
+
371
+ def publish_bodies(results: list, scope: str) -> list:
372
+ """[(kind, scan-findings body)] — one per control; every text field through scrub_text."""
373
+ out = []
374
+ for x in results:
375
+ kind = f"ctl-{x['key']}"
376
+ findings = []
377
+ if x["status"] in ("fail", "warn"):
378
+ findings.append(scrub_finding({
379
+ "finding_key": finding_key(kind, scope),
380
+ "owasp_category": x["group"],
381
+ "severity": "Medium" if x["status"] == "fail" else "Low",
382
+ "title": f"{CONTROL[x['key']]['title']}: {x['status']}"[:300],
383
+ "location_file": None,
384
+ "location_lines": None,
385
+ "remediation": f"{x['reason']}. {CONTROL[x['key']]['fix']}"[:2000],
386
+ }))
387
+ out.append((kind, {"findings": findings, "scope": scope, "complete": x["status"] != "skipped", "kind": kind}))
388
+ return out
389
+
390
+
391
+ def publish(results: list, scope: str, args) -> int:
392
+ for kind, body in publish_bodies(results, scope):
393
+ ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token, body)
394
+ if not ok:
395
+ print(f"filing {kind} FAILED: {data or 'this iKanban server does not take repository controls yet'}",
396
+ file=sys.stderr)
397
+ return 2
398
+ print(f"filed {len(results)} repository controls for {scope}", file=sys.stderr)
399
+ return 0
400
+
401
+
402
+ # ── copied from ikanban-secscan.py: scope, key-host pinning, API, work folder, Pipelines ──────
403
+ def normalize_remote(url: str) -> str:
404
+ """owner/repo from any remote style (scp, https, ssh, a host alias), lower case;
405
+ never the user or a token in the URL."""
406
+ path = re.sub(r"^[a-z+]+://[^/]*/?", "", url.strip()) # scheme, user, host[:port]
407
+ if path == url.strip():
408
+ path = path.split(":", 1)[-1] # scp style user@host:path
409
+ path = path.strip("/")
410
+ path = path[:-4] if path.endswith(".git") else path
411
+ return "/".join(path.split("/")[-2:]).lower() if path else ""
412
+
413
+
414
+ def scan_scope(root: Path) -> str:
415
+ """Plan 11 H3: which repository a scan covers, the same on every laptop and agent
416
+ run (an agent's run folder is named by its run id): origin's owner/repo, else the
417
+ repository's folder name; plus the subfolder when --path is one. A rescan closes
418
+ only findings of the same scope."""
419
+ root = root.resolve()
420
+
421
+ def git(*a):
422
+ try:
423
+ return subprocess.run(["git", "-C", str(root), *a], capture_output=True,
424
+ text=True, timeout=10).stdout.strip()
425
+ except (OSError, subprocess.SubprocessError):
426
+ return ""
427
+ top = git("rev-parse", "--show-toplevel")
428
+ base = Path(top).resolve() if top else root
429
+ base_name = normalize_remote(git("remote", "get-url", "origin")) or base.name.lower()
430
+ sub = root.relative_to(base).as_posix() if base in root.parents else ""
431
+ return f"{base_name}/{sub}" if sub else base_name
432
+
433
+
434
+ # Coding standard T3: the iKanban key goes only to iKanban's API (or this machine), and never
435
+ # on a redirect. A --base-url, IKANBAN_BASE_URL or config file cannot send it anywhere else.
436
+ IKANBAN_APIS = ("https://api.i-kanban.com", "https://dev-api.i-kanban.com")
437
+
438
+
439
+ def ikanban_api(base_url):
440
+ """The origin of base_url if it is iKanban's API; otherwise stop, naming it (never the key)."""
441
+ u = urllib.parse.urlsplit(base_url or "")
442
+ origin = f"{u.scheme}://{u.netloc}"
443
+ if u.username is None and u.path in ("", "/") and not u.query and (
444
+ origin in IKANBAN_APIS or (u.scheme == "http" and u.hostname in ("127.0.0.1", "localhost", "::1"))):
445
+ return origin
446
+ raise SystemExit(f"error: {base_url or '(empty)'} is not an iKanban API "
447
+ f"({', '.join(IKANBAN_APIS)} or this machine); the key was not sent")
448
+
449
+
450
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
451
+ def redirect_request(self, *args, **kwargs):
452
+ return None # a 3xx comes back as an HTTPError, and the key goes nowhere else
453
+
454
+
455
+ _OPENER = urllib.request.build_opener(_NoRedirect)
456
+
457
+
458
+ def api(method: str, path: str, base_url: str, token: str, payload=None):
459
+ """(ok, data | error text | None for 404)."""
460
+ req = urllib.request.Request(
461
+ f"{ikanban_api(base_url)}/api{path}",
462
+ data=json.dumps(payload).encode() if payload is not None else None,
463
+ headers={"Authorization": f"Bearer {token}",
464
+ "Content-Type": "application/json"},
465
+ method=method,
466
+ )
467
+ try:
468
+ with _OPENER.open(req, timeout=30) as r:
469
+ body = json.loads(r.read().decode() or "{}")
470
+ return True, body.get("data", body)
471
+ except urllib.error.HTTPError as e:
472
+ detail = e.read().decode()[:300]
473
+ if e.code in (404, 405) and not detail.strip():
474
+ return False, None # an unknown route: GET answers 404, POST 405, both empty
475
+ return False, f"HTTP {e.code}: {detail}"
476
+ except Exception as e: # noqa: BLE001 — surface any transport failure verbatim
477
+ return False, str(e)
478
+
479
+
480
+ # ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
481
+ # <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
482
+ # holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
483
+ def add_work_dir_arg(ap):
484
+ ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
485
+ help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
486
+ "<repository>/.iKanban/scans)")
487
+ ap.add_argument("--print-report", action="store_true",
488
+ help="Print the full report on stdout (under the ikanban CLI stdout otherwise shows the "
489
+ "counts and `ikanban scans show <id>`)")
490
+
491
+
492
+ def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
493
+ # Plan 5 X3: the ikanban CLI hands over a private temp folder (IKANBAN_SCAN_DIR) and the scan id
494
+ # (IKANBAN_SCAN_ID). That folder is this run's folder; the CLI seals it into ~/.ikanban/scans.
495
+ cli_dir = os.environ.get("IKANBAN_SCAN_DIR", "")
496
+ if override and cli_dir and os.environ.get("IKANBAN_SCAN_ID") and (
497
+ Path(override).expanduser().resolve() == Path(cli_dir).expanduser().resolve()):
498
+ run_dir = Path(cli_dir).expanduser().resolve()
499
+ run_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
500
+ os.chmod(run_dir, 0o700)
501
+ return run_dir
502
+ scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
503
+ scans.mkdir(parents=True, exist_ok=True)
504
+ ignore = scans / ".gitignore"
505
+ if not ignore.exists():
506
+ ignore.write_text("*\n")
507
+ run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
508
+ run_dir.mkdir(mode=0o700, exist_ok=True)
509
+ os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
510
+ return run_dir
511
+
512
+
513
+ def write_private(path: Path, text: str) -> None:
514
+ """Write a file (0600) without following a symlink planted at its name: write a new
515
+ temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
516
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
517
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
518
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
519
+ f.write(text)
520
+ os.replace(tmp, path)
521
+
522
+
523
+ def slug(text: str) -> str:
524
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
525
+
526
+
527
+ # ── scrub_text: identical in every scanner (test_scrub_text.py compares the copies) ──────────
528
+ # Plan 5 X2-T1: every text field of every finding passes through scrub_text before it leaves this
529
+ # machine. A secret keeps its first 4 characters; a home folder becomes ~. The server scrubs again.
530
+ _SCRUB_CUT = (
531
+ re.compile(r"-{5}BEGIN [A-Z ]*PRIVATE KEY-{5}.*?(?:-{5}END [A-Z ]*PRIVATE KEY-{5}|\Z)", re.S),
532
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
533
+ re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"),
534
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b"),
535
+ re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
536
+ re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
537
+ re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b"),
538
+ re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}\b"),
539
+ re.compile(r"\bwhsec_[A-Za-z0-9]{16,}\b"),
540
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
541
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
542
+ re.compile(r"\bvk_[A-Za-z0-9_-]{8,}"),
543
+ )
544
+ _SCRUB_ASSIGNED = re.compile(
545
+ r"(?i)(\b(?:aws_secret_access_key|aws_session_token|client_secret|secret_key|private_key|api[_-]?key|"
546
+ r"access[_-]?token|auth[_-]?token|token|password|passwd|pwd|secret)\b[\"']?\s*[:=]\s*[\"']?)([^\s\"',;&]+)")
547
+ _SCRUB_BEARER = re.compile(r"(?i)(\b(?:bearer|basic)\s+)([A-Za-z0-9._~+/=-]{12,})")
548
+ _SCRUB_URL_CREDS = re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)([^\s@/]+)(@)")
549
+ _SCRUB_HOME = re.compile(r"(?<![\w.-])(?:[A-Za-z]:\\[Uu]sers\\|/Users/|/home/)[^\\/\s\"'<>]+")
550
+
551
+
552
+ def scrub_text(text) -> str:
553
+ """Secrets, iKanban keys, tokens, AWS and GitHub keys, private key blocks and home folders
554
+ cut from one text. None becomes ""."""
555
+ if text is None:
556
+ return ""
557
+ text = str(text)
558
+ for rx in _SCRUB_CUT:
559
+ text = rx.sub(lambda m: m.group(0)[:4] + "…[hidden]", text)
560
+ text = _SCRUB_ASSIGNED.sub(
561
+ lambda m: m.group(1) + "[hidden]" if len(m.group(2)) >= 8 or any(c.isdigit() for c in m.group(2))
562
+ else m.group(0), text)
563
+ text = _SCRUB_BEARER.sub(lambda m: m.group(1) + "[hidden]", text)
564
+ text = _SCRUB_URL_CREDS.sub(lambda m: m.group(1) + "[hidden]" + m.group(3), text)
565
+ home = str(Path.home())
566
+ if home and home not in ("/", "\\") and home in text:
567
+ text = text.replace(home, "~")
568
+ return _SCRUB_HOME.sub("~", text)
569
+
570
+
571
+ def scrub_finding(value):
572
+ """scrub_text on every string in a finding, through nested dicts and lists; other values stay."""
573
+ if isinstance(value, str):
574
+ return scrub_text(value)
575
+ if isinstance(value, dict):
576
+ return {k: scrub_finding(v) for k, v in value.items()}
577
+ if isinstance(value, list):
578
+ return [scrub_finding(v) for v in value]
579
+ return value
580
+
581
+
582
+ # ── end scrub_text ──
583
+
584
+
585
+ # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
586
+ # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
587
+ # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
588
+ # then that line as the log (plan 5: never the report). A plan without Pipelines (402) or any error never fails the scan.
589
+ _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)
590
+ # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
591
+ PIPE_RUN = "security-scan"
592
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per log line
593
+ LAST_RUN = {"report": "", "detail": "", "skipped": False}
594
+
595
+
596
+ def add_pipeline_args(ap):
597
+ ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
598
+ help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
599
+ "or IKANBAN_TASK)")
600
+ ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
601
+ help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
602
+
603
+
604
+ def summary_detail(counts: dict, total: int, skipped: list) -> str:
605
+ if skipped and not total and all(s for s in skipped):
606
+ return ("SKIPPED: " + "; ".join(skipped))[:300]
607
+ worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
608
+ return f"{total} findings" + (f": {worst}" if worst else "")
609
+
610
+
611
+ def mask_report(text: str) -> str:
612
+ """scrub_text before a line leaves for Pipelines, plus a scanner's own rules (scrub_report)."""
613
+ text = scrub_text(text)
614
+ scrub = globals().get("scrub_report")
615
+ return scrub(text)[0] if scrub else text
616
+
617
+
618
+ def show_report(md: str, summary: str, print_report: bool = False) -> None:
619
+ """Plan 5 X3: under the ikanban CLI (IKANBAN_SCAN_ID set) stdout gets the counts and where the
620
+ full report is stored; the body only with --print-report. A plain python run prints the body."""
621
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
622
+ if scan_id and not print_report:
623
+ print(summary)
624
+ print(f"Full report: ikanban scans show {scan_id}")
625
+ else:
626
+ print(md)
627
+
628
+
629
+ def scan_files_note(work: Path) -> str:
630
+ """Where this run's files are: the encrypted store under the CLI, else the run folder."""
631
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
632
+ return f"scan files: sealed into ~/.ikanban/scans as {scan_id}" if scan_id else f"scan files: {work}"
633
+
634
+
635
+
636
+ # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
637
+ # scan ran, so each step also sends one line as it starts and ends; the one-line summary follows at the end.
638
+ # Progress lines are fixed status text: never raw tool output or an error body (it can carry
639
+ # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
640
+ PIPE = None
641
+
642
+
643
+ def progress(text: str) -> None:
644
+ if os.environ.get("IKANBAN_SCAN_ID") and "--json" not in sys.argv:
645
+ print(text, flush=True) # Plan 5: under the CLI stdout shows progress, not the report
646
+ if PIPE:
647
+ PIPE.log(text)
648
+
649
+
650
+
651
+ class Pipeline:
652
+ def __init__(self, stage, task, team, base_url, token):
653
+ self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
654
+ self.on = bool(task and team and token)
655
+ self.tid = None
656
+
657
+ def _off(self, why):
658
+ self.on = False
659
+ note = "the plan does not include Pipelines" if "402" in str(why) else why
660
+ print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
661
+
662
+ def _call(self, method, path, body=None):
663
+ try:
664
+ return api(method, path, self.base, self.token, body)
665
+ except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
666
+ return False, str(e)
667
+
668
+ def _post(self, path, body):
669
+ if not self.on:
670
+ return
671
+ if self.tid is None:
672
+ if _UUID.match(self.team):
673
+ self.tid = self.team
674
+ else:
675
+ ok, teams = self._call("GET", "/teams")
676
+ if not ok:
677
+ return self._off(teams)
678
+ found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
679
+ if not found:
680
+ return self._off(f"team {self.team} not found among your teams")
681
+ self.tid = found[0]
682
+ ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
683
+ if not ok:
684
+ self._off(data or "this iKanban server has no Pipelines")
685
+
686
+ def start(self):
687
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
688
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
689
+
690
+ def log(self, line):
691
+ """One progress line, sent as it happens (masked like the report). A failure only turns Pipelines off."""
692
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
693
+ "text": mask_report(line.rstrip("\n"))[:PIPE_CHUNK - 1] + "\n"})
694
+
695
+ def finish(self, code):
696
+ status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
697
+ detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
698
+ if code == 1 and not LAST_RUN["skipped"]:
699
+ detail = (detail + " (at or above --fail-on)").strip()
700
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
701
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
702
+ "detail": detail[:300] or None})
703
+ # Plan 5 X1-T2: the summary only; the report stays on the machine that ran the scan.
704
+ summary = f"{self.stage} {status}" + (f": {detail[:300]}" if detail else "")
705
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
706
+ "text": mask_report(summary) + "\n"})
707
+
708
+
709
+ def run_main(stage, main_fn, argv=None):
710
+ """main() as it is, wrapped in Pipelines events when --task is given."""
711
+ raw = sys.argv[1:] if argv is None else argv
712
+ if any(a in ("-h", "--help", "--version", "--list-apps") for a in raw):
713
+ return main_fn(argv)
714
+ pre = argparse.ArgumentParser(add_help=False)
715
+ pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
716
+ pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
717
+ pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
718
+ pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
719
+ pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
720
+ known, _ = pre.parse_known_args(raw)
721
+ pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
722
+ pl.start()
723
+ global PIPE
724
+ PIPE = pl if pl.on else None
725
+ try:
726
+ code = main_fn(argv)
727
+ except SystemExit as e:
728
+ if not isinstance(e.code, int):
729
+ print(e.code, file=sys.stderr)
730
+ code = e.code if isinstance(e.code, int) else 2
731
+ pl.finish(code)
732
+ return code
733
+
734
+
735
+ def main(argv=None):
736
+ ap = argparse.ArgumentParser(description="Repository security controls check that reports into iKanban.")
737
+ ap.add_argument("--version", action="version", version=f"ikanban controlscan {CONTROLSCAN_VERSION}")
738
+ ap.add_argument("--path", default=".", help="Repository to check (default: cwd)")
739
+ ap.add_argument("--publish", action="store_true",
740
+ help="File the control results into iKanban (one per control; the report stays here)")
741
+ ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
742
+ help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
743
+ ap.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
744
+ ap.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""),
745
+ help="Personal API key (or VIBE_API_TOKEN)")
746
+ ap.add_argument("--out", default=None, help="Also write the report here")
747
+ ap.add_argument("--json", action="store_true", help="Machine-readable result")
748
+ add_pipeline_args(ap)
749
+ add_work_dir_arg(ap)
750
+ args = ap.parse_args(argv)
751
+
752
+ root = Path(args.path).expanduser().resolve()
753
+ if not root.is_dir():
754
+ print(f"error: {root} is not a directory", file=sys.stderr)
755
+ return 2
756
+ if args.publish and (not args.token or not args.team):
757
+ print("error: --publish needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID)", file=sys.stderr)
758
+ return 2
759
+ work = scan_work_dir("controlscan", root, args.work_dir)
760
+ progress(f"controlscan {CONTROLSCAN_VERSION} · {root.name}")
761
+ results = run_checks(root)
762
+ for x in results:
763
+ progress(f"{x['key']} … {x['status']}")
764
+ md, counts = build_report(root.name, results)
765
+ detail = f"{counts['pass']} pass, {counts['warn']} warn, {counts['fail']} fail, {counts['skipped']} skipped"
766
+ LAST_RUN.update(report=md, detail=detail, skipped=counts["skipped"] == len(results))
767
+ write_private(work / "report.md", md)
768
+ write_private(work / "controls.json", json.dumps(results, indent=2))
769
+ if args.out:
770
+ Path(args.out).write_text(md, encoding="utf-8")
771
+ if args.json:
772
+ print(json.dumps({"path": str(root), "controls": results, "counts": counts}, indent=2))
773
+ else:
774
+ show_report(md, detail, args.print_report)
775
+ sys.stdout.flush()
776
+ print(scan_files_note(work), file=sys.stderr)
777
+ if args.publish:
778
+ return publish(results, scan_scope(root), args)
779
+ return 0
780
+
781
+
782
+ if __name__ == "__main__":
783
+ sys.exit(run_main("controlscan", main))