ikanban 0.4.0 → 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 +18 -0
- package/package.json +1 -1
- package/scanners/ikanban-aiscan.py +139 -67
- package/scanners/ikanban-dastscan.py +64 -23
- package/scanners/ikanban-infrascan.py +315 -36
- package/scanners/ikanban-secscan.py +42 -1
package/README.md
CHANGED
|
@@ -35,6 +35,24 @@ the site's responses and does not attack. It scans only a URL the team registere
|
|
|
35
35
|
Infrastructure, so it always needs `--team` and a key. Findings show on the Infrastructure tab. A
|
|
36
36
|
missing Trivy or Docker is reported as SKIPPED with the install command, never as a pass.
|
|
37
37
|
|
|
38
|
+
`infrascan` starts with an inventory of each `--path` (repeat it when infrastructure lives in
|
|
39
|
+
another folder or repository): what it found, what Trivy scanned, and what it could not scan and
|
|
40
|
+
why. Trivy reads Dockerfile/Containerfile, Kubernetes, Helm, Terraform, CloudFormation and Azure
|
|
41
|
+
ARM files. Images named in docker-compose files are scanned too (`--no-compose-images` to skip).
|
|
42
|
+
Infrastructure written as code (AWS CDK, Pulumi, CDK for Terraform, Vagrant, Ansible, cloud-SDK
|
|
43
|
+
scripts) is listed as found but not scannable, with the next step (for CDK: `npx cdk synth`, then
|
|
44
|
+
rescan). A path with no infrastructure-as-code file is never a pass: its control stays Missing.
|
|
45
|
+
Container images file critical and high findings (all severities stay in the report); a section
|
|
46
|
+
over 500 findings files the 500 worst and counts as incomplete. Trivy runs from the scan folder with
|
|
47
|
+
its own config and ignore file, so a repository's `trivy.yaml` or `.trivyignore` changes nothing, and
|
|
48
|
+
only valid image names from compose files are scanned.
|
|
49
|
+
|
|
50
|
+
## Scan files
|
|
51
|
+
|
|
52
|
+
Every scanner keeps its raw tool output and its report in `.iKanban/scans/<scanner>-<time>/` in
|
|
53
|
+
the repository (`--work-dir PATH` or `IKANBAN_SCAN_DIR` to put it elsewhere). The folder holds its
|
|
54
|
+
own `.gitignore`, so nothing in it is committed. Delete old runs whenever you like.
|
|
55
|
+
|
|
38
56
|
## Pipelines
|
|
39
57
|
|
|
40
58
|
Every scanner takes `--task IKA-123` (or `IKANBAN_TASK`) and `--task-team` (default `--team`). The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ikanban",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
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"],
|
|
@@ -57,7 +57,7 @@ PROMPTFOO_VERSION = "0.123.1"
|
|
|
57
57
|
# so this string is the release identity. Bump it when publishing a release —
|
|
58
58
|
# a customer pinning a version is pinning this value, and re-uploading different
|
|
59
59
|
# bytes under a version someone already fetched defeats the sha256 they pinned.
|
|
60
|
-
AISCAN_VERSION = "0.
|
|
60
|
+
AISCAN_VERSION = "0.5.0"
|
|
61
61
|
|
|
62
62
|
# The ten categories, their labels, and their plugins -- the forward map, copied
|
|
63
63
|
# from promptfoo's OWASP_LLM_TOP_10_MAPPING (src/redteam/constants/frameworks.ts).
|
|
@@ -386,14 +386,41 @@ def run_promptfoo(args, cwd, allow_remote):
|
|
|
386
386
|
EVAL_FAILED_TESTS_EXIT = 100
|
|
387
387
|
|
|
388
388
|
|
|
389
|
+
SECRET_KEY = re.compile(r"token|authorization|api.?key|secret|password|bearer", re.I)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def config_secrets(node) -> set:
|
|
393
|
+
"""Every credential-looking value in a promptfoo config (header, auth token, key)."""
|
|
394
|
+
out = set()
|
|
395
|
+
if isinstance(node, dict):
|
|
396
|
+
for k, v in node.items():
|
|
397
|
+
if isinstance(v, str) and SECRET_KEY.search(str(k)) and len(v) >= 8:
|
|
398
|
+
out |= {v, v.removeprefix("Bearer ").strip()}
|
|
399
|
+
out |= config_secrets(v)
|
|
400
|
+
elif isinstance(node, list):
|
|
401
|
+
for v in node:
|
|
402
|
+
out |= config_secrets(v)
|
|
403
|
+
return {x for x in out if len(x) >= 8}
|
|
404
|
+
|
|
405
|
+
|
|
389
406
|
def scan_category(label, target, cfg, workdir, allow_remote):
|
|
390
|
-
"""generate + eval one category. Returns (findings, plugins_that_ran, errors).
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
407
|
+
"""generate + eval one category. Returns (findings, plugins_that_ran, errors).
|
|
408
|
+
|
|
409
|
+
Plan 11 H12-T10 review: promptfoo's config and cases hold the iKanban token, so they live
|
|
410
|
+
in a private folder (0700, deleted after the category) that is also promptfoo's working
|
|
411
|
+
folder, away from the repository and its .npmrc. Only the results, with every credential
|
|
412
|
+
replaced, are copied into the scan's work folder."""
|
|
413
|
+
private = Path(tempfile.mkdtemp(prefix="ikanban-aiscan-")) # mkdtemp creates it 0700
|
|
414
|
+
try:
|
|
415
|
+
return _scan_category(label, target, cfg, workdir, allow_remote, private)
|
|
416
|
+
finally:
|
|
417
|
+
shutil.rmtree(private, ignore_errors=True)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _scan_category(label, target, cfg, workdir, allow_remote, private):
|
|
421
|
+
cfg_path = private / "config.yaml"
|
|
422
|
+
cases_path = private / "cases.yaml"
|
|
423
|
+
out_path = private / "results.json"
|
|
397
424
|
|
|
398
425
|
# promptfoo reads YAML, but its config loader accepts JSON in a .yaml file
|
|
399
426
|
# (YAML 1.2 is a JSON superset) -- which keeps this file free of a YAML
|
|
@@ -401,7 +428,7 @@ def scan_category(label, target, cfg, workdir, allow_remote):
|
|
|
401
428
|
cfg_path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
402
429
|
|
|
403
430
|
if not run_promptfoo(
|
|
404
|
-
["redteam", "generate", "-c", str(cfg_path), "-o", str(cases_path)],
|
|
431
|
+
["redteam", "generate", "-c", str(cfg_path), "-o", str(cases_path)], private, allow_remote
|
|
405
432
|
):
|
|
406
433
|
return None
|
|
407
434
|
if not cases_path.exists():
|
|
@@ -411,13 +438,18 @@ def scan_category(label, target, cfg, workdir, allow_remote):
|
|
|
411
438
|
# tests failed, i.e. attacks got through, i.e. exactly the findings we came
|
|
412
439
|
# for. The results file decides, not the exit code.
|
|
413
440
|
run_promptfoo(
|
|
414
|
-
["redteam", "eval", "-c", str(cases_path), "-o", str(out_path), "--no-cache"],
|
|
441
|
+
["redteam", "eval", "-c", str(cases_path), "-o", str(out_path), "--no-cache"], private, allow_remote
|
|
415
442
|
)
|
|
416
443
|
if not out_path.exists():
|
|
417
444
|
log(" promptfoo wrote no results file — the run genuinely failed")
|
|
418
445
|
return None
|
|
419
446
|
|
|
420
|
-
|
|
447
|
+
raw = out_path.read_text(encoding="utf-8")
|
|
448
|
+
redacted = raw
|
|
449
|
+
for secret in sorted(config_secrets(cfg), key=len, reverse=True):
|
|
450
|
+
redacted = redacted.replace(secret, "[hidden]")
|
|
451
|
+
write_private(workdir / f"results-{re.sub(r'[^A-Za-z0-9_-]+', '_', label)}.json", redacted)
|
|
452
|
+
doc = json.loads(raw)
|
|
421
453
|
rows = doc.get("results", {}).get("results", [])
|
|
422
454
|
# Coverage is whatever actually produced test rows. A plugin that needed the
|
|
423
455
|
# hosted generator contributes none, and is named in the report rather than
|
|
@@ -669,6 +701,41 @@ def publish(findings, run_id, base_url, token, team_id, coverage, plugin_results
|
|
|
669
701
|
return False, 0, 0
|
|
670
702
|
|
|
671
703
|
|
|
704
|
+
# ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
|
|
705
|
+
# <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
|
|
706
|
+
# holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
|
|
707
|
+
def add_work_dir_arg(ap):
|
|
708
|
+
ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
|
|
709
|
+
help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
|
|
710
|
+
"<repository>/.iKanban/scans)")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
|
|
714
|
+
scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
|
|
715
|
+
scans.mkdir(parents=True, exist_ok=True)
|
|
716
|
+
ignore = scans / ".gitignore"
|
|
717
|
+
if not ignore.exists():
|
|
718
|
+
ignore.write_text("*\n")
|
|
719
|
+
run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
|
|
720
|
+
run_dir.mkdir(mode=0o700, exist_ok=True)
|
|
721
|
+
os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
|
|
722
|
+
return run_dir
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def write_private(path: Path, text: str) -> None:
|
|
726
|
+
"""Write a file (0600) without following a symlink planted at its name: write a new
|
|
727
|
+
temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
|
|
728
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
729
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
730
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
731
|
+
f.write(text)
|
|
732
|
+
os.replace(tmp, path)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def slug(text: str) -> str:
|
|
736
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
|
|
737
|
+
|
|
738
|
+
|
|
672
739
|
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
673
740
|
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
674
741
|
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
@@ -828,6 +895,7 @@ def main(argv=None):
|
|
|
828
895
|
"directory. Off by default so a mistyped path cannot overwrite "
|
|
829
896
|
"something elsewhere; turn it on for CI writing to an artifacts dir.")
|
|
830
897
|
add_pipeline_args(ap)
|
|
898
|
+
add_work_dir_arg(ap)
|
|
831
899
|
args = ap.parse_args(argv)
|
|
832
900
|
|
|
833
901
|
# Resolve every caller-supplied path up front, before any scanning happens.
|
|
@@ -837,6 +905,9 @@ def main(argv=None):
|
|
|
837
905
|
args.html = safe_path(args.html, "--html", args.allow_outside_cwd)
|
|
838
906
|
if args.out:
|
|
839
907
|
args.out = safe_path(args.out, "--out", args.allow_outside_cwd)
|
|
908
|
+
# Plan 11 H12-T10: the scan's own folder follows the same rule as every other path.
|
|
909
|
+
if args.work_dir:
|
|
910
|
+
args.work_dir = str(safe_path(args.work_dir, "--work-dir", args.allow_outside_cwd))
|
|
840
911
|
|
|
841
912
|
if args.config:
|
|
842
913
|
args.config = safe_path(args.config, "--config", args.allow_outside_cwd)
|
|
@@ -910,66 +981,65 @@ def main(argv=None):
|
|
|
910
981
|
# than leaving 'no findings' to mean three different things.
|
|
911
982
|
per_category = []
|
|
912
983
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
failed.append(label)
|
|
930
|
-
per_category.append(
|
|
931
|
-
{"category": label, "asked": len(plugins), "ran": 0, "skipped": sorted(plugins)}
|
|
932
|
-
)
|
|
933
|
-
continue
|
|
934
|
-
category_findings, ran, cat_errors, tally = got
|
|
935
|
-
total_errors += cat_errors
|
|
936
|
-
plugins_ran |= ran
|
|
937
|
-
# IKA-1786: one row per plugin per category. A plugin can appear in
|
|
938
|
-
# two categories (pii:* spans LLM02 and LLM08), and those are
|
|
939
|
-
# separate measurements -- 'prompt-extraction under LLM07' is the
|
|
940
|
-
# thing a weakest-areas ranking is about, not the plugin in general.
|
|
941
|
-
for plugin, t in sorted(tally.items()):
|
|
942
|
-
plugin_results.append(
|
|
943
|
-
{
|
|
944
|
-
"plugin_id": plugin,
|
|
945
|
-
"llm_category": label,
|
|
946
|
-
"target": args.target,
|
|
947
|
-
"severity": t["severity"],
|
|
948
|
-
"attempted": t["attempted"],
|
|
949
|
-
"failed": t["failed"],
|
|
950
|
-
"errored": t["errored"],
|
|
951
|
-
}
|
|
952
|
-
)
|
|
953
|
-
missing = sorted(set(plugins) - ran)
|
|
954
|
-
plugins_skipped |= set(missing)
|
|
984
|
+
workdir = scan_work_dir("aiscan", Path.cwd(), args.work_dir)
|
|
985
|
+
for _collection, label, plugins in selected:
|
|
986
|
+
if not plugins:
|
|
987
|
+
# LLM03. promptfoo ships no supply-chain plugins at all, so this
|
|
988
|
+
# row can never be earned. Reporting "0 findings" would read as a
|
|
989
|
+
# pass, which is the one thing it must not do.
|
|
990
|
+
skipped.append(label)
|
|
991
|
+
per_category.append({"category": label, "asked": 0, "ran": 0, "skipped": []})
|
|
992
|
+
log(f"{label}: SKIPPED — promptfoo ships no plugins for this category")
|
|
993
|
+
continue
|
|
994
|
+
log(f"{label}: generating and running {args.num_tests} tests per plugin…")
|
|
995
|
+
cfg = build_config(args.target, spec, plugins, args.num_tests,
|
|
996
|
+
args.base_url, args.token, args.workspace_id)
|
|
997
|
+
got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation)
|
|
998
|
+
if got is None:
|
|
999
|
+
failed.append(label)
|
|
955
1000
|
per_category.append(
|
|
1001
|
+
{"category": label, "asked": len(plugins), "ran": 0, "skipped": sorted(plugins)}
|
|
1002
|
+
)
|
|
1003
|
+
continue
|
|
1004
|
+
category_findings, ran, cat_errors, tally = got
|
|
1005
|
+
total_errors += cat_errors
|
|
1006
|
+
plugins_ran |= ran
|
|
1007
|
+
# IKA-1786: one row per plugin per category. A plugin can appear in
|
|
1008
|
+
# two categories (pii:* spans LLM02 and LLM08), and those are
|
|
1009
|
+
# separate measurements -- 'prompt-extraction under LLM07' is the
|
|
1010
|
+
# thing a weakest-areas ranking is about, not the plugin in general.
|
|
1011
|
+
for plugin, t in sorted(tally.items()):
|
|
1012
|
+
plugin_results.append(
|
|
956
1013
|
{
|
|
957
|
-
"
|
|
958
|
-
"
|
|
959
|
-
"
|
|
960
|
-
"
|
|
1014
|
+
"plugin_id": plugin,
|
|
1015
|
+
"llm_category": label,
|
|
1016
|
+
"target": args.target,
|
|
1017
|
+
"severity": t["severity"],
|
|
1018
|
+
"attempted": t["attempted"],
|
|
1019
|
+
"failed": t["failed"],
|
|
1020
|
+
"errored": t["errored"],
|
|
961
1021
|
}
|
|
962
1022
|
)
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1023
|
+
missing = sorted(set(plugins) - ran)
|
|
1024
|
+
plugins_skipped |= set(missing)
|
|
1025
|
+
per_category.append(
|
|
1026
|
+
{
|
|
1027
|
+
"category": label,
|
|
1028
|
+
"asked": len(plugins),
|
|
1029
|
+
"ran": len(set(plugins) & ran),
|
|
1030
|
+
"skipped": missing,
|
|
1031
|
+
}
|
|
1032
|
+
)
|
|
1033
|
+
if missing:
|
|
1034
|
+
uncovered.append((label, missing))
|
|
1035
|
+
log(f" did not run: {', '.join(missing)}")
|
|
1036
|
+
if not ran:
|
|
1037
|
+
log(" NOTHING RAN — every plugin here needs promptfoo's hosted generator")
|
|
1038
|
+
else:
|
|
1039
|
+
scanned += 1
|
|
1040
|
+
log(f" {len(category_findings)} finding(s)"
|
|
1041
|
+
+ (f", {cat_errors} error(s)" if cat_errors else ""))
|
|
1042
|
+
findings.extend(category_findings)
|
|
973
1043
|
|
|
974
1044
|
log("")
|
|
975
1045
|
# Errors get their own number. A run where many tests errored is not a clean
|
|
@@ -995,6 +1065,8 @@ def main(argv=None):
|
|
|
995
1065
|
detail=summary_detail(counts, len(findings), []) + (f"; {len(failed)} categories did not run" if failed else ""),
|
|
996
1066
|
skipped=scanned == 0 and not findings,
|
|
997
1067
|
)
|
|
1068
|
+
write_private(workdir / "report.md", LAST_RUN["report"])
|
|
1069
|
+
log(f"scan files: {workdir}")
|
|
998
1070
|
|
|
999
1071
|
coverage = {
|
|
1000
1072
|
"target": args.target,
|
|
@@ -36,14 +36,13 @@ import re
|
|
|
36
36
|
import shutil
|
|
37
37
|
import subprocess
|
|
38
38
|
import sys
|
|
39
|
-
import tempfile
|
|
40
39
|
import urllib.error
|
|
41
40
|
import urllib.parse
|
|
42
41
|
import urllib.request
|
|
43
42
|
from datetime import datetime, timezone
|
|
44
43
|
from pathlib import Path
|
|
45
44
|
|
|
46
|
-
DASTSCAN_VERSION = "1.
|
|
45
|
+
DASTSCAN_VERSION = "1.1.0"
|
|
47
46
|
|
|
48
47
|
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
49
48
|
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
@@ -141,28 +140,31 @@ def build_report(url: str, findings: list, skipped: list, ran: list) -> tuple:
|
|
|
141
140
|
return "\n".join(L), counts, total
|
|
142
141
|
|
|
143
142
|
|
|
144
|
-
def run_zap(url: str, minutes: int, max_minutes: int) -> tuple[dict | None, str]:
|
|
145
|
-
"""(report, "") or (None, why it did not run)."""
|
|
143
|
+
def run_zap(url: str, minutes: int, max_minutes: int, work: Path) -> tuple[dict | None, str]:
|
|
144
|
+
"""(report, "") or (None, why it did not run). ZAP writes zap.json into <work>/zap/."""
|
|
146
145
|
if not shutil.which("docker"):
|
|
147
146
|
return None, DOCKER_INSTALL
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
147
|
+
# The ZAP image writes as its own user, so it gets its own zap/ subfolder it may write to;
|
|
148
|
+
# the run folder stays 0700, so nobody else on this machine can reach zap/ through it.
|
|
149
|
+
zap_dir = work / "zap"
|
|
150
|
+
zap_dir.mkdir(exist_ok=True)
|
|
151
|
+
os.chmod(zap_dir, 0o777)
|
|
152
|
+
cmd = ["docker", "run", "--rm", "-v", f"{zap_dir}:/zap/wrk/:rw", ZAP_IMAGE, "zap-baseline.py",
|
|
153
|
+
"-t", url, "-J", "zap.json", "-m", str(minutes), "-T", str(max_minutes)]
|
|
154
|
+
try:
|
|
155
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=(max_minutes + 5) * 60)
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
return None, DOCKER_INSTALL
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
return None, f"ZAP did not finish within {max_minutes + 5} minutes"
|
|
160
|
+
out = zap_dir / "zap.json"
|
|
161
|
+
if p.returncode not in (0, 1, 2) or not out.exists():
|
|
162
|
+
why = (p.stderr or p.stdout or "").strip()[-300:]
|
|
163
|
+
return None, f"ZAP failed (exit {p.returncode}): {why or 'no report'}"
|
|
164
|
+
try:
|
|
165
|
+
return json.loads(out.read_text() or "{}"), ""
|
|
166
|
+
except ValueError:
|
|
167
|
+
return None, "ZAP wrote no JSON report"
|
|
166
168
|
|
|
167
169
|
|
|
168
170
|
# ── copied from ikanban-secscan.py: key-host pinning and the API call ──────────────────
|
|
@@ -224,6 +226,41 @@ def registered(url: str, args) -> str | None:
|
|
|
224
226
|
return None
|
|
225
227
|
|
|
226
228
|
|
|
229
|
+
# ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
|
|
230
|
+
# <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
|
|
231
|
+
# holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
|
|
232
|
+
def add_work_dir_arg(ap):
|
|
233
|
+
ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
|
|
234
|
+
help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
|
|
235
|
+
"<repository>/.iKanban/scans)")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
|
|
239
|
+
scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
|
|
240
|
+
scans.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
ignore = scans / ".gitignore"
|
|
242
|
+
if not ignore.exists():
|
|
243
|
+
ignore.write_text("*\n")
|
|
244
|
+
run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
|
|
245
|
+
run_dir.mkdir(mode=0o700, exist_ok=True)
|
|
246
|
+
os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
|
|
247
|
+
return run_dir
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def write_private(path: Path, text: str) -> None:
|
|
251
|
+
"""Write a file (0600) without following a symlink planted at its name: write a new
|
|
252
|
+
temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
|
|
253
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
254
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
255
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
256
|
+
f.write(text)
|
|
257
|
+
os.replace(tmp, path)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def slug(text: str) -> str:
|
|
261
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
|
|
262
|
+
|
|
263
|
+
|
|
227
264
|
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
228
265
|
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
229
266
|
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
@@ -369,6 +406,7 @@ def main(argv=None):
|
|
|
369
406
|
ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
|
|
370
407
|
help="Exit 1 if any finding is at or above this severity")
|
|
371
408
|
add_pipeline_args(ap)
|
|
409
|
+
add_work_dir_arg(ap)
|
|
372
410
|
args = ap.parse_args(argv)
|
|
373
411
|
|
|
374
412
|
if args.list_targets:
|
|
@@ -404,12 +442,14 @@ def main(argv=None):
|
|
|
404
442
|
print(f"error: {why}", file=sys.stderr)
|
|
405
443
|
return 2
|
|
406
444
|
|
|
407
|
-
|
|
445
|
+
work = scan_work_dir("dastscan", Path.cwd(), args.work_dir)
|
|
446
|
+
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work)
|
|
408
447
|
findings = parse_zap(report) if report is not None else []
|
|
409
448
|
ran = [f"zap-baseline.py -t {url} -m {args.minutes}"] if report is not None else []
|
|
410
449
|
md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran)
|
|
411
450
|
LAST_RUN.update(report=md, detail=summary_detail(counts, total, [] if ran else [skipped_why]),
|
|
412
451
|
skipped=not ran)
|
|
452
|
+
write_private(work / "report.md", md)
|
|
413
453
|
if args.out:
|
|
414
454
|
Path(args.out).write_text(md)
|
|
415
455
|
if args.json:
|
|
@@ -417,6 +457,7 @@ def main(argv=None):
|
|
|
417
457
|
else:
|
|
418
458
|
print(md)
|
|
419
459
|
sys.stdout.flush()
|
|
460
|
+
print(f"scan files: {work}", file=sys.stderr)
|
|
420
461
|
|
|
421
462
|
if args.publish:
|
|
422
463
|
ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
|
|
@@ -3,10 +3,19 @@
|
|
|
3
3
|
ikanban-infrascan — container image and IaC (infrastructure as code) scan of YOUR OWN
|
|
4
4
|
repository and images, reporting into iKanban's Infrastructure tab (plan 11 H12).
|
|
5
5
|
|
|
6
|
-
npx ikanban infrascan # IaC
|
|
6
|
+
npx ikanban infrascan # inventory + IaC scan of cwd
|
|
7
|
+
npx ikanban infrascan --path . --path ../infra # infra in another folder or repo too
|
|
7
8
|
npx ikanban infrascan --image myapp:latest # also scan a container image
|
|
8
9
|
npx ikanban infrascan --publish --team TEAM # file the report and its findings
|
|
9
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
|
+
|
|
10
19
|
It runs Trivy (Apache-2.0, https://github.com/aquasecurity/trivy), which must already be
|
|
11
20
|
installed; a missing Trivy is reported as SKIPPED with the install lines, never as a clean
|
|
12
21
|
scan, and nothing is filed for a check that did not run.
|
|
@@ -35,7 +44,6 @@ import re
|
|
|
35
44
|
import shutil
|
|
36
45
|
import subprocess
|
|
37
46
|
import sys
|
|
38
|
-
import tempfile
|
|
39
47
|
import urllib.error
|
|
40
48
|
import urllib.parse
|
|
41
49
|
import urllib.request
|
|
@@ -43,7 +51,7 @@ from dataclasses import dataclass, field
|
|
|
43
51
|
from datetime import datetime, timezone
|
|
44
52
|
from pathlib import Path
|
|
45
53
|
|
|
46
|
-
INFRASCAN_VERSION = "1.
|
|
54
|
+
INFRASCAN_VERSION = "1.1.0"
|
|
47
55
|
|
|
48
56
|
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
49
57
|
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
@@ -77,6 +85,31 @@ class Section:
|
|
|
77
85
|
skipped: list = field(default_factory=list)
|
|
78
86
|
ran: list = field(default_factory=list)
|
|
79
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
|
|
80
113
|
|
|
81
114
|
|
|
82
115
|
def run(cmd, cwd=None, timeout=900):
|
|
@@ -90,19 +123,29 @@ def run(cmd, cwd=None, timeout=900):
|
|
|
90
123
|
return False, "", f"timed out after {timeout}s"
|
|
91
124
|
|
|
92
125
|
|
|
93
|
-
def trivy_json(args: list) -> tuple[dict | None, str]:
|
|
94
|
-
"""Run trivy with --format json into
|
|
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."""
|
|
95
133
|
if not shutil.which("trivy"):
|
|
96
134
|
return None, TRIVY_INSTALL
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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"
|
|
106
149
|
|
|
107
150
|
|
|
108
151
|
def parse_misconfigurations(report: dict) -> list:
|
|
@@ -142,23 +185,35 @@ def parse_vulnerabilities(report: dict, image: str) -> list:
|
|
|
142
185
|
return out
|
|
143
186
|
|
|
144
187
|
|
|
145
|
-
def scan_iac(root: Path, scope: str) -> Section:
|
|
146
|
-
sec = Section("IaC misconfiguration", "iac", scope)
|
|
147
|
-
|
|
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")
|
|
148
200
|
if report is None:
|
|
149
201
|
sec.skipped.append(why)
|
|
150
202
|
return sec
|
|
151
203
|
sec.ran.append(f"trivy fs --scanners misconfig {root.name}")
|
|
152
204
|
sec.findings = parse_misconfigurations(report)
|
|
153
205
|
for f in sec.findings: # paths relative to the repository, like secscan
|
|
154
|
-
f.location = f.location.removeprefix(str(root) + os.sep)
|
|
206
|
+
f.location = f.location.removeprefix(str(root) + os.sep).removeprefix(str(root.resolve()) + os.sep)
|
|
155
207
|
sec.complete = True
|
|
156
208
|
return sec
|
|
157
209
|
|
|
158
210
|
|
|
159
|
-
def scan_image(image: str) -> Section:
|
|
211
|
+
def scan_image(image: str, work: Path) -> Section:
|
|
160
212
|
sec = Section(f"Container image {image}", "container", image)
|
|
161
|
-
|
|
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")
|
|
162
217
|
if report is None:
|
|
163
218
|
sec.skipped.append(why)
|
|
164
219
|
return sec
|
|
@@ -168,6 +223,155 @@ def scan_image(image: str) -> Section:
|
|
|
168
223
|
return sec
|
|
169
224
|
|
|
170
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
|
+
|
|
171
375
|
def build_report(title: str, sections: list) -> tuple:
|
|
172
376
|
counts = {s: 0 for s in SEVERITIES}
|
|
173
377
|
for sec in sections:
|
|
@@ -181,6 +385,10 @@ def build_report(title: str, sections: list) -> tuple:
|
|
|
181
385
|
if not total:
|
|
182
386
|
L.append("| _none_ | 0 |")
|
|
183
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("")
|
|
184
392
|
for sec in sections:
|
|
185
393
|
L.append(f"## {sec.name}")
|
|
186
394
|
if sec.ran:
|
|
@@ -189,13 +397,16 @@ def build_report(title: str, sections: list) -> tuple:
|
|
|
189
397
|
L.append(f"- ⚠️ **SKIPPED** — {s}")
|
|
190
398
|
if not sec.findings and sec.complete:
|
|
191
399
|
L.append("- ✅ no findings")
|
|
400
|
+
for n in sec.notes:
|
|
401
|
+
L.append(f"- ℹ️ {n}")
|
|
192
402
|
for f in sorted(sec.findings, key=lambda x: (SEVERITY_RANK.get(x.severity, 99), x.title)):
|
|
193
403
|
loc = f" — `{f.location}{':' + f.line if f.line else ''}`" if f.location else ""
|
|
194
404
|
L.append(f"- **[{f.severity}]** {f.title}{loc}")
|
|
195
405
|
if f.detail:
|
|
196
406
|
L.append(f" - {f.detail}")
|
|
197
407
|
L.append("")
|
|
198
|
-
L += ["---", "", "Skipped checks are gaps, not passes — install the named tool and re-run."
|
|
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."]
|
|
199
410
|
return "\n".join(L), counts, total
|
|
200
411
|
|
|
201
412
|
|
|
@@ -218,7 +429,7 @@ def findings_payload(sec: Section, home: str | None = None) -> list:
|
|
|
218
429
|
"location_file": clean(f.location) or None,
|
|
219
430
|
"location_lines": f.line,
|
|
220
431
|
"remediation": clean(" ".join(x for x in (f.detail, f.remediation) if x)),
|
|
221
|
-
} for f in sec.findings]
|
|
432
|
+
} for f in (sec.filed if sec.filed is not None else sec.findings)]
|
|
222
433
|
|
|
223
434
|
|
|
224
435
|
# ── copied from ikanban-secscan.py: scope, key-host pinning, API ─────────────────────
|
|
@@ -306,11 +517,15 @@ def publish(md: str, title: str, sections: list, args) -> int:
|
|
|
306
517
|
if not ok:
|
|
307
518
|
return 2
|
|
308
519
|
for sec in sections:
|
|
309
|
-
|
|
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:
|
|
310
523
|
print(f"{sec.name}: did not run, nothing filed or closed", file=sys.stderr)
|
|
311
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.
|
|
312
527
|
ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token,
|
|
313
|
-
{"findings": findings_payload(sec), "scope": sec.scope, "complete":
|
|
528
|
+
{"findings": findings_payload(sec), "scope": sec.scope, "complete": sec.complete,
|
|
314
529
|
"kind": sec.kind})
|
|
315
530
|
if not ok:
|
|
316
531
|
print(f"filing {sec.name} FAILED: {data or 'this iKanban server does not take infrastructure findings yet'}",
|
|
@@ -322,6 +537,41 @@ def publish(md: str, title: str, sections: list, args) -> int:
|
|
|
322
537
|
return 0
|
|
323
538
|
|
|
324
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
|
+
|
|
325
575
|
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
326
576
|
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
327
577
|
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
@@ -329,6 +579,7 @@ def publish(md: str, title: str, sections: list, args) -> int:
|
|
|
329
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)
|
|
330
580
|
PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
|
|
331
581
|
LAST_RUN = {"report": "", "detail": "", "skipped": False}
|
|
582
|
+
INVENTORY: list = [] # (scope, table rows) per --path, for the report
|
|
332
583
|
|
|
333
584
|
|
|
334
585
|
def add_pipeline_args(ap):
|
|
@@ -451,10 +702,13 @@ def run_main(stage, main_fn, argv=None):
|
|
|
451
702
|
def main(argv=None):
|
|
452
703
|
ap = argparse.ArgumentParser(description="Container image and IaC scan (Trivy) that reports into iKanban.")
|
|
453
704
|
ap.add_argument("--version", action="version", version=f"ikanban infrascan {INFRASCAN_VERSION}")
|
|
454
|
-
ap.add_argument("--path",
|
|
705
|
+
ap.add_argument("--path", action="append", default=[],
|
|
706
|
+
help="A folder or repository holding infrastructure files (repeat for each; default: cwd)")
|
|
455
707
|
ap.add_argument("--image", action="append", default=[], metavar="IMAGE",
|
|
456
708
|
help="Also scan this container image (repeat for each image)")
|
|
457
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")
|
|
458
712
|
ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
|
|
459
713
|
ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
|
|
460
714
|
help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
|
|
@@ -466,34 +720,59 @@ def main(argv=None):
|
|
|
466
720
|
ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
|
|
467
721
|
help="Exit 1 if any finding is at or above this severity")
|
|
468
722
|
add_pipeline_args(ap)
|
|
723
|
+
add_work_dir_arg(ap)
|
|
469
724
|
args = ap.parse_args(argv)
|
|
470
725
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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]
|
|
475
732
|
if args.publish and (not args.token or not args.team):
|
|
476
733
|
print("error: --publish needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID)",
|
|
477
734
|
file=sys.stderr)
|
|
478
735
|
return 2
|
|
479
736
|
|
|
480
|
-
|
|
481
|
-
sections += [scan_image(i) for i in args.image]
|
|
482
|
-
if not sections:
|
|
737
|
+
if args.no_iac and not args.image:
|
|
483
738
|
print("error: nothing to scan: drop --no-iac or add --image", file=sys.stderr)
|
|
484
739
|
return 2
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
487
765
|
LAST_RUN.update(report=md, skipped=nothing_ran, detail=summary_detail(
|
|
488
766
|
counts, total, [s for sec in sections for s in sec.skipped] if nothing_ran else []))
|
|
489
767
|
if args.out:
|
|
490
768
|
Path(args.out).write_text(md)
|
|
491
769
|
if args.json:
|
|
492
|
-
print(json.dumps({"
|
|
493
|
-
"skipped": [s for sec in sections for s in sec.skipped]}, indent=2))
|
|
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))
|
|
494
772
|
else:
|
|
495
773
|
print(md)
|
|
496
774
|
sys.stdout.flush()
|
|
775
|
+
print(f"scan files: {work}", file=sys.stderr)
|
|
497
776
|
|
|
498
777
|
if args.publish:
|
|
499
778
|
title = f"Infrastructure scan — {root.name} — {datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC"
|
|
@@ -64,7 +64,7 @@ from pathlib import Path
|
|
|
64
64
|
# that npm no longer hosts the package. Starting at 1.0.0 rather than continuing
|
|
65
65
|
# the npm package's 0.1.x line: that counter belonged to the wrapper, not to
|
|
66
66
|
# this file, and reusing it would suggest a continuity that does not exist.
|
|
67
|
-
SECSCAN_VERSION = "1.
|
|
67
|
+
SECSCAN_VERSION = "1.5.0"
|
|
68
68
|
|
|
69
69
|
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
70
70
|
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
@@ -205,6 +205,8 @@ def iter_files(root: Path):
|
|
|
205
205
|
d for d in dirnames
|
|
206
206
|
if d not in SKIP_DIRS
|
|
207
207
|
and not d.startswith(".git")
|
|
208
|
+
# Plan 11 H12-T10: never read our own scan output.
|
|
209
|
+
and not (d == "scans" and Path(dirpath).name == ".iKanban")
|
|
208
210
|
and not _is_nested_repo(Path(dirpath) / d, root)
|
|
209
211
|
]
|
|
210
212
|
for fn in filenames:
|
|
@@ -859,6 +861,41 @@ def publish(md: str, title: str, base_url: str, token: str, team_id: str,
|
|
|
859
861
|
return False, str(e)
|
|
860
862
|
|
|
861
863
|
|
|
864
|
+
# ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
|
|
865
|
+
# <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
|
|
866
|
+
# holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
|
|
867
|
+
def add_work_dir_arg(ap):
|
|
868
|
+
ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
|
|
869
|
+
help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
|
|
870
|
+
"<repository>/.iKanban/scans)")
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
|
|
874
|
+
scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
|
|
875
|
+
scans.mkdir(parents=True, exist_ok=True)
|
|
876
|
+
ignore = scans / ".gitignore"
|
|
877
|
+
if not ignore.exists():
|
|
878
|
+
ignore.write_text("*\n")
|
|
879
|
+
run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
|
|
880
|
+
run_dir.mkdir(mode=0o700, exist_ok=True)
|
|
881
|
+
os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
|
|
882
|
+
return run_dir
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def write_private(path: Path, text: str) -> None:
|
|
886
|
+
"""Write a file (0600) without following a symlink planted at its name: write a new
|
|
887
|
+
temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
|
|
888
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
889
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
890
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
891
|
+
f.write(text)
|
|
892
|
+
os.replace(tmp, path)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def slug(text: str) -> str:
|
|
896
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
|
|
897
|
+
|
|
898
|
+
|
|
862
899
|
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
863
900
|
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
864
901
|
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
@@ -1011,6 +1048,7 @@ def main(argv=None):
|
|
|
1011
1048
|
ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
|
|
1012
1049
|
help="Exit 1 if any finding is at or above this severity")
|
|
1013
1050
|
add_pipeline_args(ap)
|
|
1051
|
+
add_work_dir_arg(ap)
|
|
1014
1052
|
args = ap.parse_args(argv)
|
|
1015
1053
|
|
|
1016
1054
|
root = Path(args.path).expanduser().resolve()
|
|
@@ -1050,6 +1088,8 @@ def main(argv=None):
|
|
|
1050
1088
|
scan_secrets(root), scan_permissions(root)]
|
|
1051
1089
|
md, counts, total = build_report(root, sections)
|
|
1052
1090
|
LAST_RUN.update(report=md, detail=summary_detail(counts, total, []), skipped=False)
|
|
1091
|
+
work = scan_work_dir("secscan", root, args.work_dir)
|
|
1092
|
+
write_private(work / "report.md", md)
|
|
1053
1093
|
|
|
1054
1094
|
if args.out:
|
|
1055
1095
|
Path(args.out).write_text(md)
|
|
@@ -1062,6 +1102,7 @@ def main(argv=None):
|
|
|
1062
1102
|
# The lines below go to stderr; flush first so they follow the report when both are
|
|
1063
1103
|
# piped into one stream (an agent's `2>&1 | tail` would otherwise cut them off).
|
|
1064
1104
|
sys.stdout.flush()
|
|
1105
|
+
print(f"scan files: {work}", file=sys.stderr)
|
|
1065
1106
|
|
|
1066
1107
|
if args.publish:
|
|
1067
1108
|
if not args.token or not args.team:
|