ikanban 0.4.5 → 0.4.7
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 +16 -1
- package/bin/ikanban.js +2 -1
- package/package.json +1 -1
- package/scanners/ikanban-cloudscan.py +635 -0
- package/scanners/ikanban-dastscan.py +227 -31
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: "
|
|
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.
|
|
3
|
+
"version": "0.4.7",
|
|
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.
|
|
59
|
+
DASTSCAN_VERSION = "1.3.1"
|
|
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"
|
|
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` (
|
|
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.
|
|
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
|
|
144
|
-
|
|
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",
|
|
153
|
-
|
|
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:
|
|
@@ -361,9 +521,12 @@ class Pipeline:
|
|
|
361
521
|
if not ok:
|
|
362
522
|
self._off(data or "this iKanban server has no Pipelines")
|
|
363
523
|
|
|
364
|
-
def start(self):
|
|
524
|
+
def start(self, url=""):
|
|
525
|
+
# Plan 11 H14: the URL as the running event's detail, so iKASecure's DAST targets list
|
|
526
|
+
# shows "Scanning…" on that target.
|
|
365
527
|
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
|
|
366
|
-
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"
|
|
528
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
529
|
+
"detail": url[:300] or None})
|
|
367
530
|
|
|
368
531
|
def log(self, line):
|
|
369
532
|
"""One progress line, sent as it happens (masked like the report). A failure only turns Pipelines off."""
|
|
@@ -393,9 +556,10 @@ def run_main(stage, main_fn, argv=None):
|
|
|
393
556
|
pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
|
|
394
557
|
pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
|
|
395
558
|
pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
|
|
559
|
+
pre.add_argument("--url", default="")
|
|
396
560
|
known, _ = pre.parse_known_args(raw)
|
|
397
561
|
pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
|
|
398
|
-
pl.start()
|
|
562
|
+
pl.start(known.url.strip())
|
|
399
563
|
global PIPE
|
|
400
564
|
PIPE = pl if pl.on else None
|
|
401
565
|
try:
|
|
@@ -409,11 +573,17 @@ def run_main(stage, main_fn, argv=None):
|
|
|
409
573
|
|
|
410
574
|
|
|
411
575
|
def main(argv=None):
|
|
412
|
-
ap = argparse.ArgumentParser(description="
|
|
576
|
+
ap = argparse.ArgumentParser(description="DAST scan of a registered URL: OWASP ZAP baseline (passive), "
|
|
577
|
+
"or with --openapi ZAP's API scan.")
|
|
413
578
|
ap.add_argument("--version", action="version", version=f"ikanban dastscan {DASTSCAN_VERSION}")
|
|
414
579
|
ap.add_argument("--url", help="The web app to scan: one of the team's registered DAST targets")
|
|
415
580
|
ap.add_argument("--list-targets", action="store_true",
|
|
416
581
|
help="Print the team's registered DAST targets (the URLs --url takes) and exit")
|
|
582
|
+
ap.add_argument("--openapi", default="", metavar="FILE_OR_URL",
|
|
583
|
+
help="Run ZAP's API scan (active) from this OpenAPI 3 description: a JSON file whose "
|
|
584
|
+
"servers are the --url origin, or a URL on that origin. Files as kind 'api'.")
|
|
585
|
+
ap.add_argument("--passive", action="store_true",
|
|
586
|
+
help="With --openapi: no attacks (ZAP -S); the only way to scan the production iKanban API")
|
|
417
587
|
ap.add_argument("--minutes", type=int, default=1, help="Spider minutes (ZAP -m, default 1)")
|
|
418
588
|
ap.add_argument("--max-minutes", type=int, default=10, help="Most minutes ZAP may take (ZAP -T, default 10)")
|
|
419
589
|
ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
|
|
@@ -458,20 +628,46 @@ def main(argv=None):
|
|
|
458
628
|
print("error: dastscan needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID) "
|
|
459
629
|
"to check the URL is a registered target", file=sys.stderr)
|
|
460
630
|
return 2
|
|
631
|
+
if args.passive and not args.openapi:
|
|
632
|
+
print("error: --passive goes with --openapi (the baseline scan is always passive)", file=sys.stderr)
|
|
633
|
+
return 2
|
|
634
|
+
kind = "api" if args.openapi else "dast"
|
|
635
|
+
if kind == "api" and urllib.parse.urlsplit(url).hostname in PRODUCTION_HOSTS and not args.passive:
|
|
636
|
+
print(f"error: an active scan of the production API ({url}) is refused; scan the dev API, "
|
|
637
|
+
"or add --passive", file=sys.stderr)
|
|
638
|
+
return 2
|
|
461
639
|
why = registered(url, args)
|
|
462
640
|
if why:
|
|
463
641
|
print(f"error: {why}", file=sys.stderr)
|
|
464
642
|
return 2
|
|
643
|
+
doc = None
|
|
644
|
+
if kind == "api":
|
|
645
|
+
# Checked (and, for a URL, downloaded) only once the target is known to be registered.
|
|
646
|
+
why, doc = load_spec(args.openapi.strip(), url, args.passive)
|
|
647
|
+
if why:
|
|
648
|
+
print(f"error: {why}", file=sys.stderr)
|
|
649
|
+
return 2
|
|
465
650
|
|
|
466
651
|
work = scan_work_dir("dastscan", Path.cwd(), args.work_dir)
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
652
|
+
if kind == "api":
|
|
653
|
+
spec_file = work / "openapi.json"
|
|
654
|
+
write_private(spec_file, json.dumps(doc))
|
|
655
|
+
os.chmod(spec_file, 0o644) # ZAP reads it as its own user; the run folder above stays 0700
|
|
656
|
+
mode = "ZAP API scan, " + ("passive" if args.passive else "active")
|
|
657
|
+
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)
|
|
659
|
+
ran = [f"zap-api-scan.py -f openapi -O {urllib.parse.urlsplit(url).netloc}"
|
|
660
|
+
+ (" -S" if args.passive else "")] if report is not None else []
|
|
661
|
+
else:
|
|
662
|
+
mode = "ZAP baseline, passive"
|
|
663
|
+
progress(f"dastscan {DASTSCAN_VERSION} · ZAP baseline on {url}: spider {args.minutes} min, "
|
|
664
|
+
f"at most {args.max_minutes} min …")
|
|
665
|
+
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work)
|
|
666
|
+
ran = [f"zap-baseline.py -t {url} -m {args.minutes}"] if report is not None else []
|
|
470
667
|
findings = parse_zap(report) if report is not None else []
|
|
471
|
-
progress(f"
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran)
|
|
668
|
+
progress(f"{mode.split(',')[0]} on {url} … " + (f"done, {len(findings)} alerts" if report is not None
|
|
669
|
+
else "SKIPPED (the reason is in the report)"))
|
|
670
|
+
md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran, mode)
|
|
475
671
|
LAST_RUN.update(report=md, detail=summary_detail(counts, total, [] if ran else [skipped_why]),
|
|
476
672
|
skipped=not ran)
|
|
477
673
|
write_private(work / "report.md", md)
|
|
@@ -486,7 +682,7 @@ def main(argv=None):
|
|
|
486
682
|
|
|
487
683
|
if args.publish:
|
|
488
684
|
ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
|
|
489
|
-
{"team_id": args.team, "title": f"DAST scan — {url} — "
|
|
685
|
+
{"team_id": args.team, "title": f"{'API scan' if kind == 'api' else 'DAST scan'} — {url} — "
|
|
490
686
|
f"{datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC", "content": md})
|
|
491
687
|
print(f"\npublished: {(info or {}).get('id', '')}" if ok else f"\npublish FAILED: {info}",
|
|
492
688
|
file=sys.stderr)
|
|
@@ -496,13 +692,13 @@ def main(argv=None):
|
|
|
496
692
|
print("ZAP did not run: nothing filed or closed", file=sys.stderr)
|
|
497
693
|
else:
|
|
498
694
|
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":
|
|
695
|
+
{"findings": findings_payload(url, findings, kind), "scope": url, "complete": True,
|
|
696
|
+
"kind": kind})
|
|
501
697
|
if not ok:
|
|
502
|
-
print(f"filing
|
|
698
|
+
print(f"filing {kind} findings FAILED: {data or 'this iKanban server does not take them yet'}",
|
|
503
699
|
file=sys.stderr)
|
|
504
700
|
return 2
|
|
505
|
-
print(f"filed {data.get('submitted', 0)}
|
|
701
|
+
print(f"filed {data.get('submitted', 0)} {kind} findings ({data.get('new', 0)} new, reopened "
|
|
506
702
|
f"{data.get('reopened', 0)}, closed {data.get('closed', 0)} no longer found at {url})",
|
|
507
703
|
file=sys.stderr)
|
|
508
704
|
|