ikanban 0.4.2 → 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.2",
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.1"
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).
@@ -788,6 +788,18 @@ def log_chunks(text: str) -> list:
788
788
  chunks.append(cur)
789
789
  return chunks
790
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
+
791
803
 
792
804
  class Pipeline:
793
805
  def __init__(self, stage, task, team, base_url, token):
@@ -828,6 +840,11 @@ class Pipeline:
828
840
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
829
841
  "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
830
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"})
847
+
831
848
  def finish(self, code):
832
849
  status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
833
850
  detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
@@ -854,6 +871,8 @@ def run_main(stage, main_fn, argv=None):
854
871
  known, _ = pre.parse_known_args(raw)
855
872
  pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
856
873
  pl.start()
874
+ global PIPE
875
+ PIPE = pl if pl.on else None
857
876
  try:
858
877
  code = main_fn(argv)
859
878
  except SystemExit as e:
@@ -992,12 +1011,16 @@ def main(argv=None):
992
1011
  skipped.append(label)
993
1012
  per_category.append({"category": label, "asked": 0, "ran": 0, "skipped": []})
994
1013
  log(f"{label}: SKIPPED — promptfoo ships no plugins for this category")
1014
+ progress(f"{label} SKIPPED · no plugins for this category")
995
1015
  continue
996
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")
997
1019
  cfg = build_config(args.target, spec, plugins, args.num_tests,
998
1020
  args.base_url, args.token, args.workspace_id)
999
1021
  got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation)
1000
1022
  if got is None:
1023
+ progress(f"{label} finished · did not run")
1001
1024
  failed.append(label)
1002
1025
  per_category.append(
1003
1026
  {"category": label, "asked": len(plugins), "ran": 0, "skipped": sorted(plugins)}
@@ -1041,6 +1064,8 @@ def main(argv=None):
1041
1064
  scanned += 1
1042
1065
  log(f" {len(category_findings)} finding(s)"
1043
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 ""))
1044
1069
  findings.extend(category_findings)
1045
1070
 
1046
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.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: