ikanban 0.4.7 → 0.4.9
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/lib/roadmap.js +5 -1
- package/package.json +1 -1
- package/scanners/ikanban-cloudscan.py +9 -2
- package/scanners/ikanban-dastscan.py +91 -25
package/lib/roadmap.js
CHANGED
|
@@ -84,9 +84,13 @@ function localSettings(dir) {
|
|
|
84
84
|
|
|
85
85
|
function connection(opts, file) {
|
|
86
86
|
const s = localSettings(path.dirname(file));
|
|
87
|
+
// IKA-2467: a key goes with the server it came with. When settings-local.json names the server,
|
|
88
|
+
// its api_key wins over a shell VIBE_API_TOKEN, which may be another server's key (a laptop with
|
|
89
|
+
// a prod key in its shell sent it to dev). --token always wins.
|
|
90
|
+
const serverFromFile = !opts["base-url"] && !process.env.IKANBAN_BASE_URL && Boolean(s.api_url);
|
|
87
91
|
return {
|
|
88
92
|
base: apiOrigin(opts["base-url"] || process.env.IKANBAN_BASE_URL || s.api_url || "https://api.i-kanban.com"),
|
|
89
|
-
token: opts.token || process.env.VIBE_API_TOKEN || s.api_key || "",
|
|
93
|
+
token: opts.token || (serverFromFile ? s.api_key || process.env.VIBE_API_TOKEN : process.env.VIBE_API_TOKEN || s.api_key) || "",
|
|
90
94
|
team: process.env.IKANBAN_TEAM_ID || s.team || "",
|
|
91
95
|
project: s.project,
|
|
92
96
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ikanban",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "iKanban CLI: AI and security scans of your own repo, with results filed into iKanban",
|
|
5
5
|
"bin": { "ikanban": "bin/ikanban.js" },
|
|
6
6
|
"files": ["bin", "lib", "hooks", "scanners", "README.md", "LICENSE.md"],
|
|
@@ -49,7 +49,7 @@ import urllib.request
|
|
|
49
49
|
from datetime import datetime, timezone
|
|
50
50
|
from pathlib import Path
|
|
51
51
|
|
|
52
|
-
CLOUDSCAN_VERSION = "1.
|
|
52
|
+
CLOUDSCAN_VERSION = "1.1.0"
|
|
53
53
|
|
|
54
54
|
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
55
55
|
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
@@ -210,6 +210,13 @@ def check_id(row: dict) -> str:
|
|
|
210
210
|
return str((row.get("metadata") or {}).get("event_code") or "")
|
|
211
211
|
|
|
212
212
|
|
|
213
|
+
def fail_title(check_title: str) -> str:
|
|
214
|
+
"""Prowler's check title names the passing state ("S3 bucket is not publicly accessible"), so the
|
|
215
|
+
published title says it fails. Built from the check title only: Prowler's status_detail / message
|
|
216
|
+
is raw scanner text that can quote resource content, so it stays in the local Prowler output."""
|
|
217
|
+
return f"Fails: {check_title}"
|
|
218
|
+
|
|
219
|
+
|
|
213
220
|
def parse_rows(rows: list, region: str) -> tuple[list, int]:
|
|
214
221
|
"""(failed findings, how many RDS resources the run saw). Only plain fields are kept:
|
|
215
222
|
check id, title, resource ARN, region, severity, Prowler's remediation text."""
|
|
@@ -230,7 +237,7 @@ def parse_rows(rows: list, region: str) -> tuple[list, int]:
|
|
|
230
237
|
findings.append({
|
|
231
238
|
"check": cid, "severity": sev, "resource": uid,
|
|
232
239
|
"region": str(res.get("region") or (r.get("cloud") or {}).get("region") or region),
|
|
233
|
-
"title": str(info.get("title") or cid),
|
|
240
|
+
"title": fail_title(str(info.get("title") or cid)),
|
|
234
241
|
"remediation": str(fix),
|
|
235
242
|
})
|
|
236
243
|
return findings, len(rds)
|
|
@@ -28,7 +28,9 @@ Plan 11 H14: --openapi FILE_OR_URL runs ZAP's API scan instead, from an OpenAPI
|
|
|
28
28
|
-e ZAP_AUTH_HEADER_SITE] ghcr.io/zaproxy/zaproxy:stable \
|
|
29
29
|
zap-api-scan.py -t SPEC -f openapi -J zap.json -T MAX_MINUTES [-S]
|
|
30
30
|
(zaproxy.org/docs/docker/api-scan: it imports the definition and runs an active scan against
|
|
31
|
-
the URLs found; -S is safe mode, a baseline (passive) scan; exit codes as above
|
|
31
|
+
the URLs found; -S is safe mode, a baseline (passive) scan; exit codes as above; -T bounds only
|
|
32
|
+
ZAP's start and passive scan, so the active scan gets -z "-config scanner.maxScanDurationInMins=N",
|
|
33
|
+
and --rate N paces requests with the Network add-on's rate limit rule, see zap_config.) The findings
|
|
32
34
|
file as kind "api". The spec is a URL on the registered target's own origin (downloaded here,
|
|
33
35
|
no redirect, at most 5 MB; ZAP never gets a URL) or a local JSON file. Every servers entry must be
|
|
34
36
|
that origin or a path starting with '/' (no templates or variables); a checked copy with each server
|
|
@@ -47,16 +49,18 @@ import hashlib
|
|
|
47
49
|
import json
|
|
48
50
|
import os
|
|
49
51
|
import re
|
|
52
|
+
import shlex
|
|
50
53
|
import shutil
|
|
51
54
|
import subprocess
|
|
52
55
|
import sys
|
|
56
|
+
import time
|
|
53
57
|
import urllib.error
|
|
54
58
|
import urllib.parse
|
|
55
59
|
import urllib.request
|
|
56
60
|
from datetime import datetime, timezone
|
|
57
61
|
from pathlib import Path
|
|
58
62
|
|
|
59
|
-
DASTSCAN_VERSION = "1.
|
|
63
|
+
DASTSCAN_VERSION = "1.4.0"
|
|
60
64
|
|
|
61
65
|
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
62
66
|
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
@@ -69,6 +73,11 @@ MAX_URL = 2000
|
|
|
69
73
|
# Plan 11 H14 (owner): an active scan of production is refused; --passive may read it.
|
|
70
74
|
PRODUCTION_HOSTS = ("api.i-kanban.com",)
|
|
71
75
|
MAX_SPEC_BYTES = 5 * 1024 * 1024
|
|
76
|
+
# The scanner waits this long past --max-minutes for ZAP to write its report, then stops it.
|
|
77
|
+
GRACE_MINUTES = 3
|
|
78
|
+
# Requests per second for an active API scan unless --rate says otherwise (the dev API
|
|
79
|
+
# answered 429 643 times without a limit). Passive scans send few requests: no limit.
|
|
80
|
+
DEFAULT_API_RATE = 10
|
|
72
81
|
|
|
73
82
|
|
|
74
83
|
def check_url(url: str) -> str | None:
|
|
@@ -252,7 +261,8 @@ def findings_payload(url: str, findings: list, kind: str = "dast") -> list:
|
|
|
252
261
|
} for f in findings]
|
|
253
262
|
|
|
254
263
|
|
|
255
|
-
def build_report(url: str, findings: list, skipped: list, ran: list, mode: str = "ZAP baseline, passive"
|
|
264
|
+
def build_report(url: str, findings: list, skipped: list, ran: list, mode: str = "ZAP baseline, passive",
|
|
265
|
+
incomplete: str = "") -> tuple:
|
|
256
266
|
counts = {s: 0 for s in SEVERITIES}
|
|
257
267
|
for f in findings:
|
|
258
268
|
counts[f["severity"]] += 1
|
|
@@ -268,6 +278,8 @@ def build_report(url: str, findings: list, skipped: list, ran: list, mode: str =
|
|
|
268
278
|
L.append(f"_ran: {', '.join(ran)}_")
|
|
269
279
|
for s in skipped:
|
|
270
280
|
L.append(f"- ⚠️ **SKIPPED** — {s}")
|
|
281
|
+
if incomplete:
|
|
282
|
+
L.append(f"- ⚠️ **INCOMPLETE** — {incomplete}")
|
|
271
283
|
if ran and not findings:
|
|
272
284
|
L.append("- ✅ no findings")
|
|
273
285
|
for f in sorted(findings, key=lambda x: SEVERITY_RANK[x["severity"]]):
|
|
@@ -280,11 +292,30 @@ def build_report(url: str, findings: list, skipped: list, ran: list, mode: str =
|
|
|
280
292
|
return "\n".join(L), counts, total
|
|
281
293
|
|
|
282
294
|
|
|
295
|
+
def zap_config(host: str, active_api: bool, max_minutes: int, rate: int) -> list:
|
|
296
|
+
"""ZAP -config options (-z). zaproxy.org/docs/docker/api-scan: -T bounds only ZAP's start and the
|
|
297
|
+
passive scan, so an active scan is capped by scanner.maxScanDurationInMins (ScannerParam.java),
|
|
298
|
+
2 minutes under --max-minutes to leave time for the start and the report. The pace is the Network
|
|
299
|
+
add-on's rate limit (Options → Network → Rate Limit; keys from RateLimitOptions.java): one rule
|
|
300
|
+
for the target host, `rate` requests per second."""
|
|
301
|
+
opts = []
|
|
302
|
+
if active_api:
|
|
303
|
+
opts += ["-config", f"scanner.maxScanDurationInMins={max(1, max_minutes - 2)}"]
|
|
304
|
+
if rate:
|
|
305
|
+
rule = "network.ratelimit.rules.rule(0)."
|
|
306
|
+
opts += ["-config", rule + "description=ikanban-dastscan", "-config", rule + "enabled=true",
|
|
307
|
+
"-config", rule + "regex=false", "-config", rule + f"matchStr={host}",
|
|
308
|
+
"-config", rule + f"reqsPerSec={rate}", "-config", rule + "groupBy=RULE"]
|
|
309
|
+
return opts
|
|
310
|
+
|
|
311
|
+
|
|
283
312
|
def run_zap(url: str, minutes: int, max_minutes: int, work: Path,
|
|
284
|
-
spec_file: Path | None = None, passive: bool = True) -> tuple[dict | None, str]:
|
|
285
|
-
"""(report, "")
|
|
286
|
-
|
|
287
|
-
|
|
313
|
+
spec_file: Path | None = None, passive: bool = True, rate: int = 0) -> tuple[dict | None, str]:
|
|
314
|
+
"""(report, "") for a complete run; (report, why) for a partial report (ZAP hit the time limit
|
|
315
|
+
or failed after writing it: filed as an incomplete run); (None, why) when there is no report.
|
|
316
|
+
ZAP writes zap.json into <work>/zap/. With spec_file: the API scan (active unless passive), the
|
|
317
|
+
sign-in header from this shell's ZAP_AUTH_HEADER_VALUE passed by name only (`-e NAME`), its value
|
|
318
|
+
in the process environment."""
|
|
288
319
|
if not shutil.which("docker"):
|
|
289
320
|
return None, DOCKER_INSTALL
|
|
290
321
|
# The ZAP image writes as its own user, so it gets its own zap/ subfolder it may write to;
|
|
@@ -292,7 +323,10 @@ def run_zap(url: str, minutes: int, max_minutes: int, work: Path,
|
|
|
292
323
|
zap_dir = work / "zap"
|
|
293
324
|
zap_dir.mkdir(exist_ok=True)
|
|
294
325
|
os.chmod(zap_dir, 0o777)
|
|
295
|
-
|
|
326
|
+
# A name, so a run past the limit can be stopped: killing `docker run` leaves the container going.
|
|
327
|
+
name = f"ikanban-dastscan-{os.getpid()}-{int(time.time())}"
|
|
328
|
+
cmd, env = ["docker", "run", "--rm", "--name", name, "-v", f"{zap_dir}:/zap/wrk/:rw"], None
|
|
329
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
296
330
|
if spec_file is not None:
|
|
297
331
|
# The checked copy, read-only; -O pins every request to the registered host
|
|
298
332
|
# (zap-api-scan.py: "-O the hostname to override in the (remote) OpenAPI spec").
|
|
@@ -303,7 +337,7 @@ def run_zap(url: str, minutes: int, max_minutes: int, work: Path,
|
|
|
303
337
|
# Only what ZAP needs, plus PATH for docker itself: never VIBE_API_TOKEN or other keys.
|
|
304
338
|
env = {k: v for k, v in os.environ.items() if k in ("PATH", "HOME", "DOCKER_HOST", "DOCKER_CONFIG",
|
|
305
339
|
"DOCKER_CONTEXT", "ZAP_AUTH_HEADER")}
|
|
306
|
-
env.update(ZAP_AUTH_HEADER_VALUE=auth, ZAP_AUTH_HEADER_SITE=
|
|
340
|
+
env.update(ZAP_AUTH_HEADER_VALUE=auth, ZAP_AUTH_HEADER_SITE=host)
|
|
307
341
|
cmd += ["-e", "ZAP_AUTH_HEADER_VALUE", "-e", "ZAP_AUTH_HEADER_SITE"]
|
|
308
342
|
if "ZAP_AUTH_HEADER" in env:
|
|
309
343
|
cmd += ["-e", "ZAP_AUTH_HEADER"]
|
|
@@ -311,20 +345,38 @@ def run_zap(url: str, minutes: int, max_minutes: int, work: Path,
|
|
|
311
345
|
"-J", "zap.json", "-T", str(max_minutes)] + (["-S"] if passive else [])
|
|
312
346
|
else:
|
|
313
347
|
cmd += [ZAP_IMAGE, "zap-baseline.py", "-t", url, "-J", "zap.json", "-m", str(minutes), "-T", str(max_minutes)]
|
|
348
|
+
opts = zap_config(host, spec_file is not None and not passive, max_minutes, rate)
|
|
349
|
+
if opts:
|
|
350
|
+
cmd += ["-z", shlex.join(opts)] # zap_common.add_zap_options splits it with shlex
|
|
351
|
+
limit = max_minutes + GRACE_MINUTES
|
|
314
352
|
try:
|
|
315
|
-
p = subprocess.run(cmd, capture_output=True, text=True, timeout=
|
|
353
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=limit * 60, env=env)
|
|
354
|
+
code, tail = p.returncode, (p.stderr or p.stdout or "").strip()[-300:]
|
|
316
355
|
except FileNotFoundError:
|
|
317
356
|
return None, DOCKER_INSTALL
|
|
318
357
|
except subprocess.TimeoutExpired:
|
|
319
|
-
|
|
358
|
+
code, tail = None, ""
|
|
359
|
+
try:
|
|
360
|
+
subprocess.run(["docker", "stop", "-t", "30", name], capture_output=True, text=True, timeout=120)
|
|
361
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
362
|
+
pass
|
|
320
363
|
out = zap_dir / "zap.json"
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
364
|
+
report = None
|
|
365
|
+
if out.exists():
|
|
366
|
+
try:
|
|
367
|
+
report = json.loads(out.read_text() or "{}")
|
|
368
|
+
except ValueError:
|
|
369
|
+
report = None
|
|
370
|
+
if report is not None and code in (0, 1, 2):
|
|
371
|
+
return report, ""
|
|
372
|
+
if report is not None:
|
|
373
|
+
how = f"ZAP was stopped at the {limit}-minute limit" if code is None else f"ZAP exited {code}"
|
|
374
|
+
return report, f"{how}; its report is partial, so this run is filed as incomplete"
|
|
375
|
+
if code is None:
|
|
376
|
+
return None, f"ZAP did not finish within {limit} minutes and wrote no report"
|
|
377
|
+
if code in (0, 1, 2) and out.exists():
|
|
327
378
|
return None, "ZAP wrote no JSON report"
|
|
379
|
+
return None, f"ZAP failed (exit {code}): {tail or 'no report'}"
|
|
328
380
|
|
|
329
381
|
|
|
330
382
|
# ── copied from ikanban-secscan.py: key-host pinning and the API call ──────────────────
|
|
@@ -585,7 +637,13 @@ def main(argv=None):
|
|
|
585
637
|
ap.add_argument("--passive", action="store_true",
|
|
586
638
|
help="With --openapi: no attacks (ZAP -S); the only way to scan the production iKanban API")
|
|
587
639
|
ap.add_argument("--minutes", type=int, default=1, help="Spider minutes (ZAP -m, default 1)")
|
|
588
|
-
ap.add_argument("--max-minutes", type=int, default=10,
|
|
640
|
+
ap.add_argument("--max-minutes", type=int, default=10,
|
|
641
|
+
help="Most minutes ZAP may take (default 10): ZAP -T, and for an active API scan "
|
|
642
|
+
"scanner.maxScanDurationInMins = this minus 2. The scanner waits 3 minutes more for "
|
|
643
|
+
"ZAP's report, then stops it; a late report files as an incomplete run")
|
|
644
|
+
ap.add_argument("--rate", type=int, default=None, metavar="N",
|
|
645
|
+
help="Most requests per second ZAP sends to the target (ZAP's Network rate limit). "
|
|
646
|
+
f"Default {DEFAULT_API_RATE} for an active API scan, no limit otherwise; 0 = no limit")
|
|
589
647
|
ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
|
|
590
648
|
ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
|
|
591
649
|
help="Team UUID (or IKANBAN_TEAM_ID); always needed, to check the URL is registered")
|
|
@@ -628,6 +686,9 @@ def main(argv=None):
|
|
|
628
686
|
print("error: dastscan needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID) "
|
|
629
687
|
"to check the URL is a registered target", file=sys.stderr)
|
|
630
688
|
return 2
|
|
689
|
+
if args.rate is not None and not 0 <= args.rate <= 1000:
|
|
690
|
+
print("error: --rate from 0 (no limit) to 1000 requests per second", file=sys.stderr)
|
|
691
|
+
return 2
|
|
631
692
|
if args.passive and not args.openapi:
|
|
632
693
|
print("error: --passive goes with --openapi (the baseline scan is always passive)", file=sys.stderr)
|
|
633
694
|
return 2
|
|
@@ -654,27 +715,32 @@ def main(argv=None):
|
|
|
654
715
|
write_private(spec_file, json.dumps(doc))
|
|
655
716
|
os.chmod(spec_file, 0o644) # ZAP reads it as its own user; the run folder above stays 0700
|
|
656
717
|
mode = "ZAP API scan, " + ("passive" if args.passive else "active")
|
|
718
|
+
rate = args.rate if args.rate is not None else (0 if args.passive else DEFAULT_API_RATE)
|
|
657
719
|
progress(f"dastscan {DASTSCAN_VERSION} · {mode} on {url}: at most {args.max_minutes} min …")
|
|
658
|
-
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work, spec_file, args.passive)
|
|
720
|
+
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work, spec_file, args.passive, rate)
|
|
659
721
|
ran = [f"zap-api-scan.py -f openapi -O {urllib.parse.urlsplit(url).netloc}"
|
|
660
722
|
+ (" -S" if args.passive else "")] if report is not None else []
|
|
661
723
|
else:
|
|
662
724
|
mode = "ZAP baseline, passive"
|
|
663
725
|
progress(f"dastscan {DASTSCAN_VERSION} · ZAP baseline on {url}: spider {args.minutes} min, "
|
|
664
726
|
f"at most {args.max_minutes} min …")
|
|
665
|
-
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work)
|
|
727
|
+
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work, None, True, args.rate or 0)
|
|
666
728
|
ran = [f"zap-baseline.py -t {url} -m {args.minutes}"] if report is not None else []
|
|
729
|
+
# A report with a reason is partial: filed, but as an incomplete run (no control reads Enforced).
|
|
730
|
+
incomplete = skipped_why if report is not None else ""
|
|
731
|
+
skipped_why = "" if report is not None else skipped_why
|
|
667
732
|
findings = parse_zap(report) if report is not None else []
|
|
668
|
-
progress(f"{mode.split(',')[0]} on {url} … " + (
|
|
669
|
-
|
|
670
|
-
md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran, mode)
|
|
733
|
+
progress(f"{mode.split(',')[0]} on {url} … " + ("SKIPPED (the reason is in the report)" if report is None else
|
|
734
|
+
f"done, {len(findings)} alerts" + (" (partial)" if incomplete else "")))
|
|
735
|
+
md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran, mode, incomplete)
|
|
671
736
|
LAST_RUN.update(report=md, detail=summary_detail(counts, total, [] if ran else [skipped_why]),
|
|
672
737
|
skipped=not ran)
|
|
673
738
|
write_private(work / "report.md", md)
|
|
674
739
|
if args.out:
|
|
675
740
|
Path(args.out).write_text(md)
|
|
676
741
|
if args.json:
|
|
677
|
-
print(json.dumps({"url": url, "total": total, "counts": counts, "skipped": bool(skipped_why)
|
|
742
|
+
print(json.dumps({"url": url, "total": total, "counts": counts, "skipped": bool(skipped_why),
|
|
743
|
+
"complete": report is not None and not incomplete}, indent=2))
|
|
678
744
|
else:
|
|
679
745
|
print(md)
|
|
680
746
|
sys.stdout.flush()
|
|
@@ -692,7 +758,7 @@ def main(argv=None):
|
|
|
692
758
|
print("ZAP did not run: nothing filed or closed", file=sys.stderr)
|
|
693
759
|
else:
|
|
694
760
|
ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token,
|
|
695
|
-
{"findings": findings_payload(url, findings, kind), "scope": url, "complete":
|
|
761
|
+
{"findings": findings_payload(url, findings, kind), "scope": url, "complete": not incomplete,
|
|
696
762
|
"kind": kind})
|
|
697
763
|
if not ok:
|
|
698
764
|
print(f"filing {kind} findings FAILED: {data or 'this iKanban server does not take them yet'}",
|