ikanban 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,9 @@ results into [iKanban](https://i-kanban.com).
7
7
  npx ikanban secscan # dependencies, committed secrets, risky file permissions
8
8
  npx ikanban aiscan --help # red-team scan of your AI app (OWASP LLM Top 10)
9
9
  npx ikanban secscan --publish # also file the report and findings into iKanban
10
+ npx ikanban infrascan --image app:1 # IaC misconfiguration and container image scan (Trivy)
11
+ npx ikanban dastscan --url URL # passive web scan of a registered URL (OWASP ZAP in Docker)
12
+ npx ikanban secscan --task IKA-123 # also show the run in that task's Pipelines
10
13
  npx ikanban roadmap sync plan.json # make the Roadmap plan match the plan file
11
14
  npx ikanban hooks install # sync plan files on every agent write and git push
12
15
  ```
@@ -23,10 +26,44 @@ exits 1 when the Roadmap differs. With no file it syncs every tracked plan file.
23
26
  agent hook for Claude Code, Gemini CLI and Copilot CLI (`.iKanban/hooks/roadmap-sync.js`).
24
27
  No CI is needed.
25
28
 
29
+ ## Infrastructure scans
30
+
31
+ `infrascan` runs [Trivy](https://github.com/aquasecurity/trivy) on the repository's IaC files
32
+ (`trivy fs --scanners misconfig`) and on each `--image` (`trivy image`). `dastscan` runs the
33
+ [OWASP ZAP](https://www.zaproxy.org/docs/docker/baseline-scan/) baseline scan in Docker: it reads
34
+ the site's responses and does not attack. It scans only a URL the team registered in iKASecure →
35
+ Infrastructure, so it always needs `--team` and a key. Findings show on the Infrastructure tab. A
36
+ missing Trivy or Docker is reported as SKIPPED with the install command, never as a pass.
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
+
56
+ ## Pipelines
57
+
58
+ Every scanner takes `--task IKA-123` (or `IKANBAN_TASK`) and `--task-team` (default `--team`). The
59
+ run then shows in that task's Pipelines view: running, then passed, failed or skipped with a
60
+ one-line summary, and the masked report as its log. A plan without Pipelines never fails the scan.
61
+
26
62
  ## What you need
27
63
 
28
64
  - Node.js 18 or newer.
29
65
  - 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.
30
67
 
31
68
  ## Free and paid
32
69
 
package/bin/ikanban.js CHANGED
@@ -10,10 +10,12 @@ const { version } = require("../package.json");
10
10
  const COMMANDS = {
11
11
  aiscan: { file: "ikanban-aiscan.py", about: "Red-team scan of your AI app (OWASP LLM Top 10)" },
12
12
  secscan: { file: "ikanban-secscan.py", about: "Dependency, secret and file-permission scan of a repo" },
13
+ infrascan: { file: "ikanban-infrascan.py", about: "Container image and IaC scan (needs Trivy)" },
14
+ dastscan: { file: "ikanban-dastscan.py", about: "Passive web scan of a registered URL (needs Docker)" },
13
15
  };
14
16
 
15
17
  function help() {
16
- const lines = Object.entries(COMMANDS).map(([n, c]) => ` ${n.padEnd(9)}${c.about}`);
18
+ const lines = Object.entries(COMMANDS).map(([n, c]) => ` ${n.padEnd(10)}${c.about}`);
17
19
  console.log(`ikanban ${version}
18
20
 
19
21
  Usage: ikanban <command> [options]
@@ -26,7 +28,8 @@ ${lines.join("\n")}
26
28
  pipeline Report a task's stage and stream its log to iKanban (any CI)
27
29
 
28
30
  Run "ikanban <command> --help" for its options. Add --publish to file the
29
- results into iKanban (needs an API token; the server checks your plan).
31
+ results into iKanban (needs an API token; the server checks your plan), and
32
+ --task IKA-123 to show the run in that task's Pipelines.
30
33
  Needs Python 3.9 or newer.`);
31
34
  }
32
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ikanban",
3
- "version": "0.3.4",
3
+ "version": "0.4.1",
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.3.2"
60
+ AISCAN_VERSION = "0.5.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).
@@ -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
- cfg_path = workdir / "config.yaml"
392
- cases_path = workdir / "cases.yaml"
393
- out_path = workdir / "results.json"
394
- for p in (cases_path, out_path):
395
- if p.exists():
396
- p.unlink()
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)], workdir, allow_remote
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"], workdir, allow_remote
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
- doc = json.loads(out_path.read_text(encoding="utf-8"))
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,6 +701,167 @@ 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
+
739
+ # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
740
+ # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
741
+ # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
742
+ # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
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
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
745
+ LAST_RUN = {"report": "", "detail": "", "skipped": False}
746
+
747
+
748
+ def add_pipeline_args(ap):
749
+ ap.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""),
750
+ help="Also show this run in the task's Pipelines (a key like IKA-123, or its id; "
751
+ "or IKANBAN_TASK)")
752
+ ap.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""),
753
+ help="The task's team: id or identifier like IKA (or IKANBAN_TASK_TEAM; default --team)")
754
+
755
+
756
+ def summary_detail(counts: dict, total: int, skipped: list) -> str:
757
+ if skipped and not total and all(s for s in skipped):
758
+ return ("SKIPPED: " + "; ".join(skipped))[:300]
759
+ worst = ", ".join(f"{counts[s]} {s}" for s in ("critical", "high", "medium", "low") if counts.get(s))
760
+ return f"{total} findings" + (f": {worst}" if worst else "")
761
+
762
+
763
+ def mask_report(text: str) -> str:
764
+ """The report is masked already; mask again before it leaves for Pipelines."""
765
+ scrub = globals().get("scrub_report")
766
+ if scrub:
767
+ return scrub(text)[0]
768
+ text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
769
+ home = str(Path.home())
770
+ return text.replace(home, "~") if home and home != "/" else text
771
+
772
+
773
+ def log_chunks(text: str) -> list:
774
+ """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
775
+ chunks, cur = [], ""
776
+ for line in text.splitlines(keepends=True):
777
+ while len(line.encode()) > PIPE_CHUNK:
778
+ head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
779
+ chunks.append(head)
780
+ line = line[len(head):]
781
+ if len((cur + line).encode()) > PIPE_CHUNK:
782
+ chunks.append(cur)
783
+ cur = ""
784
+ cur += line
785
+ if cur:
786
+ chunks.append(cur)
787
+ return chunks
788
+
789
+
790
+ class Pipeline:
791
+ def __init__(self, stage, task, team, base_url, token):
792
+ self.stage, self.task, self.team, self.base, self.token = stage, task, team, base_url, token
793
+ self.on = bool(task and team and token)
794
+ self.tid = None
795
+
796
+ def _off(self, why):
797
+ self.on = False
798
+ note = "the plan does not include Pipelines" if "402" in str(why) else why
799
+ print(f"ikanban: Pipelines not updated ({note}); the scan goes on", file=sys.stderr)
800
+
801
+ def _call(self, method, path, body=None):
802
+ try:
803
+ return api(method, path, self.base, self.token, body)
804
+ except SystemExit as e: # ikanban_api refuses a base URL that is not iKanban's
805
+ return False, str(e)
806
+
807
+ def _post(self, path, body):
808
+ if not self.on:
809
+ return
810
+ if self.tid is None:
811
+ if _UUID.match(self.team):
812
+ self.tid = self.team
813
+ else:
814
+ ok, teams = self._call("GET", "/teams")
815
+ if not ok:
816
+ return self._off(teams)
817
+ found = [t.get("id") for t in teams or [] if (t.get("identifier") or "").lower() == self.team.lower()]
818
+ if not found:
819
+ return self._off(f"team {self.team} not found among your teams")
820
+ self.tid = found[0]
821
+ ok, data = self._call("POST", f"/teams/{self.tid}/{path}", body)
822
+ if not ok:
823
+ self._off(data or "this iKanban server has no Pipelines")
824
+
825
+ def start(self):
826
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": "running",
827
+ "source": "laptop", "actor": f"ikanban-{self.stage}"})
828
+
829
+ def finish(self, code):
830
+ status = "failed" if code else ("skipped" if LAST_RUN["skipped"] else "passed")
831
+ detail = LAST_RUN["detail"] or ("the scan stopped with an error" if code == 2 else "")
832
+ if code == 1 and not LAST_RUN["skipped"]:
833
+ detail = (detail + " (at or above --fail-on)").strip()
834
+ self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
835
+ "source": "laptop", "actor": f"ikanban-{self.stage}",
836
+ "detail": detail[:300] or None})
837
+ for chunk in log_chunks(mask_report(LAST_RUN["report"])):
838
+ self._post("pipeline-logs", {"task": self.task, "stage": self.stage, "text": chunk})
839
+
840
+
841
+ def run_main(stage, main_fn, argv=None):
842
+ """main() as it is, wrapped in Pipelines events when --task is given."""
843
+ raw = sys.argv[1:] if argv is None else argv
844
+ if any(a in ("-h", "--help", "--version", "--list-apps") for a in raw):
845
+ return main_fn(argv)
846
+ pre = argparse.ArgumentParser(add_help=False)
847
+ pre.add_argument("--task", default=os.environ.get("IKANBAN_TASK", ""))
848
+ pre.add_argument("--task-team", default=os.environ.get("IKANBAN_TASK_TEAM", ""))
849
+ pre.add_argument("--team", default=os.environ.get("IKANBAN_TEAM_ID", ""))
850
+ pre.add_argument("--token", default=os.environ.get("VIBE_API_TOKEN", ""))
851
+ pre.add_argument("--base-url", default=os.environ.get("IKANBAN_BASE_URL", "https://api.i-kanban.com"))
852
+ known, _ = pre.parse_known_args(raw)
853
+ pl = Pipeline(stage, known.task, known.task_team or known.team, known.base_url, known.token)
854
+ pl.start()
855
+ try:
856
+ code = main_fn(argv)
857
+ except SystemExit as e:
858
+ if not isinstance(e.code, int):
859
+ print(e.code, file=sys.stderr)
860
+ code = e.code if isinstance(e.code, int) else 2
861
+ pl.finish(code)
862
+ return code
863
+
864
+
672
865
  def main(argv=None):
673
866
  ap = argparse.ArgumentParser(prog="ikanban aiscan")
674
867
  # Lets someone who downloaded a copy tell which release it is, and verify it
@@ -701,6 +894,8 @@ def main(argv=None):
701
894
  help="Permit --config, --html and --out to point outside the working "
702
895
  "directory. Off by default so a mistyped path cannot overwrite "
703
896
  "something elsewhere; turn it on for CI writing to an artifacts dir.")
897
+ add_pipeline_args(ap)
898
+ add_work_dir_arg(ap)
704
899
  args = ap.parse_args(argv)
705
900
 
706
901
  # Resolve every caller-supplied path up front, before any scanning happens.
@@ -710,6 +905,9 @@ def main(argv=None):
710
905
  args.html = safe_path(args.html, "--html", args.allow_outside_cwd)
711
906
  if args.out:
712
907
  args.out = safe_path(args.out, "--out", args.allow_outside_cwd)
908
+ # Plan 11 H12-T10: the scan's own folder follows the same rule as every other path.
909
+ if args.work_dir:
910
+ args.work_dir = str(safe_path(args.work_dir, "--work-dir", args.allow_outside_cwd))
713
911
 
714
912
  if args.config:
715
913
  args.config = safe_path(args.config, "--config", args.allow_outside_cwd)
@@ -783,66 +981,65 @@ def main(argv=None):
783
981
  # than leaving 'no findings' to mean three different things.
784
982
  per_category = []
785
983
 
786
- with tempfile.TemporaryDirectory(prefix="ikanban-aiscan-") as tmp:
787
- workdir = Path(tmp)
788
- for _collection, label, plugins in selected:
789
- if not plugins:
790
- # LLM03. promptfoo ships no supply-chain plugins at all, so this
791
- # row can never be earned. Reporting "0 findings" would read as a
792
- # pass, which is the one thing it must not do.
793
- skipped.append(label)
794
- per_category.append({"category": label, "asked": 0, "ran": 0, "skipped": []})
795
- log(f"{label}: SKIPPED — promptfoo ships no plugins for this category")
796
- continue
797
- log(f"{label}: generating and running {args.num_tests} tests per plugin…")
798
- cfg = build_config(args.target, spec, plugins, args.num_tests,
799
- args.base_url, args.token, args.workspace_id)
800
- got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation)
801
- if got is None:
802
- failed.append(label)
803
- per_category.append(
804
- {"category": label, "asked": len(plugins), "ran": 0, "skipped": sorted(plugins)}
805
- )
806
- continue
807
- category_findings, ran, cat_errors, tally = got
808
- total_errors += cat_errors
809
- plugins_ran |= ran
810
- # IKA-1786: one row per plugin per category. A plugin can appear in
811
- # two categories (pii:* spans LLM02 and LLM08), and those are
812
- # separate measurements -- 'prompt-extraction under LLM07' is the
813
- # thing a weakest-areas ranking is about, not the plugin in general.
814
- for plugin, t in sorted(tally.items()):
815
- plugin_results.append(
816
- {
817
- "plugin_id": plugin,
818
- "llm_category": label,
819
- "target": args.target,
820
- "severity": t["severity"],
821
- "attempted": t["attempted"],
822
- "failed": t["failed"],
823
- "errored": t["errored"],
824
- }
825
- )
826
- missing = sorted(set(plugins) - ran)
827
- plugins_skipped |= set(missing)
984
+ workdir = scan_work_dir("aiscan", Path.cwd(), args.work_dir)
985
+ for _collection, label, plugins in selected:
986
+ if not plugins:
987
+ # LLM03. promptfoo ships no supply-chain plugins at all, so this
988
+ # row can never be earned. Reporting "0 findings" would read as a
989
+ # pass, which is the one thing it must not do.
990
+ skipped.append(label)
991
+ per_category.append({"category": label, "asked": 0, "ran": 0, "skipped": []})
992
+ log(f"{label}: SKIPPED — promptfoo ships no plugins for this category")
993
+ continue
994
+ log(f"{label}: generating and running {args.num_tests} tests per plugin…")
995
+ cfg = build_config(args.target, spec, plugins, args.num_tests,
996
+ args.base_url, args.token, args.workspace_id)
997
+ got = scan_category(label, args.target, cfg, workdir, args.allow_remote_generation)
998
+ if got is None:
999
+ failed.append(label)
828
1000
  per_category.append(
1001
+ {"category": label, "asked": len(plugins), "ran": 0, "skipped": sorted(plugins)}
1002
+ )
1003
+ continue
1004
+ category_findings, ran, cat_errors, tally = got
1005
+ total_errors += cat_errors
1006
+ plugins_ran |= ran
1007
+ # IKA-1786: one row per plugin per category. A plugin can appear in
1008
+ # two categories (pii:* spans LLM02 and LLM08), and those are
1009
+ # separate measurements -- 'prompt-extraction under LLM07' is the
1010
+ # thing a weakest-areas ranking is about, not the plugin in general.
1011
+ for plugin, t in sorted(tally.items()):
1012
+ plugin_results.append(
829
1013
  {
830
- "category": label,
831
- "asked": len(plugins),
832
- "ran": len(set(plugins) & ran),
833
- "skipped": missing,
1014
+ "plugin_id": plugin,
1015
+ "llm_category": label,
1016
+ "target": args.target,
1017
+ "severity": t["severity"],
1018
+ "attempted": t["attempted"],
1019
+ "failed": t["failed"],
1020
+ "errored": t["errored"],
834
1021
  }
835
1022
  )
836
- if missing:
837
- uncovered.append((label, missing))
838
- log(f" did not run: {', '.join(missing)}")
839
- if not ran:
840
- log(" NOTHING RAN — every plugin here needs promptfoo's hosted generator")
841
- else:
842
- scanned += 1
843
- log(f" {len(category_findings)} finding(s)"
844
- + (f", {cat_errors} error(s)" if cat_errors else ""))
845
- findings.extend(category_findings)
1023
+ missing = sorted(set(plugins) - ran)
1024
+ plugins_skipped |= set(missing)
1025
+ per_category.append(
1026
+ {
1027
+ "category": label,
1028
+ "asked": len(plugins),
1029
+ "ran": len(set(plugins) & ran),
1030
+ "skipped": missing,
1031
+ }
1032
+ )
1033
+ if missing:
1034
+ uncovered.append((label, missing))
1035
+ log(f" did not run: {', '.join(missing)}")
1036
+ if not ran:
1037
+ log(" NOTHING RAN — every plugin here needs promptfoo's hosted generator")
1038
+ else:
1039
+ scanned += 1
1040
+ log(f" {len(category_findings)} finding(s)"
1041
+ + (f", {cat_errors} error(s)" if cat_errors else ""))
1042
+ findings.extend(category_findings)
846
1043
 
847
1044
  log("")
848
1045
  # Errors get their own number. A run where many tests errored is not a clean
@@ -857,6 +1054,20 @@ def main(argv=None):
857
1054
  for label, missing in uncovered:
858
1055
  log(f" partial — {label}: {len(missing)} plugin(s) unavailable locally")
859
1056
 
1057
+ # Plan 11 H12: the Pipelines log (no attack prompts or answers: titles and counts only).
1058
+ counts = {s: sum(1 for f in findings if f["severity"] == s) for s in SEVERITIES}
1059
+ LAST_RUN.update(
1060
+ report="\n".join(
1061
+ [f"# AI endpoint scan — {args.target}", "", f"{len(findings)} finding(s) across {scanned} categories tested"]
1062
+ + [f"- [{f['severity']}] {f['llm_category']}: {f['title']}" for f in findings]
1063
+ + [f"- not covered: {label}" for label in skipped]
1064
+ + [f"- DID NOT RUN: {label}" for label in failed]),
1065
+ detail=summary_detail(counts, len(findings), []) + (f"; {len(failed)} categories did not run" if failed else ""),
1066
+ skipped=scanned == 0 and not findings,
1067
+ )
1068
+ write_private(workdir / "report.md", LAST_RUN["report"])
1069
+ log(f"scan files: {workdir}")
1070
+
860
1071
  coverage = {
861
1072
  "target": args.target,
862
1073
  "categories_tested": scanned,
@@ -911,4 +1122,4 @@ def main(argv=None):
911
1122
 
912
1123
 
913
1124
  if __name__ == "__main__":
914
- sys.exit(main())
1125
+ sys.exit(run_main("aiscan", main))