ikanban 0.3.4 → 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.
- package/README.md +19 -0
- package/bin/ikanban.js +5 -2
- package/package.json +1 -1
- package/scanners/ikanban-aiscan.py +141 -2
- package/scanners/ikanban-dastscan.py +450 -0
- package/scanners/ikanban-infrascan.py +511 -0
- package/scanners/ikanban-secscan.py +132 -4
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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))
|