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 +16 -4
- package/package.json +1 -1
- package/scanners/ikanban-aiscan.py +31 -4
- package/scanners/ikanban-dastscan.py +29 -4
- package/scanners/ikanban-infrascan.py +86 -14
- package/scanners/ikanban-secscan.py +116 -11
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).
|
|
@@ -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.
|
|
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.
|
|
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
|
|
@@ -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
|
-
|
|
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.
|
|
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"."""
|
|
@@ -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
|
-
|
|
1084
|
-
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})"
|
|
1085
1191
|
app_sections.append((name, sec))
|
|
1086
1192
|
|
|
1087
|
-
sections = [audit_dependencies
|
|
1088
|
-
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)]
|
|
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:
|