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.
- package/README.md +37 -0
- package/bin/ikanban.js +5 -2
- package/package.json +1 -1
- package/scanners/ikanban-aiscan.py +279 -68
- package/scanners/ikanban-dastscan.py +491 -0
- package/scanners/ikanban-infrascan.py +790 -0
- package/scanners/ikanban-secscan.py +173 -4
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ikanban-dastscan — a passive DAST (dynamic application security testing) scan of a web app
|
|
4
|
+
YOUR TEAM HAS REGISTERED in iKanban, reporting into the Infrastructure tab (plan 11 H12).
|
|
5
|
+
|
|
6
|
+
npx ikanban dastscan --url https://staging.example.com --team TEAM --publish
|
|
7
|
+
|
|
8
|
+
It runs the OWASP ZAP baseline scan (Apache-2.0, https://github.com/zaproxy/zaproxy) in
|
|
9
|
+
Docker, which must already be installed:
|
|
10
|
+
docker run --rm -v DIR:/zap/wrk/:rw ghcr.io/zaproxy/zaproxy:stable \\
|
|
11
|
+
zap-baseline.py -t URL -J zap.json -m MINUTES -T MAX_MINUTES
|
|
12
|
+
(zaproxy.org/docs/docker/baseline-scan: it "runs the ZAP spider against the specified
|
|
13
|
+
target for (by default) 1 minute and then waits for the passive scanning to complete";
|
|
14
|
+
it "doesn't perform any actual 'attacks'". -J writes the JSON report, -m the spider
|
|
15
|
+
minutes, -T the maximum minutes; exit 0 ok, 1 FAIL, 2 WARN, 3 any other failure.)
|
|
16
|
+
|
|
17
|
+
Safety: the URL must be one of the team's registered DAST targets (iKASecure →
|
|
18
|
+
Infrastructure, set by an owner or admin); the scanner checks the list before Docker
|
|
19
|
+
starts, so it always needs --team and a key. A URL on this machine is reached from Docker
|
|
20
|
+
as http://host.docker.internal:PORT, and that is the form to register.
|
|
21
|
+
|
|
22
|
+
JSON read (zaproxy.org traditional JSON report): site[].alerts[]: pluginid, alertRef, name
|
|
23
|
+
(or alert), riskcode (a string: "3" High, "2" Medium, "1" Low, "0" Informational), desc,
|
|
24
|
+
solution, reference, cweid, count, instances[].uri.
|
|
25
|
+
|
|
26
|
+
Helpers below are copied from ikanban-secscan.py (the scanners ship as single files).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import argparse
|
|
32
|
+
import hashlib
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import shutil
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import urllib.error
|
|
40
|
+
import urllib.parse
|
|
41
|
+
import urllib.request
|
|
42
|
+
from datetime import datetime, timezone
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
|
|
45
|
+
DASTSCAN_VERSION = "1.1.0"
|
|
46
|
+
|
|
47
|
+
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
48
|
+
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
49
|
+
RISK = {"3": "high", "2": "medium", "1": "low"} # "0" Informational is not filed
|
|
50
|
+
ZAP_IMAGE = "ghcr.io/zaproxy/zaproxy:stable"
|
|
51
|
+
DOCKER_INSTALL = ("Docker is not installed or not running — install Docker Desktop "
|
|
52
|
+
"(https://docs.docker.com/get-docker/), start it, and re-run")
|
|
53
|
+
CATEGORY = "A05 — Security Misconfiguration"
|
|
54
|
+
MAX_URL = 2000
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_url(url: str) -> str | None:
|
|
58
|
+
"""Why this URL is refused, or None. http(s) only, no user:pass@, no fragment."""
|
|
59
|
+
if not url or len(url) > MAX_URL or any(c.isspace() for c in url):
|
|
60
|
+
return "give a full http or https URL of at most 2000 characters"
|
|
61
|
+
u = urllib.parse.urlsplit(url)
|
|
62
|
+
if u.scheme not in ("http", "https") or not u.hostname:
|
|
63
|
+
return "only http and https URLs can be scanned"
|
|
64
|
+
if u.username is not None or u.password is not None:
|
|
65
|
+
return "a URL with a user name or password is refused; register the plain URL"
|
|
66
|
+
if u.fragment:
|
|
67
|
+
return "remove the #fragment"
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def strip_html(s: str) -> str:
|
|
72
|
+
return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s or "")).strip()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def parse_zap(report: dict) -> list:
|
|
76
|
+
"""One finding per alert; informational alerts are left out."""
|
|
77
|
+
out = []
|
|
78
|
+
for site in report.get("site") or []:
|
|
79
|
+
for a in site.get("alerts") or []:
|
|
80
|
+
sev = RISK.get(str(a.get("riskcode", "")))
|
|
81
|
+
if not sev:
|
|
82
|
+
continue
|
|
83
|
+
uris = [i.get("uri") for i in a.get("instances") or [] if i.get("uri")]
|
|
84
|
+
cwe = str(a.get("cweid") or "")
|
|
85
|
+
detail = strip_html(a.get("desc"))[:500]
|
|
86
|
+
fix = strip_html(a.get("solution"))[:800]
|
|
87
|
+
ref = strip_html(a.get("reference")).split(" ")[0]
|
|
88
|
+
out.append({
|
|
89
|
+
"severity": sev,
|
|
90
|
+
"title": a.get("name") or a.get("alert") or f"ZAP rule {a.get('pluginid')}",
|
|
91
|
+
"rule": str(a.get("alertRef") or a.get("pluginid") or ""),
|
|
92
|
+
"location": uris[0] if uris else site.get("@name", ""),
|
|
93
|
+
"count": int(a.get("count") or len(uris) or 1),
|
|
94
|
+
"detail": detail + (f" (CWE-{cwe})" if cwe and cwe not in ("0", "-1") else ""),
|
|
95
|
+
"remediation": " ".join(x for x in (fix, ref) if x) or "See the ZAP rule for the fix.",
|
|
96
|
+
})
|
|
97
|
+
return out
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def finding_key(url: str, rule: str, title: str) -> str:
|
|
101
|
+
"""Stable per target URL and ZAP rule, so a rescan files nothing new."""
|
|
102
|
+
return "dastscan-" + hashlib.sha256(f"dast|{url}|{rule}|{title}".encode()).hexdigest()[:16]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def findings_payload(url: str, findings: list) -> list:
|
|
106
|
+
return [{
|
|
107
|
+
"finding_key": finding_key(url, f["rule"], f["title"]),
|
|
108
|
+
"owasp_category": CATEGORY,
|
|
109
|
+
"severity": f["severity"].capitalize(),
|
|
110
|
+
"title": f["title"][:300],
|
|
111
|
+
"location_file": f["location"][:500] or None,
|
|
112
|
+
"location_lines": None,
|
|
113
|
+
"remediation": " ".join(x for x in (f["detail"], f["remediation"]) if x),
|
|
114
|
+
} for f in findings]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def build_report(url: str, findings: list, skipped: list, ran: list) -> tuple:
|
|
118
|
+
counts = {s: 0 for s in SEVERITIES}
|
|
119
|
+
for f in findings:
|
|
120
|
+
counts[f["severity"]] += 1
|
|
121
|
+
total = sum(counts.values())
|
|
122
|
+
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
123
|
+
L = [f"# DAST scan — {url}", "", f"_{stamp} · `ikanban-dastscan` (ZAP baseline, passive)_", "",
|
|
124
|
+
"| Severity | Count |", "|---|---|"]
|
|
125
|
+
L += [f"| {s} | {counts[s]} |" for s in SEVERITIES if counts[s]]
|
|
126
|
+
if not total:
|
|
127
|
+
L.append("| _none_ | 0 |")
|
|
128
|
+
L += ["", f"**{total} finding(s).**", "", "## Passive web scan"]
|
|
129
|
+
if ran:
|
|
130
|
+
L.append(f"_ran: {', '.join(ran)}_")
|
|
131
|
+
for s in skipped:
|
|
132
|
+
L.append(f"- ⚠️ **SKIPPED** — {s}")
|
|
133
|
+
if ran and not findings:
|
|
134
|
+
L.append("- ✅ no findings")
|
|
135
|
+
for f in sorted(findings, key=lambda x: SEVERITY_RANK[x["severity"]]):
|
|
136
|
+
L.append(f"- **[{f['severity']}]** {f['title']} — `{f['location']}` ({f['count']} place(s))")
|
|
137
|
+
if f["detail"]:
|
|
138
|
+
L.append(f" - {f['detail']}")
|
|
139
|
+
L += ["", "---", "", "A passive scan reads responses only; it does not attack. Skipped checks are gaps, not passes."]
|
|
140
|
+
return "\n".join(L), counts, total
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def run_zap(url: str, minutes: int, max_minutes: int, work: Path) -> tuple[dict | None, str]:
|
|
144
|
+
"""(report, "") or (None, why it did not run). ZAP writes zap.json into <work>/zap/."""
|
|
145
|
+
if not shutil.which("docker"):
|
|
146
|
+
return None, DOCKER_INSTALL
|
|
147
|
+
# The ZAP image writes as its own user, so it gets its own zap/ subfolder it may write to;
|
|
148
|
+
# the run folder stays 0700, so nobody else on this machine can reach zap/ through it.
|
|
149
|
+
zap_dir = work / "zap"
|
|
150
|
+
zap_dir.mkdir(exist_ok=True)
|
|
151
|
+
os.chmod(zap_dir, 0o777)
|
|
152
|
+
cmd = ["docker", "run", "--rm", "-v", f"{zap_dir}:/zap/wrk/:rw", ZAP_IMAGE, "zap-baseline.py",
|
|
153
|
+
"-t", url, "-J", "zap.json", "-m", str(minutes), "-T", str(max_minutes)]
|
|
154
|
+
try:
|
|
155
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=(max_minutes + 5) * 60)
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
return None, DOCKER_INSTALL
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
return None, f"ZAP did not finish within {max_minutes + 5} minutes"
|
|
160
|
+
out = zap_dir / "zap.json"
|
|
161
|
+
if p.returncode not in (0, 1, 2) or not out.exists():
|
|
162
|
+
why = (p.stderr or p.stdout or "").strip()[-300:]
|
|
163
|
+
return None, f"ZAP failed (exit {p.returncode}): {why or 'no report'}"
|
|
164
|
+
try:
|
|
165
|
+
return json.loads(out.read_text() or "{}"), ""
|
|
166
|
+
except ValueError:
|
|
167
|
+
return None, "ZAP wrote no JSON report"
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── copied from ikanban-secscan.py: key-host pinning and the API call ──────────────────
|
|
171
|
+
IKANBAN_APIS = ("https://api.i-kanban.com", "https://dev-api.i-kanban.com")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def ikanban_api(base_url):
|
|
175
|
+
"""The origin of base_url if it is iKanban's API; otherwise stop, naming it (never the key)."""
|
|
176
|
+
u = urllib.parse.urlsplit(base_url or "")
|
|
177
|
+
origin = f"{u.scheme}://{u.netloc}"
|
|
178
|
+
if u.username is None and u.path in ("", "/") and not u.query and (
|
|
179
|
+
origin in IKANBAN_APIS or (u.scheme == "http" and u.hostname in ("127.0.0.1", "localhost", "::1"))):
|
|
180
|
+
return origin
|
|
181
|
+
raise SystemExit(f"error: {base_url or '(empty)'} is not an iKanban API "
|
|
182
|
+
f"({', '.join(IKANBAN_APIS)} or this machine); the key was not sent")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
186
|
+
def redirect_request(self, *args, **kwargs):
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def api(method: str, path: str, base_url: str, token: str, payload=None):
|
|
194
|
+
"""(ok, data | error text | None for an unknown route)."""
|
|
195
|
+
req = urllib.request.Request(
|
|
196
|
+
f"{ikanban_api(base_url)}/api{path}",
|
|
197
|
+
data=json.dumps(payload).encode() if payload is not None else None,
|
|
198
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
199
|
+
method=method,
|
|
200
|
+
)
|
|
201
|
+
try:
|
|
202
|
+
with _OPENER.open(req, timeout=60) as r:
|
|
203
|
+
body = json.loads(r.read().decode() or "{}")
|
|
204
|
+
return True, body.get("data", body)
|
|
205
|
+
except urllib.error.HTTPError as e:
|
|
206
|
+
detail = e.read().decode()[:300]
|
|
207
|
+
if e.code in (404, 405) and not detail.strip():
|
|
208
|
+
return False, None
|
|
209
|
+
try:
|
|
210
|
+
detail = json.loads(detail).get("error") or detail
|
|
211
|
+
except (ValueError, AttributeError):
|
|
212
|
+
pass
|
|
213
|
+
return False, f"HTTP {e.code}: {detail}"
|
|
214
|
+
except Exception as e: # noqa: BLE001 — surface any transport failure verbatim
|
|
215
|
+
return False, str(e)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def registered(url: str, args) -> str | None:
|
|
219
|
+
"""Why the URL may not be scanned (not registered, or the list could not be read), or None."""
|
|
220
|
+
ok, data = api("GET", f"/teams/{args.team}/security/dast-targets", args.base_url, args.token)
|
|
221
|
+
if not ok:
|
|
222
|
+
return f"could not read the team's DAST targets: {data or 'this iKanban server has none yet'}"
|
|
223
|
+
if url not in [t.get("url") for t in data or []]:
|
|
224
|
+
return (f"{url} is not a registered DAST target of this team; an owner or admin registers it "
|
|
225
|
+
"in iKASecure → Infrastructure first")
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
|
|
230
|
+
# <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
|
|
231
|
+
# holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
|
|
232
|
+
def add_work_dir_arg(ap):
|
|
233
|
+
ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
|
|
234
|
+
help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
|
|
235
|
+
"<repository>/.iKanban/scans)")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
|
|
239
|
+
scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
|
|
240
|
+
scans.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
ignore = scans / ".gitignore"
|
|
242
|
+
if not ignore.exists():
|
|
243
|
+
ignore.write_text("*\n")
|
|
244
|
+
run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
|
|
245
|
+
run_dir.mkdir(mode=0o700, exist_ok=True)
|
|
246
|
+
os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
|
|
247
|
+
return run_dir
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def write_private(path: Path, text: str) -> None:
|
|
251
|
+
"""Write a file (0600) without following a symlink planted at its name: write a new
|
|
252
|
+
temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
|
|
253
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
254
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
255
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
256
|
+
f.write(text)
|
|
257
|
+
os.replace(tmp, path)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def slug(text: str) -> str:
|
|
261
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
265
|
+
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
266
|
+
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
267
|
+
# then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
|
|
268
|
+
_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)
|
|
269
|
+
PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
|
|
270
|
+
LAST_RUN = {"report": "", "detail": "", "skipped": False}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def add_pipeline_args(ap):
|
|
274
|
+
ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
|
|
275
|
+
help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
|
|
276
|
+
"or IKANBAN_TASK)")
|
|
277
|
+
ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
|
|
278
|
+
help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def summary_detail(counts: dict, total: int, skipped: list) -> str:
|
|
282
|
+
if skipped and not total and all(s for s in skipped):
|
|
283
|
+
return ("SKIPPED: " + "; ".join(skipped))[:300]
|
|
284
|
+
worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
|
|
285
|
+
return f"{total} findings" + (f": {worst}" if worst else "")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def mask_report(text: str) -> str:
|
|
289
|
+
"""The report is masked already; mask again before it leaves for Pipelines."""
|
|
290
|
+
scrub = globals().get("scrub_report")
|
|
291
|
+
if scrub:
|
|
292
|
+
return scrub(text)[0]
|
|
293
|
+
text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
|
|
294
|
+
home = str(Path.home())
|
|
295
|
+
return text.replace(home, "~") if home and home != "/" else text
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def log_chunks(text: str) -> list:
|
|
299
|
+
"""Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
|
|
300
|
+
chunks, cur = [], ""
|
|
301
|
+
for line in text.splitlines(keepends=True):
|
|
302
|
+
while len(line.encode()) > PIPE_CHUNK:
|
|
303
|
+
head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
|
|
304
|
+
chunks.append(head)
|
|
305
|
+
line = line[len(head):]
|
|
306
|
+
if len((cur + line).encode()) > PIPE_CHUNK:
|
|
307
|
+
chunks.append(cur)
|
|
308
|
+
cur = ""
|
|
309
|
+
cur += line
|
|
310
|
+
if cur:
|
|
311
|
+
chunks.append(cur)
|
|
312
|
+
return chunks
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
class Pipeline:
|
|
316
|
+
def __init__(self, stage, task, team, base_url, token):
|
|
317
|
+
self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
|
|
318
|
+
self.on = bool(task and team and token)
|
|
319
|
+
self.tid = None
|
|
320
|
+
|
|
321
|
+
def _off(self, why):
|
|
322
|
+
self.on = False
|
|
323
|
+
note = "the plan does not include Pipelines" if "402" in str(why) else why
|
|
324
|
+
print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
|
|
325
|
+
|
|
326
|
+
def _call(self, method, path, body=None):
|
|
327
|
+
try:
|
|
328
|
+
return api(method, path, self.base, self.token, body)
|
|
329
|
+
except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
|
|
330
|
+
return False, str(e)
|
|
331
|
+
|
|
332
|
+
def _post(self, path, body):
|
|
333
|
+
if not self.on:
|
|
334
|
+
return
|
|
335
|
+
if self.tid is None:
|
|
336
|
+
if _UUID.match(self.team):
|
|
337
|
+
self.tid = self.team
|
|
338
|
+
else:
|
|
339
|
+
ok, teams = self._call("GET", "/teams")
|
|
340
|
+
if not ok:
|
|
341
|
+
return self._off(teams)
|
|
342
|
+
found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
|
|
343
|
+
if not found:
|
|
344
|
+
return self._off(f"team {self.team} not found among your teams")
|
|
345
|
+
self.tid = found[0]
|
|
346
|
+
ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
|
|
347
|
+
if not ok:
|
|
348
|
+
self._off(data or "this iKanban server has no Pipelines")
|
|
349
|
+
|
|
350
|
+
def start(self):
|
|
351
|
+
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
|
|
352
|
+
"source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
353
|
+
|
|
354
|
+
def finish(self, code):
|
|
355
|
+
status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
|
|
356
|
+
detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
|
|
357
|
+
if code == 1 and not LAST_RUN["skipped"]:
|
|
358
|
+
detail = (detail + " (at or above --fail-on)").strip()
|
|
359
|
+
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
|
|
360
|
+
"source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
361
|
+
"detail": detail[:300] or None})
|
|
362
|
+
for chunk in log_chunks(mask_report(LAST_RUN["report"])):
|
|
363
|
+
self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def run_main(stage, main_fn, argv=None):
|
|
367
|
+
"""main() as it is, wrapped in Pipelines events when --task is given."""
|
|
368
|
+
raw = sys.argv[1:] if argv is None else argv
|
|
369
|
+
if any(a in ("-h", "--help", "--version", "--list-apps", "--list-targets") for a in raw):
|
|
370
|
+
return main_fn(argv)
|
|
371
|
+
pre = argparse.ArgumentParser(add_help=False)
|
|
372
|
+
pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
|
|
373
|
+
pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
|
|
374
|
+
pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
|
|
375
|
+
pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
|
|
376
|
+
pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
|
|
377
|
+
known, _ = pre.parse_known_args(raw)
|
|
378
|
+
pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
|
|
379
|
+
pl.start()
|
|
380
|
+
try:
|
|
381
|
+
code = main_fn(argv)
|
|
382
|
+
except SystemExit as e:
|
|
383
|
+
if not isinstance(e.code, int):
|
|
384
|
+
print(e.code, file=sys.stderr)
|
|
385
|
+
code = e.code if isinstance(e.code, int) else 2
|
|
386
|
+
pl.finish(code)
|
|
387
|
+
return code
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def main(argv=None):
|
|
391
|
+
ap = argparse.ArgumentParser(description="Passive DAST scan (OWASP ZAP baseline) of a registered URL.")
|
|
392
|
+
ap.add_argument("--version", action="version", version=f"ikanban dastscan {DASTSCAN_VERSION}")
|
|
393
|
+
ap.add_argument("--url", help="The web app to scan: one of the team's registered DAST targets")
|
|
394
|
+
ap.add_argument("--list-targets", action="store_true",
|
|
395
|
+
help="Print the team's registered DAST targets (the URLs --url takes) and exit")
|
|
396
|
+
ap.add_argument("--minutes", type=int, default=1, help="Spider minutes (ZAP -m, default 1)")
|
|
397
|
+
ap.add_argument("--max-minutes", type=int, default=10, help="Most minutes ZAP may take (ZAP -T, default 10)")
|
|
398
|
+
ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
|
|
399
|
+
ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
|
|
400
|
+
help="Team UUID (or IKANBAN_TEAM_ID); always needed, to check the URL is registered")
|
|
401
|
+
ap.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
|
|
402
|
+
ap.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""),
|
|
403
|
+
help="Personal API key (or VIBE_API_TOKEN)")
|
|
404
|
+
ap.add_argument("--out", default=None, help="Also write the report here")
|
|
405
|
+
ap.add_argument("--json", action="store_true", help="Machine-readable summary")
|
|
406
|
+
ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
|
|
407
|
+
help="Exit 1 if any finding is at or above this severity")
|
|
408
|
+
add_pipeline_args(ap)
|
|
409
|
+
add_work_dir_arg(ap)
|
|
410
|
+
args = ap.parse_args(argv)
|
|
411
|
+
|
|
412
|
+
if args.list_targets:
|
|
413
|
+
if not args.token or not args.team:
|
|
414
|
+
print("error: --list-targets needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID)", file=sys.stderr)
|
|
415
|
+
return 2
|
|
416
|
+
ok, data = api("GET", f"/teams/{args.team}/security/dast-targets", args.base_url, args.token)
|
|
417
|
+
if not ok:
|
|
418
|
+
print(f"error: could not read the team's DAST targets: {data}", file=sys.stderr)
|
|
419
|
+
return 2
|
|
420
|
+
for t in data or []:
|
|
421
|
+
print(t.get("url"))
|
|
422
|
+
if not data:
|
|
423
|
+
print("no DAST targets: an owner or admin adds one in iKASecure → Infrastructure", file=sys.stderr)
|
|
424
|
+
return 0
|
|
425
|
+
if not args.url:
|
|
426
|
+
ap.error("--url is required (or --list-targets)")
|
|
427
|
+
|
|
428
|
+
url = args.url.strip()
|
|
429
|
+
why = check_url(url)
|
|
430
|
+
if why:
|
|
431
|
+
print(f"error: {why}", file=sys.stderr)
|
|
432
|
+
return 2
|
|
433
|
+
if not 1 <= args.minutes <= 60 or not args.minutes <= args.max_minutes <= 120:
|
|
434
|
+
print("error: --minutes 1–60 and --max-minutes from --minutes up to 120", file=sys.stderr)
|
|
435
|
+
return 2
|
|
436
|
+
if not args.token or not args.team:
|
|
437
|
+
print("error: dastscan needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID) "
|
|
438
|
+
"to check the URL is a registered target", file=sys.stderr)
|
|
439
|
+
return 2
|
|
440
|
+
why = registered(url, args)
|
|
441
|
+
if why:
|
|
442
|
+
print(f"error: {why}", file=sys.stderr)
|
|
443
|
+
return 2
|
|
444
|
+
|
|
445
|
+
work = scan_work_dir("dastscan", Path.cwd(), args.work_dir)
|
|
446
|
+
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work)
|
|
447
|
+
findings = parse_zap(report) if report is not None else []
|
|
448
|
+
ran = [f"zap-baseline.py -t {url} -m {args.minutes}"] if report is not None else []
|
|
449
|
+
md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran)
|
|
450
|
+
LAST_RUN.update(report=md, detail=summary_detail(counts, total, [] if ran else [skipped_why]),
|
|
451
|
+
skipped=not ran)
|
|
452
|
+
write_private(work / "report.md", md)
|
|
453
|
+
if args.out:
|
|
454
|
+
Path(args.out).write_text(md)
|
|
455
|
+
if args.json:
|
|
456
|
+
print(json.dumps({"url": url, "total": total, "counts": counts, "skipped": bool(skipped_why)}, indent=2))
|
|
457
|
+
else:
|
|
458
|
+
print(md)
|
|
459
|
+
sys.stdout.flush()
|
|
460
|
+
print(f"scan files: {work}", file=sys.stderr)
|
|
461
|
+
|
|
462
|
+
if args.publish:
|
|
463
|
+
ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
|
|
464
|
+
{"team_id": args.team, "title": f"DAST scan — {url} — "
|
|
465
|
+
f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC", "content": md})
|
|
466
|
+
print(f"\npublished: {(info or {}).get('id', '')}" if ok else f"\npublish FAILED: {info}",
|
|
467
|
+
file=sys.stderr)
|
|
468
|
+
if not ok:
|
|
469
|
+
return 2
|
|
470
|
+
if report is None:
|
|
471
|
+
print("ZAP did not run: nothing filed or closed", file=sys.stderr)
|
|
472
|
+
else:
|
|
473
|
+
ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token,
|
|
474
|
+
{"findings": findings_payload(url, findings), "scope": url, "complete": True,
|
|
475
|
+
"kind": "dast"})
|
|
476
|
+
if not ok:
|
|
477
|
+
print(f"filing DAST findings FAILED: {data or 'this iKanban server does not take them yet'}",
|
|
478
|
+
file=sys.stderr)
|
|
479
|
+
return 2
|
|
480
|
+
print(f"filed {data.get('submitted', 0)} dast findings ({data.get('new', 0)} new, reopened "
|
|
481
|
+
f"{data.get('reopened', 0)}, closed {data.get('closed', 0)} no longer found at {url})",
|
|
482
|
+
file=sys.stderr)
|
|
483
|
+
|
|
484
|
+
if args.fail_on and any(SEVERITY_RANK.get(s, 99) <= SEVERITY_RANK[args.fail_on] and c
|
|
485
|
+
for s, c in counts.items()):
|
|
486
|
+
return 1
|
|
487
|
+
return 0
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
if __name__ == "__main__":
|
|
491
|
+
sys.exit(run_main("dastscan", main))
|