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 +37 -0
- package/bin/ikanban.js +5 -2
- package/package.json +1 -1
- package/scanners/ikanban-aiscan.py +279 -68
- package/scanners/ikanban-dastscan.py +491 -0
- package/scanners/ikanban-infrascan.py +790 -0
- package/scanners/ikanban-secscan.py +173 -4
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(
|
|
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
|
+
"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.
|
|
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
|
-
|
|
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,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
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
"
|
|
831
|
-
"
|
|
832
|
-
"
|
|
833
|
-
"
|
|
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
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
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))
|