ikanban 0.4.1 → 0.4.3

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.1",
3
+ "version": "0.4.3",
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.0"
60
+ AISCAN_VERSION = "0.6.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).
@@ -741,6 +741,8 @@ def slug(text: str) -> str:
741
741
  # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
742
742
  # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
743
743
  _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)
744
+ # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
745
+ PIPE_RUN = "security-scan"
744
746
  PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
745
747
  LAST_RUN = {"report": "", "detail": "", "skipped": False}
746
748
 
@@ -786,6 +788,18 @@ def log_chunks(text: str) -> list:
786
788
  chunks.append(cur)
787
789
  return chunks
788
790
 
791
+ # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
792
+ # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
793
+ # Progress lines are fixed status text: never raw tool output or an error body (it can carry
794
+ # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
795
+ PIPE = None
796
+
797
+
798
+ def progress(text: str) -> None:
799
+ if PIPE:
800
+ PIPE.log(text)
801
+
802
+
789
803
 
790
804
  class Pipeline:
791
805
  def __init__(self, stage, task, team, base_url, token):
@@ -824,7 +838,12 @@ class Pipeline:
824
838
 
825
839
  def start(self):
826
840
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
827
- "source": "laptop", "actor": f"ikanban-{self.stage}"})
841
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
842
+
843
+ def log(self, line):
844
+ """One progress line, sent as it happens (masked like the report). A failure only turns Pipelines off."""
845
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
846
+ "text": mask_report(line.rstrip("\n"))[:PIPE_CHUNK - 1] + "\n"})
828
847
 
829
848
  def finish(self, code):
830
849
  status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
@@ -832,10 +851,10 @@ class Pipeline:
832
851
  if code == 1 and not LAST_RUN["skipped"]:
833
852
  detail = (detail + " (at or above --fail-on)").strip()
834
853
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
835
- "source": "laptop", "actor": f"ikanban-{self.stage}",
854
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
836
855
  "detail": detail[:300] or None})
837
856
  for chunk in log_chunks(mask_report(LAST_RUN["report"])):
838
- self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
857
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
839
858
 
840
859
 
841
860
  def run_main(stage, main_fn, argv=None):
@@ -852,6 +871,8 @@ def run_main(stage, main_fn, argv=None):
852
871
  known, _ = pre.parse_known_args(raw)
853
872
  pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
854
873
  pl.start()
874
+ global PIPE
875
+ PIPE = pl if pl.on else None
855
876
  try:
856
877
  code = main_fn(argv)
857
878
  except SystemExit as e:
@@ -990,12 +1011,16 @@ def main(argv=None):
990
1011
  skipped.append(label)
991
1012
  per_category.append({"category": label, "asked": 0, "ran": 0, "skipped": []})
992
1013
  log(f"{label}: SKIPPED — promptfoo ships no plugins for this category")
1014
+ progress(f"{label} SKIPPED · no plugins for this category")
993
1015
  continue
994
1016
  log(f"{label}: generating and running {args.num_tests} tests per plugin…")
1017
+ # Pipelines get fixed status lines only; log() also carries promptfoo's raw output.
1018
+ progress(f"{label} started · {len(plugins)} plugins, {args.num_tests} tests each")
995
1019
  cfg = build_config(args.target, spec, plugins, args.num_tests,
996
1020
  args.base_url, args.token, args.workspace_id)
997
1021
  got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation)
998
1022
  if got is None:
1023
+ progress(f"{label} finished · did not run")
999
1024
  failed.append(label)
1000
1025
  per_category.append(
1001
1026
  {"category": label, "asked": len(plugins), "ran": 0, "skipped": sorted(plugins)}
@@ -1039,6 +1064,8 @@ def main(argv=None):
1039
1064
  scanned += 1
1040
1065
  log(f" {len(category_findings)} finding(s)"
1041
1066
  + (f", {cat_errors} error(s)" if cat_errors else ""))
1067
+ progress(f"{label} finished · {len(category_findings)} findings"
1068
+ + (f", {cat_errors} errors" if cat_errors else ""))
1042
1069
  findings.extend(category_findings)
1043
1070
 
1044
1071
  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.0"
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)}
@@ -266,6 +266,8 @@ def slug(text: str) -> str:
266
266
  # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
267
267
  # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
268
268
  _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)
269
+ # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
270
+ PIPE_RUN = "security-scan"
269
271
  PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
270
272
  LAST_RUN = {"report": "", "detail": "", "skipped": False}
271
273
 
@@ -311,6 +313,18 @@ def log_chunks(text: str) -> list:
311
313
  chunks.append(cur)
312
314
  return chunks
313
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
+
314
328
 
315
329
  class Pipeline:
316
330
  def __init__(self, stage, task, team, base_url, token):
@@ -349,7 +363,12 @@ class Pipeline:
349
363
 
350
364
  def start(self):
351
365
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
352
- "source": "laptop", "actor": f"ikanban-{self.stage}"})
366
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
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"})
353
372
 
354
373
  def finish(self, code):
355
374
  status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
@@ -357,10 +376,10 @@ class Pipeline:
357
376
  if code == 1 and not LAST_RUN["skipped"]:
358
377
  detail = (detail + " (at or above --fail-on)").strip()
359
378
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
360
- "source": "laptop", "actor": f"ikanban-{self.stage}",
379
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
361
380
  "detail": detail[:300] or None})
362
381
  for chunk in log_chunks(mask_report(LAST_RUN["report"])):
363
- self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
382
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
364
383
 
365
384
 
366
385
  def run_main(stage, main_fn, argv=None):
@@ -377,6 +396,8 @@ def run_main(stage, main_fn, argv=None):
377
396
  known, _ = pre.parse_known_args(raw)
378
397
  pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
379
398
  pl.start()
399
+ global PIPE
400
+ PIPE = pl if pl.on else None
380
401
  try:
381
402
  code = main_fn(argv)
382
403
  except SystemExit as e:
@@ -443,8 +464,12 @@ def main(argv=None):
443
464
  return 2
444
465
 
445
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 …")
446
469
  report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work)
447
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)"))
448
473
  ran = [f"zap-baseline.py -t {url} -m {args.minutes}"] if report is not None else []
449
474
  md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran)
450
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.0"
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
@@ -577,6 +619,8 @@ def slug(text: str) -> str:
577
619
  # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
578
620
  # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
579
621
  _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)
622
+ # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
623
+ PIPE_RUN = "security-scan"
580
624
  PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
581
625
  LAST_RUN = {"report": "", "detail": "", "skipped": False}
582
626
  INVENTORY: list = [] # (scope, table rows) per --path, for the report
@@ -623,6 +667,18 @@ def log_chunks(text: str) -> list:
623
667
  chunks.append(cur)
624
668
  return chunks
625
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
+
626
682
 
627
683
  class Pipeline:
628
684
  def __init__(self, stage, task, team, base_url, token):
@@ -661,7 +717,12 @@ class Pipeline:
661
717
 
662
718
  def start(self):
663
719
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
664
- "source": "laptop", "actor": f"ikanban-{self.stage}"})
720
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
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"})
665
726
 
666
727
  def finish(self, code):
667
728
  status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
@@ -669,10 +730,10 @@ class Pipeline:
669
730
  if code == 1 and not LAST_RUN["skipped"]:
670
731
  detail = (detail + " (at or above --fail-on)").strip()
671
732
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
672
- "source": "laptop", "actor": f"ikanban-{self.stage}",
733
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
673
734
  "detail": detail[:300] or None})
674
735
  for chunk in log_chunks(mask_report(LAST_RUN["report"])):
675
- self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
736
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
676
737
 
677
738
 
678
739
  def run_main(stage, main_fn, argv=None):
@@ -689,6 +750,8 @@ def run_main(stage, main_fn, argv=None):
689
750
  known, _ = pre.parse_known_args(raw)
690
751
  pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
691
752
  pl.start()
753
+ global PIPE
754
+ PIPE = pl if pl.on else None
692
755
  try:
693
756
  code = main_fn(argv)
694
757
  except SystemExit as e:
@@ -738,6 +801,7 @@ def main(argv=None):
738
801
  print("error: nothing to scan: drop --no-iac or add --image", file=sys.stderr)
739
802
  return 2
740
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'}")
741
805
  sections, images, unresolved = [], list(args.image), []
742
806
  INVENTORY.clear()
743
807
  for r in roots:
@@ -748,12 +812,20 @@ def main(argv=None):
748
812
  images += [i for i in found if i not in images]
749
813
  unresolved += skipped_vars
750
814
  if not args.no_iac:
815
+ progress(f"trivy fs (IaC) in {r.name}/ …")
751
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)"))
752
819
  sections.append(sec)
753
820
  INVENTORY.append((sec.scope, inventory_rows(inv, sec.complete, "; ".join(sec.skipped), rejected)))
754
821
  else:
755
822
  INVENTORY.append((scan_scope(r), inventory_rows(inv, False, "--no-iac", rejected)))
756
- 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)
757
829
  if unresolved:
758
830
  sections.append(Section("Compose images not scanned", "container", "compose",
759
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.0"
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"."""
@@ -901,6 +974,8 @@ def slug(text: str) -> str:
901
974
  # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
902
975
  # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
903
976
  _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)
977
+ # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
978
+ PIPE_RUN = "security-scan"
904
979
  PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
905
980
  LAST_RUN = {"report": "", "detail": "", "skipped": False}
906
981
 
@@ -946,6 +1021,18 @@ def log_chunks(text: str) -> list:
946
1021
  chunks.append(cur)
947
1022
  return chunks
948
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
+
949
1036
 
950
1037
  class Pipeline:
951
1038
  def __init__(self, stage, task, team, base_url, token):
@@ -984,7 +1071,12 @@ class Pipeline:
984
1071
 
985
1072
  def start(self):
986
1073
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
987
- "source": "laptop", "actor": f"ikanban-{self.stage}"})
1074
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
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"})
988
1080
 
989
1081
  def finish(self, code):
990
1082
  status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
@@ -992,10 +1084,10 @@ class Pipeline:
992
1084
  if code == 1 and not LAST_RUN["skipped"]:
993
1085
  detail = (detail + " (at or above --fail-on)").strip()
994
1086
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
995
- "source": "laptop", "actor": f"ikanban-{self.stage}",
1087
+ "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
996
1088
  "detail": detail[:300] or None})
997
1089
  for chunk in log_chunks(mask_report(LAST_RUN["report"])):
998
- self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
1090
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
999
1091
 
1000
1092
 
1001
1093
  def run_main(stage, main_fn, argv=None):
@@ -1012,6 +1104,8 @@ def run_main(stage, main_fn, argv=None):
1012
1104
  known, _ = pre.parse_known_args(raw)
1013
1105
  pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
1014
1106
  pl.start()
1107
+ global PIPE
1108
+ PIPE = pl if pl.on else None
1015
1109
  try:
1016
1110
  code = main_fn(argv)
1017
1111
  except SystemExit as e:
@@ -1078,17 +1172,28 @@ def main(argv=None):
1078
1172
  except ValueError as e:
1079
1173
  print(f"error: {e}", file=sys.stderr)
1080
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
+
1081
1186
  app_sections = []
1082
1187
  for path, name in app_maps:
1083
- sec = audit_dependencies(path)
1084
- 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})"
1085
1191
  app_sections.append((name, sec))
1086
1192
 
1087
- sections = [audit_dependencies(root), *(s for _, s in app_sections),
1088
- 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)]
1089
1195
  md, counts, total = build_report(root, sections)
1090
1196
  LAST_RUN.update(report=md, detail=summary_detail(counts, total, []), skipped=False)
1091
- work = scan_work_dir("secscan", root, args.work_dir)
1092
1197
  write_private(work / "report.md", md)
1093
1198
 
1094
1199
  if args.out: