ikanban 0.3.3 → 0.4.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.
@@ -0,0 +1,511 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ikanban-infrascan — container image and IaC (infrastructure as code) scan of YOUR OWN
4
+ repository and images, reporting into iKanban's Infrastructure tab (plan 11 H12).
5
+
6
+ npx ikanban infrascan # IaC misconfiguration scan of cwd
7
+ npx ikanban infrascan --image myapp:latest # also scan a container image
8
+ npx ikanban infrascan --publish --team TEAM # file the report and its findings
9
+
10
+ It runs Trivy (Apache-2.0, https://github.com/aquasecurity/trivy), which must already be
11
+ installed; a missing Trivy is reported as SKIPPED with the install lines, never as a clean
12
+ scan, and nothing is filed for a check that did not run.
13
+ IaC trivy fs --scanners misconfig --format json --output FILE PATH
14
+ Container trivy image --format json --output FILE IMAGE
15
+ (trivy.dev docs: "trivy fs --scanners misconfig /path/to/dir"; reporting docs:
16
+ "trivy image --format json --output result.json debian:12".)
17
+
18
+ JSON read (pkg/types/report.go and misconfiguration.go in the Trivy source):
19
+ Results[].Target
20
+ Results[].Misconfigurations[]: ID, AVDID, Title, Message, Resolution, Severity,
21
+ PrimaryURL, Status ("PASS" | "FAIL" | "EXCEPTION"), CauseMetadata.StartLine
22
+ Results[].Vulnerabilities[]: VulnerabilityID, PkgName, InstalledVersion, FixedVersion,
23
+ Severity, Title, PrimaryURL
24
+
25
+ Helpers below are copied from ikanban-secscan.py (the scanners ship as single files).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import re
35
+ import shutil
36
+ import subprocess
37
+ import sys
38
+ import tempfile
39
+ import urllib.error
40
+ import urllib.parse
41
+ import urllib.request
42
+ from dataclasses import dataclass, field
43
+ from datetime import datetime, timezone
44
+ from pathlib import Path
45
+
46
+ INFRASCAN_VERSION = "1.0.0"
47
+
48
+ SEVERITIES = ["critical", "high", "medium", "low", "info"]
49
+ SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
50
+ # Trivy's severities; UNKNOWN (no score yet) is filed as low.
51
+ TRIVY_SEVERITY = {"CRITICAL": "critical", "HIGH": "high", "MEDIUM": "medium", "LOW": "low", "UNKNOWN": "low"}
52
+
53
+ TRIVY_INSTALL = ("Trivy is not installed — macOS: `brew install trivy`; Linux and Windows: a release "
54
+ "binary from https://github.com/aquasecurity/trivy/releases, or `docker run aquasec/trivy`")
55
+
56
+ CATEGORY = {"iac": "A05 — Security Misconfiguration",
57
+ "container": "A06 — Vulnerable and Outdated Components"}
58
+
59
+
60
+ @dataclass
61
+ class Finding:
62
+ severity: str
63
+ title: str
64
+ detail: str
65
+ location: str = ""
66
+ line: str | None = None
67
+ rule: str = ""
68
+ remediation: str = ""
69
+
70
+
71
+ @dataclass
72
+ class Section:
73
+ name: str
74
+ kind: str
75
+ scope: str
76
+ findings: list = field(default_factory=list)
77
+ skipped: list = field(default_factory=list)
78
+ ran: list = field(default_factory=list)
79
+ complete: bool = False
80
+
81
+
82
+ def run(cmd, cwd=None, timeout=900):
83
+ """Run a command, returning (ok, stdout, stderr). Never raises."""
84
+ try:
85
+ p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
86
+ return p.returncode == 0, p.stdout, p.stderr
87
+ except FileNotFoundError:
88
+ return False, "", "not-installed"
89
+ except subprocess.TimeoutExpired:
90
+ return False, "", f"timed out after {timeout}s"
91
+
92
+
93
+ def trivy_json(args: list) -> tuple[dict | None, str]:
94
+ """Run trivy with --format json into a temp file; (report, "") or (None, why skipped)."""
95
+ if not shutil.which("trivy"):
96
+ return None, TRIVY_INSTALL
97
+ with tempfile.TemporaryDirectory() as tmp:
98
+ out = os.path.join(tmp, "trivy.json")
99
+ ok, _o, err = run(["trivy", *args[:1], "--format", "json", "--output", out, *args[1:]])
100
+ if not ok or not os.path.exists(out):
101
+ return None, f"trivy {args[0]} failed: {(err or '').strip()[-300:] or 'no output'}"
102
+ try:
103
+ return json.loads(Path(out).read_text() or "{}"), ""
104
+ except ValueError:
105
+ return None, f"trivy {args[0]} wrote no JSON"
106
+
107
+
108
+ def parse_misconfigurations(report: dict) -> list:
109
+ """Failed checks only; PASS and EXCEPTION are not findings."""
110
+ out = []
111
+ for res in report.get("Results") or []:
112
+ target = res.get("Target") or ""
113
+ for m in res.get("Misconfigurations") or []:
114
+ if m.get("Status") not in (None, "FAIL"):
115
+ continue
116
+ line = (m.get("CauseMetadata") or {}).get("StartLine")
117
+ rule = m.get("AVDID") or m.get("ID") or ""
118
+ fix = " ".join(x for x in (m.get("Resolution"), m.get("PrimaryURL")) if x)
119
+ out.append(Finding(
120
+ severity=TRIVY_SEVERITY.get((m.get("Severity") or "").upper(), "low"),
121
+ title=f"{m.get('Title') or rule}",
122
+ detail=m.get("Message") or "",
123
+ location=target, line=str(line) if line else None, rule=rule,
124
+ remediation=fix or "Change the configuration as the check describes."))
125
+ return out
126
+
127
+
128
+ def parse_vulnerabilities(report: dict, image: str) -> list:
129
+ out = []
130
+ for res in report.get("Results") or []:
131
+ target = res.get("Target") or image
132
+ for v in res.get("Vulnerabilities") or []:
133
+ pkg, have, fixed = v.get("PkgName") or "", v.get("InstalledVersion") or "", v.get("FixedVersion")
134
+ vid = v.get("VulnerabilityID") or ""
135
+ fix = f"Upgrade {pkg} to {fixed}" if fixed else f"No fixed version of {pkg} yet; use another base image or package"
136
+ if v.get("PrimaryURL"):
137
+ fix += f". {v['PrimaryURL']}"
138
+ out.append(Finding(
139
+ severity=TRIVY_SEVERITY.get((v.get("Severity") or "").upper(), "low"),
140
+ title=f"{pkg} {have}: {vid}" + (f" — {v['Title']}" if v.get("Title") else ""),
141
+ detail="", location=f"{image} ({target})", rule=f"{vid}|{pkg}", remediation=fix))
142
+ return out
143
+
144
+
145
+ def scan_iac(root: Path, scope: str) -> Section:
146
+ sec = Section("IaC misconfiguration", "iac", scope)
147
+ report, why = trivy_json(["fs", "--scanners", "misconfig", str(root)])
148
+ if report is None:
149
+ sec.skipped.append(why)
150
+ return sec
151
+ sec.ran.append(f"trivy fs --scanners misconfig {root.name}")
152
+ sec.findings = parse_misconfigurations(report)
153
+ for f in sec.findings: # paths relative to the repository, like secscan
154
+ f.location = f.location.removeprefix(str(root) + os.sep)
155
+ sec.complete = True
156
+ return sec
157
+
158
+
159
+ def scan_image(image: str) -> Section:
160
+ sec = Section(f"Container image {image}", "container", image)
161
+ report, why = trivy_json(["image", image])
162
+ if report is None:
163
+ sec.skipped.append(why)
164
+ return sec
165
+ sec.ran.append(f"trivy image {image}")
166
+ sec.findings = parse_vulnerabilities(report, image)
167
+ sec.complete = True
168
+ return sec
169
+
170
+
171
+ def build_report(title: str, sections: list) -> tuple:
172
+ counts = {s: 0 for s in SEVERITIES}
173
+ for sec in sections:
174
+ for f in sec.findings:
175
+ counts[f.severity] = counts.get(f.severity, 0) + 1
176
+ total = sum(counts.values())
177
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
178
+ L = [f"# Infrastructure scan — {title}", "", f"_{stamp} · `ikanban-infrascan`_", "",
179
+ "| Severity | Count |", "|---|---|"]
180
+ L += [f"| {s} | {counts[s]} |" for s in SEVERITIES if counts[s]]
181
+ if not total:
182
+ L.append("| _none_ | 0 |")
183
+ L += ["", f"**{total} finding(s).**", ""]
184
+ for sec in sections:
185
+ L.append(f"## {sec.name}")
186
+ if sec.ran:
187
+ L.append(f"_ran: {', '.join(sec.ran)}_")
188
+ for s in sec.skipped:
189
+ L.append(f"- ⚠️ **SKIPPED** — {s}")
190
+ if not sec.findings and sec.complete:
191
+ L.append("- ✅ no findings")
192
+ for f in sorted(sec.findings, key=lambda x: (SEVERITY_RANK.get(x.severity, 99), x.title)):
193
+ loc = f" — `{f.location}{':' + f.line if f.line else ''}`" if f.location else ""
194
+ L.append(f"- **[{f.severity}]** {f.title}{loc}")
195
+ if f.detail:
196
+ L.append(f" - {f.detail}")
197
+ L.append("")
198
+ L += ["---", "", "Skipped checks are gaps, not passes — install the named tool and re-run."]
199
+ return "\n".join(L), counts, total
200
+
201
+
202
+ def finding_key(kind: str, scope: str, rule: str, location: str, line: str | None) -> str:
203
+ """Stable per kind, scope, rule and place, so a rescan files nothing new."""
204
+ raw = f"{kind}|{scope}|{rule}|{location}|{line or ''}"
205
+ return "infrascan-" + hashlib.sha256(raw.encode()).hexdigest()[:16]
206
+
207
+
208
+ def findings_payload(sec: Section, home: str | None = None) -> list:
209
+ home = home if home is not None else str(Path.home())
210
+
211
+ def clean(s):
212
+ return s.replace(home, "~") if s and home and home != "/" else s
213
+ return [{
214
+ "finding_key": finding_key(sec.kind, sec.scope, f.rule, f.location, f.line),
215
+ "owasp_category": CATEGORY[sec.kind],
216
+ "severity": "Low" if f.severity == "info" else f.severity.capitalize(),
217
+ "title": clean(f.title)[:300],
218
+ "location_file": clean(f.location) or None,
219
+ "location_lines": f.line,
220
+ "remediation": clean(" ".join(x for x in (f.detail, f.remediation) if x)),
221
+ } for f in sec.findings]
222
+
223
+
224
+ # ── copied from ikanban-secscan.py: scope, key-host pinning, API ─────────────────────
225
+ def normalize_remote(url: str) -> str:
226
+ """owner/repo from any remote style (scp, https, ssh, a host alias), lower case;
227
+ never the user or a token in the URL."""
228
+ path = re.sub(r"^[a-z+]+://[^/]*/?", "", url.strip())
229
+ if path == url.strip():
230
+ path = path.split(":", 1)[-1]
231
+ path = path.strip("/")
232
+ path = path[:-4] if path.endswith(".git") else path
233
+ return "/".join(path.split("/")[-2:]).lower() if path else ""
234
+
235
+
236
+ def scan_scope(root: Path) -> str:
237
+ """origin's owner/repo (else the folder name), plus the subfolder: the same scope
238
+ secscan uses, so a rescan closes only this repository's IaC findings."""
239
+ root = root.resolve()
240
+
241
+ def git(*a):
242
+ try:
243
+ return subprocess.run(["git", "-C", str(root), *a], capture_output=True,
244
+ text=True, timeout=10).stdout.strip()
245
+ except (OSError, subprocess.SubprocessError):
246
+ return ""
247
+ top = git("rev-parse", "--show-toplevel")
248
+ base = Path(top).resolve() if top else root
249
+ base_name = normalize_remote(git("remote", "get-url", "origin")) or base.name.lower()
250
+ sub = root.relative_to(base).as_posix() if base in root.parents else ""
251
+ return f"{base_name}/{sub}" if sub else base_name
252
+
253
+
254
+ IKANBAN_APIS = ("https://api.i-kanban.com", "https://dev-api.i-kanban.com")
255
+
256
+
257
+ def ikanban_api(base_url):
258
+ """The origin of base_url if it is iKanban's API; otherwise stop, naming it (never the key)."""
259
+ u = urllib.parse.urlsplit(base_url or "")
260
+ origin = f"{u.scheme}://{u.netloc}"
261
+ if u.username is None and u.path in ("", "/") and not u.query and (
262
+ origin in IKANBAN_APIS or (u.scheme == "http" and u.hostname in ("127.0.0.1", "localhost", "::1"))):
263
+ return origin
264
+ raise SystemExit(f"error: {base_url or '(empty)'} is not an iKanban API "
265
+ f"({', '.join(IKANBAN_APIS)} or this machine); the key was not sent")
266
+
267
+
268
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
269
+ def redirect_request(self, *args, **kwargs):
270
+ return None
271
+
272
+
273
+ _OPENER = urllib.request.build_opener(_NoRedirect)
274
+
275
+
276
+ def api(method: str, path: str, base_url: str, token: str, payload=None):
277
+ """(ok, data | error text | None for an unknown route)."""
278
+ req = urllib.request.Request(
279
+ f"{ikanban_api(base_url)}/api{path}",
280
+ data=json.dumps(payload).encode() if payload is not None else None,
281
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
282
+ method=method,
283
+ )
284
+ try:
285
+ with _OPENER.open(req, timeout=60) as r:
286
+ body = json.loads(r.read().decode() or "{}")
287
+ return True, body.get("data", body)
288
+ except urllib.error.HTTPError as e:
289
+ detail = e.read().decode()[:300]
290
+ if e.code in (404, 405) and not detail.strip():
291
+ return False, None
292
+ try:
293
+ detail = json.loads(detail).get("error") or detail
294
+ except (ValueError, AttributeError):
295
+ pass
296
+ return False, f"HTTP {e.code}: {detail}"
297
+ except Exception as e: # noqa: BLE001 — surface any transport failure verbatim
298
+ return False, str(e)
299
+
300
+
301
+ def publish(md: str, title: str, sections: list, args) -> int:
302
+ ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
303
+ {"team_id": args.team, "title": title, "content": md.replace(str(Path.home()), "~")})
304
+ print(f"\npublished: {(info or {}).get('id', '') if ok else ''}" if ok else f"\npublish FAILED: {info}",
305
+ file=sys.stderr)
306
+ if not ok:
307
+ return 2
308
+ for sec in sections:
309
+ if not sec.complete:
310
+ print(f"{sec.name}: did not run, nothing filed or closed", file=sys.stderr)
311
+ continue
312
+ ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token,
313
+ {"findings": findings_payload(sec), "scope": sec.scope, "complete": True,
314
+ "kind": sec.kind})
315
+ if not ok:
316
+ print(f"filing {sec.name} FAILED: {data or 'this iKanban server does not take infrastructure findings yet'}",
317
+ file=sys.stderr)
318
+ return 2
319
+ print(f"filed {data.get('submitted', 0)} {sec.kind} findings ({data.get('new', 0)} new, reopened "
320
+ f"{data.get('reopened', 0)}, closed {data.get('closed', 0)} no longer found in {sec.scope})",
321
+ file=sys.stderr)
322
+ return 0
323
+
324
+
325
+ # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
326
+ # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
327
+ # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
328
+ # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
329
+ _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)
330
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
331
+ LAST_RUN = {"report": "", "detail": "", "skipped": False}
332
+
333
+
334
+ def add_pipeline_args(ap):
335
+ ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
336
+ help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
337
+ "or IKANBAN_TASK)")
338
+ ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
339
+ help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
340
+
341
+
342
+ def summary_detail(counts: dict, total: int, skipped: list) -> str:
343
+ if skipped and not total and all(s for s in skipped):
344
+ return ("SKIPPED: " + "; ".join(skipped))[:300]
345
+ worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
346
+ return f"{total} findings" + (f": {worst}" if worst else "")
347
+
348
+
349
+ def mask_report(text: str) -> str:
350
+ """The report is masked already; mask again before it leaves for Pipelines."""
351
+ scrub = globals().get("scrub_report")
352
+ if scrub:
353
+ return scrub(text)[0]
354
+ text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
355
+ home = str(Path.home())
356
+ return text.replace(home, "~") if home and home != "/" else text
357
+
358
+
359
+ def log_chunks(text: str) -> list:
360
+ """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
361
+ chunks, cur = [], ""
362
+ for line in text.splitlines(keepends=True):
363
+ while len(line.encode()) > PIPE_CHUNK:
364
+ head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
365
+ chunks.append(head)
366
+ line = line[len(head):]
367
+ if len((cur + line).encode()) > PIPE_CHUNK:
368
+ chunks.append(cur)
369
+ cur = ""
370
+ cur += line
371
+ if cur:
372
+ chunks.append(cur)
373
+ return chunks
374
+
375
+
376
+ class Pipeline:
377
+ def __init__(self, stage, task, team, base_url, token):
378
+ self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
379
+ self.on = bool(task and team and token)
380
+ self.tid = None
381
+
382
+ def _off(self, why):
383
+ self.on = False
384
+ note = "the plan does not include Pipelines" if "402" in str(why) else why
385
+ print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
386
+
387
+ def _call(self, method, path, body=None):
388
+ try:
389
+ return api(method, path, self.base, self.token, body)
390
+ except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
391
+ return False, str(e)
392
+
393
+ def _post(self, path, body):
394
+ if not self.on:
395
+ return
396
+ if self.tid is None:
397
+ if _UUID.match(self.team):
398
+ self.tid = self.team
399
+ else:
400
+ ok, teams = self._call("GET", "/teams")
401
+ if not ok:
402
+ return self._off(teams)
403
+ found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
404
+ if not found:
405
+ return self._off(f"team {self.team} not found among your teams")
406
+ self.tid = found[0]
407
+ ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
408
+ if not ok:
409
+ self._off(data or "this iKanban server has no Pipelines")
410
+
411
+ def start(self):
412
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
413
+ "source": "laptop", "actor": f"ikanban-{self.stage}"})
414
+
415
+ def finish(self, code):
416
+ status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
417
+ detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
418
+ if code == 1 and not LAST_RUN["skipped"]:
419
+ detail = (detail + " (at or above --fail-on)").strip()
420
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
421
+ "source": "laptop", "actor": f"ikanban-{self.stage}",
422
+ "detail": detail[:300] or None})
423
+ for chunk in log_chunks(mask_report(LAST_RUN["report"])):
424
+ self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
425
+
426
+
427
+ def run_main(stage, main_fn, argv=None):
428
+ """main() as it is, wrapped in Pipelines events when --task is given."""
429
+ raw = sys.argv[1:] if argv is None else argv
430
+ if any(a in ("-h", "--help", "--version", "--list-apps") for a in raw):
431
+ return main_fn(argv)
432
+ pre = argparse.ArgumentParser(add_help=False)
433
+ pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
434
+ pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
435
+ pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
436
+ pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
437
+ pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
438
+ known, _ = pre.parse_known_args(raw)
439
+ pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
440
+ pl.start()
441
+ try:
442
+ code = main_fn(argv)
443
+ except SystemExit as e:
444
+ if not isinstance(e.code, int):
445
+ print(e.code, file=sys.stderr)
446
+ code = e.code if isinstance(e.code, int) else 2
447
+ pl.finish(code)
448
+ return code
449
+
450
+
451
+ def main(argv=None):
452
+ ap = argparse.ArgumentParser(description="Container image and IaC scan (Trivy) that reports into iKanban.")
453
+ ap.add_argument("--version", action="version", version=f"ikanban infrascan {INFRASCAN_VERSION}")
454
+ ap.add_argument("--path", default=".", help="Repository whose IaC files to scan (default: cwd)")
455
+ ap.add_argument("--image", action="append", default=[], metavar="IMAGE",
456
+ help="Also scan this container image (repeat for each image)")
457
+ ap.add_argument("--no-iac", action="store_true", help="Scan only the --image images")
458
+ ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
459
+ ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
460
+ help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
461
+ ap.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
462
+ ap.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""),
463
+ help="Personal API key (or VIBE_API_TOKEN)")
464
+ ap.add_argument("--out", default=None, help="Also write the report here")
465
+ ap.add_argument("--json", action="store_true", help="Machine-readable summary")
466
+ ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
467
+ help="Exit 1 if any finding is at or above this severity")
468
+ add_pipeline_args(ap)
469
+ args = ap.parse_args(argv)
470
+
471
+ root = Path(args.path).expanduser().resolve()
472
+ if not root.is_dir():
473
+ print(f"error: {root} is not a directory", file=sys.stderr)
474
+ return 2
475
+ if args.publish and (not args.token or not args.team):
476
+ print("error: --publish needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID)",
477
+ file=sys.stderr)
478
+ return 2
479
+
480
+ sections = [] if args.no_iac else [scan_iac(root, scan_scope(root))]
481
+ sections += [scan_image(i) for i in args.image]
482
+ if not sections:
483
+ print("error: nothing to scan: drop --no-iac or add --image", file=sys.stderr)
484
+ return 2
485
+ md, counts, total = build_report(root.name, sections)
486
+ nothing_ran = not any(sec.complete for sec in sections)
487
+ LAST_RUN.update(report=md, skipped=nothing_ran, detail=summary_detail(
488
+ counts, total, [s for sec in sections for s in sec.skipped] if nothing_ran else []))
489
+ if args.out:
490
+ Path(args.out).write_text(md)
491
+ if args.json:
492
+ print(json.dumps({"path": str(root), "images": args.image, "total": total, "counts": counts,
493
+ "skipped": [s for sec in sections for s in sec.skipped]}, indent=2))
494
+ else:
495
+ print(md)
496
+ sys.stdout.flush()
497
+
498
+ if args.publish:
499
+ title = f"Infrastructure scan — {root.name} — {datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC"
500
+ code = publish(md, title, sections, args)
501
+ if code:
502
+ return code
503
+
504
+ if args.fail_on and any(SEVERITY_RANK.get(s, 99) <= SEVERITY_RANK[args.fail_on] and c
505
+ for s, c in counts.items()):
506
+ return 1
507
+ return 0
508
+
509
+
510
+ if __name__ == "__main__":
511
+ sys.exit(run_main("infrascan", main))
@@ -64,7 +64,7 @@ from pathlib import Path
64
64
  # that npm no longer hosts the package. Starting at 1.0.0 rather than continuing
65
65
  # the npm package's 0.1.x line: that counter belonged to the wrapper, not to
66
66
  # this file, and reusing it would suggest a continuity that does not exist.
67
- SECSCAN_VERSION = "1.3.0"
67
+ SECSCAN_VERSION = "1.4.0"
68
68
 
69
69
  SEVERITIES = ["critical", "high", "medium", "low", "info"]
70
70
  SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
@@ -859,7 +859,133 @@ def publish(md: str, title: str, base_url: str, token: str, team_id: str,
859
859
  return False, str(e)
860
860
 
861
861
 
862
- def main():
862
+ # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
863
+ # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
864
+ # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
865
+ # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
866
+ _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)
867
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
868
+ LAST_RUN = {"report": "", "detail": "", "skipped": False}
869
+
870
+
871
+ def add_pipeline_args(ap):
872
+ ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
873
+ help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
874
+ "or IKANBAN_TASK)")
875
+ ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
876
+ help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
877
+
878
+
879
+ def summary_detail(counts: dict, total: int, skipped: list) -> str:
880
+ if skipped and not total and all(s for s in skipped):
881
+ return ("SKIPPED: " + "; ".join(skipped))[:300]
882
+ worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
883
+ return f"{total} findings" + (f": {worst}" if worst else "")
884
+
885
+
886
+ def mask_report(text: str) -> str:
887
+ """The report is masked already; mask again before it leaves for Pipelines."""
888
+ scrub = globals().get("scrub_report")
889
+ if scrub:
890
+ return scrub(text)[0]
891
+ text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
892
+ home = str(Path.home())
893
+ return text.replace(home, "~") if home and home != "/" else text
894
+
895
+
896
+ def log_chunks(text: str) -> list:
897
+ """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
898
+ chunks, cur = [], ""
899
+ for line in text.splitlines(keepends=True):
900
+ while len(line.encode()) > PIPE_CHUNK:
901
+ head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
902
+ chunks.append(head)
903
+ line = line[len(head):]
904
+ if len((cur + line).encode()) > PIPE_CHUNK:
905
+ chunks.append(cur)
906
+ cur = ""
907
+ cur += line
908
+ if cur:
909
+ chunks.append(cur)
910
+ return chunks
911
+
912
+
913
+ class Pipeline:
914
+ def __init__(self, stage, task, team, base_url, token):
915
+ self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
916
+ self.on = bool(task and team and token)
917
+ self.tid = None
918
+
919
+ def _off(self, why):
920
+ self.on = False
921
+ note = "the plan does not include Pipelines" if "402" in str(why) else why
922
+ print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
923
+
924
+ def _call(self, method, path, body=None):
925
+ try:
926
+ return api(method, path, self.base, self.token, body)
927
+ except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
928
+ return False, str(e)
929
+
930
+ def _post(self, path, body):
931
+ if not self.on:
932
+ return
933
+ if self.tid is None:
934
+ if _UUID.match(self.team):
935
+ self.tid = self.team
936
+ else:
937
+ ok, teams = self._call("GET", "/teams")
938
+ if not ok:
939
+ return self._off(teams)
940
+ found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
941
+ if not found:
942
+ return self._off(f"team {self.team} not found among your teams")
943
+ self.tid = found[0]
944
+ ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
945
+ if not ok:
946
+ self._off(data or "this iKanban server has no Pipelines")
947
+
948
+ def start(self):
949
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
950
+ "source": "laptop", "actor": f"ikanban-{self.stage}"})
951
+
952
+ def finish(self, code):
953
+ status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
954
+ detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
955
+ if code == 1 and not LAST_RUN["skipped"]:
956
+ detail = (detail + " (at or above --fail-on)").strip()
957
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
958
+ "source": "laptop", "actor": f"ikanban-{self.stage}",
959
+ "detail": detail[:300] or None})
960
+ for chunk in log_chunks(mask_report(LAST_RUN["report"])):
961
+ self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
962
+
963
+
964
+ def run_main(stage, main_fn, argv=None):
965
+ """main() as it is, wrapped in Pipelines events when --task is given."""
966
+ raw = sys.argv[1:] if argv is None else argv
967
+ if any(a in ("-h", "--help", "--version", "--list-apps") for a in raw):
968
+ return main_fn(argv)
969
+ pre = argparse.ArgumentParser(add_help=False)
970
+ pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
971
+ pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
972
+ pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
973
+ pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
974
+ pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
975
+ known, _ = pre.parse_known_args(raw)
976
+ pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
977
+ pl.start()
978
+ try:
979
+ code = main_fn(argv)
980
+ except SystemExit as e:
981
+ if not isinstance(e.code, int):
982
+ print(e.code, file=sys.stderr)
983
+ code = e.code if isinstance(e.code, int) else 2
984
+ pl.finish(code)
985
+ return code
986
+
987
+
988
+ def main(argv=None):
863
989
  ap = argparse.ArgumentParser(
864
990
  description="Standalone security scan that reports into iKanban.")
865
991
  # Lets someone who downloaded a copy tell which release it is, and check it
@@ -884,7 +1010,8 @@ def main():
884
1010
  help="Print the team's applications (the NAMEs --app takes) and exit")
885
1011
  ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
886
1012
  help="Exit 1 if any finding is at or above this severity")
887
- args = ap.parse_args()
1013
+ add_pipeline_args(ap)
1014
+ args = ap.parse_args(argv)
888
1015
 
889
1016
  root = Path(args.path).expanduser().resolve()
890
1017
  if not root.is_dir():
@@ -922,6 +1049,7 @@ def main():
922
1049
  sections = [audit_dependencies(root), *(s for _, s in app_sections),
923
1050
  scan_secrets(root), scan_permissions(root)]
924
1051
  md, counts, total = build_report(root, sections)
1052
+ LAST_RUN.update(report=md, detail=summary_detail(counts, total, []), skipped=False)
925
1053
 
926
1054
  if args.out:
927
1055
  Path(args.out).write_text(md)
@@ -997,4 +1125,4 @@ def main():
997
1125
 
998
1126
 
999
1127
  if __name__ == "__main__":
1000
- sys.exit(main())
1128
+ sys.exit(run_main("secscan", main))