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 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.0",
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.4.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
- cfg_path = workdir / "config.yaml"
392
- cases_path = workdir / "cases.yaml"
393
- out_path = workdir / "results.json"
394
- for p in (cases_path, out_path):
395
- if p.exists():
396
- p.unlink()
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)], workdir, allow_remote
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"], workdir, allow_remote
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
- doc = json.loads(out_path.read_text(encoding="utf-8"))
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
- with tempfile.TemporaryDirectory(prefix="ikanban-aiscan-") as tmp:
914
- workdir = Path(tmp)
915
- for _collection, label, plugins in selected:
916
- if not plugins:
917
- # LLM03. promptfoo ships no supply-chain plugins at all, so this
918
- # row can never be earned. Reporting "0 findings" would read as a
919
- # pass, which is the one thing it must not do.
920
- skipped.append(label)
921
- per_category.append({"category": label, "asked": 0, "ran": 0, "skipped": []})
922
- log(f"{label}: SKIPPED — promptfoo ships no plugins for this category")
923
- continue
924
- log(f"{label}: generating and running {args.num_tests} tests per plugin…")
925
- cfg = build_config(args.target, spec, plugins, args.num_tests,
926
- args.base_url, args.token, args.workspace_id)
927
- got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation)
928
- if got is None:
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
- "category": label,
958
- "asked": len(plugins),
959
- "ran": len(set(plugins) & ran),
960
- "skipped": missing,
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
- if missing:
964
- uncovered.append((label, missing))
965
- log(f" did not run: {', '.join(missing)}")
966
- if not ran:
967
- log(" NOTHING RAN — every plugin here needs promptfoo's hosted generator")
968
- else:
969
- scanned += 1
970
- log(f" {len(category_findings)} finding(s)"
971
- + (f", {cat_errors} error(s)" if cat_errors else ""))
972
- findings.extend(category_findings)
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.0.0"
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
- with tempfile.TemporaryDirectory() as tmp:
149
- os.chmod(tmp, 0o777) # the ZAP image writes as its own user
150
- cmd = ["docker", "run", "--rm", "-v", f"{tmp}:/zap/wrk/:rw", ZAP_IMAGE, "zap-baseline.py",
151
- "-t", url, "-J", "zap.json", "-m", str(minutes), "-T", str(max_minutes)]
152
- try:
153
- p = subprocess.run(cmd, capture_output=True, text=True, timeout=(max_minutes + 5) * 60)
154
- except FileNotFoundError:
155
- return None, DOCKER_INSTALL
156
- except subprocess.TimeoutExpired:
157
- return None, f"ZAP did not finish within {max_minutes + 5} minutes"
158
- out = Path(tmp) / "zap.json"
159
- if p.returncode not in (0, 1, 2) or not out.exists():
160
- why = (p.stderr or p.stdout or "").strip()[-300:]
161
- return None, f"ZAP failed (exit {p.returncode}): {why or 'no report'}"
162
- try:
163
- return json.loads(out.read_text() or "{}"), ""
164
- except ValueError:
165
- return None, "ZAP wrote no JSON report"
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
- report, skipped_why = run_zap(url, args.minutes, args.max_minutes)
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 misconfiguration scan of cwd
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.0.0"
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 a temp file; (report, "") or (None, why skipped)."""
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
- with tempfile.TemporaryDirectory() as tmp:
98
- out = os.path.join(tmp, "trivy.json")
99
- ok, _o, err = run(["trivy", *args[:1], "--format", "json", "--output", out, *args[1:]])
100
- if not ok or not os.path.exists(out):
101
- return None, f"trivy {args[0]} failed: {(err or '').strip()[-300:] or 'no output'}"
102
- try:
103
- return json.loads(Path(out).read_text() or "{}"), ""
104
- except ValueError:
105
- return None, f"trivy {args[0]} wrote no JSON"
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
- report, why = trivy_json(["fs", "--scanners", "misconfig", str(root)])
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
- report, why = trivy_json(["image", 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")
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
- if not sec.complete:
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": True,
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", default=".", help="Repository whose IaC files to scan (default: cwd)")
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
- root = Path(args.path).expanduser().resolve()
472
- if not root.is_dir():
473
- print(f"error: {root} is not a directory", file=sys.stderr)
474
- return 2
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
- sections = [] if args.no_iac else [scan_iac(root, scan_scope(root))]
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
- md, counts, total = build_report(root.name, sections)
486
- nothing_ran = not any(sec.complete for sec in sections)
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({"path": str(root), "images": args.image, "total": total, "counts": counts,
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.4.0"
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: