ikanban 0.3.4 → 0.4.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,790 @@
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 # inventory + IaC scan of cwd
7
+ npx ikanban infrascan --path . --path ../infra # infra in another folder or repo too
8
+ npx ikanban infrascan --image myapp:latest # also scan a container image
9
+ npx ikanban infrascan --publish --team TEAM # file the report and its findings
10
+
11
+ Plan 11 H12-T10 (owner: assume messy development). It starts with an inventory of each path:
12
+ what infrastructure it found, what Trivy scanned, and what it could not scan and why. Infra
13
+ written as code (CDK, Pulumi, CDK for Terraform, Vagrant, Ansible, cloud-SDK scripts) is listed
14
+ as found but not scannable. Images named in docker-compose files are scanned too. A path with no
15
+ infrastructure-as-code file is never reported complete, so its control stays Missing.
16
+ Raw Trivy JSON and the report go to <first path>/.iKanban/scans/infrascan-<UTC time>/ (or
17
+ --work-dir / IKANBAN_SCAN_DIR); that folder ignores itself in git.
18
+
19
+ It runs Trivy (Apache-2.0, https://github.com/aquasecurity/trivy), which must already be
20
+ installed; a missing Trivy is reported as SKIPPED with the install lines, never as a clean
21
+ scan, and nothing is filed for a check that did not run.
22
+ IaC trivy fs --scanners misconfig --format json --output FILE PATH
23
+ Container trivy image --format json --output FILE IMAGE
24
+ (trivy.dev docs: "trivy fs --scanners misconfig /path/to/dir"; reporting docs:
25
+ "trivy image --format json --output result.json debian:12".)
26
+
27
+ JSON read (pkg/types/report.go and misconfiguration.go in the Trivy source):
28
+ Results[].Target
29
+ Results[].Misconfigurations[]: ID, AVDID, Title, Message, Resolution, Severity,
30
+ PrimaryURL, Status ("PASS" | "FAIL" | "EXCEPTION"), CauseMetadata.StartLine
31
+ Results[].Vulnerabilities[]: VulnerabilityID, PkgName, InstalledVersion, FixedVersion,
32
+ Severity, Title, PrimaryURL
33
+
34
+ Helpers below are copied from ikanban-secscan.py (the scanners ship as single files).
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import argparse
40
+ import hashlib
41
+ import json
42
+ import os
43
+ import re
44
+ import shutil
45
+ import subprocess
46
+ import sys
47
+ import urllib.error
48
+ import urllib.parse
49
+ import urllib.request
50
+ from dataclasses import dataclass, field
51
+ from datetime import datetime, timezone
52
+ from pathlib import Path
53
+
54
+ INFRASCAN_VERSION = "1.1.0"
55
+
56
+ SEVERITIES = ["critical", "high", "medium", "low", "info"]
57
+ SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
58
+ # Trivy's severities; UNKNOWN (no score yet) is filed as low.
59
+ TRIVY_SEVERITY = {"CRITICAL": "critical", "HIGH": "high", "MEDIUM": "medium", "LOW": "low", "UNKNOWN": "low"}
60
+
61
+ TRIVY_INSTALL = ("Trivy is not installed — macOS: `brew install trivy`; Linux and Windows: a release "
62
+ "binary from https://github.com/aquasecurity/trivy/releases, or `docker run aquasec/trivy`")
63
+
64
+ CATEGORY = {"iac": "A05 — Security Misconfiguration",
65
+ "container": "A06 — Vulnerable and Outdated Components"}
66
+
67
+
68
+ @dataclass
69
+ class Finding:
70
+ severity: str
71
+ title: str
72
+ detail: str
73
+ location: str = ""
74
+ line: str | None = None
75
+ rule: str = ""
76
+ remediation: str = ""
77
+
78
+
79
+ @dataclass
80
+ class Section:
81
+ name: str
82
+ kind: str
83
+ scope: str
84
+ findings: list = field(default_factory=list)
85
+ skipped: list = field(default_factory=list)
86
+ ran: list = field(default_factory=list)
87
+ complete: bool = False
88
+ filed: list | None = None # what publish sends; None = every finding (plan_filing sets it)
89
+ notes: list = field(default_factory=list)
90
+
91
+
92
+ # The server takes at most this many findings in one publish (security_scan_findings.rs).
93
+ MAX_FILED = 500
94
+
95
+
96
+ def plan_filing(sec: Section) -> None:
97
+ """Plan 11 H12-T10 review: container images file critical and high only (the rest stay in
98
+ the report); any section over MAX_FILED files the worst MAX_FILED and is published
99
+ incomplete, so the rescan never closes findings that were only cut."""
100
+ keep = sec.findings
101
+ if sec.kind == "container":
102
+ keep = [f for f in keep if f.severity in ("critical", "high")]
103
+ if len(keep) < len(sec.findings):
104
+ sec.notes.append(f"{len(sec.findings) - len(keep)} medium and low findings are in this report only; "
105
+ "iKanban files critical and high for container images.")
106
+ if len(keep) > MAX_FILED:
107
+ keep = sorted(keep, key=lambda f: (SEVERITY_RANK.get(f.severity, 99), f.title))
108
+ sec.notes.append(f"Filed the {MAX_FILED} worst; {len(keep) - MAX_FILED} more were left out of iKanban "
109
+ "(this run counts as incomplete, so nothing closes).")
110
+ keep = keep[:MAX_FILED]
111
+ sec.complete = False
112
+ sec.filed = keep
113
+
114
+
115
+ def run(cmd, cwd=None, timeout=900):
116
+ """Run a command, returning (ok, stdout, stderr). Never raises."""
117
+ try:
118
+ p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
119
+ return p.returncode == 0, p.stdout, p.stderr
120
+ except FileNotFoundError:
121
+ return False, "", "not-installed"
122
+ except subprocess.TimeoutExpired:
123
+ return False, "", f"timed out after {timeout}s"
124
+
125
+
126
+ def trivy_json(args: list, target: str, out: Path) -> tuple[dict | None, str]:
127
+ """Run `trivy SUBCOMMAND [ARGS] -- TARGET` with --format json into `out`; (report, "") or
128
+ (None, why skipped). Plan 11 H12-T10 review: trivy runs from the scan's work folder with a
129
+ scanner-owned --config and --ignorefile (both default to files in the working folder,
130
+ trivy.dev CLI reference: `-c, --config ... (default "trivy.yaml")`, `--ignorefile ...
131
+ (default ".trivyignore")`), so a repository's trivy.yaml or .trivyignore changes nothing.
132
+ The target comes after `--`, so a name starting with '-' can never be read as a flag."""
133
+ if not shutil.which("trivy"):
134
+ return None, TRIVY_INSTALL
135
+ work = out.parent
136
+ conf, ignore = work / "trivy-config.yaml", work / "trivyignore"
137
+ if not conf.exists():
138
+ write_private(conf, "{}\n")
139
+ if not ignore.exists():
140
+ write_private(ignore, "")
141
+ ok, _o, err = run(["trivy", *args[:1], "--config", str(conf), "--ignorefile", str(ignore),
142
+ "--format", "json", "--output", str(out), *args[1:], "--", target], cwd=work)
143
+ if not ok or not out.exists():
144
+ return None, f"trivy {args[0]} failed: {(err or '').strip()[-300:] or 'no output'}"
145
+ try:
146
+ return json.loads(out.read_text() or "{}"), ""
147
+ except ValueError:
148
+ return None, f"trivy {args[0]} wrote no JSON"
149
+
150
+
151
+ def parse_misconfigurations(report: dict) -> list:
152
+ """Failed checks only; PASS and EXCEPTION are not findings."""
153
+ out = []
154
+ for res in report.get("Results") or []:
155
+ target = res.get("Target") or ""
156
+ for m in res.get("Misconfigurations") or []:
157
+ if m.get("Status") not in (None, "FAIL"):
158
+ continue
159
+ line = (m.get("CauseMetadata") or {}).get("StartLine")
160
+ rule = m.get("AVDID") or m.get("ID") or ""
161
+ fix = " ".join(x for x in (m.get("Resolution"), m.get("PrimaryURL")) if x)
162
+ out.append(Finding(
163
+ severity=TRIVY_SEVERITY.get((m.get("Severity") or "").upper(), "low"),
164
+ title=f"{m.get('Title') or rule}",
165
+ detail=m.get("Message") or "",
166
+ location=target, line=str(line) if line else None, rule=rule,
167
+ remediation=fix or "Change the configuration as the check describes."))
168
+ return out
169
+
170
+
171
+ def parse_vulnerabilities(report: dict, image: str) -> list:
172
+ out = []
173
+ for res in report.get("Results") or []:
174
+ target = res.get("Target") or image
175
+ for v in res.get("Vulnerabilities") or []:
176
+ pkg, have, fixed = v.get("PkgName") or "", v.get("InstalledVersion") or "", v.get("FixedVersion")
177
+ vid = v.get("VulnerabilityID") or ""
178
+ fix = f"Upgrade {pkg} to {fixed}" if fixed else f"No fixed version of {pkg} yet; use another base image or package"
179
+ if v.get("PrimaryURL"):
180
+ fix += f". {v['PrimaryURL']}"
181
+ out.append(Finding(
182
+ severity=TRIVY_SEVERITY.get((v.get("Severity") or "").upper(), "low"),
183
+ title=f"{pkg} {have}: {vid}" + (f" — {v['Title']}" if v.get("Title") else ""),
184
+ detail="", location=f"{image} ({target})", rule=f"{vid}|{pkg}", remediation=fix))
185
+ return out
186
+
187
+
188
+ def scan_iac(root: Path, scope: str, inv: dict, work: Path) -> Section:
189
+ sec = Section(f"IaC misconfiguration — {scope}", "iac", scope)
190
+ if not any(inv[c] for c in TRIVY_IAC):
191
+ # Owner: no IaC file found is not a pass. complete stays False: the control stays Missing.
192
+ sec.skipped.append(f"No infrastructure-as-code files found in {root} — if the infrastructure lives in "
193
+ "another folder or repository, add it with another --path")
194
+ return sec
195
+ # ponytail: trivy.dev does not say what --skip-dirs is relative to with an absolute target;
196
+ # the absolute and the ** form are both passed. A miss only means a vendored folder is read too.
197
+ skip = [x for d in SKIP_DIRS for x in ("--skip-dirs", f"{root.resolve()}/{d}", "--skip-dirs", f"**/{d}")]
198
+ report, why = trivy_json(["fs", "--scanners", "misconfig", *skip], str(root.resolve()),
199
+ work / f"trivy-iac-{slug(scope)}.json")
200
+ if report is None:
201
+ sec.skipped.append(why)
202
+ return sec
203
+ sec.ran.append(f"trivy fs --scanners misconfig {root.name}")
204
+ sec.findings = parse_misconfigurations(report)
205
+ for f in sec.findings: # paths relative to the repository, like secscan
206
+ f.location = f.location.removeprefix(str(root) + os.sep).removeprefix(str(root.resolve()) + os.sep)
207
+ sec.complete = True
208
+ return sec
209
+
210
+
211
+ def scan_image(image: str, work: Path) -> Section:
212
+ sec = Section(f"Container image {image}", "container", image)
213
+ if not valid_image_ref(image):
214
+ sec.skipped.append(f"`{image[:80]}` is not a valid image name; not scanned")
215
+ return sec
216
+ report, why = trivy_json(["image"], image, work / f"trivy-image-{slug(image)}.json")
217
+ if report is None:
218
+ sec.skipped.append(why)
219
+ return sec
220
+ sec.ran.append(f"trivy image {image}")
221
+ sec.findings = parse_vulnerabilities(report, image)
222
+ sec.complete = True
223
+ return sec
224
+
225
+
226
+ # ── Inventory (plan 11 H12-T10) ───────────────────────────────────────────────────────
227
+ # What Trivy's misconfiguration scanner reads, from trivy.dev "IaC" coverage (Kubernetes,
228
+ # Docker: Dockerfile/Containerfile, Terraform: *.tf *.tf.json, CloudFormation, Azure ARM,
229
+ # Helm). docker-compose, Ansible, Bicep, CDK, Pulumi and Vagrant are not in that table.
230
+ TRIVY_IAC = ("Dockerfile", "Kubernetes manifest", "Helm chart", "Terraform", "CloudFormation", "Azure ARM template")
231
+ NOT_SCANNABLE = {
232
+ "Dockerfile with another name": "Trivy reads files named Dockerfile or Containerfile; this one may not be checked",
233
+ "docker-compose file": "its images are scanned as containers; the compose settings themselves are not checked",
234
+ "AWS CDK app": "run `npx cdk synth` and rescan: the templates in cdk.out are scanned as CloudFormation",
235
+ "CDK for Terraform app": "run `cdktf synth` and rescan: the Terraform JSON in cdktf.out is scanned",
236
+ "Pulumi program": "found, not scannable: review by hand or audit the cloud account",
237
+ "Vagrant": "found, not scannable: provisioning by script; review by hand",
238
+ "Ansible playbook": "found, not scannable by Trivy: review by hand",
239
+ "Azure Bicep": "run `az bicep build` and rescan: the ARM JSON it writes is scanned",
240
+ "Cloud SDK code": "found, not scannable: infrastructure created in code; review by hand or audit the cloud account",
241
+ }
242
+ # Imports that mean code talks to a cloud API (examples; the real set is broader).
243
+ SDK_MARKERS = ("import boto3", "from boto3", "from botocore", "@aws-sdk/", "'aws-sdk'", '"aws-sdk"',
244
+ "github.com/aws/aws-sdk-go", "software.amazon.awssdk", "from google.cloud",
245
+ "from google.cloud import", "@google-cloud/", "cloud.google.com/go/", "azure.mgmt",
246
+ "@azure/arm-", "github.com/Azure/azure-sdk-for-go")
247
+ SDK_EXTS = {".py", ".js", ".mjs", ".cjs", ".ts", ".go", ".java", ".kt", ".cs", ".rb"}
248
+ SKIP_DIRS = (".git", "node_modules", "vendor", "dist", "build", ".venv", "venv", "target", ".iKanban/scans",
249
+ ".terraform", "__pycache__")
250
+ MAX_READ = 512 * 1024
251
+
252
+
253
+ def _head(p: Path) -> str:
254
+ try:
255
+ with p.open("rb") as f:
256
+ return f.read(MAX_READ).decode("utf-8", "ignore")
257
+ except OSError:
258
+ return ""
259
+
260
+
261
+ def _skipped(rel: Path) -> bool:
262
+ parts = rel.parts
263
+ return any(d in parts for d in SKIP_DIRS if "/" not in d) or rel.as_posix().startswith(".iKanban/scans")
264
+
265
+
266
+ def inventory(root: Path) -> dict:
267
+ """Infra files under root, by kind (paths relative to root). Never raises."""
268
+ inv = {k: [] for k in (*TRIVY_IAC, *NOT_SCANNABLE)}
269
+ files = []
270
+ for dirpath, dirnames, filenames in os.walk(root):
271
+ rel_dir = Path(dirpath).relative_to(root)
272
+ dirnames[:] = [d for d in dirnames if not _skipped(rel_dir / d)]
273
+ files += [rel_dir / f for f in filenames]
274
+ charts = {f.parent for f in files if f.name == "Chart.yaml"}
275
+ inv["Helm chart"] = sorted(c.as_posix() or "." for c in charts)
276
+ for rel in sorted(files):
277
+ name, low = rel.name, rel.name.lower()
278
+ posix = rel.as_posix()
279
+ in_chart = any(c == rel.parent or c in rel.parents for c in charts)
280
+ if name in ("Dockerfile", "Containerfile"):
281
+ inv["Dockerfile"].append(posix)
282
+ elif low.endswith(".dockerfile") or low.startswith("dockerfile."):
283
+ inv["Dockerfile with another name"].append(posix)
284
+ elif re.match(r"^(docker-)?compose[\w.-]*\.ya?ml$", low):
285
+ inv["docker-compose file"].append(posix)
286
+ elif low.endswith((".tf", ".tf.json")):
287
+ inv["Terraform"].append(posix)
288
+ elif name == "cdk.json":
289
+ inv["AWS CDK app"].append(posix)
290
+ elif name == "cdktf.json":
291
+ inv["CDK for Terraform app"].append(posix)
292
+ elif name in ("Pulumi.yaml", "Pulumi.yml"):
293
+ inv["Pulumi program"].append(posix)
294
+ elif name == "Vagrantfile":
295
+ inv["Vagrant"].append(posix)
296
+ elif low.endswith(".bicep"):
297
+ inv["Azure Bicep"].append(posix)
298
+ elif low.endswith((".yaml", ".yml", ".json")) and not in_chart:
299
+ text = _head(root / rel)
300
+ if "AWSTemplateFormatVersion" in text or re.search(r"""["']?Type["']?\s*:\s*["']?AWS::""", text):
301
+ inv["CloudFormation"].append(posix)
302
+ elif low.endswith(".json") and "deploymentTemplate.json" in text:
303
+ inv["Azure ARM template"].append(posix)
304
+ elif re.search(r"(?m)^apiVersion:\s*\S", text) and re.search(r"(?m)^kind:\s*\S", text):
305
+ inv["Kubernetes manifest"].append(posix)
306
+ elif re.search(r"(?m)^-\s+(name:.*\n\s+)?hosts:\s*\S", text) and re.search(r"(?m)^\s+(tasks|roles):", text):
307
+ inv["Ansible playbook"].append(posix)
308
+ elif rel.suffix in SDK_EXTS:
309
+ text = _head(root / rel)
310
+ if any(m in text for m in SDK_MARKERS):
311
+ inv["Cloud SDK code"].append(posix)
312
+ return inv
313
+
314
+
315
+ def cdk_note(inv: dict) -> str:
316
+ """CDK synth output (cdk.out) holds CloudFormation templates: say they were scanned."""
317
+ synthed = [p for p in inv["CloudFormation"] if p.split("/")[-2:-1] == ["cdk.out"] or "/cdk.out/" in f"/{p}"]
318
+ return "synthesized templates in cdk.out are scanned as CloudFormation" if synthed else NOT_SCANNABLE["AWS CDK app"]
319
+
320
+
321
+ # Plan 11 H12-T10 review: compose `image:` values come from the repository, so only a plain
322
+ # image reference (registry[:port]/path:tag@sha256:digest, lower case, no leading '-') may
323
+ # reach trivy's command line.
324
+ IMAGE_REF = re.compile(
325
+ r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?::[0-9]{1,5})?" # registry host or first path part
326
+ r"(?:/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)*" # more path parts
327
+ r"(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?" # tag
328
+ r"(?:@sha256:[a-f0-9]{64})?$")
329
+
330
+
331
+ def valid_image_ref(ref: str) -> bool:
332
+ return 0 < len(ref) <= 255 and ".." not in ref and bool(IMAGE_REF.match(ref))
333
+
334
+
335
+ IMAGE_LINE = re.compile(r"""^\s*image:\s*["']?([^"'\s#]+)["']?\s*(#.*)?$""")
336
+
337
+
338
+ def compose_images(root: Path, inv: dict) -> tuple[list, list, list]:
339
+ """(images named in compose files, names with ${VARS}, names that are not a valid image)."""
340
+ images, unresolved, rejected = [], [], []
341
+ for rel in inv["docker-compose file"]:
342
+ for line in _head(root / rel).splitlines():
343
+ m = IMAGE_LINE.match(line)
344
+ if not m:
345
+ continue
346
+ name = m.group(1)
347
+ (unresolved if "$" in name else images if valid_image_ref(name) else rejected).append(name)
348
+ return sorted(set(images)), sorted(set(unresolved)), sorted(set(rejected))
349
+
350
+
351
+ def inventory_rows(inv: dict, scanned_iac: bool, iac_why: str, rejected: list = ()) -> list:
352
+ """Markdown table rows: what | found | scanned | note."""
353
+ rows = []
354
+ if rejected:
355
+ shown = ", ".join(f"`{r[:60]}`" for r in rejected[:5])
356
+ rows.append(f"| Image name in a compose file | {len(rejected)}: {shown} | no | not a valid image name, "
357
+ "so it was not passed to Trivy; check the compose file |")
358
+ for kind, paths in inv.items():
359
+ if not paths:
360
+ continue
361
+ shown = ", ".join(f"`{p}`" for p in paths[:5]) + (f" (+{len(paths) - 5} more)" if len(paths) > 5 else "")
362
+ if kind in TRIVY_IAC:
363
+ state, note = ("yes", "Trivy misconfiguration checks") if scanned_iac else ("no", iac_why)
364
+ elif kind == "AWS CDK app":
365
+ note = cdk_note(inv)
366
+ state = "yes (cdk.out)" if note.startswith("synthesized") else "no"
367
+ elif kind == "docker-compose file":
368
+ state, note = "images only", NOT_SCANNABLE[kind]
369
+ else:
370
+ state, note = "no", NOT_SCANNABLE[kind]
371
+ rows.append(f"| {kind} | {len(paths)}: {shown} | {state} | {note} |")
372
+ return rows
373
+
374
+
375
+ def build_report(title: str, sections: list) -> tuple:
376
+ counts = {s: 0 for s in SEVERITIES}
377
+ for sec in sections:
378
+ for f in sec.findings:
379
+ counts[f.severity] = counts.get(f.severity, 0) + 1
380
+ total = sum(counts.values())
381
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
382
+ L = [f"# Infrastructure scan — {title}", "", f"_{stamp} · `ikanban-infrascan`_", "",
383
+ "| Severity | Count |", "|---|---|"]
384
+ L += [f"| {s} | {counts[s]} |" for s in SEVERITIES if counts[s]]
385
+ if not total:
386
+ L.append("| _none_ | 0 |")
387
+ L += ["", f"**{total} finding(s).**", ""]
388
+ for scope, rows in INVENTORY:
389
+ L += [f"## Inventory — {scope}", "", "| What | Found | Scanned | Note |", "|---|---|---|---|"]
390
+ L += rows or ["| _no infrastructure files found_ | 0 | — | add the folder or repository that holds them with another --path |"]
391
+ L.append("")
392
+ for sec in sections:
393
+ L.append(f"## {sec.name}")
394
+ if sec.ran:
395
+ L.append(f"_ran: {', '.join(sec.ran)}_")
396
+ for s in sec.skipped:
397
+ L.append(f"- ⚠️ **SKIPPED** — {s}")
398
+ if not sec.findings and sec.complete:
399
+ L.append("- ✅ no findings")
400
+ for n in sec.notes:
401
+ L.append(f"- ℹ️ {n}")
402
+ for f in sorted(sec.findings, key=lambda x: (SEVERITY_RANK.get(x.severity, 99), x.title)):
403
+ loc = f" — `{f.location}{':' + f.line if f.line else ''}`" if f.location else ""
404
+ L.append(f"- **[{f.severity}]** {f.title}{loc}")
405
+ if f.detail:
406
+ L.append(f" - {f.detail}")
407
+ L.append("")
408
+ L += ["---", "", "Skipped checks are gaps, not passes — install the named tool and re-run.",
409
+ "Found but not scannable is a gap too: review it by hand."]
410
+ return "\n".join(L), counts, total
411
+
412
+
413
+ def finding_key(kind: str, scope: str, rule: str, location: str, line: str | None) -> str:
414
+ """Stable per kind, scope, rule and place, so a rescan files nothing new."""
415
+ raw = f"{kind}|{scope}|{rule}|{location}|{line or ''}"
416
+ return "infrascan-" + hashlib.sha256(raw.encode()).hexdigest()[:16]
417
+
418
+
419
+ def findings_payload(sec: Section, home: str | None = None) -> list:
420
+ home = home if home is not None else str(Path.home())
421
+
422
+ def clean(s):
423
+ return s.replace(home, "~") if s and home and home != "/" else s
424
+ return [{
425
+ "finding_key": finding_key(sec.kind, sec.scope, f.rule, f.location, f.line),
426
+ "owasp_category": CATEGORY[sec.kind],
427
+ "severity": "Low" if f.severity == "info" else f.severity.capitalize(),
428
+ "title": clean(f.title)[:300],
429
+ "location_file": clean(f.location) or None,
430
+ "location_lines": f.line,
431
+ "remediation": clean(" ".join(x for x in (f.detail, f.remediation) if x)),
432
+ } for f in (sec.filed if sec.filed is not None else sec.findings)]
433
+
434
+
435
+ # ── copied from ikanban-secscan.py: scope, key-host pinning, API ─────────────────────
436
+ def normalize_remote(url: str) -> str:
437
+ """owner/repo from any remote style (scp, https, ssh, a host alias), lower case;
438
+ never the user or a token in the URL."""
439
+ path = re.sub(r"^[a-z+]+://[^/]*/?", "", url.strip())
440
+ if path == url.strip():
441
+ path = path.split(":", 1)[-1]
442
+ path = path.strip("/")
443
+ path = path[:-4] if path.endswith(".git") else path
444
+ return "/".join(path.split("/")[-2:]).lower() if path else ""
445
+
446
+
447
+ def scan_scope(root: Path) -> str:
448
+ """origin's owner/repo (else the folder name), plus the subfolder: the same scope
449
+ secscan uses, so a rescan closes only this repository's IaC findings."""
450
+ root = root.resolve()
451
+
452
+ def git(*a):
453
+ try:
454
+ return subprocess.run(["git", "-C", str(root), *a], capture_output=True,
455
+ text=True, timeout=10).stdout.strip()
456
+ except (OSError, subprocess.SubprocessError):
457
+ return ""
458
+ top = git("rev-parse", "--show-toplevel")
459
+ base = Path(top).resolve() if top else root
460
+ base_name = normalize_remote(git("remote", "get-url", "origin")) or base.name.lower()
461
+ sub = root.relative_to(base).as_posix() if base in root.parents else ""
462
+ return f"{base_name}/{sub}" if sub else base_name
463
+
464
+
465
+ IKANBAN_APIS = ("https://api.i-kanban.com", "https://dev-api.i-kanban.com")
466
+
467
+
468
+ def ikanban_api(base_url):
469
+ """The origin of base_url if it is iKanban's API; otherwise stop, naming it (never the key)."""
470
+ u = urllib.parse.urlsplit(base_url or "")
471
+ origin = f"{u.scheme}://{u.netloc}"
472
+ if u.username is None and u.path in ("", "/") and not u.query and (
473
+ origin in IKANBAN_APIS or (u.scheme == "http" and u.hostname in ("127.0.0.1", "localhost", "::1"))):
474
+ return origin
475
+ raise SystemExit(f"error: {base_url or '(empty)'} is not an iKanban API "
476
+ f"({', '.join(IKANBAN_APIS)} or this machine); the key was not sent")
477
+
478
+
479
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
480
+ def redirect_request(self, *args, **kwargs):
481
+ return None
482
+
483
+
484
+ _OPENER = urllib.request.build_opener(_NoRedirect)
485
+
486
+
487
+ def api(method: str, path: str, base_url: str, token: str, payload=None):
488
+ """(ok, data | error text | None for an unknown route)."""
489
+ req = urllib.request.Request(
490
+ f"{ikanban_api(base_url)}/api{path}",
491
+ data=json.dumps(payload).encode() if payload is not None else None,
492
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
493
+ method=method,
494
+ )
495
+ try:
496
+ with _OPENER.open(req, timeout=60) as r:
497
+ body = json.loads(r.read().decode() or "{}")
498
+ return True, body.get("data", body)
499
+ except urllib.error.HTTPError as e:
500
+ detail = e.read().decode()[:300]
501
+ if e.code in (404, 405) and not detail.strip():
502
+ return False, None
503
+ try:
504
+ detail = json.loads(detail).get("error") or detail
505
+ except (ValueError, AttributeError):
506
+ pass
507
+ return False, f"HTTP {e.code}: {detail}"
508
+ except Exception as e: # noqa: BLE001 — surface any transport failure verbatim
509
+ return False, str(e)
510
+
511
+
512
+ def publish(md: str, title: str, sections: list, args) -> int:
513
+ ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
514
+ {"team_id": args.team, "title": title, "content": md.replace(str(Path.home()), "~")})
515
+ print(f"\npublished: {(info or {}).get('id', '') if ok else ''}" if ok else f"\npublish FAILED: {info}",
516
+ file=sys.stderr)
517
+ if not ok:
518
+ return 2
519
+ for sec in sections:
520
+ nothing_found = any(x.startswith("No infrastructure-as-code files found") for x in sec.skipped)
521
+ cut = any("more were left out" in n for n in sec.notes)
522
+ if not sec.complete and not nothing_found and not cut:
523
+ print(f"{sec.name}: did not run, nothing filed or closed", file=sys.stderr)
524
+ continue
525
+ # A path with no IaC file is recorded as an incomplete run: iKanban shows when it was
526
+ # checked, the control stays Missing, and nothing closes.
527
+ ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token,
528
+ {"findings": findings_payload(sec), "scope": sec.scope, "complete": sec.complete,
529
+ "kind": sec.kind})
530
+ if not ok:
531
+ print(f"filing {sec.name} FAILED: {data or 'this iKanban server does not take infrastructure findings yet'}",
532
+ file=sys.stderr)
533
+ return 2
534
+ print(f"filed {data.get('submitted', 0)} {sec.kind} findings ({data.get('new', 0)} new, reopened "
535
+ f"{data.get('reopened', 0)}, closed {data.get('closed', 0)} no longer found in {sec.scope})",
536
+ file=sys.stderr)
537
+ return 0
538
+
539
+
540
+ # ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
541
+ # <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
542
+ # holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
543
+ def add_work_dir_arg(ap):
544
+ ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
545
+ help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
546
+ "<repository>/.iKanban/scans)")
547
+
548
+
549
+ def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
550
+ scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
551
+ scans.mkdir(parents=True, exist_ok=True)
552
+ ignore = scans / ".gitignore"
553
+ if not ignore.exists():
554
+ ignore.write_text("*\n")
555
+ run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
556
+ run_dir.mkdir(mode=0o700, exist_ok=True)
557
+ os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
558
+ return run_dir
559
+
560
+
561
+ def write_private(path: Path, text: str) -> None:
562
+ """Write a file (0600) without following a symlink planted at its name: write a new
563
+ temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
564
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
565
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
566
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
567
+ f.write(text)
568
+ os.replace(tmp, path)
569
+
570
+
571
+ def slug(text: str) -> str:
572
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
573
+
574
+
575
+ # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
576
+ # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
577
+ # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
578
+ # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
579
+ _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)
580
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
581
+ LAST_RUN = {"report": "", "detail": "", "skipped": False}
582
+ INVENTORY: list = [] # (scope, table rows) per --path, for the report
583
+
584
+
585
+ def add_pipeline_args(ap):
586
+ ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
587
+ help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
588
+ "or IKANBAN_TASK)")
589
+ ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
590
+ help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
591
+
592
+
593
+ def summary_detail(counts: dict, total: int, skipped: list) -> str:
594
+ if skipped and not total and all(s for s in skipped):
595
+ return ("SKIPPED: " + "; ".join(skipped))[:300]
596
+ worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
597
+ return f"{total} findings" + (f": {worst}" if worst else "")
598
+
599
+
600
+ def mask_report(text: str) -> str:
601
+ """The report is masked already; mask again before it leaves for Pipelines."""
602
+ scrub = globals().get("scrub_report")
603
+ if scrub:
604
+ return scrub(text)[0]
605
+ text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
606
+ home = str(Path.home())
607
+ return text.replace(home, "~") if home and home != "/" else text
608
+
609
+
610
+ def log_chunks(text: str) -> list:
611
+ """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
612
+ chunks, cur = [], ""
613
+ for line in text.splitlines(keepends=True):
614
+ while len(line.encode()) > PIPE_CHUNK:
615
+ head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
616
+ chunks.append(head)
617
+ line = line[len(head):]
618
+ if len((cur + line).encode()) > PIPE_CHUNK:
619
+ chunks.append(cur)
620
+ cur = ""
621
+ cur += line
622
+ if cur:
623
+ chunks.append(cur)
624
+ return chunks
625
+
626
+
627
+ class Pipeline:
628
+ def __init__(self, stage, task, team, base_url, token):
629
+ self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
630
+ self.on = bool(task and team and token)
631
+ self.tid = None
632
+
633
+ def _off(self, why):
634
+ self.on = False
635
+ note = "the plan does not include Pipelines" if "402" in str(why) else why
636
+ print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
637
+
638
+ def _call(self, method, path, body=None):
639
+ try:
640
+ return api(method, path, self.base, self.token, body)
641
+ except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
642
+ return False, str(e)
643
+
644
+ def _post(self, path, body):
645
+ if not self.on:
646
+ return
647
+ if self.tid is None:
648
+ if _UUID.match(self.team):
649
+ self.tid = self.team
650
+ else:
651
+ ok, teams = self._call("GET", "/teams")
652
+ if not ok:
653
+ return self._off(teams)
654
+ found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
655
+ if not found:
656
+ return self._off(f"team {self.team} not found among your teams")
657
+ self.tid = found[0]
658
+ ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
659
+ if not ok:
660
+ self._off(data or "this iKanban server has no Pipelines")
661
+
662
+ def start(self):
663
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
664
+ "source": "laptop", "actor": f"ikanban-{self.stage}"})
665
+
666
+ def finish(self, code):
667
+ status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
668
+ detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
669
+ if code == 1 and not LAST_RUN["skipped"]:
670
+ detail = (detail + " (at or above --fail-on)").strip()
671
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
672
+ "source": "laptop", "actor": f"ikanban-{self.stage}",
673
+ "detail": detail[:300] or None})
674
+ for chunk in log_chunks(mask_report(LAST_RUN["report"])):
675
+ self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
676
+
677
+
678
+ def run_main(stage, main_fn, argv=None):
679
+ """main() as it is, wrapped in Pipelines events when --task is given."""
680
+ raw = sys.argv[1:] if argv is None else argv
681
+ if any(a in ("-h", "--help", "--version", "--list-apps") for a in raw):
682
+ return main_fn(argv)
683
+ pre = argparse.ArgumentParser(add_help=False)
684
+ pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
685
+ pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
686
+ pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
687
+ pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
688
+ pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
689
+ known, _ = pre.parse_known_args(raw)
690
+ pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
691
+ pl.start()
692
+ try:
693
+ code = main_fn(argv)
694
+ except SystemExit as e:
695
+ if not isinstance(e.code, int):
696
+ print(e.code, file=sys.stderr)
697
+ code = e.code if isinstance(e.code, int) else 2
698
+ pl.finish(code)
699
+ return code
700
+
701
+
702
+ def main(argv=None):
703
+ ap = argparse.ArgumentParser(description="Container image and IaC scan (Trivy) that reports into iKanban.")
704
+ ap.add_argument("--version", action="version", version=f"ikanban infrascan {INFRASCAN_VERSION}")
705
+ ap.add_argument("--path", action="append", default=[],
706
+ help="A folder or repository holding infrastructure files (repeat for each; default: cwd)")
707
+ ap.add_argument("--image", action="append", default=[], metavar="IMAGE",
708
+ help="Also scan this container image (repeat for each image)")
709
+ ap.add_argument("--no-iac", action="store_true", help="Scan only the --image images")
710
+ ap.add_argument("--no-compose-images", action="store_true",
711
+ help="Do not scan the images that docker-compose files name")
712
+ ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
713
+ ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
714
+ help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
715
+ ap.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
716
+ ap.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""),
717
+ help="Personal API key (or VIBE_API_TOKEN)")
718
+ ap.add_argument("--out", default=None, help="Also write the report here")
719
+ ap.add_argument("--json", action="store_true", help="Machine-readable summary")
720
+ ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
721
+ help="Exit 1 if any finding is at or above this severity")
722
+ add_pipeline_args(ap)
723
+ add_work_dir_arg(ap)
724
+ args = ap.parse_args(argv)
725
+
726
+ roots = [Path(p).expanduser().resolve() for p in (args.path or ["."])]
727
+ for root in roots:
728
+ if not root.is_dir():
729
+ print(f"error: {root} is not a directory", file=sys.stderr)
730
+ return 2
731
+ root = roots[0]
732
+ if args.publish and (not args.token or not args.team):
733
+ print("error: --publish needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID)",
734
+ file=sys.stderr)
735
+ return 2
736
+
737
+ if args.no_iac and not args.image:
738
+ print("error: nothing to scan: drop --no-iac or add --image", file=sys.stderr)
739
+ return 2
740
+ work = scan_work_dir("infrascan", root, args.work_dir)
741
+ sections, images, unresolved = [], list(args.image), []
742
+ INVENTORY.clear()
743
+ for r in roots:
744
+ inv = inventory(r)
745
+ rejected = []
746
+ if not args.no_compose_images:
747
+ found, skipped_vars, rejected = compose_images(r, inv)
748
+ images += [i for i in found if i not in images]
749
+ unresolved += skipped_vars
750
+ if not args.no_iac:
751
+ sec = scan_iac(r, scan_scope(r), inv, work)
752
+ sections.append(sec)
753
+ INVENTORY.append((sec.scope, inventory_rows(inv, sec.complete, "; ".join(sec.skipped), rejected)))
754
+ else:
755
+ INVENTORY.append((scan_scope(r), inventory_rows(inv, False, "--no-iac", rejected)))
756
+ sections += [scan_image(i, work) for i in images]
757
+ if unresolved:
758
+ sections.append(Section("Compose images not scanned", "container", "compose",
759
+ skipped=[f"`{i}` uses a ${{VARIABLE}}; pass the real name with --image" for i in unresolved]))
760
+ for sec in sections:
761
+ plan_filing(sec)
762
+ md, counts, total = build_report(", ".join(r.name for r in roots), sections)
763
+ write_private(work / "report.md", md)
764
+ nothing_ran = not any(sec.ran for sec in sections) # a cut section still ran
765
+ LAST_RUN.update(report=md, skipped=nothing_ran, detail=summary_detail(
766
+ counts, total, [s for sec in sections for s in sec.skipped] if nothing_ran else []))
767
+ if args.out:
768
+ Path(args.out).write_text(md)
769
+ if args.json:
770
+ print(json.dumps({"paths": [str(r) for r in roots], "images": images, "total": total, "counts": counts,
771
+ "skipped": [s for sec in sections for s in sec.skipped], "work_dir": str(work)}, indent=2))
772
+ else:
773
+ print(md)
774
+ sys.stdout.flush()
775
+ print(f"scan files: {work}", file=sys.stderr)
776
+
777
+ if args.publish:
778
+ title = f"Infrastructure scan — {root.name} — {datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC"
779
+ code = publish(md, title, sections, args)
780
+ if code:
781
+ return code
782
+
783
+ if args.fail_on and any(SEVERITY_RANK.get(s, 99) <= SEVERITY_RANK[args.fail_on] and c
784
+ for s, c in counts.items()):
785
+ return 1
786
+ return 0
787
+
788
+
789
+ if __name__ == "__main__":
790
+ sys.exit(run_main("infrascan", main))