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 +16 -4
- package/package.json +1 -1
- package/scanners/ikanban-aiscan.py +26 -1
- package/scanners/ikanban-dastscan.py +24 -1
- package/scanners/ikanban-infrascan.py +81 -11
- package/scanners/ikanban-secscan.py +111 -8
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.
|
|
36
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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`;
|
|
62
|
-
"binary from https://github.com/aquasecurity/trivy/releases,
|
|
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
|
-
|
|
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
|
|
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([
|
|
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"
|
|
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
|
-
|
|
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"
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1086
|
-
sec
|
|
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
|
|
1090
|
-
scan_secrets
|
|
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:
|