ikanban 0.4.5 → 0.4.6

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 CHANGED
@@ -9,6 +9,8 @@ npx ikanban aiscan --help # red-team scan of your AI app (OWASP LLM To
9
9
  npx ikanban secscan --publish # also file the report and findings into iKanban
10
10
  npx ikanban infrascan --image app:1 # IaC misconfiguration and container image scan (Trivy)
11
11
  npx ikanban dastscan --url URL # passive web scan of a registered URL (OWASP ZAP in Docker)
12
+ npx ikanban dastscan --url URL --openapi openapi.json # API scan of a registered URL (ZAP, active)
13
+ npx ikanban cloudscan --aws-profile P --region R --account ID # read-only AWS account audit (Prowler)
12
14
  npx ikanban secscan --task IKA-123 # also show the run in that task's Pipelines
13
15
  npx ikanban roadmap sync plan.json # make the Roadmap plan match the plan file
14
16
  npx ikanban hooks install # sync plan files on every agent write and git push
@@ -33,6 +35,19 @@ No CI is needed.
33
35
  [OWASP ZAP](https://www.zaproxy.org/docs/docker/baseline-scan/) baseline scan in Docker: it reads
34
36
  the site's responses and does not attack. It scans only a URL the team registered in iKASecure →
35
37
  Infrastructure, so it always needs `--team` and a key. Findings show on the Infrastructure tab.
38
+ With `--openapi FILE_OR_URL`, `dastscan` runs ZAP's [API scan](https://www.zaproxy.org/docs/docker/api-scan/)
39
+ instead: an active scan of the routes the OpenAPI 3 description lists (`--passive` makes it read-only).
40
+ The description is a JSON file whose `servers` are the registered URL's origin, or a URL on that origin.
41
+ The production iKanban API is scanned only with `--passive`. A sign-in header comes from
42
+ `ZAP_AUTH_HEADER_VALUE` in your shell and reaches ZAP through the container's environment only.
43
+
44
+ `cloudscan` runs [Prowler](https://github.com/prowler-cloud/prowler) (`prowlercloud/prowler:5.44.0`)
45
+ in Docker against one AWS account and region with your own profile. It checks the account first
46
+ (`aws sts get-caller-identity`, refused if it is not `--account`), passes short-lived credentials
47
+ from `aws configure export-credentials` by name in the container environment, and never mounts
48
+ `~/.aws`. Use a read-only role (SecurityAudit plus ViewOnlyAccess). Only findings are filed: the
49
+ AWS infrastructure audit, Database network isolation and Database security review rows.
50
+
36
51
  Without a `trivy` command, `infrascan` runs Trivy's official container (`aquasec/trivy:0.76.0`)
37
52
  when Docker is running: the repository is mounted read-only, and the Docker socket only for an
38
53
  image that exists on this machine alone. The report names the Trivy that ran. With neither Trivy
@@ -75,7 +90,7 @@ line as each step starts and ends. A plan without Pipelines never fails the scan
75
90
 
76
91
  - Node.js 18 or newer.
77
92
  - Python 3.9 or newer. The scanners are Python; `ikanban` finds `python3`, `python` or `py -3`.
78
- - `infrascan`: Trivy (macOS `brew install trivy`; other systems: a release binary), or Docker running. `dastscan`: Docker.
93
+ - `infrascan`: Trivy (macOS `brew install trivy`; other systems: a release binary), or Docker running. `dastscan`: Docker. `cloudscan`: the AWS CLI v2 and Docker.
79
94
 
80
95
  ## Free and paid
81
96
 
package/bin/ikanban.js CHANGED
@@ -11,7 +11,8 @@ const COMMANDS = {
11
11
  aiscan: { file: "ikanban-aiscan.py", about: "Red-team scan of your AI app (OWASP LLM Top 10)" },
12
12
  secscan: { file: "ikanban-secscan.py", about: "Dependency, secret and file-permission scan of a repo" },
13
13
  infrascan: { file: "ikanban-infrascan.py", about: "Container image and IaC scan (needs Trivy)" },
14
- dastscan: { file: "ikanban-dastscan.py", about: "Passive web scan of a registered URL (needs Docker)" },
14
+ dastscan: { file: "ikanban-dastscan.py", about: "Web or API scan of a registered URL (needs Docker)" },
15
+ cloudscan: { file: "ikanban-cloudscan.py", about: "Read-only AWS account audit (needs AWS CLI, Docker)" },
15
16
  };
16
17
 
17
18
  function help() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikanban",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
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"],
@@ -0,0 +1,635 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ikanban-cloudscan — a read-only audit of YOUR OWN AWS account, reporting into iKanban's
4
+ Infrastructure tab (plan 11 H14): AWS infrastructure audit, Database network isolation and
5
+ Database security review.
6
+
7
+ npx ikanban cloudscan --aws-profile NAME --region ap-southeast-1 --account 123456789012
8
+ npx ikanban cloudscan ... --publish --team TEAM # file the report and findings
9
+
10
+ It runs Prowler (Apache-2.0, https://github.com/prowler-cloud/prowler) from its pinned image in
11
+ Docker, which must already be running:
12
+ docker run --rm --env AWS_ACCESS_KEY_ID --env AWS_SECRET_ACCESS_KEY --env AWS_SESSION_TOKEN \
13
+ -v DIR:/out prowlercloud/prowler:5.44.0 aws -f REGION -s SERVICES... -M json-ocsf -o /out -z
14
+ (docs.prowler.com, Prowler CLI: -f region, -s services, -M output mode, -o output folder,
15
+ -z ignore exit code 3, which only means failed checks were found.)
16
+
17
+ Credential safety (h14-design.md):
18
+ - The AWS CLI on this machine exports short-lived credentials for the named profile
19
+ (`aws configure export-credentials --profile NAME --format process`). They go into the
20
+ container's environment by name (`--env NAME`); their values are never on a command line,
21
+ in a file, in the report or in a progress line. ~/.aws is never mounted: this machine may
22
+ hold other profiles.
23
+ - `aws sts get-caller-identity` runs first; a different account than --account stops the scan.
24
+ - Prowler makes read calls only. A role whose name has none of ReadOnly, SecurityAudit or
25
+ ViewOnly gets a warning, because write access cannot be proved absent.
26
+ - Only findings leave this machine: check id, resource ARN, region, severity and Prowler's
27
+ plain remediation text. Never the credentials, never Prowler's raw output.
28
+
29
+ One run files three kinds: "cloud" (every failed check), "db-network" and "db-config" (only the
30
+ check ids listed below). An account with no RDS resource publishes the two database kinds as
31
+ incomplete, so those controls never read Enforced on an empty account.
32
+
33
+ Helpers below are copied from ikanban-secscan.py (the scanners ship as single files).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import argparse
39
+ import hashlib
40
+ import json
41
+ import os
42
+ import re
43
+ import shutil
44
+ import subprocess
45
+ import sys
46
+ import urllib.error
47
+ import urllib.parse
48
+ import urllib.request
49
+ from datetime import datetime, timezone
50
+ from pathlib import Path
51
+
52
+ CLOUDSCAN_VERSION = "1.0.0"
53
+
54
+ SEVERITIES = ["critical", "high", "medium", "low", "info"]
55
+ SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
56
+ # Pinned released tag (hub.docker.com prowlercloud/prowler tags: 5.44.0, amd64 and arm64).
57
+ PROWLER_IMAGE = "prowlercloud/prowler:5.44.0"
58
+ SERVICES = ["iam", "s3", "ec2", "rds", "cloudtrail", "kms"]
59
+ CRED_VARS = ("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY", "AWS_SESSION_TOKEN")
60
+ READ_ONLY_HINTS = ("readonly", "securityaudit", "viewonly")
61
+ DOCKER_INSTALL = ("Docker is not installed or not running — install Docker Desktop "
62
+ "(https://docs.docker.com/get-docker/), start it, and re-run")
63
+ AWS_INSTALL = ("the AWS CLI is not installed — install it (macOS: brew install awscli; others: "
64
+ "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html), "
65
+ "sign in with `aws sso login --profile NAME`, and re-run")
66
+
67
+ # h14-design.md: the checks that decide each database row.
68
+ DB_NETWORK_CHECKS = (
69
+ "rds_instance_no_public_access",
70
+ "rds_instance_inside_vpc",
71
+ "rds_snapshots_public_access",
72
+ "ec2_securitygroup_allow_ingress_from_internet_to_tcp_port_postgres_5432",
73
+ )
74
+ DB_CONFIG_CHECKS = (
75
+ "rds_instance_storage_encrypted",
76
+ "rds_cluster_storage_encrypted",
77
+ "rds_snapshots_encrypted",
78
+ "rds_instance_transport_encrypted",
79
+ "rds_instance_backup_enabled",
80
+ "rds_instance_protected_by_backup_plan",
81
+ "rds_instance_deletion_protection",
82
+ "rds_cluster_deletion_protection",
83
+ "rds_instance_iam_authentication_enabled",
84
+ "rds_cluster_iam_authentication_enabled",
85
+ "rds_instance_minor_version_upgrade_enabled",
86
+ "rds_cluster_minor_version_upgrade_enabled",
87
+ "rds_instance_enhanced_monitoring_enabled",
88
+ "rds_instance_multi_az",
89
+ "rds_instance_deprecated_engine_version",
90
+ "rds_instance_default_admin",
91
+ )
92
+ KINDS = (("cloud", "AWS infrastructure audit", None),
93
+ ("db-network", "Database network isolation", DB_NETWORK_CHECKS),
94
+ ("db-config", "Database security review", DB_CONFIG_CHECKS))
95
+ CATEGORY = {"cloud": "A05 — Security Misconfiguration",
96
+ "db-network": "A01 — Broken Access Control",
97
+ "db-config": "A02 — Cryptographic Failures"}
98
+ OCSF_SEVERITY = {"critical": "critical", "high": "high", "medium": "medium", "low": "low"} # Informational: report only
99
+ MAX_FILED = 500 # the server takes at most this many findings in one publish
100
+
101
+ PROFILE_RE = re.compile(r"^[A-Za-z0-9_.+=,@-]{1,64}$")
102
+ REGION_RE = re.compile(r"^[a-z]{2}(-[a-z]+)+-\d{1,2}$")
103
+ ACCOUNT_RE = re.compile(r"^\d{12}$")
104
+ SECRETS: list = [] # this run's credential values, cut from anything printed or sent
105
+
106
+
107
+ def run(cmd, env=None, timeout=900):
108
+ """(exit code, stdout, stderr). Never raises."""
109
+ try:
110
+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env)
111
+ return p.returncode, p.stdout, p.stderr
112
+ except FileNotFoundError:
113
+ return 127, "", "not-installed"
114
+ except subprocess.TimeoutExpired:
115
+ return 124, "", f"timed out after {timeout}s"
116
+
117
+
118
+ def scrub_report(text: str) -> tuple:
119
+ """Cut this run's credential values, any AWS access key id, iKanban keys and the home folder."""
120
+ for s in SECRETS:
121
+ if s:
122
+ text = text.replace(s, "[hidden]")
123
+ text = re.sub(r"\b(AKIA|ASIA)[A-Z0-9]{16}\b", "[hidden key id]", text)
124
+ text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
125
+ home = str(Path.home())
126
+ return (text.replace(home, "~") if home and home != "/" else text), 0
127
+
128
+
129
+ def check_args(args) -> str | None:
130
+ if not PROFILE_RE.match(args.aws_profile or ""):
131
+ return "--aws-profile takes a profile name from your AWS config (letters, digits, . _ - + = , @)"
132
+ if not REGION_RE.match(args.region or ""):
133
+ return "--region takes an AWS region such as ap-southeast-1"
134
+ if not ACCOUNT_RE.match(args.account or ""):
135
+ return "--account takes the 12-digit AWS account id the profile must belong to"
136
+ bad = [s for s in args.services if not re.match(r"^[a-z0-9-]{2,40}$", s)]
137
+ return f"--services: not a Prowler service name: {bad[0]}" if bad else None
138
+
139
+
140
+ def credentials(profile: str) -> tuple[dict | None, str]:
141
+ """The profile's short-lived credentials as environment values, or (None, why)."""
142
+ code, out, _err = run(["aws", "configure", "export-credentials", "--profile", profile, "--format", "process"],
143
+ timeout=120)
144
+ if code == 127:
145
+ return None, AWS_INSTALL
146
+ try:
147
+ c = json.loads(out) if code == 0 else {}
148
+ except ValueError:
149
+ c = {}
150
+ vals = {"AWS_ACCESS_KEY_ID": c.get("AccessKeyId"), "AWS_SECRET_ACCESS_KEY": c.get("SecretAccessKey"),
151
+ "AWS_SESSION_TOKEN": c.get("SessionToken")}
152
+ if not vals["AWS_ACCESS_KEY_ID"] or not vals["AWS_SECRET_ACCESS_KEY"]:
153
+ return None, (f"the AWS CLI gave no credentials for profile {profile}: sign in "
154
+ f"(`aws sso login --profile {profile}`) and re-run")
155
+ SECRETS.extend(v for v in vals.values() if v)
156
+ return {k: v for k, v in vals.items() if v}, ""
157
+
158
+
159
+ def caller(profile: str) -> tuple[dict | None, str]:
160
+ code, out, _err = run(["aws", "sts", "get-caller-identity", "--profile", profile, "--output", "json"], timeout=120)
161
+ if code == 127:
162
+ return None, AWS_INSTALL
163
+ try:
164
+ ident = json.loads(out) if code == 0 else None
165
+ except ValueError:
166
+ ident = None
167
+ return (ident, "") if isinstance(ident, dict) else (None, f"`aws sts get-caller-identity` failed for profile {profile}")
168
+
169
+
170
+ def role_name(arn: str) -> str:
171
+ """The role (or user) in a caller ARN: assumed-role/NAME/session → NAME."""
172
+ tail = arn.split(":", 5)[-1]
173
+ parts = tail.split("/")
174
+ return parts[1] if parts[0] == "assumed-role" and len(parts) > 1 else parts[-1]
175
+
176
+
177
+ def run_prowler(region: str, services: list, creds: dict, work: Path) -> tuple[list | None, str]:
178
+ """(OCSF rows, "") or (None, why it did not run). Prowler writes into <work>/prowler/."""
179
+ if not shutil.which("docker") or run(["docker", "info"], timeout=30)[0] != 0:
180
+ return None, DOCKER_INSTALL
181
+ out = work / "prowler"
182
+ out.mkdir(exist_ok=True)
183
+ os.chmod(out, 0o777) # the image writes as its own user; the run folder above stays 0700
184
+ cmd = ["docker", "run", "--rm"]
185
+ for name in CRED_VARS:
186
+ if name in creds:
187
+ cmd += ["--env", name]
188
+ cmd += ["-v", f"{out}:/out", PROWLER_IMAGE, "aws", "-f", region, "-s", *services,
189
+ "-M", "json-ocsf", "-o", "/out", "-z"]
190
+ env = {k: v for k, v in os.environ.items() if k in ("PATH", "HOME", "DOCKER_HOST", "DOCKER_CONFIG", "DOCKER_CONTEXT")}
191
+ env.update(creds)
192
+ code, _o, err = run(cmd, env=env, timeout=3600)
193
+ if code != 0:
194
+ print(f"prowler: {scrub_report((err or '').strip()[-300:])[0]}", file=sys.stderr)
195
+ return None, f"Prowler failed (exit {code}); its error is on this terminal"
196
+ files = sorted(out.rglob("*.ocsf.json"))
197
+ if not files:
198
+ return None, "Prowler wrote no json-ocsf file"
199
+ rows = []
200
+ for f in files:
201
+ try:
202
+ data = json.loads(f.read_text() or "[]")
203
+ except ValueError:
204
+ return None, "Prowler wrote a json-ocsf file that is not JSON"
205
+ rows += data if isinstance(data, list) else []
206
+ return rows, ""
207
+
208
+
209
+ def check_id(row: dict) -> str:
210
+ return str((row.get("metadata") or {}).get("event_code") or "")
211
+
212
+
213
+ def parse_rows(rows: list, region: str) -> tuple[list, int]:
214
+ """(failed findings, how many RDS resources the run saw). Only plain fields are kept:
215
+ check id, title, resource ARN, region, severity, Prowler's remediation text."""
216
+ findings, rds = [], set()
217
+ for r in rows:
218
+ cid = check_id(r)
219
+ res = (r.get("resources") or [{}])[0] or {}
220
+ uid = str(res.get("uid") or "")
221
+ if cid.startswith("rds_") and uid:
222
+ rds.add(uid)
223
+ if str(r.get("status_code", "")).upper() != "FAIL":
224
+ continue
225
+ sev = OCSF_SEVERITY.get(str(r.get("severity", "")).lower())
226
+ if not sev or not cid:
227
+ continue
228
+ info = r.get("finding_info") or {}
229
+ fix = (r.get("remediation") or {}).get("desc") or "See the Prowler check for the fix."
230
+ findings.append({
231
+ "check": cid, "severity": sev, "resource": uid,
232
+ "region": str(res.get("region") or (r.get("cloud") or {}).get("region") or region),
233
+ "title": str(info.get("title") or cid),
234
+ "remediation": str(fix),
235
+ })
236
+ return findings, len(rds)
237
+
238
+
239
+ def finding_key(kind: str, scope: str, f: dict) -> str:
240
+ raw = f"{kind}|{scope}|{f['check']}|{f['resource']}"
241
+ return "cloudscan-" + hashlib.sha256(raw.encode()).hexdigest()[:16]
242
+
243
+
244
+ def section(kind: str, findings: list, checks) -> tuple[list, bool]:
245
+ """The findings a kind files (the worst MAX_FILED), and whether that is all of them."""
246
+ keep = [f for f in findings if checks is None or f["check"] in checks]
247
+ keep.sort(key=lambda f: (SEVERITY_RANK[f["severity"]], f["check"], f["resource"]))
248
+ return keep[:MAX_FILED], len(keep) <= MAX_FILED
249
+
250
+
251
+ def findings_payload(kind: str, scope: str, findings: list) -> list:
252
+ return [{
253
+ "finding_key": finding_key(kind, scope, f),
254
+ "owasp_category": CATEGORY[kind],
255
+ "severity": f["severity"].capitalize(),
256
+ "title": scrub_report(f"{f['title']} ({f['check']})")[0][:300],
257
+ "location_file": f["resource"][:500] or None,
258
+ "location_lines": None,
259
+ "remediation": scrub_report(f"Region {f['region']}. Check {f['check']}. {f['remediation']}")[0][:2000],
260
+ } for f in findings]
261
+
262
+
263
+ def build_report(scope: str, findings: list, skipped: list, ran: list, rds: int, notes: list) -> tuple:
264
+ counts = {s: 0 for s in SEVERITIES}
265
+ for f in findings:
266
+ counts[f["severity"]] += 1
267
+ total = sum(counts.values())
268
+ stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
269
+ L = [f"# Cloud account audit — {scope}", "", f"_{stamp} · `ikanban-cloudscan` (Prowler, read-only)_", "",
270
+ "| Severity | Count |", "|---|---|"]
271
+ L += [f"| {s} | {counts[s]} |" for s in SEVERITIES if counts[s]]
272
+ if not total:
273
+ L.append("| _none_ | 0 |")
274
+ L += ["", f"**{total} failed check(s).**", ""]
275
+ if ran:
276
+ L.append(f"_ran: {', '.join(ran)}_")
277
+ for s in skipped:
278
+ L.append(f"- ⚠️ **SKIPPED** — {s}")
279
+ for n in notes:
280
+ L.append(f"- {n}")
281
+ for kind, name, checks in KINDS:
282
+ rows = [f for f in findings if checks is None or f["check"] in checks]
283
+ L += ["", f"## {name} ({kind})"]
284
+ if not ran:
285
+ L.append("- not checked")
286
+ elif kind != "cloud" and not rds:
287
+ L.append("- no RDS resource in this region: not counted as a pass")
288
+ elif not rows:
289
+ L.append("- ✅ no failed checks")
290
+ for f in sorted(rows, key=lambda x: SEVERITY_RANK[x["severity"]]):
291
+ L.append(f"- **[{f['severity']}]** {f['title']} (`{f['check']}`) — `{f['resource']}`")
292
+ L += ["", "---", "", "Read-only checks with your own AWS profile; only findings are filed. Skipped checks are gaps, not passes."]
293
+ return scrub_report("\n".join(L))[0], counts, total
294
+
295
+
296
+ # ── copied from ikanban-secscan.py: key-host pinning and the API call ──────────────────
297
+ IKANBAN_APIS = ("https://api.i-kanban.com", "https://dev-api.i-kanban.com")
298
+
299
+
300
+ def ikanban_api(base_url):
301
+ """The origin of base_url if it is iKanban's API; otherwise stop, naming it (never the key)."""
302
+ u = urllib.parse.urlsplit(base_url or "")
303
+ origin = f"{u.scheme}://{u.netloc}"
304
+ if u.username is None and u.path in ("", "/") and not u.query and (
305
+ origin in IKANBAN_APIS or (u.scheme == "http" and u.hostname in ("127.0.0.1", "localhost", "::1"))):
306
+ return origin
307
+ raise SystemExit(f"error: {base_url or '(empty)'} is not an iKanban API "
308
+ f"({', '.join(IKANBAN_APIS)} or this machine); the key was not sent")
309
+
310
+
311
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
312
+ def redirect_request(self, *args, **kwargs):
313
+ return None
314
+
315
+
316
+ _OPENER = urllib.request.build_opener(_NoRedirect)
317
+
318
+
319
+ def api(method: str, path: str, base_url: str, token: str, payload=None):
320
+ """(ok, data | error text | None for an unknown route)."""
321
+ req = urllib.request.Request(
322
+ f"{ikanban_api(base_url)}/api{path}",
323
+ data=json.dumps(payload).encode() if payload is not None else None,
324
+ headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
325
+ method=method,
326
+ )
327
+ try:
328
+ with _OPENER.open(req, timeout=60) as r:
329
+ body = json.loads(r.read().decode() or "{}")
330
+ return True, body.get("data", body)
331
+ except urllib.error.HTTPError as e:
332
+ detail = e.read().decode()[:300]
333
+ if e.code in (404, 405) and not detail.strip():
334
+ return False, None
335
+ try:
336
+ detail = json.loads(detail).get("error") or detail
337
+ except (ValueError, AttributeError):
338
+ pass
339
+ return False, f"HTTP {e.code}: {detail}"
340
+ except Exception as e: # noqa: BLE001 — surface any transport failure verbatim
341
+ return False, str(e)
342
+
343
+
344
+ # ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
345
+ # <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
346
+ # holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
347
+ def add_work_dir_arg(ap):
348
+ ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
349
+ help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
350
+ "<repository>/.iKanban/scans)")
351
+
352
+
353
+ def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
354
+ scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
355
+ scans.mkdir(parents=True, exist_ok=True)
356
+ ignore = scans / ".gitignore"
357
+ if not ignore.exists():
358
+ ignore.write_text("*\n")
359
+ run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
360
+ run_dir.mkdir(mode=0o700, exist_ok=True)
361
+ os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
362
+ return run_dir
363
+
364
+
365
+ def write_private(path: Path, text: str) -> None:
366
+ """Write a file (0600) without following a symlink planted at its name: write a new
367
+ temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
368
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
369
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
370
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
371
+ f.write(text)
372
+ os.replace(tmp, path)
373
+
374
+
375
+ def slug(text: str) -> str:
376
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
377
+
378
+
379
+ # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
380
+ # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
381
+ # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
382
+ # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
383
+ _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)
384
+ # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
385
+ PIPE_RUN = "security-scan"
386
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
387
+ LAST_RUN = {"report": "", "detail": "", "skipped": False}
388
+
389
+
390
+ def add_pipeline_args(ap):
391
+ ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
392
+ help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
393
+ "or IKANBAN_TASK)")
394
+ ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
395
+ help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
396
+
397
+
398
+ def summary_detail(counts: dict, total: int, skipped: list) -> str:
399
+ if skipped and not total and all(s for s in skipped):
400
+ return ("SKIPPED: " + "; ".join(skipped))[:300]
401
+ worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
402
+ return f"{total} findings" + (f": {worst}" if worst else "")
403
+
404
+
405
+ def mask_report(text: str) -> str:
406
+ """The report is masked already; mask again before it leaves for Pipelines."""
407
+ scrub = globals().get("scrub_report")
408
+ if scrub:
409
+ return scrub(text)[0]
410
+ text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
411
+ home = str(Path.home())
412
+ return text.replace(home, "~") if home and home != "/" else text
413
+
414
+
415
+ def log_chunks(text: str) -> list:
416
+ """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
417
+ chunks, cur = [], ""
418
+ for line in text.splitlines(keepends=True):
419
+ while len(line.encode()) > PIPE_CHUNK:
420
+ head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
421
+ chunks.append(head)
422
+ line = line[len(head):]
423
+ if len((cur + line).encode()) > PIPE_CHUNK:
424
+ chunks.append(cur)
425
+ cur = ""
426
+ cur += line
427
+ if cur:
428
+ chunks.append(cur)
429
+ return chunks
430
+
431
+ # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
432
+ # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
433
+ # Progress lines are fixed status text: never raw tool output or an error body (it can carry
434
+ # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
435
+ PIPE = None
436
+
437
+
438
+ def progress(text: str) -> None:
439
+ if PIPE:
440
+ PIPE.log(text)
441
+
442
+
443
+
444
+ class Pipeline:
445
+ def __init__(self, stage, task, team, base_url, token):
446
+ self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
447
+ self.on = bool(task and team and token)
448
+ self.tid = None
449
+
450
+ def _off(self, why):
451
+ self.on = False
452
+ note = "the plan does not include Pipelines" if "402" in str(why) else why
453
+ print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
454
+
455
+ def _call(self, method, path, body=None):
456
+ try:
457
+ return api(method, path, self.base, self.token, body)
458
+ except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
459
+ return False, str(e)
460
+
461
+ def _post(self, path, body):
462
+ if not self.on:
463
+ return
464
+ if self.tid is None:
465
+ if _UUID.match(self.team):
466
+ self.tid = self.team
467
+ else:
468
+ ok, teams = self._call("GET", "/teams")
469
+ if not ok:
470
+ return self._off(teams)
471
+ found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
472
+ if not found:
473
+ return self._off(f"team {self.team} not found among your teams")
474
+ self.tid = found[0]
475
+ ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
476
+ if not ok:
477
+ self._off(data or "this iKanban server has no Pipelines")
478
+
479
+ def start(self):
480
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
481
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
482
+
483
+ def log(self, line):
484
+ """One progress line, sent as it happens (masked like the report). A failure only turns Pipelines off."""
485
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
486
+ "text": mask_report(line.rstrip("\n"))[:PIPE_CHUNK - 1] + "\n"})
487
+
488
+ def finish(self, code):
489
+ status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
490
+ detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
491
+ if code == 1 and not LAST_RUN["skipped"]:
492
+ detail = (detail + " (at or above --fail-on)").strip()
493
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
494
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
495
+ "detail": detail[:300] or None})
496
+ for chunk in log_chunks(mask_report(LAST_RUN["report"])):
497
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
498
+
499
+
500
+ def run_main(stage, main_fn, argv=None):
501
+ """main() as it is, wrapped in Pipelines events when --task is given."""
502
+ raw = sys.argv[1:] if argv is None else argv
503
+ if any(a in ("-h", "--help", "--version", "--list-apps", "--list-targets") for a in raw):
504
+ return main_fn(argv)
505
+ pre = argparse.ArgumentParser(add_help=False)
506
+ pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
507
+ pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
508
+ pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
509
+ pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
510
+ pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
511
+ known, _ = pre.parse_known_args(raw)
512
+ pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
513
+ pl.start()
514
+ global PIPE
515
+ PIPE = pl if pl.on else None
516
+ try:
517
+ code = main_fn(argv)
518
+ except SystemExit as e:
519
+ if not isinstance(e.code, int):
520
+ print(e.code, file=sys.stderr)
521
+ code = e.code if isinstance(e.code, int) else 2
522
+ pl.finish(code)
523
+ return code
524
+
525
+
526
+ def main(argv=None):
527
+ ap = argparse.ArgumentParser(description="Read-only AWS account audit (Prowler) that reports into iKanban.")
528
+ ap.add_argument("--version", action="version", version=f"ikanban cloudscan {CLOUDSCAN_VERSION}")
529
+ ap.add_argument("--aws-profile", required=True, help="The AWS CLI profile to audit with (read-only role)")
530
+ ap.add_argument("--region", required=True, help="The AWS region to audit, e.g. ap-southeast-1")
531
+ ap.add_argument("--account", required=True, help="The 12-digit account id the profile must belong to")
532
+ ap.add_argument("--services", nargs="+", default=SERVICES,
533
+ help=f"Prowler services to check (default: {' '.join(SERVICES)})")
534
+ ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
535
+ ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
536
+ help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
537
+ ap.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
538
+ ap.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""),
539
+ help="Personal API key (or VIBE_API_TOKEN)")
540
+ ap.add_argument("--out", default=None, help="Also write the report here")
541
+ ap.add_argument("--json", action="store_true", help="Machine-readable summary")
542
+ ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
543
+ help="Exit 1 if any finding is at or above this severity")
544
+ add_pipeline_args(ap)
545
+ add_work_dir_arg(ap)
546
+ args = ap.parse_args(argv)
547
+
548
+ why = check_args(args)
549
+ if why:
550
+ print(f"error: {why}", file=sys.stderr)
551
+ return 2
552
+ if args.publish and (not args.token or not args.team):
553
+ print("error: --publish needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID)", file=sys.stderr)
554
+ return 2
555
+ scope = f"aws:{args.account}:{args.region}"
556
+ progress(f"cloudscan {CLOUDSCAN_VERSION} · checking the AWS account for profile {args.aws_profile} …")
557
+ ident, why = caller(args.aws_profile)
558
+ if ident is not None and str(ident.get("Account")) != args.account:
559
+ print(f"error: profile {args.aws_profile} belongs to account {ident.get('Account')}, not {args.account}; "
560
+ "nothing was scanned", file=sys.stderr)
561
+ progress("account check … refused: the profile belongs to another account")
562
+ return 2
563
+ rows, skipped, ran = None, [], []
564
+ work = scan_work_dir("cloudscan", Path.cwd(), args.work_dir)
565
+ if ident is None:
566
+ skipped.append(why)
567
+ else:
568
+ role = role_name(str(ident.get("Arn", "")))
569
+ if not any(h in role.lower() for h in READ_ONLY_HINTS):
570
+ print(f"warning: role {role} has none of ReadOnly, SecurityAudit or ViewOnly in its name; Prowler only "
571
+ "reads, but write access cannot be ruled out. Use a read-only role.", file=sys.stderr)
572
+ creds, why = credentials(args.aws_profile)
573
+ if creds is None:
574
+ skipped.append(why)
575
+ else:
576
+ progress(f"Prowler {PROWLER_IMAGE.split(':')[-1]} in {args.region}: {' '.join(args.services)} …")
577
+ rows, why = run_prowler(args.region, args.services, creds, work)
578
+ if rows is None:
579
+ skipped.append(why)
580
+ else:
581
+ ran.append(f"prowler aws -f {args.region} -s {' '.join(args.services)}")
582
+ findings, rds = parse_rows(rows or [], args.region)
583
+ progress("Prowler … " + (f"done, {len(findings)} failed checks" if ran else "SKIPPED (the reason is in the report)"))
584
+ sections, notes = [], []
585
+ for kind, name, checks in KINDS:
586
+ keep, whole = section(kind, findings, checks)
587
+ if not whole:
588
+ notes.append(f"{name}: filed the {MAX_FILED} worst; the rest are in this report only "
589
+ "(this run counts as incomplete, so nothing closes).")
590
+ sections.append((kind, keep, whole))
591
+ md, counts, total = build_report(scope, findings, skipped, ran, rds, notes)
592
+ LAST_RUN.update(report=md, detail=summary_detail(counts, total, [] if ran else skipped), skipped=not ran)
593
+ write_private(work / "report.md", md)
594
+ if args.out:
595
+ Path(args.out).write_text(md)
596
+ if args.json:
597
+ print(json.dumps({"scope": scope, "total": total, "counts": counts, "rds_resources": rds,
598
+ "skipped": skipped}, indent=2))
599
+ else:
600
+ print(md)
601
+ sys.stdout.flush()
602
+ print(f"scan files: {work}", file=sys.stderr)
603
+
604
+ if args.publish:
605
+ ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
606
+ {"team_id": args.team, "title": f"Cloud account audit — {scope} — "
607
+ f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC", "content": md})
608
+ print(f"\npublished: {(info or {}).get('id', '')}" if ok else f"\npublish FAILED: {info}", file=sys.stderr)
609
+ if not ok:
610
+ return 2
611
+ if not ran:
612
+ print("Prowler did not run: nothing filed or closed", file=sys.stderr)
613
+ for kind, keep, whole in sections if ran else []:
614
+ db = kind != "cloud"
615
+ body = {"findings": findings_payload(kind, scope, keep), "scope": scope,
616
+ "complete": whole and (rds > 0 or not db), "kind": kind}
617
+ if db:
618
+ body["resources"] = rds
619
+ ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token, body)
620
+ if not ok:
621
+ print(f"filing {kind} findings FAILED: {data or 'this iKanban server does not take them yet'}",
622
+ file=sys.stderr)
623
+ return 2
624
+ print(f"filed {data.get('submitted', 0)} {kind} findings ({data.get('new', 0)} new, reopened "
625
+ f"{data.get('reopened', 0)}, closed {data.get('closed', 0)} no longer found in {scope})"
626
+ + ("; no RDS resource, so not counted as checked" if db and not rds else ""), file=sys.stderr)
627
+
628
+ if args.fail_on and any(SEVERITY_RANK.get(s, 99) <= SEVERITY_RANK[args.fail_on] and c
629
+ for s, c in counts.items()):
630
+ return 1
631
+ return 0
632
+
633
+
634
+ if __name__ == "__main__":
635
+ sys.exit(run_main("cloudscan", main))
@@ -23,6 +23,20 @@ JSON read (zaproxy.org traditional JSON report): site[].alerts[]: pluginid, aler
23
23
  (or alert), riskcode (a string: "3" High, "2" Medium, "1" Low, "0" Informational), desc,
24
24
  solution, reference, cweid, count, instances[].uri.
25
25
 
26
+ Plan 11 H14: --openapi FILE_OR_URL runs ZAP's API scan instead, from an OpenAPI description:
27
+ docker run --rm -v DIR:/zap/wrk/:rw [-v SPEC:/zap/spec/NAME:ro] [-e ZAP_AUTH_HEADER_VALUE
28
+ -e ZAP_AUTH_HEADER_SITE] ghcr.io/zaproxy/zaproxy:stable \
29
+ zap-api-scan.py -t SPEC -f openapi -J zap.json -T MAX_MINUTES [-S]
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.) The findings
32
+ file as kind "api". The spec is a URL on the registered target's own origin (downloaded here,
33
+ no redirect, at most 5 MB; ZAP never gets a URL) or a local JSON file. Every servers entry must be
34
+ that origin or a path starting with '/' (no templates or variables); a checked copy with each server
35
+ pinned to the target goes into the run folder, mounted read-only, and -O pins the host. The production iKanban API is
36
+ scanned only with --passive. A sign-in header comes from ZAP_AUTH_HEADER_VALUE (and
37
+ ZAP_AUTH_HEADER, default Authorization) in this shell; it reaches ZAP through the container's
38
+ environment only (zaproxy.org: handling authentication yourself), never a command line or file.
39
+
26
40
  Helpers below are copied from ikanban-secscan.py (the scanners ship as single files).
27
41
  """
28
42
 
@@ -42,7 +56,7 @@ import urllib.request
42
56
  from datetime import datetime, timezone
43
57
  from pathlib import Path
44
58
 
45
- DASTSCAN_VERSION = "1.2.0"
59
+ DASTSCAN_VERSION = "1.3.0"
46
60
 
47
61
  SEVERITIES = ["critical", "high", "medium", "low", "info"]
48
62
  SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
@@ -52,6 +66,9 @@ DOCKER_INSTALL = ("Docker is not installed or not running — install Docker Des
52
66
  "(https://docs.docker.com/get-docker/), start it, and re-run")
53
67
  CATEGORY = "A05 — Security Misconfiguration"
54
68
  MAX_URL = 2000
69
+ # Plan 11 H14 (owner): an active scan of production is refused; --passive may read it.
70
+ PRODUCTION_HOSTS = ("api.i-kanban.com",)
71
+ MAX_SPEC_BYTES = 5 * 1024 * 1024
55
72
 
56
73
 
57
74
  def check_url(url: str) -> str | None:
@@ -68,6 +85,127 @@ def check_url(url: str) -> str | None:
68
85
  return None
69
86
 
70
87
 
88
+ def origin(url: str) -> str:
89
+ u = urllib.parse.urlsplit(url)
90
+ return f"{u.scheme}://{(u.netloc or '').lower()}"
91
+
92
+
93
+ def server_problem(entry, target: str, passive: bool) -> str | None:
94
+ """Why one `servers` entry is refused, or None. Only an http(s) URL on the target's own
95
+ origin, or a path starting with a single '/'. No template, no variables, no user info."""
96
+ if not isinstance(entry, dict) or not isinstance(entry.get("url"), str):
97
+ return "is not a {url: ...} object"
98
+ url = entry["url"]
99
+ if "variables" in entry or "{" in url or "}" in url:
100
+ return f"{url!r} uses server variables or a template"
101
+ if url.startswith("/") and not url.startswith("//"):
102
+ return None
103
+ u = urllib.parse.urlsplit(url)
104
+ if u.scheme not in ("http", "https") or not u.hostname or u.username is not None or "@" in u.netloc:
105
+ return f"{url!r} is not a plain http(s) URL or a path starting with '/'"
106
+ if u.hostname in PRODUCTION_HOSTS and not passive:
107
+ return f"{url!r} is the production API; scan it only with --passive"
108
+ if origin(url) != origin(target):
109
+ return f"{url!r} is not {origin(target)}, the registered target"
110
+ return None
111
+
112
+
113
+ def walk(node, where="$"):
114
+ """Every (where, key, value) in the document, at any depth."""
115
+ if isinstance(node, dict):
116
+ for k, v in node.items():
117
+ yield f"{where}.{k}", k, v
118
+ yield from walk(v, f"{where}.{k}")
119
+ elif isinstance(node, list):
120
+ for n, v in enumerate(node):
121
+ yield from walk(v, f"{where}[{n}]")
122
+
123
+
124
+ BAD_PATH = re.compile(r"//|[@\\#?\s\x00-\x1f\x7f]")
125
+
126
+
127
+ def path_problem(key) -> str | None:
128
+ if not isinstance(key, str) or not key.startswith("/") or BAD_PATH.search(key):
129
+ return (f"path {key!r} must start with one '/' and hold no '//', '@', '\\', '#', '?', "
130
+ "spaces or control characters")
131
+ return None
132
+
133
+
134
+ def fetch_spec(url: str) -> tuple[bytes | None, str]:
135
+ """Download the spec ourselves: same origin checked by the caller, no redirect (_OPENER),
136
+ a 5 MB cap and a timeout. ZAP is never handed a URL to fetch."""
137
+ try:
138
+ with _OPENER.open(urllib.request.Request(url, headers={"Accept": "application/json"}), timeout=30) as r:
139
+ body = r.read(MAX_SPEC_BYTES + 1)
140
+ except urllib.error.HTTPError as e:
141
+ return None, f"--openapi: {url} answered HTTP {e.code} (a redirect is not followed)"
142
+ except Exception as e: # noqa: BLE001 — any transport failure stops the scan
143
+ return None, f"--openapi: could not download {url}: {type(e).__name__}"
144
+ return (None, "--openapi: the description is over 5 MB") if len(body) > MAX_SPEC_BYTES else (body, "")
145
+
146
+
147
+ def load_spec(spec: str, target: str, passive: bool) -> tuple[str | None, dict | None]:
148
+ """(why the OpenAPI description is refused, or None; the checked document with every server
149
+ pinned to the target). A URL must be on the target's origin and is downloaded here; a file
150
+ must be JSON. Every servers entry must pass server_problem."""
151
+ if spec.startswith(("http://", "https://")):
152
+ why = check_url(spec)
153
+ if why:
154
+ return f"--openapi: {why}", None
155
+ if origin(spec) != origin(target):
156
+ return f"--openapi must be on {origin(target)}, the registered target's own address", None
157
+ raw, why = fetch_spec(spec)
158
+ if raw is None:
159
+ return why, None
160
+ else:
161
+ p = Path(spec).expanduser()
162
+ if not p.is_file():
163
+ return f"--openapi: {spec} is not a file", None
164
+ if p.stat().st_size > MAX_SPEC_BYTES:
165
+ return "--openapi: the description is over 5 MB", None
166
+ raw = p.read_bytes()
167
+ try:
168
+ doc = json.loads(raw.decode("utf-8"))
169
+ except (ValueError, UnicodeDecodeError):
170
+ return "--openapi: write the OpenAPI description as JSON (openapi.json)", None
171
+ if not isinstance(doc, dict) or not str(doc.get("openapi", "")).startswith("3"):
172
+ return "--openapi: the description is not OpenAPI 3", None
173
+ # One walk over the whole document (security review): every $ref stays inside it, and every
174
+ # server object, wherever it sits (servers lists, links' server, callbacks), passes the
175
+ # origin rule. Relative servers are pinned to the target as origin + path.
176
+ entries = []
177
+ for where, key, value in list(walk(doc)):
178
+ if key == "$ref" and not (isinstance(value, str) and value.startswith("#/")):
179
+ return f"--openapi: {where} {value!r} points outside the document; only '#/...' references", None
180
+ if key == "servers":
181
+ if not isinstance(value, list):
182
+ return f"--openapi: {where} is not a list", None
183
+ entries += [(f"{where}[{n}]", e) for n, e in enumerate(value)]
184
+ elif key == "server":
185
+ entries.append((where, value))
186
+ for where, entry in entries:
187
+ why = server_problem(entry, target, passive)
188
+ if why:
189
+ return f"--openapi: {where} {why}", None
190
+ if entry["url"].startswith("/"):
191
+ entry["url"] = origin(target) + entry["url"].rstrip("/")
192
+ paths = doc.get("paths") or {}
193
+ if not isinstance(paths, dict):
194
+ return "--openapi: paths is not an object", None
195
+ for key in paths:
196
+ why = path_problem(key)
197
+ if why:
198
+ return f"--openapi: {why}", None
199
+ if not doc.get("servers"):
200
+ doc["servers"] = [{"url": origin(target)}]
201
+ # Last check: every server + path URL ZAP can build is on the registered origin.
202
+ for server in [e["url"] for _, e in entries] + [s["url"] for s in doc["servers"]]:
203
+ for key in list(paths) or ["/"]:
204
+ if origin(server + key) != origin(target):
205
+ return f"--openapi: {server}{key} leaves {origin(target)}", None
206
+ return None, doc
207
+
208
+
71
209
  def strip_html(s: str) -> str:
72
210
  return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s or "")).strip()
73
211
 
@@ -97,14 +235,14 @@ def parse_zap(report: dict) -> list:
97
235
  return out
98
236
 
99
237
 
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]
238
+ def finding_key(url: str, rule: str, title: str, kind: str = "dast") -> str:
239
+ """Stable per kind, target URL and ZAP rule, so a rescan files nothing new."""
240
+ return "dastscan-" + hashlib.sha256(f"{kind}|{url}|{rule}|{title}".encode()).hexdigest()[:16]
103
241
 
104
242
 
105
- def findings_payload(url: str, findings: list) -> list:
243
+ def findings_payload(url: str, findings: list, kind: str = "dast") -> list:
106
244
  return [{
107
- "finding_key": finding_key(url, f["rule"], f["title"]),
245
+ "finding_key": finding_key(url, f["rule"], f["title"], kind),
108
246
  "owasp_category": CATEGORY,
109
247
  "severity": f["severity"].capitalize(),
110
248
  "title": f["title"][:300],
@@ -114,18 +252,18 @@ def findings_payload(url: str, findings: list) -> list:
114
252
  } for f in findings]
115
253
 
116
254
 
117
- def build_report(url: str, findings: list, skipped: list, ran: list) -> tuple:
255
+ def build_report(url: str, findings: list, skipped: list, ran: list, mode: str = "ZAP baseline, passive") -> tuple:
118
256
  counts = {s: 0 for s in SEVERITIES}
119
257
  for f in findings:
120
258
  counts[f["severity"]] += 1
121
259
  total = sum(counts.values())
122
260
  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)_", "",
261
+ L = [f"# DAST scan — {url}", "", f"_{stamp} · `ikanban-dastscan` ({mode})_", "",
124
262
  "| Severity | Count |", "|---|---|"]
125
263
  L += [f"| {s} | {counts[s]} |" for s in SEVERITIES if counts[s]]
126
264
  if not total:
127
265
  L.append("| _none_ | 0 |")
128
- L += ["", f"**{total} finding(s).**", "", "## Passive web scan"]
266
+ L += ["", f"**{total} finding(s).**", "", "## Passive web scan" if "passive" in mode else "## API scan"]
129
267
  if ran:
130
268
  L.append(f"_ran: {', '.join(ran)}_")
131
269
  for s in skipped:
@@ -136,12 +274,17 @@ def build_report(url: str, findings: list, skipped: list, ran: list) -> tuple:
136
274
  L.append(f"- **[{f['severity']}]** {f['title']} — `{f['location']}` ({f['count']} place(s))")
137
275
  if f["detail"]:
138
276
  L.append(f" - {f['detail']}")
139
- L += ["", "---", "", "A passive scan reads responses only; it does not attack. Skipped checks are gaps, not passes."]
277
+ L += ["", "---", "", ("A passive scan reads responses only; it does not attack." if "passive" in mode else
278
+ "An active API scan sends attack requests to the registered target.")
279
+ + " Skipped checks are gaps, not passes."]
140
280
  return "\n".join(L), counts, total
141
281
 
142
282
 
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/."""
283
+ 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, "") or (None, why it did not run). ZAP writes zap.json into <work>/zap/.
286
+ With spec_file: the API scan (active unless passive), the sign-in header from this shell's
287
+ ZAP_AUTH_HEADER_VALUE passed by name only (`-e NAME`), its value in the process environment."""
145
288
  if not shutil.which("docker"):
146
289
  return None, DOCKER_INSTALL
147
290
  # The ZAP image writes as its own user, so it gets its own zap/ subfolder it may write to;
@@ -149,10 +292,27 @@ def run_zap(url: str, minutes: int, max_minutes: int, work: Path) -> tuple[dict
149
292
  zap_dir = work / "zap"
150
293
  zap_dir.mkdir(exist_ok=True)
151
294
  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)]
295
+ cmd, env = ["docker", "run", "--rm", "-v", f"{zap_dir}:/zap/wrk/:rw"], None
296
+ if spec_file is not None:
297
+ # The checked copy, read-only; -O pins every request to the registered host
298
+ # (zap-api-scan.py: "-O the hostname to override in the (remote) OpenAPI spec").
299
+ target = "/zap/spec/openapi.json"
300
+ cmd += ["-v", f"{spec_file}:{target}:ro"]
301
+ auth = os.environ.get("ZAP_AUTH_HEADER_VALUE", "")
302
+ if auth:
303
+ # Only what ZAP needs, plus PATH for docker itself: never VIBE_API_TOKEN or other keys.
304
+ env = {k: v for k, v in os.environ.items() if k in ("PATH", "HOME", "DOCKER_HOST", "DOCKER_CONFIG",
305
+ "DOCKER_CONTEXT", "ZAP_AUTH_HEADER")}
306
+ env.update(ZAP_AUTH_HEADER_VALUE=auth, ZAP_AUTH_HEADER_SITE=urllib.parse.urlsplit(url).hostname or "")
307
+ cmd += ["-e", "ZAP_AUTH_HEADER_VALUE", "-e", "ZAP_AUTH_HEADER_SITE"]
308
+ if "ZAP_AUTH_HEADER" in env:
309
+ cmd += ["-e", "ZAP_AUTH_HEADER"]
310
+ cmd += [ZAP_IMAGE, "zap-api-scan.py", "-t", target, "-f", "openapi", "-O", urllib.parse.urlsplit(url).netloc,
311
+ "-J", "zap.json", "-T", str(max_minutes)] + (["-S"] if passive else [])
312
+ else:
313
+ cmd += [ZAP_IMAGE, "zap-baseline.py", "-t", url, "-J", "zap.json", "-m", str(minutes), "-T", str(max_minutes)]
154
314
  try:
155
- p = subprocess.run(cmd, capture_output=True, text=True, timeout=(max_minutes + 5) * 60)
315
+ p = subprocess.run(cmd, capture_output=True, text=True, timeout=(max_minutes + 5) * 60, env=env)
156
316
  except FileNotFoundError:
157
317
  return None, DOCKER_INSTALL
158
318
  except subprocess.TimeoutExpired:
@@ -409,11 +569,17 @@ def run_main(stage, main_fn, argv=None):
409
569
 
410
570
 
411
571
  def main(argv=None):
412
- ap = argparse.ArgumentParser(description="Passive DAST scan (OWASP ZAP baseline) of a registered URL.")
572
+ ap = argparse.ArgumentParser(description="DAST scan of a registered URL: OWASP ZAP baseline (passive), "
573
+ "or with --openapi ZAP's API scan.")
413
574
  ap.add_argument("--version", action="version", version=f"ikanban dastscan {DASTSCAN_VERSION}")
414
575
  ap.add_argument("--url", help="The web app to scan: one of the team's registered DAST targets")
415
576
  ap.add_argument("--list-targets", action="store_true",
416
577
  help="Print the team's registered DAST targets (the URLs --url takes) and exit")
578
+ ap.add_argument("--openapi", default="", metavar="FILE_OR_URL",
579
+ help="Run ZAP's API scan (active) from this OpenAPI 3 description: a JSON file whose "
580
+ "servers are the --url origin, or a URL on that origin. Files as kind 'api'.")
581
+ ap.add_argument("--passive", action="store_true",
582
+ help="With --openapi: no attacks (ZAP -S); the only way to scan the production iKanban API")
417
583
  ap.add_argument("--minutes", type=int, default=1, help="Spider minutes (ZAP -m, default 1)")
418
584
  ap.add_argument("--max-minutes", type=int, default=10, help="Most minutes ZAP may take (ZAP -T, default 10)")
419
585
  ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
@@ -458,20 +624,46 @@ def main(argv=None):
458
624
  print("error: dastscan needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID) "
459
625
  "to check the URL is a registered target", file=sys.stderr)
460
626
  return 2
627
+ if args.passive and not args.openapi:
628
+ print("error: --passive goes with --openapi (the baseline scan is always passive)", file=sys.stderr)
629
+ return 2
630
+ kind = "api" if args.openapi else "dast"
631
+ if kind == "api" and urllib.parse.urlsplit(url).hostname in PRODUCTION_HOSTS and not args.passive:
632
+ print(f"error: an active scan of the production API ({url}) is refused; scan the dev API, "
633
+ "or add --passive", file=sys.stderr)
634
+ return 2
461
635
  why = registered(url, args)
462
636
  if why:
463
637
  print(f"error: {why}", file=sys.stderr)
464
638
  return 2
639
+ doc = None
640
+ if kind == "api":
641
+ # Checked (and, for a URL, downloaded) only once the target is known to be registered.
642
+ why, doc = load_spec(args.openapi.strip(), url, args.passive)
643
+ if why:
644
+ print(f"error: {why}", file=sys.stderr)
645
+ return 2
465
646
 
466
647
  work = scan_work_dir("dastscan", Path.cwd(), args.work_dir)
467
- progress(f"dastscan {DASTSCAN_VERSION} · ZAP baseline on {url}: spider {args.minutes} min, "
468
- f"at most {args.max_minutes} min …")
469
- report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work)
648
+ if kind == "api":
649
+ spec_file = work / "openapi.json"
650
+ write_private(spec_file, json.dumps(doc))
651
+ os.chmod(spec_file, 0o644) # ZAP reads it as its own user; the run folder above stays 0700
652
+ mode = "ZAP API scan, " + ("passive" if args.passive else "active")
653
+ progress(f"dastscan {DASTSCAN_VERSION} · {mode} on {url}: at most {args.max_minutes} min …")
654
+ report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work, spec_file, args.passive)
655
+ ran = [f"zap-api-scan.py -f openapi -O {urllib.parse.urlsplit(url).netloc}"
656
+ + (" -S" if args.passive else "")] if report is not None else []
657
+ else:
658
+ mode = "ZAP baseline, passive"
659
+ progress(f"dastscan {DASTSCAN_VERSION} · ZAP baseline on {url}: spider {args.minutes} min, "
660
+ f"at most {args.max_minutes} min …")
661
+ report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work)
662
+ ran = [f"zap-baseline.py -t {url} -m {args.minutes}"] if report is not None else []
470
663
  findings = parse_zap(report) if report is not None else []
471
- progress(f"ZAP baseline on {url} … " + (f"done, {len(findings)} alerts" if report is not None
472
- else "SKIPPED (the reason is in the report)"))
473
- ran = [f"zap-baseline.py -t {url} -m {args.minutes}"] if report is not None else []
474
- md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran)
664
+ progress(f"{mode.split(',')[0]} on {url} … " + (f"done, {len(findings)} alerts" if report is not None
665
+ else "SKIPPED (the reason is in the report)"))
666
+ md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran, mode)
475
667
  LAST_RUN.update(report=md, detail=summary_detail(counts, total, [] if ran else [skipped_why]),
476
668
  skipped=not ran)
477
669
  write_private(work / "report.md", md)
@@ -486,7 +678,7 @@ def main(argv=None):
486
678
 
487
679
  if args.publish:
488
680
  ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
489
- {"team_id": args.team, "title": f"DAST scan — {url} — "
681
+ {"team_id": args.team, "title": f"{'API scan' if kind == 'api' else 'DAST scan'} — {url} — "
490
682
  f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC", "content": md})
491
683
  print(f"\npublished: {(info or {}).get('id', '')}" if ok else f"\npublish FAILED: {info}",
492
684
  file=sys.stderr)
@@ -496,13 +688,13 @@ def main(argv=None):
496
688
  print("ZAP did not run: nothing filed or closed", file=sys.stderr)
497
689
  else:
498
690
  ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token,
499
- {"findings": findings_payload(url, findings), "scope": url, "complete": True,
500
- "kind": "dast"})
691
+ {"findings": findings_payload(url, findings, kind), "scope": url, "complete": True,
692
+ "kind": kind})
501
693
  if not ok:
502
- print(f"filing DAST findings FAILED: {data or 'this iKanban server does not take them yet'}",
694
+ print(f"filing {kind} findings FAILED: {data or 'this iKanban server does not take them yet'}",
503
695
  file=sys.stderr)
504
696
  return 2
505
- print(f"filed {data.get('submitted', 0)} dast findings ({data.get('new', 0)} new, reopened "
697
+ print(f"filed {data.get('submitted', 0)} {kind} findings ({data.get('new', 0)} new, reopened "
506
698
  f"{data.get('reopened', 0)}, closed {data.get('closed', 0)} no longer found at {url})",
507
699
  file=sys.stderr)
508
700