ikanban 0.4.0 → 0.4.2
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 +18 -0
- package/package.json +1 -1
- package/scanners/ikanban-aiscan.py +144 -70
- package/scanners/ikanban-dastscan.py +69 -26
- package/scanners/ikanban-infrascan.py +320 -39
- package/scanners/ikanban-secscan.py +47 -4
package/README.md
CHANGED
|
@@ -35,6 +35,24 @@ the site's responses and does not attack. It scans only a URL the team registere
|
|
|
35
35
|
Infrastructure, so it always needs `--team` and a key. Findings show on the Infrastructure tab. A
|
|
36
36
|
missing Trivy or Docker is reported as SKIPPED with the install command, never as a pass.
|
|
37
37
|
|
|
38
|
+
`infrascan` starts with an inventory of each `--path` (repeat it when infrastructure lives in
|
|
39
|
+
another folder or repository): what it found, what Trivy scanned, and what it could not scan and
|
|
40
|
+
why. Trivy reads Dockerfile/Containerfile, Kubernetes, Helm, Terraform, CloudFormation and Azure
|
|
41
|
+
ARM files. Images named in docker-compose files are scanned too (`--no-compose-images` to skip).
|
|
42
|
+
Infrastructure written as code (AWS CDK, Pulumi, CDK for Terraform, Vagrant, Ansible, cloud-SDK
|
|
43
|
+
scripts) is listed as found but not scannable, with the next step (for CDK: `npx cdk synth`, then
|
|
44
|
+
rescan). A path with no infrastructure-as-code file is never a pass: its control stays Missing.
|
|
45
|
+
Container images file critical and high findings (all severities stay in the report); a section
|
|
46
|
+
over 500 findings files the 500 worst and counts as incomplete. Trivy runs from the scan folder with
|
|
47
|
+
its own config and ignore file, so a repository's `trivy.yaml` or `.trivyignore` changes nothing, and
|
|
48
|
+
only valid image names from compose files are scanned.
|
|
49
|
+
|
|
50
|
+
## Scan files
|
|
51
|
+
|
|
52
|
+
Every scanner keeps its raw tool output and its report in `.iKanban/scans/<scanner>-<time>/` in
|
|
53
|
+
the repository (`--work-dir PATH` or `IKANBAN_SCAN_DIR` to put it elsewhere). The folder holds its
|
|
54
|
+
own `.gitignore`, so nothing in it is committed. Delete old runs whenever you like.
|
|
55
|
+
|
|
38
56
|
## Pipelines
|
|
39
57
|
|
|
40
58
|
Every scanner takes `--task IKA-123` (or `IKANBAN_TASK`) and `--task-team` (default `--team`). The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ikanban",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
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.5.1"
|
|
61
61
|
|
|
62
62
|
# The ten categories, their labels, and their plugins -- the forward map, copied
|
|
63
63
|
# from promptfoo's OWASP_LLM_TOP_10_MAPPING (src/redteam/constants/frameworks.ts).
|
|
@@ -386,14 +386,41 @@ def run_promptfoo(args, cwd, allow_remote):
|
|
|
386
386
|
EVAL_FAILED_TESTS_EXIT = 100
|
|
387
387
|
|
|
388
388
|
|
|
389
|
+
SECRET_KEY = re.compile(r"token|authorization|api.?key|secret|password|bearer", re.I)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def config_secrets(node) -> set:
|
|
393
|
+
"""Every credential-looking value in a promptfoo config (header, auth token, key)."""
|
|
394
|
+
out = set()
|
|
395
|
+
if isinstance(node, dict):
|
|
396
|
+
for k, v in node.items():
|
|
397
|
+
if isinstance(v, str) and SECRET_KEY.search(str(k)) and len(v) >= 8:
|
|
398
|
+
out |= {v, v.removeprefix("Bearer ").strip()}
|
|
399
|
+
out |= config_secrets(v)
|
|
400
|
+
elif isinstance(node, list):
|
|
401
|
+
for v in node:
|
|
402
|
+
out |= config_secrets(v)
|
|
403
|
+
return {x for x in out if len(x) >= 8}
|
|
404
|
+
|
|
405
|
+
|
|
389
406
|
def scan_category(label, target, cfg, workdir, allow_remote):
|
|
390
|
-
"""generate + eval one category. Returns (findings, plugins_that_ran, errors).
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
407
|
+
"""generate + eval one category. Returns (findings, plugins_that_ran, errors).
|
|
408
|
+
|
|
409
|
+
Plan 11 H12-T10 review: promptfoo's config and cases hold the iKanban token, so they live
|
|
410
|
+
in a private folder (0700, deleted after the category) that is also promptfoo's working
|
|
411
|
+
folder, away from the repository and its .npmrc. Only the results, with every credential
|
|
412
|
+
replaced, are copied into the scan's work folder."""
|
|
413
|
+
private = Path(tempfile.mkdtemp(prefix="ikanban-aiscan-")) # mkdtemp creates it 0700
|
|
414
|
+
try:
|
|
415
|
+
return _scan_category(label, target, cfg, workdir, allow_remote, private)
|
|
416
|
+
finally:
|
|
417
|
+
shutil.rmtree(private, ignore_errors=True)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _scan_category(label, target, cfg, workdir, allow_remote, private):
|
|
421
|
+
cfg_path = private / "config.yaml"
|
|
422
|
+
cases_path = private / "cases.yaml"
|
|
423
|
+
out_path = private / "results.json"
|
|
397
424
|
|
|
398
425
|
# promptfoo reads YAML, but its config loader accepts JSON in a .yaml file
|
|
399
426
|
# (YAML 1.2 is a JSON superset) -- which keeps this file free of a YAML
|
|
@@ -401,7 +428,7 @@ def scan_category(label, target, cfg, workdir, allow_remote):
|
|
|
401
428
|
cfg_path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
|
402
429
|
|
|
403
430
|
if not run_promptfoo(
|
|
404
|
-
["redteam", "generate", "-c", str(cfg_path), "-o", str(cases_path)],
|
|
431
|
+
["redteam", "generate", "-c", str(cfg_path), "-o", str(cases_path)], private, allow_remote
|
|
405
432
|
):
|
|
406
433
|
return None
|
|
407
434
|
if not cases_path.exists():
|
|
@@ -411,13 +438,18 @@ def scan_category(label, target, cfg, workdir, allow_remote):
|
|
|
411
438
|
# tests failed, i.e. attacks got through, i.e. exactly the findings we came
|
|
412
439
|
# for. The results file decides, not the exit code.
|
|
413
440
|
run_promptfoo(
|
|
414
|
-
["redteam", "eval", "-c", str(cases_path), "-o", str(out_path), "--no-cache"],
|
|
441
|
+
["redteam", "eval", "-c", str(cases_path), "-o", str(out_path), "--no-cache"], private, allow_remote
|
|
415
442
|
)
|
|
416
443
|
if not out_path.exists():
|
|
417
444
|
log(" promptfoo wrote no results file — the run genuinely failed")
|
|
418
445
|
return None
|
|
419
446
|
|
|
420
|
-
|
|
447
|
+
raw = out_path.read_text(encoding="utf-8")
|
|
448
|
+
redacted = raw
|
|
449
|
+
for secret in sorted(config_secrets(cfg), key=len, reverse=True):
|
|
450
|
+
redacted = redacted.replace(secret, "[hidden]")
|
|
451
|
+
write_private(workdir / f"results-{re.sub(r'[^A-Za-z0-9_-]+', '_', label)}.json", redacted)
|
|
452
|
+
doc = json.loads(raw)
|
|
421
453
|
rows = doc.get("results", {}).get("results", [])
|
|
422
454
|
# Coverage is whatever actually produced test rows. A plugin that needed the
|
|
423
455
|
# hosted generator contributes none, and is named in the report rather than
|
|
@@ -669,11 +701,48 @@ def publish(findings, run_id, base_url, token, team_id, coverage, plugin_results
|
|
|
669
701
|
return False, 0, 0
|
|
670
702
|
|
|
671
703
|
|
|
704
|
+
# ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
|
|
705
|
+
# <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
|
|
706
|
+
# holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
|
|
707
|
+
def add_work_dir_arg(ap):
|
|
708
|
+
ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
|
|
709
|
+
help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
|
|
710
|
+
"<repository>/.iKanban/scans)")
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
|
|
714
|
+
scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
|
|
715
|
+
scans.mkdir(parents=True, exist_ok=True)
|
|
716
|
+
ignore = scans / ".gitignore"
|
|
717
|
+
if not ignore.exists():
|
|
718
|
+
ignore.write_text("*\n")
|
|
719
|
+
run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
|
|
720
|
+
run_dir.mkdir(mode=0o700, exist_ok=True)
|
|
721
|
+
os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
|
|
722
|
+
return run_dir
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def write_private(path: Path, text: str) -> None:
|
|
726
|
+
"""Write a file (0600) without following a symlink planted at its name: write a new
|
|
727
|
+
temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
|
|
728
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
729
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
730
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
731
|
+
f.write(text)
|
|
732
|
+
os.replace(tmp, path)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def slug(text: str) -> str:
|
|
736
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
|
|
737
|
+
|
|
738
|
+
|
|
672
739
|
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
673
740
|
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
674
741
|
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
675
742
|
# then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
|
|
676
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"
|
|
677
746
|
PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
|
|
678
747
|
LAST_RUN = {"report": "", "detail": "", "skipped": False}
|
|
679
748
|
|
|
@@ -757,7 +826,7 @@ class Pipeline:
|
|
|
757
826
|
|
|
758
827
|
def start(self):
|
|
759
828
|
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
|
|
760
|
-
"source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
829
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
761
830
|
|
|
762
831
|
def finish(self, code):
|
|
763
832
|
status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
|
|
@@ -765,10 +834,10 @@ class Pipeline:
|
|
|
765
834
|
if code == 1 and not LAST_RUN["skipped"]:
|
|
766
835
|
detail = (detail + " (at or above --fail-on)").strip()
|
|
767
836
|
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
|
|
768
|
-
"source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
837
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
769
838
|
"detail": detail[:300] or None})
|
|
770
839
|
for chunk in log_chunks(mask_report(LAST_RUN["report"])):
|
|
771
|
-
self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
|
|
840
|
+
self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
|
|
772
841
|
|
|
773
842
|
|
|
774
843
|
def run_main(stage, main_fn, argv=None):
|
|
@@ -828,6 +897,7 @@ def main(argv=None):
|
|
|
828
897
|
"directory. Off by default so a mistyped path cannot overwrite "
|
|
829
898
|
"something elsewhere; turn it on for CI writing to an artifacts dir.")
|
|
830
899
|
add_pipeline_args(ap)
|
|
900
|
+
add_work_dir_arg(ap)
|
|
831
901
|
args = ap.parse_args(argv)
|
|
832
902
|
|
|
833
903
|
# Resolve every caller-supplied path up front, before any scanning happens.
|
|
@@ -837,6 +907,9 @@ def main(argv=None):
|
|
|
837
907
|
args.html = safe_path(args.html, "--html", args.allow_outside_cwd)
|
|
838
908
|
if args.out:
|
|
839
909
|
args.out = safe_path(args.out, "--out", args.allow_outside_cwd)
|
|
910
|
+
# Plan 11 H12-T10: the scan's own folder follows the same rule as every other path.
|
|
911
|
+
if args.work_dir:
|
|
912
|
+
args.work_dir = str(safe_path(args.work_dir, "--work-dir", args.allow_outside_cwd))
|
|
840
913
|
|
|
841
914
|
if args.config:
|
|
842
915
|
args.config = safe_path(args.config, "--config", args.allow_outside_cwd)
|
|
@@ -910,66 +983,65 @@ def main(argv=None):
|
|
|
910
983
|
# than leaving 'no findings' to mean three different things.
|
|
911
984
|
per_category = []
|
|
912
985
|
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
failed.append(label)
|
|
930
|
-
per_category.append(
|
|
931
|
-
{"category": label, "asked": len(plugins), "ran": 0, "skipped": sorted(plugins)}
|
|
932
|
-
)
|
|
933
|
-
continue
|
|
934
|
-
category_findings, ran, cat_errors, tally = got
|
|
935
|
-
total_errors += cat_errors
|
|
936
|
-
plugins_ran |= ran
|
|
937
|
-
# IKA-1786: one row per plugin per category. A plugin can appear in
|
|
938
|
-
# two categories (pii:* spans LLM02 and LLM08), and those are
|
|
939
|
-
# separate measurements -- 'prompt-extraction under LLM07' is the
|
|
940
|
-
# thing a weakest-areas ranking is about, not the plugin in general.
|
|
941
|
-
for plugin, t in sorted(tally.items()):
|
|
942
|
-
plugin_results.append(
|
|
943
|
-
{
|
|
944
|
-
"plugin_id": plugin,
|
|
945
|
-
"llm_category": label,
|
|
946
|
-
"target": args.target,
|
|
947
|
-
"severity": t["severity"],
|
|
948
|
-
"attempted": t["attempted"],
|
|
949
|
-
"failed": t["failed"],
|
|
950
|
-
"errored": t["errored"],
|
|
951
|
-
}
|
|
952
|
-
)
|
|
953
|
-
missing = sorted(set(plugins) - ran)
|
|
954
|
-
plugins_skipped |= set(missing)
|
|
986
|
+
workdir = scan_work_dir("aiscan", Path.cwd(), args.work_dir)
|
|
987
|
+
for _collection, label, plugins in selected:
|
|
988
|
+
if not plugins:
|
|
989
|
+
# LLM03. promptfoo ships no supply-chain plugins at all, so this
|
|
990
|
+
# row can never be earned. Reporting "0 findings" would read as a
|
|
991
|
+
# pass, which is the one thing it must not do.
|
|
992
|
+
skipped.append(label)
|
|
993
|
+
per_category.append({"category": label, "asked": 0, "ran": 0, "skipped": []})
|
|
994
|
+
log(f"{label}: SKIPPED — promptfoo ships no plugins for this category")
|
|
995
|
+
continue
|
|
996
|
+
log(f"{label}: generating and running {args.num_tests} tests per plugin…")
|
|
997
|
+
cfg = build_config(args.target, spec, plugins, args.num_tests,
|
|
998
|
+
args.base_url, args.token, args.workspace_id)
|
|
999
|
+
got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation)
|
|
1000
|
+
if got is None:
|
|
1001
|
+
failed.append(label)
|
|
955
1002
|
per_category.append(
|
|
1003
|
+
{"category": label, "asked": len(plugins), "ran": 0, "skipped": sorted(plugins)}
|
|
1004
|
+
)
|
|
1005
|
+
continue
|
|
1006
|
+
category_findings, ran, cat_errors, tally = got
|
|
1007
|
+
total_errors += cat_errors
|
|
1008
|
+
plugins_ran |= ran
|
|
1009
|
+
# IKA-1786: one row per plugin per category. A plugin can appear in
|
|
1010
|
+
# two categories (pii:* spans LLM02 and LLM08), and those are
|
|
1011
|
+
# separate measurements -- 'prompt-extraction under LLM07' is the
|
|
1012
|
+
# thing a weakest-areas ranking is about, not the plugin in general.
|
|
1013
|
+
for plugin, t in sorted(tally.items()):
|
|
1014
|
+
plugin_results.append(
|
|
956
1015
|
{
|
|
957
|
-
"
|
|
958
|
-
"
|
|
959
|
-
"
|
|
960
|
-
"
|
|
1016
|
+
"plugin_id": plugin,
|
|
1017
|
+
"llm_category": label,
|
|
1018
|
+
"target": args.target,
|
|
1019
|
+
"severity": t["severity"],
|
|
1020
|
+
"attempted": t["attempted"],
|
|
1021
|
+
"failed": t["failed"],
|
|
1022
|
+
"errored": t["errored"],
|
|
961
1023
|
}
|
|
962
1024
|
)
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
1025
|
+
missing = sorted(set(plugins) - ran)
|
|
1026
|
+
plugins_skipped |= set(missing)
|
|
1027
|
+
per_category.append(
|
|
1028
|
+
{
|
|
1029
|
+
"category": label,
|
|
1030
|
+
"asked": len(plugins),
|
|
1031
|
+
"ran": len(set(plugins) & ran),
|
|
1032
|
+
"skipped": missing,
|
|
1033
|
+
}
|
|
1034
|
+
)
|
|
1035
|
+
if missing:
|
|
1036
|
+
uncovered.append((label, missing))
|
|
1037
|
+
log(f" did not run: {', '.join(missing)}")
|
|
1038
|
+
if not ran:
|
|
1039
|
+
log(" NOTHING RAN — every plugin here needs promptfoo's hosted generator")
|
|
1040
|
+
else:
|
|
1041
|
+
scanned += 1
|
|
1042
|
+
log(f" {len(category_findings)} finding(s)"
|
|
1043
|
+
+ (f", {cat_errors} error(s)" if cat_errors else ""))
|
|
1044
|
+
findings.extend(category_findings)
|
|
973
1045
|
|
|
974
1046
|
log("")
|
|
975
1047
|
# Errors get their own number. A run where many tests errored is not a clean
|
|
@@ -995,6 +1067,8 @@ def main(argv=None):
|
|
|
995
1067
|
detail=summary_detail(counts, len(findings), []) + (f"; {len(failed)} categories did not run" if failed else ""),
|
|
996
1068
|
skipped=scanned == 0 and not findings,
|
|
997
1069
|
)
|
|
1070
|
+
write_private(workdir / "report.md", LAST_RUN["report"])
|
|
1071
|
+
log(f"scan files: {workdir}")
|
|
998
1072
|
|
|
999
1073
|
coverage = {
|
|
1000
1074
|
"target": args.target,
|
|
@@ -36,14 +36,13 @@ import re
|
|
|
36
36
|
import shutil
|
|
37
37
|
import subprocess
|
|
38
38
|
import sys
|
|
39
|
-
import tempfile
|
|
40
39
|
import urllib.error
|
|
41
40
|
import urllib.parse
|
|
42
41
|
import urllib.request
|
|
43
42
|
from datetime import datetime, timezone
|
|
44
43
|
from pathlib import Path
|
|
45
44
|
|
|
46
|
-
DASTSCAN_VERSION = "1.
|
|
45
|
+
DASTSCAN_VERSION = "1.1.1"
|
|
47
46
|
|
|
48
47
|
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
49
48
|
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
@@ -141,28 +140,31 @@ def build_report(url: str, findings: list, skipped: list, ran: list) -> tuple:
|
|
|
141
140
|
return "\n".join(L), counts, total
|
|
142
141
|
|
|
143
142
|
|
|
144
|
-
def run_zap(url: str, minutes: int, max_minutes: int) -> tuple[dict | None, str]:
|
|
145
|
-
"""(report, "") or (None, why it did not run)."""
|
|
143
|
+
def run_zap(url: str, minutes: int, max_minutes: int, work: Path) -> tuple[dict | None, str]:
|
|
144
|
+
"""(report, "") or (None, why it did not run). ZAP writes zap.json into <work>/zap/."""
|
|
146
145
|
if not shutil.which("docker"):
|
|
147
146
|
return None, DOCKER_INSTALL
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
147
|
+
# The ZAP image writes as its own user, so it gets its own zap/ subfolder it may write to;
|
|
148
|
+
# the run folder stays 0700, so nobody else on this machine can reach zap/ through it.
|
|
149
|
+
zap_dir = work / "zap"
|
|
150
|
+
zap_dir.mkdir(exist_ok=True)
|
|
151
|
+
os.chmod(zap_dir, 0o777)
|
|
152
|
+
cmd = ["docker", "run", "--rm", "-v", f"{zap_dir}:/zap/wrk/:rw", ZAP_IMAGE, "zap-baseline.py",
|
|
153
|
+
"-t", url, "-J", "zap.json", "-m", str(minutes), "-T", str(max_minutes)]
|
|
154
|
+
try:
|
|
155
|
+
p = subprocess.run(cmd, capture_output=True, text=True, timeout=(max_minutes + 5) * 60)
|
|
156
|
+
except FileNotFoundError:
|
|
157
|
+
return None, DOCKER_INSTALL
|
|
158
|
+
except subprocess.TimeoutExpired:
|
|
159
|
+
return None, f"ZAP did not finish within {max_minutes + 5} minutes"
|
|
160
|
+
out = zap_dir / "zap.json"
|
|
161
|
+
if p.returncode not in (0, 1, 2) or not out.exists():
|
|
162
|
+
why = (p.stderr or p.stdout or "").strip()[-300:]
|
|
163
|
+
return None, f"ZAP failed (exit {p.returncode}): {why or 'no report'}"
|
|
164
|
+
try:
|
|
165
|
+
return json.loads(out.read_text() or "{}"), ""
|
|
166
|
+
except ValueError:
|
|
167
|
+
return None, "ZAP wrote no JSON report"
|
|
166
168
|
|
|
167
169
|
|
|
168
170
|
# ── copied from ikanban-secscan.py: key-host pinning and the API call ──────────────────
|
|
@@ -224,11 +226,48 @@ def registered(url: str, args) -> str | None:
|
|
|
224
226
|
return None
|
|
225
227
|
|
|
226
228
|
|
|
229
|
+
# ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
|
|
230
|
+
# <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
|
|
231
|
+
# holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
|
|
232
|
+
def add_work_dir_arg(ap):
|
|
233
|
+
ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
|
|
234
|
+
help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
|
|
235
|
+
"<repository>/.iKanban/scans)")
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
|
|
239
|
+
scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
|
|
240
|
+
scans.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
ignore = scans / ".gitignore"
|
|
242
|
+
if not ignore.exists():
|
|
243
|
+
ignore.write_text("*\n")
|
|
244
|
+
run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
|
|
245
|
+
run_dir.mkdir(mode=0o700, exist_ok=True)
|
|
246
|
+
os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
|
|
247
|
+
return run_dir
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def write_private(path: Path, text: str) -> None:
|
|
251
|
+
"""Write a file (0600) without following a symlink planted at its name: write a new
|
|
252
|
+
temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
|
|
253
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
254
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
255
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
256
|
+
f.write(text)
|
|
257
|
+
os.replace(tmp, path)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def slug(text: str) -> str:
|
|
261
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
|
|
262
|
+
|
|
263
|
+
|
|
227
264
|
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
228
265
|
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
229
266
|
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
230
267
|
# then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
|
|
231
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"
|
|
232
271
|
PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
|
|
233
272
|
LAST_RUN = {"report": "", "detail": "", "skipped": False}
|
|
234
273
|
|
|
@@ -312,7 +351,7 @@ class Pipeline:
|
|
|
312
351
|
|
|
313
352
|
def start(self):
|
|
314
353
|
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
|
|
315
|
-
"source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
354
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
316
355
|
|
|
317
356
|
def finish(self, code):
|
|
318
357
|
status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
|
|
@@ -320,10 +359,10 @@ class Pipeline:
|
|
|
320
359
|
if code == 1 and not LAST_RUN["skipped"]:
|
|
321
360
|
detail = (detail + " (at or above --fail-on)").strip()
|
|
322
361
|
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
|
|
323
|
-
"source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
362
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
324
363
|
"detail": detail[:300] or None})
|
|
325
364
|
for chunk in log_chunks(mask_report(LAST_RUN["report"])):
|
|
326
|
-
self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
|
|
365
|
+
self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
|
|
327
366
|
|
|
328
367
|
|
|
329
368
|
def run_main(stage, main_fn, argv=None):
|
|
@@ -369,6 +408,7 @@ def main(argv=None):
|
|
|
369
408
|
ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
|
|
370
409
|
help="Exit 1 if any finding is at or above this severity")
|
|
371
410
|
add_pipeline_args(ap)
|
|
411
|
+
add_work_dir_arg(ap)
|
|
372
412
|
args = ap.parse_args(argv)
|
|
373
413
|
|
|
374
414
|
if args.list_targets:
|
|
@@ -404,12 +444,14 @@ def main(argv=None):
|
|
|
404
444
|
print(f"error: {why}", file=sys.stderr)
|
|
405
445
|
return 2
|
|
406
446
|
|
|
407
|
-
|
|
447
|
+
work = scan_work_dir("dastscan", Path.cwd(), args.work_dir)
|
|
448
|
+
report, skipped_why = run_zap(url, args.minutes, args.max_minutes, work)
|
|
408
449
|
findings = parse_zap(report) if report is not None else []
|
|
409
450
|
ran = [f"zap-baseline.py -t {url} -m {args.minutes}"] if report is not None else []
|
|
410
451
|
md, counts, total = build_report(url, findings, [skipped_why] if skipped_why else [], ran)
|
|
411
452
|
LAST_RUN.update(report=md, detail=summary_detail(counts, total, [] if ran else [skipped_why]),
|
|
412
453
|
skipped=not ran)
|
|
454
|
+
write_private(work / "report.md", md)
|
|
413
455
|
if args.out:
|
|
414
456
|
Path(args.out).write_text(md)
|
|
415
457
|
if args.json:
|
|
@@ -417,6 +459,7 @@ def main(argv=None):
|
|
|
417
459
|
else:
|
|
418
460
|
print(md)
|
|
419
461
|
sys.stdout.flush()
|
|
462
|
+
print(f"scan files: {work}", file=sys.stderr)
|
|
420
463
|
|
|
421
464
|
if args.publish:
|
|
422
465
|
ok, info = api("POST", "/security/scan-reports", args.base_url, args.token,
|
|
@@ -3,10 +3,19 @@
|
|
|
3
3
|
ikanban-infrascan — container image and IaC (infrastructure as code) scan of YOUR OWN
|
|
4
4
|
repository and images, reporting into iKanban's Infrastructure tab (plan 11 H12).
|
|
5
5
|
|
|
6
|
-
npx ikanban infrascan # IaC
|
|
6
|
+
npx ikanban infrascan # inventory + IaC scan of cwd
|
|
7
|
+
npx ikanban infrascan --path . --path ../infra # infra in another folder or repo too
|
|
7
8
|
npx ikanban infrascan --image myapp:latest # also scan a container image
|
|
8
9
|
npx ikanban infrascan --publish --team TEAM # file the report and its findings
|
|
9
10
|
|
|
11
|
+
Plan 11 H12-T10 (owner: assume messy development). It starts with an inventory of each path:
|
|
12
|
+
what infrastructure it found, what Trivy scanned, and what it could not scan and why. Infra
|
|
13
|
+
written as code (CDK, Pulumi, CDK for Terraform, Vagrant, Ansible, cloud-SDK scripts) is listed
|
|
14
|
+
as found but not scannable. Images named in docker-compose files are scanned too. A path with no
|
|
15
|
+
infrastructure-as-code file is never reported complete, so its control stays Missing.
|
|
16
|
+
Raw Trivy JSON and the report go to <first path>/.iKanban/scans/infrascan-<UTC time>/ (or
|
|
17
|
+
--work-dir / IKANBAN_SCAN_DIR); that folder ignores itself in git.
|
|
18
|
+
|
|
10
19
|
It runs Trivy (Apache-2.0, https://github.com/aquasecurity/trivy), which must already be
|
|
11
20
|
installed; a missing Trivy is reported as SKIPPED with the install lines, never as a clean
|
|
12
21
|
scan, and nothing is filed for a check that did not run.
|
|
@@ -35,7 +44,6 @@ import re
|
|
|
35
44
|
import shutil
|
|
36
45
|
import subprocess
|
|
37
46
|
import sys
|
|
38
|
-
import tempfile
|
|
39
47
|
import urllib.error
|
|
40
48
|
import urllib.parse
|
|
41
49
|
import urllib.request
|
|
@@ -43,7 +51,7 @@ from dataclasses import dataclass, field
|
|
|
43
51
|
from datetime import datetime, timezone
|
|
44
52
|
from pathlib import Path
|
|
45
53
|
|
|
46
|
-
INFRASCAN_VERSION = "1.
|
|
54
|
+
INFRASCAN_VERSION = "1.1.1"
|
|
47
55
|
|
|
48
56
|
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
49
57
|
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
@@ -77,6 +85,31 @@ class Section:
|
|
|
77
85
|
skipped: list = field(default_factory=list)
|
|
78
86
|
ran: list = field(default_factory=list)
|
|
79
87
|
complete: bool = False
|
|
88
|
+
filed: list | None = None # what publish sends; None = every finding (plan_filing sets it)
|
|
89
|
+
notes: list = field(default_factory=list)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# The server takes at most this many findings in one publish (security_scan_findings.rs).
|
|
93
|
+
MAX_FILED = 500
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def plan_filing(sec: Section) -> None:
|
|
97
|
+
"""Plan 11 H12-T10 review: container images file critical and high only (the rest stay in
|
|
98
|
+
the report); any section over MAX_FILED files the worst MAX_FILED and is published
|
|
99
|
+
incomplete, so the rescan never closes findings that were only cut."""
|
|
100
|
+
keep = sec.findings
|
|
101
|
+
if sec.kind == "container":
|
|
102
|
+
keep = [f for f in keep if f.severity in ("critical", "high")]
|
|
103
|
+
if len(keep) < len(sec.findings):
|
|
104
|
+
sec.notes.append(f"{len(sec.findings) - len(keep)} medium and low findings are in this report only; "
|
|
105
|
+
"iKanban files critical and high for container images.")
|
|
106
|
+
if len(keep) > MAX_FILED:
|
|
107
|
+
keep = sorted(keep, key=lambda f: (SEVERITY_RANK.get(f.severity, 99), f.title))
|
|
108
|
+
sec.notes.append(f"Filed the {MAX_FILED} worst; {len(keep) - MAX_FILED} more were left out of iKanban "
|
|
109
|
+
"(this run counts as incomplete, so nothing closes).")
|
|
110
|
+
keep = keep[:MAX_FILED]
|
|
111
|
+
sec.complete = False
|
|
112
|
+
sec.filed = keep
|
|
80
113
|
|
|
81
114
|
|
|
82
115
|
def run(cmd, cwd=None, timeout=900):
|
|
@@ -90,19 +123,29 @@ def run(cmd, cwd=None, timeout=900):
|
|
|
90
123
|
return False, "", f"timed out after {timeout}s"
|
|
91
124
|
|
|
92
125
|
|
|
93
|
-
def trivy_json(args: list) -> tuple[dict | None, str]:
|
|
94
|
-
"""Run trivy with --format json into
|
|
126
|
+
def trivy_json(args: list, target: str, out: Path) -> tuple[dict | None, str]:
|
|
127
|
+
"""Run `trivy SUBCOMMAND [ARGS] -- TARGET` with --format json into `out`; (report, "") or
|
|
128
|
+
(None, why skipped). Plan 11 H12-T10 review: trivy runs from the scan's work folder with a
|
|
129
|
+
scanner-owned --config and --ignorefile (both default to files in the working folder,
|
|
130
|
+
trivy.dev CLI reference: `-c, --config ... (default "trivy.yaml")`, `--ignorefile ...
|
|
131
|
+
(default ".trivyignore")`), so a repository's trivy.yaml or .trivyignore changes nothing.
|
|
132
|
+
The target comes after `--`, so a name starting with '-' can never be read as a flag."""
|
|
95
133
|
if not shutil.which("trivy"):
|
|
96
134
|
return None, TRIVY_INSTALL
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
135
|
+
work = out.parent
|
|
136
|
+
conf, ignore = work / "trivy-config.yaml", work / "trivyignore"
|
|
137
|
+
if not conf.exists():
|
|
138
|
+
write_private(conf, "{}\n")
|
|
139
|
+
if not ignore.exists():
|
|
140
|
+
write_private(ignore, "")
|
|
141
|
+
ok, _o, err = run(["trivy", *args[:1], "--config", str(conf), "--ignorefile", str(ignore),
|
|
142
|
+
"--format", "json", "--output", str(out), *args[1:], "--", target], cwd=work)
|
|
143
|
+
if not ok or not out.exists():
|
|
144
|
+
return None, f"trivy {args[0]} failed: {(err or '').strip()[-300:] or 'no output'}"
|
|
145
|
+
try:
|
|
146
|
+
return json.loads(out.read_text() or "{}"), ""
|
|
147
|
+
except ValueError:
|
|
148
|
+
return None, f"trivy {args[0]} wrote no JSON"
|
|
106
149
|
|
|
107
150
|
|
|
108
151
|
def parse_misconfigurations(report: dict) -> list:
|
|
@@ -142,23 +185,35 @@ def parse_vulnerabilities(report: dict, image: str) -> list:
|
|
|
142
185
|
return out
|
|
143
186
|
|
|
144
187
|
|
|
145
|
-
def scan_iac(root: Path, scope: str) -> Section:
|
|
146
|
-
sec = Section("IaC misconfiguration", "iac", scope)
|
|
147
|
-
|
|
188
|
+
def scan_iac(root: Path, scope: str, inv: dict, work: Path) -> Section:
|
|
189
|
+
sec = Section(f"IaC misconfiguration — {scope}", "iac", scope)
|
|
190
|
+
if not any(inv[c] for c in TRIVY_IAC):
|
|
191
|
+
# Owner: no IaC file found is not a pass. complete stays False: the control stays Missing.
|
|
192
|
+
sec.skipped.append(f"No infrastructure-as-code files found in {root} — if the infrastructure lives in "
|
|
193
|
+
"another folder or repository, add it with another --path")
|
|
194
|
+
return sec
|
|
195
|
+
# ponytail: trivy.dev does not say what --skip-dirs is relative to with an absolute target;
|
|
196
|
+
# the absolute and the ** form are both passed. A miss only means a vendored folder is read too.
|
|
197
|
+
skip = [x for d in SKIP_DIRS for x in ("--skip-dirs", f"{root.resolve()}/{d}", "--skip-dirs", f"**/{d}")]
|
|
198
|
+
report, why = trivy_json(["fs", "--scanners", "misconfig", *skip], str(root.resolve()),
|
|
199
|
+
work / f"trivy-iac-{slug(scope)}.json")
|
|
148
200
|
if report is None:
|
|
149
201
|
sec.skipped.append(why)
|
|
150
202
|
return sec
|
|
151
203
|
sec.ran.append(f"trivy fs --scanners misconfig {root.name}")
|
|
152
204
|
sec.findings = parse_misconfigurations(report)
|
|
153
205
|
for f in sec.findings: # paths relative to the repository, like secscan
|
|
154
|
-
f.location = f.location.removeprefix(str(root) + os.sep)
|
|
206
|
+
f.location = f.location.removeprefix(str(root) + os.sep).removeprefix(str(root.resolve()) + os.sep)
|
|
155
207
|
sec.complete = True
|
|
156
208
|
return sec
|
|
157
209
|
|
|
158
210
|
|
|
159
|
-
def scan_image(image: str) -> Section:
|
|
211
|
+
def scan_image(image: str, work: Path) -> Section:
|
|
160
212
|
sec = Section(f"Container image {image}", "container", image)
|
|
161
|
-
|
|
213
|
+
if not valid_image_ref(image):
|
|
214
|
+
sec.skipped.append(f"`{image[:80]}` is not a valid image name; not scanned")
|
|
215
|
+
return sec
|
|
216
|
+
report, why = trivy_json(["image"], image, work / f"trivy-image-{slug(image)}.json")
|
|
162
217
|
if report is None:
|
|
163
218
|
sec.skipped.append(why)
|
|
164
219
|
return sec
|
|
@@ -168,6 +223,155 @@ def scan_image(image: str) -> Section:
|
|
|
168
223
|
return sec
|
|
169
224
|
|
|
170
225
|
|
|
226
|
+
# ── Inventory (plan 11 H12-T10) ───────────────────────────────────────────────────────
|
|
227
|
+
# What Trivy's misconfiguration scanner reads, from trivy.dev "IaC" coverage (Kubernetes,
|
|
228
|
+
# Docker: Dockerfile/Containerfile, Terraform: *.tf *.tf.json, CloudFormation, Azure ARM,
|
|
229
|
+
# Helm). docker-compose, Ansible, Bicep, CDK, Pulumi and Vagrant are not in that table.
|
|
230
|
+
TRIVY_IAC = ("Dockerfile", "Kubernetes manifest", "Helm chart", "Terraform", "CloudFormation", "Azure ARM template")
|
|
231
|
+
NOT_SCANNABLE = {
|
|
232
|
+
"Dockerfile with another name": "Trivy reads files named Dockerfile or Containerfile; this one may not be checked",
|
|
233
|
+
"docker-compose file": "its images are scanned as containers; the compose settings themselves are not checked",
|
|
234
|
+
"AWS CDK app": "run `npx cdk synth` and rescan: the templates in cdk.out are scanned as CloudFormation",
|
|
235
|
+
"CDK for Terraform app": "run `cdktf synth` and rescan: the Terraform JSON in cdktf.out is scanned",
|
|
236
|
+
"Pulumi program": "found, not scannable: review by hand or audit the cloud account",
|
|
237
|
+
"Vagrant": "found, not scannable: provisioning by script; review by hand",
|
|
238
|
+
"Ansible playbook": "found, not scannable by Trivy: review by hand",
|
|
239
|
+
"Azure Bicep": "run `az bicep build` and rescan: the ARM JSON it writes is scanned",
|
|
240
|
+
"Cloud SDK code": "found, not scannable: infrastructure created in code; review by hand or audit the cloud account",
|
|
241
|
+
}
|
|
242
|
+
# Imports that mean code talks to a cloud API (examples; the real set is broader).
|
|
243
|
+
SDK_MARKERS = ("import boto3", "from boto3", "from botocore", "@aws-sdk/", "'aws-sdk'", '"aws-sdk"',
|
|
244
|
+
"github.com/aws/aws-sdk-go", "software.amazon.awssdk", "from google.cloud",
|
|
245
|
+
"from google.cloud import", "@google-cloud/", "cloud.google.com/go/", "azure.mgmt",
|
|
246
|
+
"@azure/arm-", "github.com/Azure/azure-sdk-for-go")
|
|
247
|
+
SDK_EXTS = {".py", ".js", ".mjs", ".cjs", ".ts", ".go", ".java", ".kt", ".cs", ".rb"}
|
|
248
|
+
SKIP_DIRS = (".git", "node_modules", "vendor", "dist", "build", ".venv", "venv", "target", ".iKanban/scans",
|
|
249
|
+
".terraform", "__pycache__")
|
|
250
|
+
MAX_READ = 512 * 1024
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _head(p: Path) -> str:
|
|
254
|
+
try:
|
|
255
|
+
with p.open("rb") as f:
|
|
256
|
+
return f.read(MAX_READ).decode("utf-8", "ignore")
|
|
257
|
+
except OSError:
|
|
258
|
+
return ""
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _skipped(rel: Path) -> bool:
|
|
262
|
+
parts = rel.parts
|
|
263
|
+
return any(d in parts for d in SKIP_DIRS if "/" not in d) or rel.as_posix().startswith(".iKanban/scans")
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def inventory(root: Path) -> dict:
|
|
267
|
+
"""Infra files under root, by kind (paths relative to root). Never raises."""
|
|
268
|
+
inv = {k: [] for k in (*TRIVY_IAC, *NOT_SCANNABLE)}
|
|
269
|
+
files = []
|
|
270
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
271
|
+
rel_dir = Path(dirpath).relative_to(root)
|
|
272
|
+
dirnames[:] = [d for d in dirnames if not _skipped(rel_dir / d)]
|
|
273
|
+
files += [rel_dir / f for f in filenames]
|
|
274
|
+
charts = {f.parent for f in files if f.name == "Chart.yaml"}
|
|
275
|
+
inv["Helm chart"] = sorted(c.as_posix() or "." for c in charts)
|
|
276
|
+
for rel in sorted(files):
|
|
277
|
+
name, low = rel.name, rel.name.lower()
|
|
278
|
+
posix = rel.as_posix()
|
|
279
|
+
in_chart = any(c == rel.parent or c in rel.parents for c in charts)
|
|
280
|
+
if name in ("Dockerfile", "Containerfile"):
|
|
281
|
+
inv["Dockerfile"].append(posix)
|
|
282
|
+
elif low.endswith(".dockerfile") or low.startswith("dockerfile."):
|
|
283
|
+
inv["Dockerfile with another name"].append(posix)
|
|
284
|
+
elif re.match(r"^(docker-)?compose[\w.-]*\.ya?ml$", low):
|
|
285
|
+
inv["docker-compose file"].append(posix)
|
|
286
|
+
elif low.endswith((".tf", ".tf.json")):
|
|
287
|
+
inv["Terraform"].append(posix)
|
|
288
|
+
elif name == "cdk.json":
|
|
289
|
+
inv["AWS CDK app"].append(posix)
|
|
290
|
+
elif name == "cdktf.json":
|
|
291
|
+
inv["CDK for Terraform app"].append(posix)
|
|
292
|
+
elif name in ("Pulumi.yaml", "Pulumi.yml"):
|
|
293
|
+
inv["Pulumi program"].append(posix)
|
|
294
|
+
elif name == "Vagrantfile":
|
|
295
|
+
inv["Vagrant"].append(posix)
|
|
296
|
+
elif low.endswith(".bicep"):
|
|
297
|
+
inv["Azure Bicep"].append(posix)
|
|
298
|
+
elif low.endswith((".yaml", ".yml", ".json")) and not in_chart:
|
|
299
|
+
text = _head(root / rel)
|
|
300
|
+
if "AWSTemplateFormatVersion" in text or re.search(r"""["']?Type["']?\s*:\s*["']?AWS::""", text):
|
|
301
|
+
inv["CloudFormation"].append(posix)
|
|
302
|
+
elif low.endswith(".json") and "deploymentTemplate.json" in text:
|
|
303
|
+
inv["Azure ARM template"].append(posix)
|
|
304
|
+
elif re.search(r"(?m)^apiVersion:\s*\S", text) and re.search(r"(?m)^kind:\s*\S", text):
|
|
305
|
+
inv["Kubernetes manifest"].append(posix)
|
|
306
|
+
elif re.search(r"(?m)^-\s+(name:.*\n\s+)?hosts:\s*\S", text) and re.search(r"(?m)^\s+(tasks|roles):", text):
|
|
307
|
+
inv["Ansible playbook"].append(posix)
|
|
308
|
+
elif rel.suffix in SDK_EXTS:
|
|
309
|
+
text = _head(root / rel)
|
|
310
|
+
if any(m in text for m in SDK_MARKERS):
|
|
311
|
+
inv["Cloud SDK code"].append(posix)
|
|
312
|
+
return inv
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def cdk_note(inv: dict) -> str:
|
|
316
|
+
"""CDK synth output (cdk.out) holds CloudFormation templates: say they were scanned."""
|
|
317
|
+
synthed = [p for p in inv["CloudFormation"] if p.split("/")[-2:-1] == ["cdk.out"] or "/cdk.out/" in f"/{p}"]
|
|
318
|
+
return "synthesized templates in cdk.out are scanned as CloudFormation" if synthed else NOT_SCANNABLE["AWS CDK app"]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
# Plan 11 H12-T10 review: compose `image:` values come from the repository, so only a plain
|
|
322
|
+
# image reference (registry[:port]/path:tag@sha256:digest, lower case, no leading '-') may
|
|
323
|
+
# reach trivy's command line.
|
|
324
|
+
IMAGE_REF = re.compile(
|
|
325
|
+
r"^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?(?::[0-9]{1,5})?" # registry host or first path part
|
|
326
|
+
r"(?:/[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?)*" # more path parts
|
|
327
|
+
r"(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?" # tag
|
|
328
|
+
r"(?:@sha256:[a-f0-9]{64})?$")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def valid_image_ref(ref: str) -> bool:
|
|
332
|
+
return 0 < len(ref) <= 255 and ".." not in ref and bool(IMAGE_REF.match(ref))
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
IMAGE_LINE = re.compile(r"""^\s*image:\s*["']?([^"'\s#]+)["']?\s*(#.*)?$""")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def compose_images(root: Path, inv: dict) -> tuple[list, list, list]:
|
|
339
|
+
"""(images named in compose files, names with ${VARS}, names that are not a valid image)."""
|
|
340
|
+
images, unresolved, rejected = [], [], []
|
|
341
|
+
for rel in inv["docker-compose file"]:
|
|
342
|
+
for line in _head(root / rel).splitlines():
|
|
343
|
+
m = IMAGE_LINE.match(line)
|
|
344
|
+
if not m:
|
|
345
|
+
continue
|
|
346
|
+
name = m.group(1)
|
|
347
|
+
(unresolved if "$" in name else images if valid_image_ref(name) else rejected).append(name)
|
|
348
|
+
return sorted(set(images)), sorted(set(unresolved)), sorted(set(rejected))
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def inventory_rows(inv: dict, scanned_iac: bool, iac_why: str, rejected: list = ()) -> list:
|
|
352
|
+
"""Markdown table rows: what | found | scanned | note."""
|
|
353
|
+
rows = []
|
|
354
|
+
if rejected:
|
|
355
|
+
shown = ", ".join(f"`{r[:60]}`" for r in rejected[:5])
|
|
356
|
+
rows.append(f"| Image name in a compose file | {len(rejected)}: {shown} | no | not a valid image name, "
|
|
357
|
+
"so it was not passed to Trivy; check the compose file |")
|
|
358
|
+
for kind, paths in inv.items():
|
|
359
|
+
if not paths:
|
|
360
|
+
continue
|
|
361
|
+
shown = ", ".join(f"`{p}`" for p in paths[:5]) + (f" (+{len(paths) - 5} more)" if len(paths) > 5 else "")
|
|
362
|
+
if kind in TRIVY_IAC:
|
|
363
|
+
state, note = ("yes", "Trivy misconfiguration checks") if scanned_iac else ("no", iac_why)
|
|
364
|
+
elif kind == "AWS CDK app":
|
|
365
|
+
note = cdk_note(inv)
|
|
366
|
+
state = "yes (cdk.out)" if note.startswith("synthesized") else "no"
|
|
367
|
+
elif kind == "docker-compose file":
|
|
368
|
+
state, note = "images only", NOT_SCANNABLE[kind]
|
|
369
|
+
else:
|
|
370
|
+
state, note = "no", NOT_SCANNABLE[kind]
|
|
371
|
+
rows.append(f"| {kind} | {len(paths)}: {shown} | {state} | {note} |")
|
|
372
|
+
return rows
|
|
373
|
+
|
|
374
|
+
|
|
171
375
|
def build_report(title: str, sections: list) -> tuple:
|
|
172
376
|
counts = {s: 0 for s in SEVERITIES}
|
|
173
377
|
for sec in sections:
|
|
@@ -181,6 +385,10 @@ def build_report(title: str, sections: list) -> tuple:
|
|
|
181
385
|
if not total:
|
|
182
386
|
L.append("| _none_ | 0 |")
|
|
183
387
|
L += ["", f"**{total} finding(s).**", ""]
|
|
388
|
+
for scope, rows in INVENTORY:
|
|
389
|
+
L += [f"## Inventory — {scope}", "", "| What | Found | Scanned | Note |", "|---|---|---|---|"]
|
|
390
|
+
L += rows or ["| _no infrastructure files found_ | 0 | — | add the folder or repository that holds them with another --path |"]
|
|
391
|
+
L.append("")
|
|
184
392
|
for sec in sections:
|
|
185
393
|
L.append(f"## {sec.name}")
|
|
186
394
|
if sec.ran:
|
|
@@ -189,13 +397,16 @@ def build_report(title: str, sections: list) -> tuple:
|
|
|
189
397
|
L.append(f"- ⚠️ **SKIPPED** — {s}")
|
|
190
398
|
if not sec.findings and sec.complete:
|
|
191
399
|
L.append("- ✅ no findings")
|
|
400
|
+
for n in sec.notes:
|
|
401
|
+
L.append(f"- ℹ️ {n}")
|
|
192
402
|
for f in sorted(sec.findings, key=lambda x: (SEVERITY_RANK.get(x.severity, 99), x.title)):
|
|
193
403
|
loc = f" — `{f.location}{':' + f.line if f.line else ''}`" if f.location else ""
|
|
194
404
|
L.append(f"- **[{f.severity}]** {f.title}{loc}")
|
|
195
405
|
if f.detail:
|
|
196
406
|
L.append(f" - {f.detail}")
|
|
197
407
|
L.append("")
|
|
198
|
-
L += ["---", "", "Skipped checks are gaps, not passes — install the named tool and re-run."
|
|
408
|
+
L += ["---", "", "Skipped checks are gaps, not passes — install the named tool and re-run.",
|
|
409
|
+
"Found but not scannable is a gap too: review it by hand."]
|
|
199
410
|
return "\n".join(L), counts, total
|
|
200
411
|
|
|
201
412
|
|
|
@@ -218,7 +429,7 @@ def findings_payload(sec: Section, home: str | None = None) -> list:
|
|
|
218
429
|
"location_file": clean(f.location) or None,
|
|
219
430
|
"location_lines": f.line,
|
|
220
431
|
"remediation": clean(" ".join(x for x in (f.detail, f.remediation) if x)),
|
|
221
|
-
} for f in sec.findings]
|
|
432
|
+
} for f in (sec.filed if sec.filed is not None else sec.findings)]
|
|
222
433
|
|
|
223
434
|
|
|
224
435
|
# ── copied from ikanban-secscan.py: scope, key-host pinning, API ─────────────────────
|
|
@@ -306,11 +517,15 @@ def publish(md: str, title: str, sections: list, args) -> int:
|
|
|
306
517
|
if not ok:
|
|
307
518
|
return 2
|
|
308
519
|
for sec in sections:
|
|
309
|
-
|
|
520
|
+
nothing_found = any(x.startswith("No infrastructure-as-code files found") for x in sec.skipped)
|
|
521
|
+
cut = any("more were left out" in n for n in sec.notes)
|
|
522
|
+
if not sec.complete and not nothing_found and not cut:
|
|
310
523
|
print(f"{sec.name}: did not run, nothing filed or closed", file=sys.stderr)
|
|
311
524
|
continue
|
|
525
|
+
# A path with no IaC file is recorded as an incomplete run: iKanban shows when it was
|
|
526
|
+
# checked, the control stays Missing, and nothing closes.
|
|
312
527
|
ok, data = api("POST", f"/teams/{args.team}/security/scan-findings", args.base_url, args.token,
|
|
313
|
-
{"findings": findings_payload(sec), "scope": sec.scope, "complete":
|
|
528
|
+
{"findings": findings_payload(sec), "scope": sec.scope, "complete": sec.complete,
|
|
314
529
|
"kind": sec.kind})
|
|
315
530
|
if not ok:
|
|
316
531
|
print(f"filing {sec.name} FAILED: {data or 'this iKanban server does not take infrastructure findings yet'}",
|
|
@@ -322,13 +537,51 @@ def publish(md: str, title: str, sections: list, args) -> int:
|
|
|
322
537
|
return 0
|
|
323
538
|
|
|
324
539
|
|
|
540
|
+
# ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
|
|
541
|
+
# <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
|
|
542
|
+
# holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
|
|
543
|
+
def add_work_dir_arg(ap):
|
|
544
|
+
ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
|
|
545
|
+
help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
|
|
546
|
+
"<repository>/.iKanban/scans)")
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
|
|
550
|
+
scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
|
|
551
|
+
scans.mkdir(parents=True, exist_ok=True)
|
|
552
|
+
ignore = scans / ".gitignore"
|
|
553
|
+
if not ignore.exists():
|
|
554
|
+
ignore.write_text("*\n")
|
|
555
|
+
run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
|
|
556
|
+
run_dir.mkdir(mode=0o700, exist_ok=True)
|
|
557
|
+
os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
|
|
558
|
+
return run_dir
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def write_private(path: Path, text: str) -> None:
|
|
562
|
+
"""Write a file (0600) without following a symlink planted at its name: write a new
|
|
563
|
+
temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
|
|
564
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
565
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
566
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
567
|
+
f.write(text)
|
|
568
|
+
os.replace(tmp, path)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def slug(text: str) -> str:
|
|
572
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
|
|
573
|
+
|
|
574
|
+
|
|
325
575
|
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
326
576
|
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
327
577
|
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
328
578
|
# then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
|
|
329
579
|
_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)
|
|
580
|
+
# Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
|
|
581
|
+
PIPE_RUN = "security-scan"
|
|
330
582
|
PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
|
|
331
583
|
LAST_RUN = {"report": "", "detail": "", "skipped": False}
|
|
584
|
+
INVENTORY: list = [] # (scope, table rows) per --path, for the report
|
|
332
585
|
|
|
333
586
|
|
|
334
587
|
def add_pipeline_args(ap):
|
|
@@ -410,7 +663,7 @@ class Pipeline:
|
|
|
410
663
|
|
|
411
664
|
def start(self):
|
|
412
665
|
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
|
|
413
|
-
"source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
666
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
414
667
|
|
|
415
668
|
def finish(self, code):
|
|
416
669
|
status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
|
|
@@ -418,10 +671,10 @@ class Pipeline:
|
|
|
418
671
|
if code == 1 and not LAST_RUN["skipped"]:
|
|
419
672
|
detail = (detail + " (at or above --fail-on)").strip()
|
|
420
673
|
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
|
|
421
|
-
"source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
674
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
422
675
|
"detail": detail[:300] or None})
|
|
423
676
|
for chunk in log_chunks(mask_report(LAST_RUN["report"])):
|
|
424
|
-
self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
|
|
677
|
+
self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
|
|
425
678
|
|
|
426
679
|
|
|
427
680
|
def run_main(stage, main_fn, argv=None):
|
|
@@ -451,10 +704,13 @@ def run_main(stage, main_fn, argv=None):
|
|
|
451
704
|
def main(argv=None):
|
|
452
705
|
ap = argparse.ArgumentParser(description="Container image and IaC scan (Trivy) that reports into iKanban.")
|
|
453
706
|
ap.add_argument("--version", action="version", version=f"ikanban infrascan {INFRASCAN_VERSION}")
|
|
454
|
-
ap.add_argument("--path",
|
|
707
|
+
ap.add_argument("--path", action="append", default=[],
|
|
708
|
+
help="A folder or repository holding infrastructure files (repeat for each; default: cwd)")
|
|
455
709
|
ap.add_argument("--image", action="append", default=[], metavar="IMAGE",
|
|
456
710
|
help="Also scan this container image (repeat for each image)")
|
|
457
711
|
ap.add_argument("--no-iac", action="store_true", help="Scan only the --image images")
|
|
712
|
+
ap.add_argument("--no-compose-images", action="store_true",
|
|
713
|
+
help="Do not scan the images that docker-compose files name")
|
|
458
714
|
ap.add_argument("--publish", action="store_true", help="File the report and findings into iKanban")
|
|
459
715
|
ap.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""),
|
|
460
716
|
help="Team UUID to publish into (or IKANBAN_TEAM_ID)")
|
|
@@ -466,34 +722,59 @@ def main(argv=None):
|
|
|
466
722
|
ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
|
|
467
723
|
help="Exit 1 if any finding is at or above this severity")
|
|
468
724
|
add_pipeline_args(ap)
|
|
725
|
+
add_work_dir_arg(ap)
|
|
469
726
|
args = ap.parse_args(argv)
|
|
470
727
|
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
728
|
+
roots = [Path(p).expanduser().resolve() for p in (args.path or ["."])]
|
|
729
|
+
for root in roots:
|
|
730
|
+
if not root.is_dir():
|
|
731
|
+
print(f"error: {root} is not a directory", file=sys.stderr)
|
|
732
|
+
return 2
|
|
733
|
+
root = roots[0]
|
|
475
734
|
if args.publish and (not args.token or not args.team):
|
|
476
735
|
print("error: --publish needs --token (or VIBE_API_TOKEN) and --team (or IKANBAN_TEAM_ID)",
|
|
477
736
|
file=sys.stderr)
|
|
478
737
|
return 2
|
|
479
738
|
|
|
480
|
-
|
|
481
|
-
sections += [scan_image(i) for i in args.image]
|
|
482
|
-
if not sections:
|
|
739
|
+
if args.no_iac and not args.image:
|
|
483
740
|
print("error: nothing to scan: drop --no-iac or add --image", file=sys.stderr)
|
|
484
741
|
return 2
|
|
485
|
-
|
|
486
|
-
|
|
742
|
+
work = scan_work_dir("infrascan", root, args.work_dir)
|
|
743
|
+
sections, images, unresolved = [], list(args.image), []
|
|
744
|
+
INVENTORY.clear()
|
|
745
|
+
for r in roots:
|
|
746
|
+
inv = inventory(r)
|
|
747
|
+
rejected = []
|
|
748
|
+
if not args.no_compose_images:
|
|
749
|
+
found, skipped_vars, rejected = compose_images(r, inv)
|
|
750
|
+
images += [i for i in found if i not in images]
|
|
751
|
+
unresolved += skipped_vars
|
|
752
|
+
if not args.no_iac:
|
|
753
|
+
sec = scan_iac(r, scan_scope(r), inv, work)
|
|
754
|
+
sections.append(sec)
|
|
755
|
+
INVENTORY.append((sec.scope, inventory_rows(inv, sec.complete, "; ".join(sec.skipped), rejected)))
|
|
756
|
+
else:
|
|
757
|
+
INVENTORY.append((scan_scope(r), inventory_rows(inv, False, "--no-iac", rejected)))
|
|
758
|
+
sections += [scan_image(i, work) for i in images]
|
|
759
|
+
if unresolved:
|
|
760
|
+
sections.append(Section("Compose images not scanned", "container", "compose",
|
|
761
|
+
skipped=[f"`{i}` uses a ${{VARIABLE}}; pass the real name with --image" for i in unresolved]))
|
|
762
|
+
for sec in sections:
|
|
763
|
+
plan_filing(sec)
|
|
764
|
+
md, counts, total = build_report(", ".join(r.name for r in roots), sections)
|
|
765
|
+
write_private(work / "report.md", md)
|
|
766
|
+
nothing_ran = not any(sec.ran for sec in sections) # a cut section still ran
|
|
487
767
|
LAST_RUN.update(report=md, skipped=nothing_ran, detail=summary_detail(
|
|
488
768
|
counts, total, [s for sec in sections for s in sec.skipped] if nothing_ran else []))
|
|
489
769
|
if args.out:
|
|
490
770
|
Path(args.out).write_text(md)
|
|
491
771
|
if args.json:
|
|
492
|
-
print(json.dumps({"
|
|
493
|
-
"skipped": [s for sec in sections for s in sec.skipped]}, indent=2))
|
|
772
|
+
print(json.dumps({"paths": [str(r) for r in roots], "images": images, "total": total, "counts": counts,
|
|
773
|
+
"skipped": [s for sec in sections for s in sec.skipped], "work_dir": str(work)}, indent=2))
|
|
494
774
|
else:
|
|
495
775
|
print(md)
|
|
496
776
|
sys.stdout.flush()
|
|
777
|
+
print(f"scan files: {work}", file=sys.stderr)
|
|
497
778
|
|
|
498
779
|
if args.publish:
|
|
499
780
|
title = f"Infrastructure scan — {root.name} — {datetime.now(timezone.utc):%Y-%m-%d %H:%M} UTC"
|
|
@@ -64,7 +64,7 @@ from pathlib import Path
|
|
|
64
64
|
# that npm no longer hosts the package. Starting at 1.0.0 rather than continuing
|
|
65
65
|
# the npm package's 0.1.x line: that counter belonged to the wrapper, not to
|
|
66
66
|
# this file, and reusing it would suggest a continuity that does not exist.
|
|
67
|
-
SECSCAN_VERSION = "1.
|
|
67
|
+
SECSCAN_VERSION = "1.5.1"
|
|
68
68
|
|
|
69
69
|
SEVERITIES = ["critical", "high", "medium", "low", "info"]
|
|
70
70
|
SEVERITY_RANK = {s: i for i, s in enumerate(SEVERITIES)}
|
|
@@ -205,6 +205,8 @@ def iter_files(root: Path):
|
|
|
205
205
|
d for d in dirnames
|
|
206
206
|
if d not in SKIP_DIRS
|
|
207
207
|
and not d.startswith(".git")
|
|
208
|
+
# Plan 11 H12-T10: never read our own scan output.
|
|
209
|
+
and not (d == "scans" and Path(dirpath).name == ".iKanban")
|
|
208
210
|
and not _is_nested_repo(Path(dirpath) / d, root)
|
|
209
211
|
]
|
|
210
212
|
for fn in filenames:
|
|
@@ -859,11 +861,48 @@ def publish(md: str, title: str, base_url: str, token: str, team_id: str,
|
|
|
859
861
|
return False, str(e)
|
|
860
862
|
|
|
861
863
|
|
|
864
|
+
# ── Scan work folder (plan 11 H12-T10, owner): raw tool output and the report live under
|
|
865
|
+
# <repo>/.iKanban/scans/<scanner>-<UTC time>/, never the system temp folder. The scans folder
|
|
866
|
+
# holds its own .gitignore ("*"), so nothing in it is committed and no repo .gitignore is edited.
|
|
867
|
+
def add_work_dir_arg(ap):
|
|
868
|
+
ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
|
|
869
|
+
help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
|
|
870
|
+
"<repository>/.iKanban/scans)")
|
|
871
|
+
|
|
872
|
+
|
|
873
|
+
def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
|
|
874
|
+
scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
|
|
875
|
+
scans.mkdir(parents=True, exist_ok=True)
|
|
876
|
+
ignore = scans / ".gitignore"
|
|
877
|
+
if not ignore.exists():
|
|
878
|
+
ignore.write_text("*\n")
|
|
879
|
+
run_dir = scans / f"{scanner}-{datetime.now(timezone.utc):%Y%m%d-%H%M%S}"
|
|
880
|
+
run_dir.mkdir(mode=0o700, exist_ok=True)
|
|
881
|
+
os.chmod(run_dir, 0o700) # mkdir's mode is masked by umask; raw tool output stays private
|
|
882
|
+
return run_dir
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def write_private(path: Path, text: str) -> None:
|
|
886
|
+
"""Write a file (0600) without following a symlink planted at its name: write a new
|
|
887
|
+
temp file next to it (O_EXCL | O_NOFOLLOW), then rename it over the name."""
|
|
888
|
+
tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
889
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
|
|
890
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
891
|
+
f.write(text)
|
|
892
|
+
os.replace(tmp, path)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def slug(text: str) -> str:
|
|
896
|
+
return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
|
|
897
|
+
|
|
898
|
+
|
|
862
899
|
# ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
|
|
863
900
|
# Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
|
|
864
901
|
# "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
|
|
865
902
|
# then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
|
|
866
903
|
_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)
|
|
904
|
+
# Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
|
|
905
|
+
PIPE_RUN = "security-scan"
|
|
867
906
|
PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
|
|
868
907
|
LAST_RUN = {"report": "", "detail": "", "skipped": False}
|
|
869
908
|
|
|
@@ -947,7 +986,7 @@ class Pipeline:
|
|
|
947
986
|
|
|
948
987
|
def start(self):
|
|
949
988
|
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
|
|
950
|
-
"source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
989
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}"})
|
|
951
990
|
|
|
952
991
|
def finish(self, code):
|
|
953
992
|
status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
|
|
@@ -955,10 +994,10 @@ class Pipeline:
|
|
|
955
994
|
if code == 1 and not LAST_RUN["skipped"]:
|
|
956
995
|
detail = (detail + " (at or above --fail-on)").strip()
|
|
957
996
|
self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
|
|
958
|
-
"source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
997
|
+
"run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
|
|
959
998
|
"detail": detail[:300] or None})
|
|
960
999
|
for chunk in log_chunks(mask_report(LAST_RUN["report"])):
|
|
961
|
-
self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
|
|
1000
|
+
self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
|
|
962
1001
|
|
|
963
1002
|
|
|
964
1003
|
def run_main(stage, main_fn, argv=None):
|
|
@@ -1011,6 +1050,7 @@ def main(argv=None):
|
|
|
1011
1050
|
ap.add_argument("--fail-on", choices=SEVERITIES, default=None,
|
|
1012
1051
|
help="Exit 1 if any finding is at or above this severity")
|
|
1013
1052
|
add_pipeline_args(ap)
|
|
1053
|
+
add_work_dir_arg(ap)
|
|
1014
1054
|
args = ap.parse_args(argv)
|
|
1015
1055
|
|
|
1016
1056
|
root = Path(args.path).expanduser().resolve()
|
|
@@ -1050,6 +1090,8 @@ def main(argv=None):
|
|
|
1050
1090
|
scan_secrets(root), scan_permissions(root)]
|
|
1051
1091
|
md, counts, total = build_report(root, sections)
|
|
1052
1092
|
LAST_RUN.update(report=md, detail=summary_detail(counts, total, []), skipped=False)
|
|
1093
|
+
work = scan_work_dir("secscan", root, args.work_dir)
|
|
1094
|
+
write_private(work / "report.md", md)
|
|
1053
1095
|
|
|
1054
1096
|
if args.out:
|
|
1055
1097
|
Path(args.out).write_text(md)
|
|
@@ -1062,6 +1104,7 @@ def main(argv=None):
|
|
|
1062
1104
|
# The lines below go to stderr; flush first so they follow the report when both are
|
|
1063
1105
|
# piped into one stream (an agent's `2>&1 | tail` would otherwise cut them off).
|
|
1064
1106
|
sys.stdout.flush()
|
|
1107
|
+
print(f"scan files: {work}", file=sys.stderr)
|
|
1065
1108
|
|
|
1066
1109
|
if args.publish:
|
|
1067
1110
|
if not args.token or not args.team:
|