ikanban 0.4.2 → 0.4.4

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
@@ -32,8 +32,11 @@ No CI is needed.
32
32
  (`trivy fs --scanners misconfig`) and on each `--image` (`trivy image`). `dastscan` runs the
33
33
  [OWASP ZAP](https://www.zaproxy.org/docs/docker/baseline-scan/) baseline scan in Docker: it reads
34
34
  the site's responses and does not attack. It scans only a URL the team registered in iKASecure →
35
- Infrastructure, so it always needs `--team` and a key. Findings show on the Infrastructure tab. A
36
- missing Trivy or Docker is reported as SKIPPED with the install command, never as a pass.
35
+ Infrastructure, so it always needs `--team` and a key. Findings show on the Infrastructure tab.
36
+ Without a `trivy` command, `infrascan` runs Trivy's official container (`aquasec/trivy:0.76.0`)
37
+ when Docker is running: the repository is mounted read-only, and the Docker socket only for an
38
+ image that exists on this machine alone. The report names the Trivy that ran. With neither Trivy
39
+ nor Docker, the scan is reported as SKIPPED with both fixes, never as a pass.
37
40
 
38
41
  `infrascan` starts with an inventory of each `--path` (repeat it when infrastructure lives in
39
42
  another folder or repository): what it found, what Trivy scanned, and what it could not scan and
@@ -47,6 +50,14 @@ over 500 findings files the 500 worst and counts as incomplete. Trivy runs from
47
50
  its own config and ignore file, so a repository's `trivy.yaml` or `.trivyignore` changes nothing, and
48
51
  only valid image names from compose files are scanned.
49
52
 
53
+ ## Dependencies without a lockfile
54
+
55
+ `npm audit` needs a lockfile. When a folder has none and npm cannot resolve its tree (ERESOLVE, a
56
+ peer conflict), `secscan` writes a lockfile for a copy of `package.json` in its scan folder
57
+ (`npm install --package-lock-only --ignore-scripts --legacy-peer-deps`, without the folder's
58
+ `.npmrc`) and audits that copy. Nothing is written into the repository. If npm still cannot
59
+ resolve the tree, the folder is SKIPPED with the fix: commit a `package-lock.json`.
60
+
50
61
  ## Scan files
51
62
 
52
63
  Every scanner keeps its raw tool output and its report in `.iKanban/scans/<scanner>-<time>/` in
@@ -57,13 +68,14 @@ own `.gitignore`, so nothing in it is committed. Delete old runs whenever you li
57
68
 
58
69
  Every scanner takes `--task IKA-123` (or `IKANBAN_TASK`) and `--task-team` (default `--team`). The
59
70
  run then shows in that task's Pipelines view: running, then passed, failed or skipped with a
60
- one-line summary, and the masked report as its log. A plan without Pipelines never fails the scan.
71
+ one-line summary, and the masked report as its log. While the scan runs, the stage log gets one
72
+ line as each step starts and ends. A plan without Pipelines never fails the scan.
61
73
 
62
74
  ## What you need
63
75
 
64
76
  - Node.js 18 or newer.
65
77
  - Python 3.9 or newer. The scanners are Python; `ikanban` finds `python3`, `python` or `py -3`.
66
- - `infrascan`: Trivy (macOS `brew install trivy`; other systems: a release binary). `dastscan`: Docker.
78
+ - `infrascan`: Trivy (macOS `brew install trivy`; other systems: a release binary), or Docker running. `dastscan`: Docker.
67
79
 
68
80
  ## Free and paid
69
81
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikanban",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
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.5.1"
60
+ AISCAN_VERSION = "0.6.1"
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).
@@ -356,9 +356,15 @@ def build_config(target, spec, plugins, num_tests, base_url, token, workspace_id
356
356
  }
357
357
 
358
358
 
359
- def run_promptfoo(args, cwd, allow_remote):
359
+ def run_promptfoo(args, cwd, allow_remote, openai=None):
360
360
  cmd = ["npx", "-y", f"promptfoo@{PROMPTFOO_VERSION}"] + args
361
361
  env = dict(os.environ)
362
+ # G22: openai_env's names; None removes a name from promptfoo's environment.
363
+ for name, value in (openai or {}).items():
364
+ if value is None:
365
+ env.pop(name, None)
366
+ else:
367
+ env[name] = value
362
368
  if not allow_remote:
363
369
  # Pinned here rather than left to the caller's shell. promptfoo decides
364
370
  # remote-vs-local by looking for OPENAI_API_KEY (shouldGenerateRemote in
@@ -403,7 +409,7 @@ def config_secrets(node) -> set:
403
409
  return {x for x in out if len(x) >= 8}
404
410
 
405
411
 
406
- def scan_category(label, target, cfg, workdir, allow_remote):
412
+ def scan_category(label, target, cfg, workdir, allow_remote, openai=None):
407
413
  """generate + eval one category. Returns (findings, plugins_that_ran, errors).
408
414
 
409
415
  Plan 11 H12-T10 review: promptfoo's config and cases hold the iKanban token, so they live
@@ -412,12 +418,12 @@ def scan_category(label, target, cfg, workdir, allow_remote):
412
418
  replaced, are copied into the scan's work folder."""
413
419
  private = Path(tempfile.mkdtemp(prefix="ikanban-aiscan-")) # mkdtemp creates it 0700
414
420
  try:
415
- return _scan_category(label, target, cfg, workdir, allow_remote, private)
421
+ return _scan_category(label, target, cfg, workdir, allow_remote, private, openai)
416
422
  finally:
417
423
  shutil.rmtree(private, ignore_errors=True)
418
424
 
419
425
 
420
- def _scan_category(label, target, cfg, workdir, allow_remote, private):
426
+ def _scan_category(label, target, cfg, workdir, allow_remote, private, openai=None):
421
427
  cfg_path = private / "config.yaml"
422
428
  cases_path = private / "cases.yaml"
423
429
  out_path = private / "results.json"
@@ -428,7 +434,7 @@ def _scan_category(label, target, cfg, workdir, allow_remote, private):
428
434
  cfg_path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
429
435
 
430
436
  if not run_promptfoo(
431
- ["redteam", "generate", "-c", str(cfg_path), "-o", str(cases_path)], private, allow_remote
437
+ ["redteam", "generate", "-c", str(cfg_path), "-o", str(cases_path)], private, allow_remote, openai
432
438
  ):
433
439
  return None
434
440
  if not cases_path.exists():
@@ -438,7 +444,7 @@ def _scan_category(label, target, cfg, workdir, allow_remote, private):
438
444
  # tests failed, i.e. attacks got through, i.e. exactly the findings we came
439
445
  # for. The results file decides, not the exit code.
440
446
  run_promptfoo(
441
- ["redteam", "eval", "-c", str(cases_path), "-o", str(out_path), "--no-cache"], private, allow_remote
447
+ ["redteam", "eval", "-c", str(cases_path), "-o", str(out_path), "--no-cache"], private, allow_remote, openai
442
448
  )
443
449
  if not out_path.exists():
444
450
  log(" promptfoo wrote no results file — the run genuinely failed")
@@ -446,7 +452,9 @@ def _scan_category(label, target, cfg, workdir, allow_remote, private):
446
452
 
447
453
  raw = out_path.read_text(encoding="utf-8")
448
454
  redacted = raw
449
- for secret in sorted(config_secrets(cfg), key=len, reverse=True):
455
+ # G22: the iKanban key promptfoo sends to the relay is hidden like the config's own.
456
+ relay_key = (openai or {}).get("OPENAI_API_KEY")
457
+ for secret in sorted(config_secrets(cfg) | ({relay_key} if relay_key else set()), key=len, reverse=True):
450
458
  redacted = redacted.replace(secret, "[hidden]")
451
459
  write_private(workdir / f"results-{re.sub(r'[^A-Za-z0-9_-]+', '_', label)}.json", redacted)
452
460
  doc = json.loads(raw)
@@ -701,6 +709,43 @@ def publish(findings, run_id, base_url, token, team_id, coverage, plugin_results
701
709
  return False, 0, 0
702
710
 
703
711
 
712
+ # G22 (owner): the attacks are made with the team's OpenAI key kept in iKanban. The key
713
+ # stays on the server: promptfoo's OpenAI base URL is iKanban's relay, which adds the key and
714
+ # forwards only the OpenAI calls promptfoo makes. promptfoo 0.123.1 reads OPENAI_API_HOST
715
+ # first, then OPENAI_API_BASE_URL, then OPENAI_BASE_URL (OpenAiGenericProvider.getApiUrl).
716
+ NO_OPENAI_KEY = ("AI endpoints skipped: no OpenAI key. Add the team's OpenAI key in iKanban "
717
+ "(Settings → AI Provider Keys), or set OPENAI_API_KEY in the shell that runs the scan.")
718
+ NO_AI_PLAN = "AI endpoints skipped: the team's plan has no AI findings (Pro+)."
719
+
720
+
721
+ def openai_env(environ, base_url, token, team):
722
+ """(promptfoo env changes, None) to scan, or (None, the SKIPPED line). A shell key wins."""
723
+ if environ.get("OPENAI_API_KEY"):
724
+ return {}, None
725
+ if not (token and team):
726
+ return None, NO_OPENAI_KEY
727
+ relay = f"{ikanban_api(base_url)}/api/teams/{urllib.parse.quote(team, safe='')}/security/ai-relay"
728
+ req = urllib.request.Request(relay, headers={"Authorization": f"Bearer {token}"})
729
+ try:
730
+ with _OPENER.open(req, timeout=30) as resp:
731
+ body = json.loads(resp.read().decode())
732
+ except urllib.error.HTTPError as exc:
733
+ if exc.code == 402:
734
+ return None, NO_AI_PLAN
735
+ return None, f"AI endpoints skipped: iKanban answered {exc.code} for the team's OpenAI key."
736
+ except (urllib.error.URLError, ValueError) as exc:
737
+ return None, f"AI endpoints skipped: could not reach iKanban for the team's OpenAI key ({type(exc).__name__})."
738
+ if not body.get("data", body).get("openai_key"):
739
+ return None, NO_OPENAI_KEY
740
+ return {
741
+ "OPENAI_API_HOST": None,
742
+ "OPENAI_API_BASE_URL": f"{relay}/v1",
743
+ "OPENAI_BASE_URL": f"{relay}/v1",
744
+ # The iKanban key, sent only to the relay above (iKanban's API, T3).
745
+ "OPENAI_API_KEY": token,
746
+ }, None
747
+
748
+
704
749
  # ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
705
750
  # <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
706
751
  # holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
@@ -788,6 +833,18 @@ def log_chunks(text: str) -> list:
788
833
  chunks.append(cur)
789
834
  return chunks
790
835
 
836
+ # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
837
+ # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
838
+ # Progress lines are fixed status text: never raw tool output or an error body (it can carry
839
+ # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
840
+ PIPE = None
841
+
842
+
843
+ def progress(text: str) -> None:
844
+ if PIPE:
845
+ PIPE.log(text)
846
+
847
+
791
848
 
792
849
  class Pipeline:
793
850
  def __init__(self, stage, task, team, base_url, token):
@@ -828,6 +885,11 @@ class Pipeline:
828
885
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
829
886
  "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
830
887
 
888
+ def log(self, line):
889
+ """One progress line, sent as it happens (masked like the report). A failure only turns Pipelines off."""
890
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
891
+ "text": mask_report(line.rstrip("\n"))[:PIPE_CHUNK - 1] + "\n"})
892
+
831
893
  def finish(self, code):
832
894
  status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
833
895
  detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
@@ -854,6 +916,8 @@ def run_main(stage, main_fn, argv=None):
854
916
  known, _ = pre.parse_known_args(raw)
855
917
  pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
856
918
  pl.start()
919
+ global PIPE
920
+ PIPE = pl if pl.on else None
857
921
  try:
858
922
  code = main_fn(argv)
859
923
  except SystemExit as e:
@@ -890,7 +954,8 @@ def main(argv=None):
890
954
  ap.add_argument("--allow-remote-generation", action="store_true",
891
955
  help="Let promptfoo generate attacks on its hosted service. Off by default: "
892
956
  "it needs an email registered with promptfoo, and it sends the purpose "
893
- "string off this machine. Local generation needs OPENAI_API_KEY instead.")
957
+ "string off this machine. Local generation uses OPENAI_API_KEY from the shell, "
958
+ "else the team's OpenAI key in iKanban through its relay.")
894
959
  ap.add_argument("--dry-run", action="store_true", help="Print the payload, POST nothing")
895
960
  ap.add_argument("--allow-outside-cwd", action="store_true",
896
961
  help="Permit --config, --html and --out to point outside the working "
@@ -961,6 +1026,15 @@ def main(argv=None):
961
1026
  log(f"error: unknown collection {args.plugins!r}")
962
1027
  return 2
963
1028
 
1029
+ openai = None
1030
+ if not args.allow_remote_generation:
1031
+ openai, why = openai_env(os.environ, args.base_url, args.token, args.team)
1032
+ if openai is None:
1033
+ log(why)
1034
+ progress(why)
1035
+ LAST_RUN.update(report=f"# AI endpoint scan — {args.target}\n\n{why}", detail=why[:300], skipped=True)
1036
+ return 0
1037
+
964
1038
  # MEASURED 2026-09-20, promptfoo 0.123.1: of the MCP plugin set, only `rbac`
965
1039
  # generates locally. `mcp` itself, `bfla` and `bola` all require promptfoo's
966
1040
  # hosted generator and produce zero tests without it -- so a local-only MCP
@@ -992,12 +1066,16 @@ def main(argv=None):
992
1066
  skipped.append(label)
993
1067
  per_category.append({"category": label, "asked": 0, "ran": 0, "skipped": []})
994
1068
  log(f"{label}: SKIPPED — promptfoo ships no plugins for this category")
1069
+ progress(f"{label} SKIPPED · no plugins for this category")
995
1070
  continue
996
1071
  log(f"{label}: generating and running {args.num_tests} tests per plugin…")
1072
+ # Pipelines get fixed status lines only; log() also carries promptfoo's raw output.
1073
+ progress(f"{label} started · {len(plugins)} plugins, {args.num_tests} tests each")
997
1074
  cfg = build_config(args.target, spec, plugins, args.num_tests,
998
1075
  args.base_url, args.token, args.workspace_id)
999
- got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation)
1076
+ got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation, openai)
1000
1077
  if got is None:
1078
+ progress(f"{label} finished · did not run")
1001
1079
  failed.append(label)
1002
1080
  per_category.append(
1003
1081
  {"category": label, "asked": len(plugins), "ran": 0, "skipped": sorted(plugins)}
@@ -1041,6 +1119,8 @@ def main(argv=None):
1041
1119
  scanned += 1
1042
1120
  log(f" {len(category_findings)} finding(s)"
1043
1121
  + (f", {cat_errors} error(s)" if cat_errors else ""))
1122
+ progress(f"{label} finished · {len(category_findings)} findings"
1123
+ + (f", {cat_errors} errors" if cat_errors else ""))
1044
1124
  findings.extend(category_findings)
1045
1125
 
1046
1126
  log("")
@@ -42,7 +42,7 @@ import urllib.request
42
42
  from datetime import datetime, timezone
43
43
  from pathlib import Path
44
44
 
45
- DASTSCAN_VERSION = "1.1.1"
45
+ DASTSCAN_VERSION = "1.2.0"
46
46
 
47
47
  SEVERITIES = ["critical", "high", "medium", "low", "info"]
48
48
  SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
@@ -313,6 +313,18 @@ def log_chunks(text: str) -> list:
313
313
  chunks.append(cur)
314
314
  return chunks
315
315
 
316
+ # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
317
+ # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
318
+ # Progress lines are fixed status text: never raw tool output or an error body (it can carry
319
+ # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
320
+ PIPE = None
321
+
322
+
323
+ def progress(text: str) -> None:
324
+ if PIPE:
325
+ PIPE.log(text)
326
+
327
+
316
328
 
317
329
  class Pipeline:
318
330
  def __init__(self, stage, task, team, base_url, token):
@@ -353,6 +365,11 @@ class Pipeline:
353
365
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
354
366
  "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
355
367
 
368
+ def log(self, line):
369
+ """One progress line, sent as it happens (masked like the report). A failure only turns Pipelines off."""
370
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
371
+ "text": mask_report(line.rstrip("\n"))[:PIPE_CHUNK - 1] + "\n"})
372
+
356
373
  def finish(self, code):
357
374
  status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
358
375
  detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
@@ -379,6 +396,8 @@ def run_main(stage, main_fn, argv=None):
379
396
  known, _ = pre.parse_known_args(raw)
380
397
  pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
381
398
  pl.start()
399
+ global PIPE
400
+ PIPE = pl if pl.on else None
382
401
  try:
383
402
  code = main_fn(argv)
384
403
  except SystemExit as e:
@@ -445,8 +464,12 @@ def main(argv=None):
445
464
  return 2
446
465
 
447
466
  work = scan_work_dir("dastscan", Path.cwd(), args.work_dir)
467
+ progress(f"dastscan {DASTSCAN_VERSION} · ZAP baseline on {url}: spider {args.minutes} min, "
468
+ f"at most {args.max_minutes} min …")
448
469
  report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work)
449
470
  findings = parse_zap(report) if report is not None else []
471
+ progress(f"ZAP baseline on {url} … " + (f"done, {len(findings)} alerts" if report is not None
472
+ else "SKIPPED (the reason is in the report)"))
450
473
  ran = [f"zap-baseline.py -t {url} -m {args.minutes}"] if report is not None else []
451
474
  md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran)
452
475
  LAST_RUN.update(report=md, detail=summary_detail(counts, total, [] if ran else [skipped_why]),
@@ -37,6 +37,7 @@ Helpers below are copied from ikanban-secscan.py (the scanners ship as single fi
37
37
  from __future__ import annotations
38
38
 
39
39
  import argparse
40
+ import functools
40
41
  import hashlib
41
42
  import json
42
43
  import os
@@ -51,15 +52,20 @@ from dataclasses import dataclass, field
51
52
  from datetime import datetime, timezone
52
53
  from pathlib import Path
53
54
 
54
- INFRASCAN_VERSION = "1.1.1"
55
+ INFRASCAN_VERSION = "1.2.0"
55
56
 
56
57
  SEVERITIES = ["critical", "high", "medium", "low", "info"]
57
58
  SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
58
59
  # Trivy's severities; UNKNOWN (no score yet) is filed as low.
59
60
  TRIVY_SEVERITY = {"CRITICAL": "critical", "HIGH": "high", "MEDIUM": "medium", "LOW": "low", "UNKNOWN": "low"}
60
61
 
61
- TRIVY_INSTALL = ("Trivy is not installed — macOS: `brew install trivy`; Linux and Windows: a release "
62
- "binary from https://github.com/aquasecurity/trivy/releases, or `docker run aquasec/trivy`")
62
+ TRIVY_INSTALL = ("Trivy is not installed and Docker is not running — install Trivy (macOS: `brew install trivy`; "
63
+ "Linux and Windows: a release binary from https://github.com/aquasecurity/trivy/releases), "
64
+ "or start Docker and the scan runs Trivy's official container")
65
+ # Plan 11 H12-T14 (G13): without a trivy binary, Trivy runs from its official image. Pinned to a
66
+ # released tag (hub.docker.com aquasec/trivy tags: 0.76.0); the DB is cached in a named volume.
67
+ TRIVY_IMAGE = "aquasec/trivy:0.76.0"
68
+ TRIVY_CACHE = "ikanban-trivy-cache:/root/.cache/trivy"
63
69
 
64
70
  CATEGORY = {"iac": "A05 — Security Misconfiguration",
65
71
  "container": "A06 — Vulnerable and Outdated Components"}
@@ -123,14 +129,42 @@ def run(cmd, cwd=None, timeout=900):
123
129
  return False, "", f"timed out after {timeout}s"
124
130
 
125
131
 
126
- def trivy_json(args: list, target: str, out: Path) -> tuple[dict | None, str]:
132
+ @functools.lru_cache(maxsize=1)
133
+ def trivy_mode() -> str:
134
+ """'binary' (trivy on PATH), 'container' (Docker answers) or '' (neither)."""
135
+ if shutil.which("trivy"):
136
+ return "binary"
137
+ if shutil.which("docker") and run(["docker", "info"], timeout=30)[0]:
138
+ return "container"
139
+ return ""
140
+
141
+
142
+ def trivy_label() -> str:
143
+ return f"trivy (container {TRIVY_IMAGE})" if trivy_mode() == "container" else "trivy"
144
+
145
+
146
+ def trivy_cmd(work: Path, ro_mounts: tuple = (), socket: bool = False) -> list:
147
+ """The command that starts Trivy. As a container it sees only the work folder (read-write),
148
+ the paths in ro_mounts (read-only, at their own absolute paths) and a cache volume; the
149
+ Docker socket only when asked (a local image), since the socket gives full control of Docker."""
150
+ if trivy_mode() == "binary":
151
+ return ["trivy"]
152
+ cmd = ["docker", "run", "--rm", "-w", str(work), "-v", f"{work}:{work}", "-v", TRIVY_CACHE]
153
+ for p in ro_mounts:
154
+ cmd += ["-v", f"{p}:{p}:ro"]
155
+ if socket:
156
+ cmd += ["-v", "/var/run/docker.sock:/var/run/docker.sock"]
157
+ return [*cmd, TRIVY_IMAGE]
158
+
159
+
160
+ def trivy_json(args: list, target: str, out: Path, ro_mounts: tuple = (), socket: bool = False) -> tuple[dict | None, str]:
127
161
  """Run `trivy SUBCOMMAND [ARGS] -- TARGET` with --format json into `out`; (report, "") or
128
162
  (None, why skipped). Plan 11 H12-T10 review: trivy runs from the scan's work folder with a
129
163
  scanner-owned --config and --ignorefile (both default to files in the working folder,
130
164
  trivy.dev CLI reference: `-c, --config ... (default "trivy.yaml")`, `--ignorefile ...
131
165
  (default ".trivyignore")`), so a repository's trivy.yaml or .trivyignore changes nothing.
132
166
  The target comes after `--`, so a name starting with '-' can never be read as a flag."""
133
- if not shutil.which("trivy"):
167
+ if not trivy_mode():
134
168
  return None, TRIVY_INSTALL
135
169
  work = out.parent
136
170
  conf, ignore = work / "trivy-config.yaml", work / "trivyignore"
@@ -138,7 +172,7 @@ def trivy_json(args: list, target: str, out: Path) -> tuple[dict | None, str]:
138
172
  write_private(conf, "{}\n")
139
173
  if not ignore.exists():
140
174
  write_private(ignore, "")
141
- ok, _o, err = run(["trivy", *args[:1], "--config", str(conf), "--ignorefile", str(ignore),
175
+ ok, _o, err = run([*trivy_cmd(work, ro_mounts, socket), *args[:1], "--config", str(conf), "--ignorefile", str(ignore),
142
176
  "--format", "json", "--output", str(out), *args[1:], "--", target], cwd=work)
143
177
  if not ok or not out.exists():
144
178
  return None, f"trivy {args[0]} failed: {(err or '').strip()[-300:] or 'no output'}"
@@ -196,11 +230,11 @@ def scan_iac(root: Path, scope: str, inv: dict, work: Path) -> Section:
196
230
  # the absolute and the ** form are both passed. A miss only means a vendored folder is read too.
197
231
  skip = [x for d in SKIP_DIRS for x in ("--skip-dirs", f"{root.resolve()}/{d}", "--skip-dirs", f"**/{d}")]
198
232
  report, why = trivy_json(["fs", "--scanners", "misconfig", *skip], str(root.resolve()),
199
- work / f"trivy-iac-{slug(scope)}.json")
233
+ work / f"trivy-iac-{slug(scope)}.json", ro_mounts=(root.resolve(),))
200
234
  if report is None:
201
235
  sec.skipped.append(why)
202
236
  return sec
203
- sec.ran.append(f"trivy fs --scanners misconfig {root.name}")
237
+ sec.ran.append(f"{trivy_label()} fs --scanners misconfig {root.name}")
204
238
  sec.findings = parse_misconfigurations(report)
205
239
  for f in sec.findings: # paths relative to the repository, like secscan
206
240
  f.location = f.location.removeprefix(str(root) + os.sep).removeprefix(str(root.resolve()) + os.sep)
@@ -213,11 +247,19 @@ def scan_image(image: str, work: Path) -> Section:
213
247
  if not valid_image_ref(image):
214
248
  sec.skipped.append(f"`{image[:80]}` is not a valid image name; not scanned")
215
249
  return sec
216
- report, why = trivy_json(["image"], image, work / f"trivy-image-{slug(image)}.json")
250
+ out = work / f"trivy-image-{slug(image)}.json"
251
+ if trivy_mode() == "container":
252
+ # A registry image is pulled inside Trivy, with no Docker socket. Only an image that exists
253
+ # just on this machine (built locally, e.g. by compose) is read through the socket.
254
+ report, why = trivy_json(["image", "--image-src", "remote"], image, out)
255
+ if report is None and run(["docker", "image", "inspect", "--", image], timeout=30)[0]:
256
+ report, why = trivy_json(["image", "--image-src", "docker"], image, out, socket=True)
257
+ else:
258
+ report, why = trivy_json(["image"], image, out)
217
259
  if report is None:
218
260
  sec.skipped.append(why)
219
261
  return sec
220
- sec.ran.append(f"trivy image {image}")
262
+ sec.ran.append(f"{trivy_label()} image {image}")
221
263
  sec.findings = parse_vulnerabilities(report, image)
222
264
  sec.complete = True
223
265
  return sec
@@ -625,6 +667,18 @@ def log_chunks(text: str) -> list:
625
667
  chunks.append(cur)
626
668
  return chunks
627
669
 
670
+ # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
671
+ # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
672
+ # Progress lines are fixed status text: never raw tool output or an error body (it can carry
673
+ # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
674
+ PIPE = None
675
+
676
+
677
+ def progress(text: str) -> None:
678
+ if PIPE:
679
+ PIPE.log(text)
680
+
681
+
628
682
 
629
683
  class Pipeline:
630
684
  def __init__(self, stage, task, team, base_url, token):
@@ -665,6 +719,11 @@ class Pipeline:
665
719
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
666
720
  "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
667
721
 
722
+ def log(self, line):
723
+ """One progress line, sent as it happens (masked like the report). A failure only turns Pipelines off."""
724
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
725
+ "text": mask_report(line.rstrip("\n"))[:PIPE_CHUNK - 1] + "\n"})
726
+
668
727
  def finish(self, code):
669
728
  status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
670
729
  detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
@@ -691,6 +750,8 @@ def run_main(stage, main_fn, argv=None):
691
750
  known, _ = pre.parse_known_args(raw)
692
751
  pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
693
752
  pl.start()
753
+ global PIPE
754
+ PIPE = pl if pl.on else None
694
755
  try:
695
756
  code = main_fn(argv)
696
757
  except SystemExit as e:
@@ -740,6 +801,7 @@ def main(argv=None):
740
801
  print("error: nothing to scan: drop --no-iac or add --image", file=sys.stderr)
741
802
  return 2
742
803
  work = scan_work_dir("infrascan", root, args.work_dir)
804
+ progress(f"infrascan {INFRASCAN_VERSION} · {trivy_label() if trivy_mode() else 'no Trivy and no Docker'}")
743
805
  sections, images, unresolved = [], list(args.image), []
744
806
  INVENTORY.clear()
745
807
  for r in roots:
@@ -750,12 +812,20 @@ def main(argv=None):
750
812
  images += [i for i in found if i not in images]
751
813
  unresolved += skipped_vars
752
814
  if not args.no_iac:
815
+ progress(f"trivy fs (IaC) in {r.name}/ …")
753
816
  sec = scan_iac(r, scan_scope(r), inv, work)
817
+ progress(f"trivy fs (IaC) in {r.name}/ … " + (f"done, {len(sec.findings)} findings" if sec.complete
818
+ else "SKIPPED (the reason is in the report)"))
754
819
  sections.append(sec)
755
820
  INVENTORY.append((sec.scope, inventory_rows(inv, sec.complete, "; ".join(sec.skipped), rejected)))
756
821
  else:
757
822
  INVENTORY.append((scan_scope(r), inventory_rows(inv, False, "--no-iac", rejected)))
758
- sections += [scan_image(i, work) for i in images]
823
+ for i in images:
824
+ progress(f"trivy image {i} …")
825
+ sec = scan_image(i, work)
826
+ progress(f"trivy image {i} … " + (f"done, {len(sec.findings)} critical/high" if sec.complete
827
+ else "SKIPPED (the reason is in the report)"))
828
+ sections.append(sec)
759
829
  if unresolved:
760
830
  sections.append(Section("Compose images not scanned", "container", "compose",
761
831
  skipped=[f"`{i}` uses a ${{VARIABLE}}; pass the real name with --image" for i in unresolved]))
@@ -53,6 +53,7 @@ import shutil
53
53
  import stat
54
54
  import subprocess
55
55
  import sys
56
+ import tempfile
56
57
  import urllib.error
57
58
  import urllib.parse
58
59
  import urllib.request
@@ -64,7 +65,7 @@ from pathlib import Path
64
65
  # that npm no longer hosts the package. Starting at 1.0.0 rather than continuing
65
66
  # the npm package's 0.1.x line: that counter belonged to the wrapper, not to
66
67
  # this file, and reusing it would suggest a continuity that does not exist.
67
- SECSCAN_VERSION = "1.5.1"
68
+ SECSCAN_VERSION = "1.6.0"
68
69
 
69
70
  SEVERITIES = ["critical", "high", "medium", "low", "info"]
70
71
  SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
@@ -222,7 +223,7 @@ def iter_files(root: Path):
222
223
 
223
224
 
224
225
  # ── 1. Dependency audits ─────────────────────────────────────────────────────
225
- def audit_dependencies(root: Path) -> Section:
226
+ def audit_dependencies(root: Path, work: Path | None = None) -> Section:
226
227
  sec = Section("Dependency vulnerabilities")
227
228
 
228
229
  if (root / "Cargo.lock").exists():
@@ -245,9 +246,18 @@ def audit_dependencies(root: Path) -> Section:
245
246
  sec.ran.append("pnpm audit")
246
247
  sec.findings += _parse_npm_audit(out)
247
248
  _note_unanswered(sec, "pnpm audit", out, err)
249
+ elif shutil.which("npm") and not _has_npm_lock(root) and _non_registry(root):
250
+ # Without a lockfile npm resolves the tree itself, and a git, file, link or URL
251
+ # dependency makes it fetch or read from outside the registry. Never do that here.
252
+ sec.skipped.append("npm audit: its package.json names non-registry dependencies; "
253
+ "commit a package-lock.json to audit it")
248
254
  elif shutil.which("npm"):
249
255
  ok, out, err = run(["npm", "audit", "--json"], cwd=root)
250
- sec.ran.append("npm audit")
256
+ tool = "npm audit"
257
+ if not audit_answered(out) and work is not None and not _has_npm_lock(root):
258
+ out, err = npm_audit_scratch(root, work)
259
+ tool = "npm audit (no lockfile in the repository: one resolved with --legacy-peer-deps in the scan folder)"
260
+ sec.ran.append(tool)
251
261
  sec.findings += _parse_npm_audit(out)
252
262
  _note_unanswered(sec, "npm audit", out, err)
253
263
  else:
@@ -269,6 +279,69 @@ def audit_dependencies(root: Path) -> Section:
269
279
  return sec
270
280
 
271
281
 
282
+ # A registry semver range or a dist-tag: no ":" (git+, github:, file:, link:, workspace:, npm:,
283
+ # URLs), no "/" (user/repo, paths), no tarball name. An override may also be "$name": the spec of
284
+ # a direct dependency of that name, which is checked itself.
285
+ _REGISTRY_SPEC = re.compile(r"^[A-Za-z0-9.\-+^~<>=| *]*$")
286
+ _TARBALL = re.compile(r"\.(tgz|tar|tar\.gz)$", re.IGNORECASE)
287
+
288
+
289
+ def non_registry_specs(pkg: dict) -> list:
290
+ """Every dependency spec in package.json that is not a registry range or dist-tag."""
291
+ bad = []
292
+ direct = {n for k in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies")
293
+ if isinstance(pkg.get(k), dict) for n in pkg[k]}
294
+
295
+ def walk(name, spec, override=False):
296
+ if isinstance(spec, dict):
297
+ for k, v in spec.items():
298
+ walk(k, v, override)
299
+ elif override and isinstance(spec, str) and spec.startswith("$") and spec[1:] in direct:
300
+ return
301
+ elif not isinstance(spec, str) or not _REGISTRY_SPEC.match(spec) or _TARBALL.search(spec):
302
+ bad.append(f"{name}: {spec!r}"[:120])
303
+
304
+ if pkg.get("workspaces"):
305
+ bad.append("workspaces") # npm resolves local folders outside the checked lists
306
+ for key in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies", "overrides"):
307
+ section = pkg.get(key) or {}
308
+ if not isinstance(section, dict):
309
+ bad.append(key)
310
+ continue
311
+ for name, spec in section.items():
312
+ walk(name, spec, key == "overrides")
313
+ return bad
314
+
315
+
316
+ def _has_npm_lock(root: Path) -> bool:
317
+ return any((root / f).exists() for f in ("package-lock.json", "npm-shrinkwrap.json"))
318
+
319
+
320
+ def _non_registry(root: Path) -> bool:
321
+ try:
322
+ pkg = json.loads((root / "package.json").read_text(encoding="utf-8"))
323
+ except (OSError, ValueError):
324
+ return True # unreadable: npm would fail too; never guess
325
+ return not isinstance(pkg, dict) or bool(non_registry_specs(pkg))
326
+
327
+
328
+ def npm_audit_scratch(root: Path, work: Path):
329
+ """Plan 11 H12-T14 (G14): npm audit needs a lockfile. Without one npm builds the tree
330
+ itself, and a peer conflict stops it (ERESOLVE: Juice Shop's frontend). So write a
331
+ lockfile for a copy of package.json alone in the scan folder, then audit that copy.
332
+ No .npmrc (it may say package-lock=false), no node_modules, no install scripts."""
333
+ scratch = Path(tempfile.mkdtemp(prefix="npm-lock-", dir=work))
334
+ write_private(scratch / "package.json", (root / "package.json").read_text(encoding="utf-8"))
335
+ ok, out, err = run(["npm", "install", "--package-lock-only", "--ignore-scripts",
336
+ "--legacy-peer-deps", "--no-audit"], cwd=scratch, timeout=600)
337
+ if not ok:
338
+ why = (err or out or "no output").strip().splitlines()
339
+ return "", (f"npm cannot resolve the dependency tree, even with --legacy-peer-deps "
340
+ f"({why[0][:120] if why else ''}). Fix: commit a package-lock.json for {root.name}/")
341
+ ok, out, err = run(["npm", "audit", "--json"], cwd=scratch)
342
+ return out, err
343
+
344
+
272
345
  def audit_answered(out: str) -> bool:
273
346
  """Plan 11 H3: did the auditor return a result? Not JSON, or npm's {"error": …}
274
347
  (no lockfile, no network), is no answer, so it must not read as "nothing found"."""
@@ -948,6 +1021,18 @@ def log_chunks(text: str) -> list:
948
1021
  chunks.append(cur)
949
1022
  return chunks
950
1023
 
1024
+ # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
1025
+ # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
1026
+ # Progress lines are fixed status text: never raw tool output or an error body (it can carry
1027
+ # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
1028
+ PIPE = None
1029
+
1030
+
1031
+ def progress(text: str) -> None:
1032
+ if PIPE:
1033
+ PIPE.log(text)
1034
+
1035
+
951
1036
 
952
1037
  class Pipeline:
953
1038
  def __init__(self, stage, task, team, base_url, token):
@@ -988,6 +1073,11 @@ class Pipeline:
988
1073
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
989
1074
  "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
990
1075
 
1076
+ def log(self, line):
1077
+ """One progress line, sent as it happens (masked like the report). A failure only turns Pipelines off."""
1078
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
1079
+ "text": mask_report(line.rstrip("\n"))[:PIPE_CHUNK - 1] + "\n"})
1080
+
991
1081
  def finish(self, code):
992
1082
  status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
993
1083
  detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
@@ -1014,6 +1104,8 @@ def run_main(stage, main_fn, argv=None):
1014
1104
  known, _ = pre.parse_known_args(raw)
1015
1105
  pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
1016
1106
  pl.start()
1107
+ global PIPE
1108
+ PIPE = pl if pl.on else None
1017
1109
  try:
1018
1110
  code = main_fn(argv)
1019
1111
  except SystemExit as e:
@@ -1080,17 +1172,28 @@ def main(argv=None):
1080
1172
  except ValueError as e:
1081
1173
  print(f"error: {e}", file=sys.stderr)
1082
1174
  return 2
1175
+ work = scan_work_dir("secscan", root, args.work_dir)
1176
+ progress(f"secscan {SECSCAN_VERSION} · {root.name}")
1177
+
1178
+ def step(label, fn, *a):
1179
+ progress(f"{label} …")
1180
+ sec = fn(*a)
1181
+ note = f"{len(sec.findings)} findings" if sec.ran else "SKIPPED"
1182
+ progress(f"{label} … {note}" + (f" ({'; '.join(sec.ran)})" if sec.ran else "")
1183
+ + (f"; {len(sec.skipped)} skipped" if sec.skipped else ""))
1184
+ return sec
1185
+
1083
1186
  app_sections = []
1084
1187
  for path, name in app_maps:
1085
- sec = audit_dependencies(path)
1086
- sec.name = f"Dependency vulnerabilities — {path.relative_to(root) or '.'} ({name})"
1188
+ rel = path.relative_to(root) or "."
1189
+ sec = step(f"dependency audit in {rel}/", audit_dependencies, path, work)
1190
+ sec.name = f"Dependency vulnerabilities — {rel} ({name})"
1087
1191
  app_sections.append((name, sec))
1088
1192
 
1089
- sections = [audit_dependencies(root), *(s for _, s in app_sections),
1090
- scan_secrets(root), scan_permissions(root)]
1193
+ sections = [step("dependency audit in ./", audit_dependencies, root, work), *(s for _, s in app_sections),
1194
+ step("committed secrets", scan_secrets, root), step("risky file permissions", scan_permissions, root)]
1091
1195
  md, counts, total = build_report(root, sections)
1092
1196
  LAST_RUN.update(report=md, detail=summary_detail(counts, total, []), skipped=False)
1093
- work = scan_work_dir("secscan", root, args.work_dir)
1094
1197
  write_private(work / "report.md", md)
1095
1198
 
1096
1199
  if args.out: