ikanban 0.3.4 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -0
- package/bin/ikanban.js +5 -2
- package/package.json +1 -1
- package/scanners/ikanban-aiscan.py +279 -68
- package/scanners/ikanban-dastscan.py +491 -0
- package/scanners/ikanban-infrascan.py +790 -0
- package/scanners/ikanban-secscan.py +173 -4
|
@@ -0,0 +1,790 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
ikanban-infrascan — container image and IaC (infrastructure as code) scan of YOUR OWN
|
|
4
|
+
repository and images, reporting into iKanban's Infrastructure tab (plan 11 H12).
|
|
5
|
+
|
|
6
|
+
npx ikanban infrascan # inventory + IaC scan of cwd
|
|
7
|
+
npx ikanban infrascan --path . --path ../infra # infra in another folder or repo too
|
|
8
|
+
npx ikanban infrascan --image myapp:latest # also scan a container image
|
|
9
|
+
npx ikanban infrascan --publish --team TEAM # file the report and its findings
|
|
10
|
+
|
|
11
|
+
Plan 11 H12-T10 (owner: assume messy development). It starts with an inventory of each path:
|
|
12
|
+
what infrastructure it found, what Trivy scanned, and what it could not scan and why. Infra
|
|
13
|
+
written as code (CDK, Pulumi, CDK for Terraform, Vagrant, Ansible, cloud-SDK scripts) is listed
|
|
14
|
+
as found but not scannable. Images named in docker-compose files are scanned too. A path with no
|
|
15
|
+
infrastructure-as-code file is never reported complete, so its control stays Missing.
|
|
16
|
+
Raw Trivy JSON and the report go to <first path>/.iKanban/scans/infrascan-<UTC time>/ (or
|
|
17
|
+
--work-dir / IKANBAN_SCAN_DIR); that folder ignores itself in git.
|
|
18
|
+
|
|
19
|
+
It runs Trivy (Apache-2.0, https://github.com/aquasecurity/trivy), which must already be
|
|
20
|
+
installed; a missing Trivy is reported as SKIPPED with the install lines, never as a clean
|
|
21
|
+
scan, and nothing is filed for a check that did not run.
|
|
22
|
+
IaC trivy fs --scanners misconfig --format json --output FILE PATH
|
|
23
|
+
Container trivy image --format json --output FILE IMAGE
|
|
24
|
+
(trivy.dev docs: "trivy fs --scanners misconfig /path/to/dir"; reporting docs:
|
|
25
|
+
"trivy image --format json --output result.json debian:12".)
|
|
26
|
+
|
|
27
|
+
JSON read (pkg/types/report.go and misconfiguration.go in the Trivy source):
|
|
28
|
+
Results[].Target
|
|
29
|
+
Results[].Misconfigurations[]: ID, AVDID, Title, Message, Resolution, Severity,
|
|
30
|
+
PrimaryURL, Status ("PASS" | "FAIL" | "EXCEPTION"), CauseMetadata.StartLine
|
|
31
|
+
Results[].Vulnerabilities[]: VulnerabilityID, PkgName, InstalledVersion, FixedVersion,
|
|
32
|
+
Severity, Title, PrimaryURL
|
|
33
|
+
|
|
34
|
+
Helpers below are copied from ikanban-secscan.py (the scanners ship as single files).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import argparse
|
|
40
|
+
import hashlib
|
|
41
|
+
import json
|
|
42
|
+
import os
|
|
43
|
+
import re
|
|
44
|
+
import shutil
|
|
45
|
+
import subprocess
|
|
46
|
+
import sys
|
|
47
|
+
import urllib.error
|
|
48
|
+
import urllib.parse
|
|
49
|
+
import urllib.request
|
|
50
|
+
from dataclasses import dataclass, field
|
|
51
|
+
from datetime import datetime, timezone
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
|
|
54
|
+
INFRASCAN_VERSION = "1.1.0"
|
|
55
|
+
|
|
56
|
+
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
57
|
+
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
58
|
+
# Trivy's severities; UNKNOWN (no score yet) is filed as low.
|
|
59
|
+
TRIVY_SEVERITY = {"CRITICAL": "critical", "HIGH": "high", "MEDIUM": "medium", "LOW": "low", "UNKNOWN": "low"}
|
|
60
|
+
|
|
61
|
+
TRIVY_INSTALL = ("Trivy is not installed — macOS: `brew install trivy`; Linux and Windows: a release "
|
|
62
|
+
"binary from https://github.com/aquasecurity/trivy/releases, or `docker run aquasec/trivy`")
|
|
63
|
+
|
|
64
|
+
CATEGORY = {"iac": "A05 — Security Misconfiguration",
|
|
65
|
+
"container": "A06 — Vulnerable and Outdated Components"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Finding:
|
|
70
|
+
severity: str
|
|
71
|
+
title: str
|
|
72
|
+
detail: str
|
|
73
|
+
location: str = ""
|
|
74
|
+
line: str | None = None
|
|
75
|
+
rule: str = ""
|
|
76
|
+
remediation: str = ""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class Section:
|
|
81
|
+
name: str
|
|
82
|
+
kind: str
|
|
83
|
+
scope: str
|
|
84
|
+
findings: list = field(default_factory=list)
|
|
85
|
+
skipped: list = field(default_factory=list)
|
|
86
|
+
ran: list = field(default_factory=list)
|
|
87
|
+
complete: bool = False
|
|
88
|
+
filed: list | None = None # what publish sends; None = every finding (plan_filing sets it)
|
|
89
|
+
notes: list = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# The server takes at most this many findings in one publish (security_scan_findings.rs).
|
|
93
|
+
MAX_FILED = 500
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def plan_filing(sec: Section) -> None:
|
|
97
|
+
"""Plan 11 H12-T10 review: container images file critical and high only (the rest stay in
|
|
98
|
+
the report); any section over MAX_FILED files the worst MAX_FILED and is published
|
|
99
|
+
incomplete, so the rescan never closes findings that were only cut."""
|
|
100
|
+
keep = sec.findings
|
|
101
|
+
if sec.kind == "container":
|
|
102
|
+
keep = [f for f in keep if f.severity in ("critical", "high")]
|
|
103
|
+
if len(keep) < len(sec.findings):
|
|
104
|
+
sec.notes.append(f"{len(sec.findings) - len(keep)} medium and low findings are in this report only; "
|
|
105
|
+
"iKanban files critical and high for container images.")
|
|
106
|
+
if len(keep) > MAX_FILED:
|
|
107
|
+
keep = sorted(keep, key=lambda f: (SEVERITY_RANK.get(f.severity, 99), f.title))
|
|
108
|
+
sec.notes.append(f"Filed the {MAX_FILED} worst; {len(keep) - MAX_FILED} more were left out of iKanban "
|
|
109
|
+
"(this run counts as incomplete, so nothing closes).")
|
|
110
|
+
keep = keep[:MAX_FILED]
|
|
111
|
+
sec.complete = False
|
|
112
|
+
sec.filed = keep
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def run(cmd, cwd=None, timeout=900):
|
|
116
|
+
"""Run a command, returning (ok, stdout, stderr). Never raises."""
|
|
117
|
+
try:
|
|
118
|
+
p = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
|
119
|
+
return p.returncode == 0, p.stdout, p.stderr
|
|
120
|
+
except FileNotFoundError:
|
|
121
|
+
return False, "", "not-installed"
|
|
122
|
+
except subprocess.TimeoutExpired:
|
|
123
|
+
return False, "", f"timed out after {timeout}s"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def trivy_json(args: list, target: str, out: Path) -> tuple[dict | None, str]:
|
|
127
|
+
"""Run `trivy SUBCOMMAND [ARGS] -- TARGET` with --format json into `out`; (report, "") or
|
|
128
|
+
(None, why skipped). Plan 11 H12-T10 review: trivy runs from the scan's work folder with a
|
|
129
|
+
scanner-owned --config and --ignorefile (both default to files in the working folder,
|
|
130
|
+
trivy.dev CLI reference: `-c, --config ... (default "trivy.yaml")`, `--ignorefile ...
|
|
131
|
+
(default ".trivyignore")`), so a repository's trivy.yaml or .trivyignore changes nothing.
|
|
132
|
+
The target comes after `--`, so a name starting with '-' can never be read as a flag."""
|
|
133
|
+
if not shutil.which("trivy"):
|
|
134
|
+
return None, TRIVY_INSTALL
|
|
135
|
+
work = out.parent
|
|
136
|
+
conf, ignore = work / "trivy-config.yaml", work / "trivyignore"
|
|
137
|
+
if not conf.exists():
|
|
138
|
+
write_private(conf, "{}\n")
|
|
139
|
+
if not ignore.exists():
|
|
140
|
+
write_private(ignore, "")
|
|
141
|
+
ok, _o, err = run(["trivy", *args[:1], "--config", str(conf), "--ignorefile", str(ignore),
|
|
142
|
+
"--format", "json", "--output", str(out), *args[1:], "--", target], cwd=work)
|
|
143
|
+
if not ok or not out.exists():
|
|
144
|
+
return None, f"trivy {args[0]} failed: {(err or '').strip()[-300:] or 'no output'}"
|
|
145
|
+
try:
|
|
146
|
+
return json.loads(out.read_text() or "{}"), ""
|
|
147
|
+
except ValueError:
|
|
148
|
+
return None, f"trivy {args[0]} wrote no JSON"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def parse_misconfigurations(report: dict) -> list:
|
|
152
|
+
"""Failed checks only; PASS and EXCEPTION are not findings."""
|
|
153
|
+
out = []
|
|
154
|
+
for res in report.get("Results") or []:
|
|
155
|
+
target = res.get("Target") or ""
|
|
156
|
+
for m in res.get("Misconfigurations") or []:
|
|
157
|
+
if m.get("Status") not in (None, "FAIL"):
|
|
158
|
+
continue
|
|
159
|
+
line = (m.get("CauseMetadata") or {}).get("StartLine")
|
|
160
|
+
rule = m.get("AVDID") or m.get("ID") or ""
|
|
161
|
+
fix = " ".join(x for x in (m.get("Resolution"), m.get("PrimaryURL")) if x)
|
|
162
|
+
out.append(Finding(
|
|
163
|
+
severity=TRIVY_SEVERITY.get((m.get("Severity") or "").upper(), "low"),
|
|
164
|
+
title=f"{m.get('Title') or rule}",
|
|
165
|
+
detail=m.get("Message") or "",
|
|
166
|
+
location=target, line=str(line) if line else None, rule=rule,
|
|
167
|
+
remediation=fix or "Change the configuration as the check describes."))
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_vulnerabilities(report: dict, image: str) -> list:
|
|
172
|
+
out = []
|
|
173
|
+
for res in report.get("Results") or []:
|
|
174
|
+
target = res.get("Target") or image
|
|
175
|
+
for v in res.get("Vulnerabilities") or []:
|
|
176
|
+
pkg, have, fixed = v.get("PkgName") or "", v.get("InstalledVersion") or "", v.get("FixedVersion")
|
|
177
|
+
vid = v.get("VulnerabilityID") or ""
|
|
178
|
+
fix = f"Upgrade {pkg} to {fixed}" if fixed else f"No fixed version of {pkg} yet; use another base image or package"
|
|
179
|
+
if v.get("PrimaryURL"):
|
|
180
|
+
fix += f". {v['PrimaryURL']}"
|
|
181
|
+
out.append(Finding(
|
|
182
|
+
severity=TRIVY_SEVERITY.get((v.get("Severity") or "").upper(), "low"),
|
|
183
|
+
title=f"{pkg} {have}: {vid}" + (f" — {v['Title']}" if v.get("Title") else ""),
|
|
184
|
+
detail="", location=f"{image} ({target})", rule=f"{vid}|{pkg}", remediation=fix))
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def scan_iac(root: Path, scope: str, inv: dict, work: Path) -> Section:
|
|
189
|
+
sec = Section(f"IaC misconfiguration — {scope}", "iac", scope)
|
|
190
|
+
if not any(inv[c] for c in TRIVY_IAC):
|
|
191
|
+
# Owner: no IaC file found is not a pass. complete stays False: the control stays Missing.
|
|
192
|
+
sec.skipped.append(f"No infrastructure-as-code files found in {root} — if the infrastructure lives in "
|
|
193
|
+
"another folder or repository, add it with another --path")
|
|
194
|
+
return sec
|
|
195
|
+
# ponytail: trivy.dev does not say what --skip-dirs is relative to with an absolute target;
|
|
196
|
+
# the absolute and the ** form are both passed. A miss only means a vendored folder is read too.
|
|
197
|
+
skip = [x for d in SKIP_DIRS for x in ("--skip-dirs", f"{root.resolve()}/{d}", "--skip-dirs", f"**/{d}")]
|
|
198
|
+
report, why = trivy_json(["fs", "--scanners", "misconfig", *skip], str(root.resolve()),
|
|
199
|
+
work / f"trivy-iac-{slug(scope)}.json")
|
|
200
|
+
if report is None:
|
|
201
|
+
sec.skipped.append(why)
|
|
202
|
+
return sec
|
|
203
|
+
sec.ran.append(f"trivy fs --scanners misconfig {root.name}")
|
|
204
|
+
sec.findings = parse_misconfigurations(report)
|
|
205
|
+
for f in sec.findings: # paths relative to the repository, like secscan
|
|
206
|
+
f.location = f.location.removeprefix(str(root) + os.sep).removeprefix(str(root.resolve()) + os.sep)
|
|
207
|
+
sec.complete = True
|
|
208
|
+
return sec
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def scan_image(image: str, work: Path) -> Section:
|
|
212
|
+
sec = Section(f"Container image {image}", "container", image)
|
|
213
|
+
if not valid_image_ref(image):
|
|
214
|
+
sec.skipped.append(f"`{image[:80]}` is not a valid image name; not scanned")
|
|
215
|
+
return sec
|
|
216
|
+
report, why = trivy_json(["image"], image, work / f"trivy-image-{slug(image)}.json")
|
|
217
|
+
if report is None:
|
|
218
|
+
sec.skipped.append(why)
|
|
219
|
+
return sec
|
|
220
|
+
sec.ran.append(f"trivy image {image}")
|
|
221
|
+
sec.findings = parse_vulnerabilities(report, image)
|
|
222
|
+
sec.complete = True
|
|
223
|
+
return sec
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── Inventory (plan 11 H12-T10) ───────────────────────────────────────────────────────
|
|
227
|
+
# What Trivy's misconfiguration scanner reads, from trivy.dev "IaC" coverage (Kubernetes,
|
|
228
|
+
# Docker: Dockerfile/Containerfile, Terraform: *.tf *.tf.json, CloudFormation, Azure ARM,
|
|
229
|
+
# Helm). docker-compose, Ansible, Bicep, CDK, Pulumi and Vagrant are not in that table.
|
|
230
|
+
TRIVY_IAC = ("Dockerfile", "Kubernetes manifest", "Helm chart", "Terraform", "CloudFormation", "Azure ARM template")
|
|
231
|
+
NOT_SCANNABLE = {
|
|
232
|
+
"Dockerfile with another name": "Trivy reads files named Dockerfile or Containerfile; this one may not be checked",
|
|
233
|
+
"docker-compose file": "its images are scanned as containers; the compose settings themselves are not checked",
|
|
234
|
+
"AWS CDK app": "run `npx cdk synth` and rescan: the templates in cdk.out are scanned as CloudFormation",
|
|
235
|
+
"CDK for Terraform app": "run `cdktf synth` and rescan: the Terraform JSON in cdktf.out is scanned",
|
|
236
|
+
"Pulumi program": "found, not scannable: review by hand or audit the cloud account",
|
|
237
|
+
"Vagrant": "found, not scannable: provisioning by script; review by hand",
|
|
238
|
+
"Ansible playbook": "found, not scannable by Trivy: review by hand",
|
|
239
|
+
"Azure Bicep": "run `az bicep build` and rescan: the ARM JSON it writes is scanned",
|
|
240
|
+
"Cloud SDK code": "found, not scannable: infrastructure created in code; review by hand or audit the cloud account",
|
|
241
|
+
}
|
|
242
|
+
# Imports that mean code talks to a cloud API (examples; the real set is broader).
|
|
243
|
+
SDK_MARKERS = ("import boto3", "from boto3", "from botocore", "@aws-sdk/", "'aws-sdk'", '"aws-sdk"',
|
|
244
|
+
"github.com/aws/aws-sdk-go", "software.amazon.awssdk", "from google.cloud",
|
|
245
|
+
"from google.cloud import", "@google-cloud/", "cloud.google.com/go/", "azure.mgmt",
|
|
246
|
+
"@azure/arm-", "github.com/Azure/azure-sdk-for-go")
|
|
247
|
+
SDK_EXTS = {".py", ".js", ".mjs", ".cjs", ".ts", ".go", ".java", ".kt", ".cs", ".rb"}
|
|
248
|
+
SKIP_DIRS = (".git", "node_modules", "vendor", "dist", "build", ".venv", "venv", "target", ".iKanban/scans",
|
|
249
|
+
".terraform", "__pycache__")
|
|
250
|
+
MAX_READ = 512 * 1024
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _head(p: Path) -> str:
|
|
254
|
+
try:
|
|
255
|
+
with p.open("rb") as f:
|
|
256
|
+
return f.read(MAX_READ).decode("utf-8", "ignore")
|
|
257
|
+
except OSError:
|
|
258
|
+
return ""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _skipped(rel: Path) -> bool:
|
|
262
|
+
parts = rel.parts
|
|
263
|
+
return any(d in parts for d in SKIP_DIRS if "/" not in d) or rel.as_posix().startswith(".iKanban/scans")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def inventory(root: Path) -> dict:
|
|
267
|
+
"""Infra files under root, by kind (paths relative to root). Never raises."""
|
|
268
|
+
inv = {k: [] for k in (*TRIVY_IAC, *NOT_SCANNABLE)}
|
|
269
|
+
files = []
|
|
270
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
271
|
+
rel_dir = Path(dirpath).relative_to(root)
|
|
272
|
+
dirnames[:] = [d for d in dirnames if not _skipped(rel_dir / d)]
|
|
273
|
+
files += [rel_dir / f for f in filenames]
|
|
274
|
+
charts = {f.parent for f in files if f.name == "Chart.yaml"}
|
|
275
|
+
inv["Helm chart"] = sorted(c.as_posix() or "." for c in charts)
|
|
276
|
+
for rel in sorted(files):
|
|
277
|
+
name, low = rel.name, rel.name.lower()
|
|
278
|
+
posix = rel.as_posix()
|
|
279
|
+
in_chart = any(c == rel.parent or c in rel.parents for c in charts)
|
|
280
|
+
if name in ("Dockerfile", "Containerfile"):
|
|
281
|
+
inv["Dockerfile"].append(posix)
|
|
282
|
+
elif low.endswith(".dockerfile") or low.startswith("dockerfile."):
|
|
283
|
+
inv["Dockerfile with another name"].append(posix)
|
|
284
|
+
elif re.match(r"^(docker-)?compose[\w.-]*\.ya?ml$", low):
|
|
285
|
+
inv["docker-compose file"].append(posix)
|
|
286
|
+
elif low.endswith((".tf", ".tf.json")):
|
|
287
|
+
inv["Terraform"].append(posix)
|
|
288
|
+
elif name == "cdk.json":
|
|
289
|
+
inv["AWS CDK app"].append(posix)
|
|
290
|
+
elif name == "cdktf.json":
|
|
291
|
+
inv["CDK for Terraform app"].append(posix)
|
|
292
|
+
elif name in ("Pulumi.yaml", "Pulumi.yml"):
|
|
293
|
+
inv["Pulumi program"].append(posix)
|
|
294
|
+
elif name == "Vagrantfile":
|
|
295
|
+
inv["Vagrant"].append(posix)
|
|
296
|
+
elif low.endswith(".bicep"):
|
|
297
|
+
inv["Azure Bicep"].append(posix)
|
|
298
|
+
elif low.endswith((".yaml", ".yml", ".json")) and not in_chart:
|
|
299
|
+
text = _head(root / rel)
|
|
300
|
+
if "AWSTemplateFormatVersion" in text or re.search(r"""["']?Type["']?\s*:\s*["']?AWS::""", text):
|
|
301
|
+
inv["CloudFormation"].append(posix)
|
|
302
|
+
elif low.endswith(".json") and "deploymentTemplate.json" in text:
|
|
303
|
+
inv["Azure ARM template"].append(posix)
|
|
304
|
+
elif re.search(r"(?m)^apiVersion:\s*\S", text) and re.search(r"(?m)^kind:\s*\S", text):
|
|
305
|
+
inv["Kubernetes manifest"].append(posix)
|
|
306
|
+
elif re.search(r"(?m)^-\s+(name:.*\n\s+)?hosts:\s*\S", text) and re.search(r"(?m)^\s+(tasks|roles):", text):
|
|
307
|
+
inv["Ansible playbook"].append(posix)
|
|
308
|
+
elif rel.suffix in SDK_EXTS:
|
|
309
|
+
text = _head(root / rel)
|
|
310
|
+
if any(m in text for m in SDK_MARKERS):
|
|
311
|
+
inv["Cloud SDK code"].append(posix)
|
|
312
|
+
return inv
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def cdk_note(inv: dict) -> str:
|
|
316
|
+
"""CDK synth output (cdk.out) holds CloudFormation templates: say they were scanned."""
|
|
317
|
+
synthed = [p for p in inv["CloudFormation"] if p.split("/")[-2:-1] == ["cdk.out"] or "/cdk.out/" in f"/{p}"]
|
|
318
|
+
return "synthesized templates in cdk.out are scanned as CloudFormation" if synthed else NOT_SCANNABLE["AWS CDK app"]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# Plan 11 H12-T10 review: compose `image:` values come from the repository, so only a plain
|
|
322
|
+
# image reference (registry[:port]/path:tag@sha256:digest, lower case, no leading '-') may
|
|
323
|
+
# reach trivy's command line.
|
|
324
|
+
IMAGE_REF = re.compile(
|
|
325
|
+
r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?::[0-9]{1,5})?" # registry host or first path part
|
|
326
|
+
r"(?:/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)*" # more path parts
|
|
327
|
+
r"(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?" # tag
|
|
328
|
+
r"(?:@sha256:[a-f0-9]{64})?$")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def valid_image_ref(ref: str) -> bool:
|
|
332
|
+
return 0 < len(ref) <= 255 and ".." not in ref and bool(IMAGE_REF.match(ref))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
IMAGE_LINE = re.compile(r"""^\s*image:\s*["']?([^"'\s#]+)["']?\s*(#.*)?$""")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def compose_images(root: Path, inv: dict) -> tuple[list, list, list]:
|
|
339
|
+
"""(images named in compose files, names with ${VARS}, names that are not a valid image)."""
|
|
340
|
+
images, unresolved, rejected = [], [], []
|
|
341
|
+
for rel in inv["docker-compose file"]:
|
|
342
|
+
for line in _head(root / rel).splitlines():
|
|
343
|
+
m = IMAGE_LINE.match(line)
|
|
344
|
+
if not m:
|
|
345
|
+
continue
|
|
346
|
+
name = m.group(1)
|
|
347
|
+
(unresolved if "$" in name else images if valid_image_ref(name) else rejected).append(name)
|
|
348
|
+
return sorted(set(images)), sorted(set(unresolved)), sorted(set(rejected))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def inventory_rows(inv: dict, scanned_iac: bool, iac_why: str, rejected: list = ()) -> list:
|
|
352
|
+
"""Markdown table rows: what | found | scanned | note."""
|
|
353
|
+
rows = []
|
|
354
|
+
if rejected:
|
|
355
|
+
shown = ", ".join(f"`{r[:60]}`" for r in rejected[:5])
|
|
356
|
+
rows.append(f"| Image name in a compose file | {len(rejected)}: {shown} | no | not a valid image name, "
|
|
357
|
+
"so it was not passed to Trivy; check the compose file |")
|
|
358
|
+
for kind, paths in inv.items():
|
|
359
|
+
if not paths:
|
|
360
|
+
continue
|
|
361
|
+
shown = ", ".join(f"`{p}`" for p in paths[:5]) + (f" (+{len(paths) - 5} more)" if len(paths) > 5 else "")
|
|
362
|
+
if kind in TRIVY_IAC:
|
|
363
|
+
state, note = ("yes", "Trivy misconfiguration checks") if scanned_iac else ("no", iac_why)
|
|
364
|
+
elif kind == "AWS CDK app":
|
|
365
|
+
note = cdk_note(inv)
|
|
366
|
+
state = "yes (cdk.out)" if note.startswith("synthesized") else "no"
|
|
367
|
+
elif kind == "docker-compose file":
|
|
368
|
+
state, note = "images only", NOT_SCANNABLE[kind]
|
|
369
|
+
else:
|
|
370
|
+
state, note = "no", NOT_SCANNABLE[kind]
|
|
371
|
+
rows.append(f"| {kind} | {len(paths)}: {shown} | {state} | {note} |")
|
|
372
|
+
return rows
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def build_report(title: str, sections: list) -> tuple:
|
|
376
|
+
counts = {s: 0 for s in SEVERITIES}
|
|
377
|
+
for sec in sections:
|
|
378
|
+
for f in sec.findings:
|
|
379
|
+
counts[f.severity] = counts.get(f.severity, 0) + 1
|
|
380
|
+
total = sum(counts.values())
|
|
381
|
+
stamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
382
|
+
L = [f"# Infrastructure scan — {title}", "", f"_{stamp} · `ikanban-infrascan`_", "",
|
|
383
|
+
"| Severity | Count |", "|---|---|"]
|
|
384
|
+
L += [f"| {s} | {counts[s]} |" for s in SEVERITIES if counts[s]]
|
|
385
|
+
if not total:
|
|
386
|
+
L.append("| _none_ | 0 |")
|
|
387
|
+
L += ["", f"**{total} finding(s).**", ""]
|
|
388
|
+
for scope, rows in INVENTORY:
|
|
389
|
+
L += [f"## Inventory — {scope}", "", "| What | Found | Scanned | Note |", "|---|---|---|---|"]
|
|
390
|
+
L += rows or ["| _no infrastructure files found_ | 0 | — | add the folder or repository that holds them with another --path |"]
|
|
391
|
+
L.append("")
|
|
392
|
+
for sec in sections:
|
|
393
|
+
L.append(f"## {sec.name}")
|
|
394
|
+
if sec.ran:
|
|
395
|
+
L.append(f"_ran: {', '.join(sec.ran)}_")
|
|
396
|
+
for s in sec.skipped:
|
|
397
|
+
L.append(f"- ⚠️ **SKIPPED** — {s}")
|
|
398
|
+
if not sec.findings and sec.complete:
|
|
399
|
+
L.append("- ✅ no findings")
|
|
400
|
+
for n in sec.notes:
|
|
401
|
+
L.append(f"- ℹ️ {n}")
|
|
402
|
+
for f in sorted(sec.findings, key=lambda x: (SEVERITY_RANK.get(x.severity, 99), x.title)):
|
|
403
|
+
loc = f" — `{f.location}{':' + f.line if f.line else ''}`" if f.location else ""
|
|
404
|
+
L.append(f"- **[{f.severity}]** {f.title}{loc}")
|
|
405
|
+
if f.detail:
|
|
406
|
+
L.append(f" - {f.detail}")
|
|
407
|
+
L.append("")
|
|
408
|
+
L += ["---", "", "Skipped checks are gaps, not passes — install the named tool and re-run.",
|
|
409
|
+
"Found but not scannable is a gap too: review it by hand."]
|
|
410
|
+
return "\n".join(L), counts, total
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def finding_key(kind: str, scope: str, rule: str, location: str, line: str | None) -> str:
|
|
414
|
+
"""Stable per kind, scope, rule and place, so a rescan files nothing new."""
|
|
415
|
+
raw = f"{kind}|{scope}|{rule}|{location}|{line or ''}"
|
|
416
|
+
return "infrascan-" + hashlib.sha256(raw.encode()).hexdigest()[:16]
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def findings_payload(sec: Section, home: str | None = None) -> list:
|
|
420
|
+
home = home if home is not None else str(Path.home())
|
|
421
|
+
|
|
422
|
+
def clean(s):
|
|
423
|
+
return s.replace(home, "~") if s and home and home != "/" else s
|
|
424
|
+
return [{
|
|
425
|
+
"finding_key": finding_key(sec.kind, sec.scope, f.rule, f.location, f.line),
|
|
426
|
+
"owasp_category": CATEGORY[sec.kind],
|
|
427
|
+
"severity": "Low" if f.severity == "info" else f.severity.capitalize(),
|
|
428
|
+
"title": clean(f.title)[:300],
|
|
429
|
+
"location_file": clean(f.location) or None,
|
|
430
|
+
"location_lines": f.line,
|
|
431
|
+
"remediation": clean(" ".join(x for x in (f.detail, f.remediation) if x)),
|
|
432
|
+
} for f in (sec.filed if sec.filed is not None else sec.findings)]
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ── copied from ikanban-secscan.py: scope, key-host pinning, API ─────────────────────
|
|
436
|
+
def normalize_remote(url: str) -> str:
|
|
437
|
+
"""owner/repo from any remote style (scp, https, ssh, a host alias), lower case;
|
|
438
|
+
never the user or a token in the URL."""
|
|
439
|
+
path = re.sub(r"^[a-z+]+://[^/]*/?", "", url.strip())
|
|
440
|
+
if path == url.strip():
|
|
441
|
+
path = path.split(":", 1)[-1]
|
|
442
|
+
path = path.strip("/")
|
|
443
|
+
path = path[:-4] if path.endswith(".git") else path
|
|
444
|
+
return "/".join(path.split("/")[-2:]).lower() if path else ""
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def scan_scope(root: Path) -> str:
|
|
448
|
+
"""origin's owner/repo (else the folder name), plus the subfolder: the same scope
|
|
449
|
+
secscan uses, so a rescan closes only this repository's IaC findings."""
|
|
450
|
+
root = root.resolve()
|
|
451
|
+
|
|
452
|
+
def git(*a):
|
|
453
|
+
try:
|
|
454
|
+
return subprocess.run(["git", "-C", str(root), *a], capture_output=True,
|
|
455
|
+
text=True, timeout=10).stdout.strip()
|
|
456
|
+
except (OSError, subprocess.SubprocessError):
|
|
457
|
+
return ""
|
|
458
|
+
top = git("rev-parse", "--show-toplevel")
|
|
459
|
+
base = Path(top).resolve() if top else root
|
|
460
|
+
base_name = normalize_remote(git("remote", "get-url", "origin")) or base.name.lower()
|
|
461
|
+
sub = root.relative_to(base).as_posix() if base in root.parents else ""
|
|
462
|
+
return f"{base_name}/{sub}" if sub else base_name
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
IKANBAN_APIS = ("https://api.i-kanban.com", "https://dev-api.i-kanban.com")
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def ikanban_api(base_url):
|
|
469
|
+
"""The origin of base_url if it is iKanban's API; otherwise stop, naming it (never the key)."""
|
|
470
|
+
u = urllib.parse.urlsplit(base_url or "")
|
|
471
|
+
origin = f"{u.scheme}://{u.netloc}"
|
|
472
|
+
if u.username is None and u.path in ("", "/") and not u.query and (
|
|
473
|
+
origin in IKANBAN_APIS or (u.scheme == "http" and u.hostname in ("127.0.0.1", "localhost", "::1"))):
|
|
474
|
+
return origin
|
|
475
|
+
raise SystemExit(f"error: {base_url or '(empty)'} is not an iKanban API "
|
|
476
|
+
f"({', '.join(IKANBAN_APIS)} or this machine); the key was not sent")
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
480
|
+
def redirect_request(self, *args, **kwargs):
|
|
481
|
+
return None
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def api(method: str, path: str, base_url: str, token: str, payload=None):
|
|
488
|
+
"""(ok, data | error text | None for an unknown route)."""
|
|
489
|
+
req = urllib.request.Request(
|
|
490
|
+
f"{ikanban_api(base_url)}/api{path}",
|
|
491
|
+
data=json.dumps(payload).encode() if payload is not None else None,
|
|
492
|
+
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
|
493
|
+
method=method,
|
|
494
|
+
)
|
|
495
|
+
try:
|
|
496
|
+
with _OPENER.open(req, timeout=60) as r:
|
|
497
|
+
body = json.loads(r.read().decode() or "{}")
|
|
498
|
+
return True, body.get("data", body)
|
|
499
|
+
except urllib.error.HTTPError as e:
|
|
500
|
+
detail = e.read().decode()[:300]
|
|
501
|
+
if e.code in (404, 405) and not detail.strip():
|
|
502
|
+
return False, None
|
|
503
|
+
try:
|
|
504
|
+
detail = json.loads(detail).get("error") or detail
|
|
505
|
+
except (ValueError, AttributeError):
|
|
506
|
+
pass
|
|
507
|
+
return False, f"HTTP {e.code}: {detail}"
|
|
508
|
+
except Exception as e: # noqa: BLE001 — surface any transport failure verbatim
|
|
509
|
+
return False, str(e)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def publish(md: str, title: str, sections: list, args) -> int:
|
|
513
|
+
ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
|
|
514
|
+
{"team_id": args.team, "title": title, "content": md.replace(str(Path.home()), "~")})
|
|
515
|
+
print(f"\npublished: {(info or {}).get('id', '') if ok else ''}" if ok else f"\npublish FAILED: {info}",
|
|
516
|
+
file=sys.stderr)
|
|
517
|
+
if not ok:
|
|
518
|
+
return 2
|
|
519
|
+
for sec in sections:
|
|
520
|
+
nothing_found = any(x.startswith("No infrastructure-as-code files found") for x in sec.skipped)
|
|
521
|
+
cut = any("more were left out" in n for n in sec.notes)
|
|
522
|
+
if not sec.complete and not nothing_found and not cut:
|
|
523
|
+
print(f"{sec.name}: did not run, nothing filed or closed", file=sys.stderr)
|
|
524
|
+
continue
|
|
525
|
+
# A path with no IaC file is recorded as an incomplete run: iKanban shows when it was
|
|
526
|
+
# checked, the control stays Missing, and nothing closes.
|
|
527
|
+
ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token,
|
|
528
|
+
{"findings": findings_payload(sec), "scope": sec.scope, "complete": sec.complete,
|
|
529
|
+
"kind": sec.kind})
|
|
530
|
+
if not ok:
|
|
531
|
+
print(f"filing {sec.name} FAILED: {data or 'this iKanban server does not take infrastructure findings yet'}",
|
|
532
|
+
file=sys.stderr)
|
|
533
|
+
return 2
|
|
534
|
+
print(f"filed {data.get('submitted', 0)} {sec.kind} findings ({data.get('new', 0)} new, reopened "
|
|
535
|
+
f"{data.get('reopened', 0)}, closed {data.get('closed', 0)} no longer found in {sec.scope})",
|
|
536
|
+
file=sys.stderr)
|
|
537
|
+
return 0
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
|
|
541
|
+
# <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
|
|
542
|
+
# holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
|
|
543
|
+
def add_work_dir_arg(ap):
|
|
544
|
+
ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
|
|
545
|
+
help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
|
|
546
|
+
"<repository>/.iKanban/scans)")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
|
|
550
|
+
scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
|
|
551
|
+
scans.mkdir(parents=True, exist_ok=True)
|
|
552
|
+
ignore = scans / ".gitignore"
|
|
553
|
+
if not ignore.exists():
|
|
554
|
+
ignore.write_text("*\n")
|
|
555
|
+
run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
|
|
556
|
+
run_dir.mkdir(mode=0o700, exist_ok=True)
|
|
557
|
+
os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
|
|
558
|
+
return run_dir
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def write_private(path: Path, text: str) -> None:
|
|
562
|
+
"""Write a file (0600) without following a symlink planted at its name: write a new
|
|
563
|
+
temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
|
|
564
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
565
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
566
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
567
|
+
f.write(text)
|
|
568
|
+
os.replace(tmp, path)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def slug(text: str) -> str:
|
|
572
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
576
|
+
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
577
|
+
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
578
|
+
# then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
|
|
579
|
+
_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)
|
|
580
|
+
PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
|
|
581
|
+
LAST_RUN = {"report": "", "detail": "", "skipped": False}
|
|
582
|
+
INVENTORY: list = [] # (scope, table rows) per --path, for the report
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def add_pipeline_args(ap):
|
|
586
|
+
ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
|
|
587
|
+
help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
|
|
588
|
+
"or IKANBAN_TASK)")
|
|
589
|
+
ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
|
|
590
|
+
help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def summary_detail(counts: dict, total: int, skipped: list) -> str:
|
|
594
|
+
if skipped and not total and all(s for s in skipped):
|
|
595
|
+
return ("SKIPPED: " + "; ".join(skipped))[:300]
|
|
596
|
+
worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
|
|
597
|
+
return f"{total} findings" + (f": {worst}" if worst else "")
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def mask_report(text: str) -> str:
|
|
601
|
+
"""The report is masked already; mask again before it leaves for Pipelines."""
|
|
602
|
+
scrub = globals().get("scrub_report")
|
|
603
|
+
if scrub:
|
|
604
|
+
return scrub(text)[0]
|
|
605
|
+
text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
|
|
606
|
+
home = str(Path.home())
|
|
607
|
+
return text.replace(home, "~") if home and home != "/" else text
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def log_chunks(text: str) -> list:
|
|
611
|
+
"""Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
|
|
612
|
+
chunks, cur = [], ""
|
|
613
|
+
for line in text.splitlines(keepends=True):
|
|
614
|
+
while len(line.encode()) > PIPE_CHUNK:
|
|
615
|
+
head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
|
|
616
|
+
chunks.append(head)
|
|
617
|
+
line = line[len(head):]
|
|
618
|
+
if len((cur + line).encode()) > PIPE_CHUNK:
|
|
619
|
+
chunks.append(cur)
|
|
620
|
+
cur = ""
|
|
621
|
+
cur += line
|
|
622
|
+
if cur:
|
|
623
|
+
chunks.append(cur)
|
|
624
|
+
return chunks
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
class Pipeline:
|
|
628
|
+
def __init__(self, stage, task, team, base_url, token):
|
|
629
|
+
self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
|
|
630
|
+
self.on = bool(task and team and token)
|
|
631
|
+
self.tid = None
|
|
632
|
+
|
|
633
|
+
def _off(self, why):
|
|
634
|
+
self.on = False
|
|
635
|
+
note = "the plan does not include Pipelines" if "402" in str(why) else why
|
|
636
|
+
print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
|
|
637
|
+
|
|
638
|
+
def _call(self, method, path, body=None):
|
|
639
|
+
try:
|
|
640
|
+
return api(method, path, self.base, self.token, body)
|
|
641
|
+
except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
|
|
642
|
+
return False, str(e)
|
|
643
|
+
|
|
644
|
+
def _post(self, path, body):
|
|
645
|
+
if not self.on:
|
|
646
|
+
return
|
|
647
|
+
if self.tid is None:
|
|
648
|
+
if _UUID.match(self.team):
|
|
649
|
+
self.tid = self.team
|
|
650
|
+
else:
|
|
651
|
+
ok, teams = self._call("GET", "/teams")
|
|
652
|
+
if not ok:
|
|
653
|
+
return self._off(teams)
|
|
654
|
+
found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
|
|
655
|
+
if not found:
|
|
656
|
+
return self._off(f"team {self.team} not found among your teams")
|
|
657
|
+
self.tid = found[0]
|
|
658
|
+
ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
|
|
659
|
+
if not ok:
|
|
660
|
+
self._off(data or "this iKanban server has no Pipelines")
|
|
661
|
+
|
|
662
|
+
def start(self):
|
|
663
|
+
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
|
|
664
|
+
"source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
665
|
+
|
|
666
|
+
def finish(self, code):
|
|
667
|
+
status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
|
|
668
|
+
detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
|
|
669
|
+
if code == 1 and not LAST_RUN["skipped"]:
|
|
670
|
+
detail = (detail + " (at or above --fail-on)").strip()
|
|
671
|
+
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
|
|
672
|
+
"source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
673
|
+
"detail": detail[:300] or None})
|
|
674
|
+
for chunk in log_chunks(mask_report(LAST_RUN["report"])):
|
|
675
|
+
self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def run_main(stage, main_fn, argv=None):
|
|
679
|
+
"""main() as it is, wrapped in Pipelines events when --task is given."""
|
|
680
|
+
raw = sys.argv[1:] if argv is None else argv
|
|
681
|
+
if any(a in ("-h", "--help", "--version", "--list-apps") for a in raw):
|
|
682
|
+
return main_fn(argv)
|
|
683
|
+
pre = argparse.ArgumentParser(add_help=False)
|
|
684
|
+
pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
|
|
685
|
+
pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
|
|
686
|
+
pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
|
|
687
|
+
pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
|
|
688
|
+
pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
|
|
689
|
+
known, _ = pre.parse_known_args(raw)
|
|
690
|
+
pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
|
|
691
|
+
pl.start()
|
|
692
|
+
try:
|
|
693
|
+
code = main_fn(argv)
|
|
694
|
+
except SystemExit as e:
|
|
695
|
+
if not isinstance(e.code, int):
|
|
696
|
+
print(e.code, file=sys.stderr)
|
|
697
|
+
code = e.code if isinstance(e.code, int) else 2
|
|
698
|
+
pl.finish(code)
|
|
699
|
+
return code
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
def main(argv=None):
|
|
703
|
+
ap = argparse.ArgumentParser(description="Container image and IaC scan (Trivy) that reports into iKanban.")
|
|
704
|
+
ap.add_argument("--version", action="version", version=f"ikanban infrascan {INFRASCAN_VERSION}")
|
|
705
|
+
ap.add_argument("--path", action="append", default=[],
|
|
706
|
+
help="A folder or repository holding infrastructure files (repeat for each; default: cwd)")
|
|
707
|
+
ap.add_argument("--image", action="append", default=[], metavar="IMAGE",
|
|
708
|
+
help="Also scan this container image (repeat for each image)")
|
|
709
|
+
ap.add_argument("--no-iac", action="store_true", help="Scan only the --image images")
|
|
710
|
+
ap.add_argument("--no-compose-images", action="store_true",
|
|
711
|
+
help="Do not scan the images that docker-compose files name")
|
|
712
|
+
ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
|
|
713
|
+
ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
|
|
714
|
+
help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
|
|
715
|
+
ap.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
|
|
716
|
+
ap.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""),
|
|
717
|
+
help="Personal API key (or VIBE_API_TOKEN)")
|
|
718
|
+
ap.add_argument("--out", default=None, help="Also write the report here")
|
|
719
|
+
ap.add_argument("--json", action="store_true", help="Machine-readable summary")
|
|
720
|
+
ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
|
|
721
|
+
help="Exit 1 if any finding is at or above this severity")
|
|
722
|
+
add_pipeline_args(ap)
|
|
723
|
+
add_work_dir_arg(ap)
|
|
724
|
+
args = ap.parse_args(argv)
|
|
725
|
+
|
|
726
|
+
roots = [Path(p).expanduser().resolve() for p in (args.path or ["."])]
|
|
727
|
+
for root in roots:
|
|
728
|
+
if not root.is_dir():
|
|
729
|
+
print(f"error: {root} is not a directory", file=sys.stderr)
|
|
730
|
+
return 2
|
|
731
|
+
root = roots[0]
|
|
732
|
+
if args.publish and (not args.token or not args.team):
|
|
733
|
+
print("error: --publish needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID)",
|
|
734
|
+
file=sys.stderr)
|
|
735
|
+
return 2
|
|
736
|
+
|
|
737
|
+
if args.no_iac and not args.image:
|
|
738
|
+
print("error: nothing to scan: drop --no-iac or add --image", file=sys.stderr)
|
|
739
|
+
return 2
|
|
740
|
+
work = scan_work_dir("infrascan", root, args.work_dir)
|
|
741
|
+
sections, images, unresolved = [], list(args.image), []
|
|
742
|
+
INVENTORY.clear()
|
|
743
|
+
for r in roots:
|
|
744
|
+
inv = inventory(r)
|
|
745
|
+
rejected = []
|
|
746
|
+
if not args.no_compose_images:
|
|
747
|
+
found, skipped_vars, rejected = compose_images(r, inv)
|
|
748
|
+
images += [i for i in found if i not in images]
|
|
749
|
+
unresolved += skipped_vars
|
|
750
|
+
if not args.no_iac:
|
|
751
|
+
sec = scan_iac(r, scan_scope(r), inv, work)
|
|
752
|
+
sections.append(sec)
|
|
753
|
+
INVENTORY.append((sec.scope, inventory_rows(inv, sec.complete, "; ".join(sec.skipped), rejected)))
|
|
754
|
+
else:
|
|
755
|
+
INVENTORY.append((scan_scope(r), inventory_rows(inv, False, "--no-iac", rejected)))
|
|
756
|
+
sections += [scan_image(i, work) for i in images]
|
|
757
|
+
if unresolved:
|
|
758
|
+
sections.append(Section("Compose images not scanned", "container", "compose",
|
|
759
|
+
skipped=[f"`{i}` uses a ${{VARIABLE}}; pass the real name with --image" for i in unresolved]))
|
|
760
|
+
for sec in sections:
|
|
761
|
+
plan_filing(sec)
|
|
762
|
+
md, counts, total = build_report(", ".join(r.name for r in roots), sections)
|
|
763
|
+
write_private(work / "report.md", md)
|
|
764
|
+
nothing_ran = not any(sec.ran for sec in sections) # a cut section still ran
|
|
765
|
+
LAST_RUN.update(report=md, skipped=nothing_ran, detail=summary_detail(
|
|
766
|
+
counts, total, [s for sec in sections for s in sec.skipped] if nothing_ran else []))
|
|
767
|
+
if args.out:
|
|
768
|
+
Path(args.out).write_text(md)
|
|
769
|
+
if args.json:
|
|
770
|
+
print(json.dumps({"paths": [str(r) for r in roots], "images": images, "total": total, "counts": counts,
|
|
771
|
+
"skipped": [s for sec in sections for s in sec.skipped], "work_dir": str(work)}, indent=2))
|
|
772
|
+
else:
|
|
773
|
+
print(md)
|
|
774
|
+
sys.stdout.flush()
|
|
775
|
+
print(f"scan files: {work}", file=sys.stderr)
|
|
776
|
+
|
|
777
|
+
if args.publish:
|
|
778
|
+
title = f"Infrastructure scan — {root.name} — {datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC"
|
|
779
|
+
code = publish(md, title, sections, args)
|
|
780
|
+
if code:
|
|
781
|
+
return code
|
|
782
|
+
|
|
783
|
+
if args.fail_on and any(SEVERITY_RANK.get(s, 99) <= SEVERITY_RANK[args.fail_on] and c
|
|
784
|
+
for s, c in counts.items()):
|
|
785
|
+
return 1
|
|
786
|
+
return 0
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
if __name__ == "__main__":
|
|
790
|
+
sys.exit(run_main("infrascan", main))
|