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,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))