ikanban 0.4.9 → 0.5.0

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.
@@ -0,0 +1,403 @@
1
+ // Plan 5 X3: the encrypted scan store. A scanner writes its report and raw tool output into a
2
+ // private temp folder; afterwards every file is sealed with AES-256-GCM into
3
+ // ~/.ikanban/scans/<repo>-<8 hex of sha256(repo path)>/<scan id>/ and the temp folder is removed.
4
+ // The key (32 random bytes) lives in the OS keychain: macOS `security`, Windows DPAPI (CurrentUser)
5
+ // through PowerShell, Linux `secret-tool`; else an owner-only file. Node standard library only.
6
+ // The key is never printed, logged, or put on a command line: it travels on stdin and stdout pipes.
7
+ const crypto = require("node:crypto");
8
+ const fs = require("node:fs");
9
+ const os = require("node:os");
10
+ const path = require("node:path");
11
+ const { runCommand } = require("./ikanban-safe.js");
12
+
13
+ const SERVICE = "ikanban-scan-store";
14
+ const ACCOUNT = "ikanban";
15
+ const MAGIC = Buffer.from("IKS1");
16
+ const SCAN_ID = /^[a-z]+-[0-9A-Za-z-]+$/;
17
+ const OLD_SCAN_DIR = /^(secscan|aiscan|infrascan|dastscan|cloudscan|controlscan|import)-\d{8}-\d{6}$/;
18
+ const BAD_SEAL = "cannot decrypt: the stored file was changed, or the key is not the one that sealed it";
19
+
20
+ class ScanStoreError extends Error {}
21
+
22
+ function newScanId(scanner, now = new Date()) {
23
+ const t = now.toISOString().replace(/[-:]/g, "").replace(/\.\d+Z$/, "Z");
24
+ return `${scanner}-${t}`;
25
+ }
26
+
27
+ function repoDirName(repoPath) {
28
+ const abs = path.resolve(repoPath);
29
+ const digest = crypto.createHash("sha256").update(abs).digest("hex").slice(0, 8);
30
+ const name = (path.basename(abs) || "root").replace(/[^A-Za-z0-9._-]+/g, "_");
31
+ return `${name}-${digest}`;
32
+ }
33
+
34
+ function mkdirPrivate(dir) {
35
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
36
+ fs.chmodSync(dir, 0o700); // mkdir's mode is masked by umask
37
+ }
38
+
39
+ function writePrivate(file, data) {
40
+ fs.writeFileSync(file, data, { mode: 0o600 });
41
+ fs.chmodSync(file, 0o600);
42
+ }
43
+
44
+ // ── key stores: get() → Buffer | null, set(Buffer) ─────────────────────────────
45
+ function checkKey(buf, where) {
46
+ if (!buf || buf.length !== 32) throw new ScanStoreError(`the scan store key in ${where} is not 32 bytes`);
47
+ return buf;
48
+ }
49
+
50
+ function fileKeyStore(home, note = (l) => console.error(l)) {
51
+ const file = path.join(home, ".ikanban", "scan-store.key");
52
+ return {
53
+ kind: "file",
54
+ get() {
55
+ if (!fs.existsSync(file)) return null;
56
+ return checkKey(Buffer.from(fs.readFileSync(file, "utf8").trim(), "hex"), file);
57
+ },
58
+ set(key) {
59
+ mkdirPrivate(path.dirname(file));
60
+ fs.writeFileSync(file, key.toString("hex"), { mode: 0o600, flag: "wx" });
61
+ fs.chmodSync(file, 0o600);
62
+ note(`ikanban: the scan store key is a file (${file}, owner only): no OS keychain was found on this machine.`);
63
+ },
64
+ };
65
+ }
66
+
67
+ function macKeyStore() {
68
+ return {
69
+ kind: "macos-keychain",
70
+ get() {
71
+ const r = runCommand("security", ["find-generic-password", "-s", SERVICE, "-a", ACCOUNT, "-w"], { encoding: "utf8" });
72
+ if (r.status !== 0) return null;
73
+ return checkKey(Buffer.from(r.stdout.trim(), "hex"), "the macOS keychain");
74
+ },
75
+ set(key) {
76
+ // `security -i` reads its command from stdin, so the key is never in the process list.
77
+ const r = runCommand("security", ["-i"], {
78
+ input: `add-generic-password -U -s ${SERVICE} -a ${ACCOUNT} -w ${key.toString("hex")}\n`, encoding: "utf8",
79
+ });
80
+ if (r.status !== 0) throw new ScanStoreError("the macOS keychain refused the scan store key");
81
+ },
82
+ };
83
+ }
84
+
85
+ function linuxKeyStore() {
86
+ return {
87
+ kind: "secret-tool",
88
+ get() {
89
+ const r = runCommand("secret-tool", ["lookup", "service", SERVICE], { encoding: "utf8" });
90
+ if (r.status !== 0 || !r.stdout.trim()) return null;
91
+ return checkKey(Buffer.from(r.stdout.trim(), "hex"), "secret-tool");
92
+ },
93
+ set(key) {
94
+ const r = runCommand("secret-tool", ["store", "--label", "ikanban scan store", "service", SERVICE], {
95
+ input: key.toString("hex"), encoding: "utf8",
96
+ });
97
+ if (r.status !== 0) throw new ScanStoreError("secret-tool could not store the scan store key (no keyring running?)");
98
+ },
99
+ };
100
+ }
101
+
102
+ const PS_PROTECT = "Add-Type -AssemblyName System.Security; $d=[Convert]::FromBase64String([Console]::In.ReadToEnd().Trim()); " +
103
+ "$p=[System.Security.Cryptography.ProtectedData]::Protect($d,$null,[System.Security.Cryptography.DataProtectionScope]::CurrentUser); " +
104
+ "[Console]::Out.Write([Convert]::ToBase64String($p))";
105
+ const PS_UNPROTECT = PS_PROTECT.replace("]::Protect(", "]::Unprotect(");
106
+
107
+ function powershell(script, input) {
108
+ const r = runCommand("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { input, encoding: "utf8" });
109
+ if (r.status !== 0) throw new ScanStoreError("Windows DPAPI (PowerShell) could not handle the scan store key");
110
+ return r.stdout.trim();
111
+ }
112
+
113
+ function windowsKeyStore(home) {
114
+ const file = path.join(home, ".ikanban", "scan-store.key.dpapi");
115
+ return {
116
+ kind: "windows-dpapi",
117
+ get() {
118
+ if (!fs.existsSync(file)) return null;
119
+ return checkKey(Buffer.from(powershell(PS_UNPROTECT, fs.readFileSync(file, "utf8").trim()), "base64"), file);
120
+ },
121
+ set(key) {
122
+ const blob = powershell(PS_PROTECT, key.toString("base64"));
123
+ mkdirPrivate(path.dirname(file));
124
+ writePrivate(file, blob);
125
+ },
126
+ };
127
+ }
128
+
129
+ function hasTool(tool) {
130
+ const r = runCommand(process.platform === "win32" ? "where" : "which", [tool], { encoding: "utf8" });
131
+ return r.status === 0;
132
+ }
133
+
134
+ function pickKeyStoreKind(platform, has) {
135
+ if (platform === "darwin") return "macos-keychain";
136
+ if (platform === "win32") return "windows-dpapi";
137
+ if (platform === "linux" && has("secret-tool")) return "secret-tool";
138
+ return "file";
139
+ }
140
+
141
+ /** The OS key store, falling back to the key file when the keychain tool fails. */
142
+ function osKeyStore(home) {
143
+ const file = fileKeyStore(home);
144
+ if (process.env.IKANBAN_SCAN_STORE === "file") return file; // CI runners and tests: no keychain
145
+ const kind = pickKeyStoreKind(process.platform, hasTool);
146
+ const main = { "macos-keychain": macKeyStore, "windows-dpapi": () => windowsKeyStore(home), "secret-tool": linuxKeyStore }[kind];
147
+ if (!main) return file;
148
+ const ks = main();
149
+ return {
150
+ kind,
151
+ get() {
152
+ return file.get() || ks.get(); // a key file exists only after the keychain failed once
153
+ },
154
+ set(key) {
155
+ try {
156
+ ks.set(key);
157
+ if (!checkKey(ks.get(), kind).equals(key)) throw new ScanStoreError(`${kind} gave back a different key`);
158
+ } catch {
159
+ file.set(key); // prints the one-line note that the key is a file
160
+ }
161
+ },
162
+ };
163
+ }
164
+
165
+ // ── the store ───────────────────────────────────────────────────────────────────
166
+ function listFiles(dir, base = dir) {
167
+ const out = [];
168
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
169
+ const full = path.join(dir, entry.name);
170
+ if (entry.isDirectory()) out.push(...listFiles(full, base));
171
+ else if (entry.isFile()) out.push(path.relative(base, full).split(path.sep).join("/"));
172
+ }
173
+ return out;
174
+ }
175
+
176
+ function aad(scanId, name) {
177
+ return Buffer.from(`${scanId}\0${name}`, "utf8");
178
+ }
179
+
180
+ function createStore({ home = os.homedir(), keyStore = null } = {}) {
181
+ const root = path.join(home, ".ikanban", "scans");
182
+ const ks = keyStore || osKeyStore(home);
183
+ let cached = null;
184
+
185
+ function key(create) {
186
+ if (cached) return cached;
187
+ let k = ks.get();
188
+ if (!k && create) {
189
+ k = crypto.randomBytes(32);
190
+ ks.set(k);
191
+ }
192
+ if (!k) throw new ScanStoreError("no scan store key on this machine: scans sealed elsewhere cannot be opened here");
193
+ cached = checkKey(k, ks.kind);
194
+ return cached;
195
+ }
196
+
197
+ function makeTempDir() {
198
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ikanban-scan-"));
199
+ fs.chmodSync(dir, 0o700);
200
+ return dir;
201
+ }
202
+
203
+ function seal(srcDir, { scanner, scanId, repoPath }) {
204
+ if (!SCAN_ID.test(scanId)) throw new ScanStoreError(`not a scan id: ${scanId}`);
205
+ const names = listFiles(srcDir);
206
+ const k = key(true);
207
+ const repoDir = path.join(root, repoDirName(repoPath));
208
+ const dest = path.join(repoDir, scanId);
209
+ if (fs.existsSync(dest)) throw new ScanStoreError(`a scan named ${scanId} is already stored`);
210
+ const partial = path.join(repoDir, `.${scanId}.partial`);
211
+ fs.rmSync(partial, { recursive: true, force: true });
212
+ mkdirPrivate(root);
213
+ mkdirPrivate(repoDir);
214
+ mkdirPrivate(partial);
215
+ const files = names.map((name, i) => {
216
+ const plain = fs.readFileSync(path.join(srcDir, ...name.split("/")));
217
+ const iv = crypto.randomBytes(12);
218
+ const c = crypto.createCipheriv("aes-256-gcm", k, iv);
219
+ c.setAAD(aad(scanId, name));
220
+ const ct = Buffer.concat([c.update(plain), c.final()]);
221
+ const blob = `${String(i + 1).padStart(4, "0")}.bin`;
222
+ writePrivate(path.join(partial, blob), Buffer.concat([MAGIC, iv, c.getAuthTag(), ct]));
223
+ return { name, size: plain.length, blob };
224
+ });
225
+ const manifest = { version: 1, scan_id: scanId, scanner, repo_path: path.resolve(repoPath), created: new Date().toISOString(), files };
226
+ writePrivate(path.join(partial, "manifest.json"), JSON.stringify(manifest, null, 2));
227
+ fs.renameSync(partial, dest);
228
+ fs.rmSync(srcDir, { recursive: true, force: true });
229
+ return dest;
230
+ }
231
+
232
+ function manifests() {
233
+ if (!fs.existsSync(root)) return [];
234
+ const out = [];
235
+ for (const repo of fs.readdirSync(root)) {
236
+ const repoDir = path.join(root, repo);
237
+ if (!fs.statSync(repoDir).isDirectory()) continue;
238
+ for (const id of fs.readdirSync(repoDir)) {
239
+ const mfile = path.join(repoDir, id, "manifest.json");
240
+ if (id.startsWith(".") || !fs.existsSync(mfile)) continue;
241
+ try {
242
+ const m = JSON.parse(fs.readFileSync(mfile, "utf8"));
243
+ out.push({ id, repo, dir: path.join(repoDir, id), scanner: m.scanner, created: m.created, repoPath: m.repo_path, files: m.files || [] });
244
+ } catch {
245
+ out.push({ id, repo, dir: path.join(repoDir, id), scanner: "?", created: "", repoPath: "", files: [], broken: true });
246
+ }
247
+ }
248
+ }
249
+ return out.sort((a, b) => String(b.created).localeCompare(String(a.created)));
250
+ }
251
+
252
+ function list(repoPath) {
253
+ const all = manifests();
254
+ return repoPath ? all.filter((m) => m.repo === repoDirName(repoPath)) : all;
255
+ }
256
+
257
+ function find(id, repoPath) {
258
+ const hits = list(repoPath).filter((m) => m.id === id);
259
+ if (!hits.length) throw new ScanStoreError(`no scan ${id} in ${root} (see: ikanban scans list)`);
260
+ if (hits.length > 1) throw new ScanStoreError(`${id} is stored for ${hits.length} repositories; add --repo PATH`);
261
+ if (hits[0].broken) throw new ScanStoreError(`the manifest of ${id} cannot be read`);
262
+ return hits[0];
263
+ }
264
+
265
+ function read(id, name, repoPath) {
266
+ const m = find(id, repoPath);
267
+ const entry = m.files.find((f) => f.name === name);
268
+ if (!entry) throw new ScanStoreError(`${id} has no file ${name}; it has: ${m.files.map((f) => f.name).join(", ")}`);
269
+ if (!/^\d{4}\.bin$/.test(entry.blob || "")) throw new ScanStoreError(`the manifest of ${id} is damaged`);
270
+ const data = fs.readFileSync(path.join(m.dir, entry.blob));
271
+ if (data.length < 32 || !data.subarray(0, 4).equals(MAGIC)) throw new ScanStoreError(BAD_SEAL);
272
+ try {
273
+ const d = crypto.createDecipheriv("aes-256-gcm", key(false), data.subarray(4, 16));
274
+ d.setAAD(aad(id, name));
275
+ d.setAuthTag(data.subarray(16, 32));
276
+ return Buffer.concat([d.update(data.subarray(32)), d.final()]);
277
+ } catch (e) {
278
+ if (e instanceof ScanStoreError) throw e;
279
+ throw new ScanStoreError(BAD_SEAL);
280
+ }
281
+ }
282
+
283
+ /** Run a scanner with IKANBAN_SCAN_DIR / IKANBAN_SCAN_ID set, then seal what it wrote. */
284
+ function runSealed({ scanner, repoPath, run, now = new Date(), log = (l) => console.error(l), extraDirs = () => [] }) {
285
+ const scanId = newScanId(scanner, now);
286
+ const dir = makeTempDir();
287
+ let status;
288
+ try {
289
+ status = run({ IKANBAN_SCAN_DIR: dir, IKANBAN_SCAN_ID: scanId });
290
+ } finally {
291
+ try {
292
+ for (const extra of extraDirs()) {
293
+ fs.cpSync(extra, dir, { recursive: true }); // the --work-dir run folder becomes the scan root
294
+ fs.rmSync(extra, { recursive: true, force: true });
295
+ }
296
+ if (listFiles(dir).length) {
297
+ seal(dir, { scanner, scanId, repoPath });
298
+ } else {
299
+ fs.rmSync(dir, { recursive: true, force: true });
300
+ }
301
+ } catch (e) {
302
+ log(`ikanban: the scan files were NOT sealed (${e.message}); they are still in ${dir}`);
303
+ }
304
+ }
305
+ return status === null || status === undefined ? 1 : status;
306
+ }
307
+
308
+ /** Seal old plain <repo>/.iKanban/scans/<scanner>-* folders and remove them. */
309
+ function migrate(repoPath) {
310
+ const scans = path.join(path.resolve(repoPath), ".iKanban", "scans");
311
+ if (!fs.existsSync(scans)) return [];
312
+ const done = [];
313
+ for (const entry of fs.readdirSync(scans, { withFileTypes: true })) {
314
+ if (!entry.isDirectory() || !OLD_SCAN_DIR.test(entry.name)) continue;
315
+ seal(path.join(scans, entry.name), { scanner: entry.name.split("-")[0], scanId: entry.name, repoPath });
316
+ done.push(entry.name);
317
+ }
318
+ return done.sort();
319
+ }
320
+
321
+ return { root, keyStore: ks, makeTempDir, seal, list, read, find, runSealed, migrate };
322
+ }
323
+
324
+ // ── `ikanban scans …` ───────────────────────────────────────────────────────────
325
+ function option(args, name) {
326
+ const i = args.indexOf(name);
327
+ if (i >= 0) return args[i + 1];
328
+ const eq = args.find((a) => a.startsWith(`${name}=`));
329
+ return eq ? eq.slice(name.length + 1) : undefined;
330
+ }
331
+
332
+ function positional(args) {
333
+ const out = [];
334
+ for (let i = 0; i < args.length; i++) {
335
+ if (["--repo", "--out", "--path"].includes(args[i])) i++;
336
+ else if (!args[i].startsWith("--")) out.push(args[i]);
337
+ }
338
+ return out;
339
+ }
340
+
341
+ const SCANS_HELP = `Usage:
342
+ ikanban scans list [--repo PATH | --all] stored scans of a repository (default: this folder)
343
+ ikanban scans show <id> [file] [--out FILE] decrypt a file to the terminal (default report.md)
344
+ ikanban scans migrate [--path PATH] seal old .iKanban/scans folders and remove the plain copies
345
+
346
+ Scans are stored encrypted in ~/.ikanban/scans; the key stays in this machine's keychain.`;
347
+
348
+ function run(args, { store = null, out = process.stdout } = {}) {
349
+ const [sub, ...rest] = args;
350
+ if (!sub || sub === "--help" || sub === "-h" || sub === "help") {
351
+ console.log(SCANS_HELP);
352
+ return 0;
353
+ }
354
+ try {
355
+ const s = store || createStore();
356
+ if (sub === "list") {
357
+ const rows = s.list(rest.includes("--all") ? undefined : option(rest, "--repo") || process.cwd());
358
+ if (!rows.length) {
359
+ console.log("no scans stored for this repository (run a scan, or: ikanban scans list --all)");
360
+ return 0;
361
+ }
362
+ for (const r of rows) {
363
+ const size = r.files.reduce((n, f) => n + (f.size || 0), 0);
364
+ console.log(`${r.id.padEnd(34)} ${String(r.created).slice(0, 19).padEnd(20)} ${String(r.files.length).padStart(3)} files ${String(size).padStart(9)} B ${r.repoPath}`);
365
+ }
366
+ return 0;
367
+ }
368
+ if (sub === "show") {
369
+ const [id, file] = positional(rest);
370
+ if (!id) throw new ScanStoreError("give a scan id: ikanban scans show <id> [file]");
371
+ const repo = option(rest, "--repo");
372
+ const m = s.find(id, repo);
373
+ const name = file || ["report.md", "report.html"].find((n) => m.files.some((f) => f.name === n));
374
+ if (!name) {
375
+ console.log(`${id} holds: ${m.files.map((f) => f.name).join(", ")}\nShow one: ikanban scans show ${id} <file>`);
376
+ return 0;
377
+ }
378
+ const data = s.read(id, name, repo);
379
+ const target = option(rest, "--out");
380
+ if (target) {
381
+ writePrivate(path.resolve(target), data);
382
+ console.error(`wrote ${name} to ${path.resolve(target)}`);
383
+ } else {
384
+ out.write(data);
385
+ }
386
+ return 0;
387
+ }
388
+ if (sub === "migrate") {
389
+ const repo = option(rest, "--path") || process.cwd();
390
+ const ids = s.migrate(repo);
391
+ console.log(ids.length ? `sealed and removed ${ids.length} plain scan folder(s): ${ids.join(", ")}` : `no old scan folders in ${path.join(path.resolve(repo), ".iKanban", "scans")}`);
392
+ return 0;
393
+ }
394
+ throw new ScanStoreError(`unknown subcommand "${sub}"\n${SCANS_HELP}`);
395
+ } catch (e) {
396
+ console.error(`ikanban scans: ${e instanceof ScanStoreError ? e.message : e.message}`);
397
+ return 1;
398
+ }
399
+ }
400
+
401
+ module.exports = {
402
+ ScanStoreError, newScanId, repoDirName, createStore, fileKeyStore, osKeyStore, pickKeyStoreKind, run,
403
+ };
@@ -0,0 +1,13 @@
1
+ // The Roadmap's status words, shared by the sync (roadmap.js) and the plan check (plancheck.js),
2
+ // so a plan that passes the check is a plan the sync accepts. No other require: the plan gate
3
+ // loads plancheck.js from a plain checkout, where the copied ikanban-safe.js may be missing.
4
+ const TASK_STATUS = {
5
+ done: "done", complete: "done", completed: "done",
6
+ in_progress: "inprogress", inprogress: "inprogress", "in-progress": "inprogress", started: "inprogress",
7
+ in_review: "inreview", inreview: "inreview", review: "inreview",
8
+ pending: "todo", todo: "todo", waiting: "todo", deferred: "todo", blocked: "todo",
9
+ cancelled: "cancelled", canceled: "cancelled", skipped: "cancelled", dropped: "cancelled",
10
+ };
11
+ const PHASE_STATUS = { done: "done", in_progress: "in_progress", inprogress: "in_progress", "in-progress": "in_progress" };
12
+
13
+ module.exports = { TASK_STATUS, PHASE_STATUS };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "ikanban",
3
- "version": "0.4.9",
4
- "description": "iKanban CLI: AI and security scans of your own repo, with results filed into iKanban",
3
+ "version": "0.5.0",
4
+ "description": "iKanban CLI: AI and security scans of your own repo; findings filed into iKanban, reports kept encrypted on your machine",
5
5
  "bin": { "ikanban": "bin/ikanban.js" },
6
6
  "files": ["bin", "lib", "hooks", "scanners", "README.md", "LICENSE.md"],
7
7
  "scripts": {
8
8
  "prepack": "node scripts/copy-scanners.js",
9
9
  "pretest": "node scripts/copy-scanners.js",
10
- "test": "node --test test/cli.test.js test/safe.test.js test/roadmap.test.js test/pipeline.test.js"
10
+ "test": "node --test test/cli.test.js test/safe.test.js test/roadmap.test.js test/pipeline.test.js test/scanstore.test.js test/trace.test.js test/deploy-check.test.js test/plan-check.test.js test/plan-gate.test.js"
11
11
  },
12
12
  "engines": { "node": ">=18" },
13
13
  "homepage": "https://i-kanban.com",
@@ -686,11 +686,27 @@ def api(method: str, path: str, base_url: str, token: str, payload=None):
686
686
  return False, str(e)
687
687
 
688
688
 
689
+ AI_EVIDENCE = ("attack_prompt", "model_response", "grader_reason")
690
+ AI_EVIDENCE_MAX = 2000 # plan 5 D3: evidence is sent scrubbed and cut to 2,000 characters
691
+
692
+
693
+ def findings_payload(findings):
694
+ """The findings --publish sends: every text field through scrub_text, evidence cut (plan 5 X2)."""
695
+ out = []
696
+ for f in findings:
697
+ f = scrub_finding(f)
698
+ for key in AI_EVIDENCE:
699
+ if isinstance(f.get(key), str):
700
+ f[key] = f[key][:AI_EVIDENCE_MAX]
701
+ out.append(f)
702
+ return out
703
+
704
+
689
705
  def publish(findings, run_id, base_url, token, team_id, coverage, plugin_results):
690
706
  payload = {
691
707
  "scan_run_id": run_id,
692
708
  "scanned_at": datetime.now(timezone.utc).isoformat(),
693
- "findings": findings,
709
+ "findings": findings_payload(findings),
694
710
  # Sent even when findings is empty. A clean scan that records nothing is
695
711
  # indistinguishable from never having scanned, and "0 findings" means
696
712
  # something different at 17 plugins than at 2.
@@ -776,9 +792,21 @@ def add_work_dir_arg(ap):
776
792
  ap.add_argument("--work-dir", default=os.environ.get("IKANBAN_SCAN_DIR", ""),
777
793
  help="Folder for this scan's files (or IKANBAN_SCAN_DIR; default "
778
794
  "<repository>/.iKanban/scans)")
795
+ ap.add_argument("--print-report", action="store_true",
796
+ help="Print the full report on stdout (under the ikanban CLI stdout otherwise shows the "
797
+ "counts and `ikanban scans show <id>`)")
779
798
 
780
799
 
781
800
  def scan_work_dir(scanner: str, base: Path, override: str = "") -> Path:
801
+ # Plan 5 X3: the ikanban CLI hands over a private temp folder (IKANBAN_SCAN_DIR) and the scan id
802
+ # (IKANBAN_SCAN_ID). That folder is this run's folder; the CLI seals it into ~/.ikanban/scans.
803
+ cli_dir = os.environ.get("IKANBAN_SCAN_DIR", "")
804
+ if override and cli_dir and os.environ.get("IKANBAN_SCAN_ID") and (
805
+ Path(override).expanduser().resolve() == Path(cli_dir).expanduser().resolve()):
806
+ run_dir = Path(cli_dir).expanduser().resolve()
807
+ run_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
808
+ os.chmod(run_dir, 0o700)
809
+ return run_dir
782
810
  scans = Path(override).expanduser().resolve() if override else base / ".iKanban" / "scans"
783
811
  scans.mkdir(parents=True, exist_ok=True)
784
812
  ignore = scans / ".gitignore"
@@ -804,14 +832,72 @@ def slug(text: str) -> str:
804
832
  return re.sub(r"[^A-Za-z0-9._-]+", "_", text).strip("_")[:80] or "x"
805
833
 
806
834
 
835
+ # ── scrub_text: identical in every scanner (test_scrub_text.py compares the copies) ──────────
836
+ # Plan 5 X2-T1: every text field of every finding passes through scrub_text before it leaves this
837
+ # machine. A secret keeps its first 4 characters; a home folder becomes ~. The server scrubs again.
838
+ _SCRUB_CUT = (
839
+ re.compile(r"-{5}BEGIN [A-Z ]*PRIVATE KEY-{5}.*?(?:-{5}END [A-Z ]*PRIVATE KEY-{5}|\Z)", re.S),
840
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
841
+ re.compile(r"\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"),
842
+ re.compile(r"\bgithub_pat_[A-Za-z0-9_]{22,}\b"),
843
+ re.compile(r"\bglpat-[A-Za-z0-9_-]{20,}\b"),
844
+ re.compile(r"\bxox[abposr]-[A-Za-z0-9-]{10,}\b"),
845
+ re.compile(r"\b(?:sk|rk)_live_[A-Za-z0-9]{20,}\b"),
846
+ re.compile(r"\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}\b"),
847
+ re.compile(r"\bwhsec_[A-Za-z0-9]{16,}\b"),
848
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
849
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"),
850
+ re.compile(r"\bvk_[A-Za-z0-9_-]{8,}"),
851
+ )
852
+ _SCRUB_ASSIGNED = re.compile(
853
+ r"(?i)(\b(?:aws_secret_access_key|aws_session_token|client_secret|secret_key|private_key|api[_-]?key|"
854
+ r"access[_-]?token|auth[_-]?token|token|password|passwd|pwd|secret)\b[\"']?\s*[:=]\s*[\"']?)([^\s\"',;&]+)")
855
+ _SCRUB_BEARER = re.compile(r"(?i)(\b(?:bearer|basic)\s+)([A-Za-z0-9._~+/=-]{12,})")
856
+ _SCRUB_URL_CREDS = re.compile(r"(\b[a-z][a-z0-9+.-]*://[^\s/:@]+:)([^\s@/]+)(@)")
857
+ _SCRUB_HOME = re.compile(r"(?<![\w.-])(?:[A-Za-z]:\\[Uu]sers\\|/Users/|/home/)[^\\/\s\"'<>]+")
858
+
859
+
860
+ def scrub_text(text) -> str:
861
+ """Secrets, iKanban keys, tokens, AWS and GitHub keys, private key blocks and home folders
862
+ cut from one text. None becomes ""."""
863
+ if text is None:
864
+ return ""
865
+ text = str(text)
866
+ for rx in _SCRUB_CUT:
867
+ text = rx.sub(lambda m: m.group(0)[:4] + "…[hidden]", text)
868
+ text = _SCRUB_ASSIGNED.sub(
869
+ lambda m: m.group(1) + "[hidden]" if len(m.group(2)) >= 8 or any(c.isdigit() for c in m.group(2))
870
+ else m.group(0), text)
871
+ text = _SCRUB_BEARER.sub(lambda m: m.group(1) + "[hidden]", text)
872
+ text = _SCRUB_URL_CREDS.sub(lambda m: m.group(1) + "[hidden]" + m.group(3), text)
873
+ home = str(Path.home())
874
+ if home and home not in ("/", "\\") and home in text:
875
+ text = text.replace(home, "~")
876
+ return _SCRUB_HOME.sub("~", text)
877
+
878
+
879
+ def scrub_finding(value):
880
+ """scrub_text on every string in a finding, through nested dicts and lists; other values stay."""
881
+ if isinstance(value, str):
882
+ return scrub_text(value)
883
+ if isinstance(value, dict):
884
+ return {k: scrub_finding(v) for k, v in value.items()}
885
+ if isinstance(value, list):
886
+ return [scrub_finding(v) for v in value]
887
+ return value
888
+
889
+
890
+ # ── end scrub_text ──
891
+
892
+
807
893
  # ── Pipelines (plan 11 H12, owner): with --task, the scan shows in that task's Pipelines view ──
808
894
  # Same calls as `ikanban pipeline` (vibe-cli/lib/pipeline.js → routes/pipeline.rs): an event
809
895
  # "running" at the start, "passed" / "failed" / "skipped" at the end with a one-line detail,
810
- # then the report as log chunks. A plan without Pipelines (402) or any error never fails the scan.
896
+ # then that line as the log (plan 5: never the report). A plan without Pipelines (402) or any error never fails the scan.
811
897
  _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)
812
898
  # Every iKanban scanner reports into one named lane, not the server's "legacy" lane.
813
899
  PIPE_RUN = "security-scan"
814
- PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per chunk
900
+ PIPE_CHUNK = 15 * 1024 # the server takes 16 KB per log line
815
901
  LAST_RUN = {"report": "", "detail": "", "skipped": False}
816
902
 
817
903
 
@@ -831,39 +917,40 @@ def summary_detail(counts: dict, total: int, skipped: list) -> str:
831
917
 
832
918
 
833
919
  def mask_report(text: str) -> str:
834
- """The report is masked already; mask again before it leaves for Pipelines."""
920
+ """scrub_text before a line leaves for Pipelines, plus a scanner's own rules (scrub_report)."""
921
+ text = scrub_text(text)
835
922
  scrub = globals().get("scrub_report")
836
- if scrub:
837
- return scrub(text)[0]
838
- text = re.sub(r"\bvk_[A-Za-z0-9_-]{8,}", lambda m: m.group(0)[:4] + "…[hidden]", text)
839
- home = str(Path.home())
840
- return text.replace(home, "~") if home and home != "/" else text
841
-
842
-
843
- def log_chunks(text: str) -> list:
844
- """Whole lines, each chunk under PIPE_CHUNK bytes (a longer single line is cut)."""
845
- chunks, cur = [], ""
846
- for line in text.splitlines(keepends=True):
847
- while len(line.encode()) > PIPE_CHUNK:
848
- head = line.encode()[:PIPE_CHUNK].decode(errors="ignore")
849
- chunks.append(head)
850
- line = line[len(head):]
851
- if len((cur + line).encode()) > PIPE_CHUNK:
852
- chunks.append(cur)
853
- cur = ""
854
- cur += line
855
- if cur:
856
- chunks.append(cur)
857
- return chunks
923
+ return scrub(text)[0] if scrub else text
924
+
925
+
926
+ def show_report(md: str, summary: str, print_report: bool = False) -> None:
927
+ """Plan 5 X3: under the ikanban CLI (IKANBAN_SCAN_ID set) stdout gets the counts and where the
928
+ full report is stored; the body only with --print-report. A plain python run prints the body."""
929
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
930
+ if scan_id and not print_report:
931
+ print(summary)
932
+ print(f"Full report: ikanban scans show {scan_id}")
933
+ else:
934
+ print(md)
935
+
936
+
937
+ def scan_files_note(work: Path) -> str:
938
+ """Where this run's files are: the encrypted store under the CLI, else the run folder."""
939
+ scan_id = os.environ.get("IKANBAN_SCAN_ID", "")
940
+ return f"scan files: sealed into ~/.ikanban/scans as {scan_id}" if scan_id else f"scan files: {work}"
941
+
942
+
858
943
 
859
944
  # The running Pipeline (set by run_main with --task). Owner: Pipelines showed nothing while a
860
- # scan ran, so each step also sends one line as it starts and ends; the full report follows at the end.
945
+ # scan ran, so each step also sends one line as it starts and ends; the one-line summary follows at the end.
861
946
  # Progress lines are fixed status text: never raw tool output or an error body (it can carry
862
947
  # secrets or text an attacker wrote). Those stay on local stderr and in the masked report.
863
948
  PIPE = None
864
949
 
865
950
 
866
951
  def progress(text: str) -> None:
952
+ if os.environ.get("IKANBAN_SCAN_ID") and "--json" not in sys.argv:
953
+ print(text, flush=True) # Plan 5: under the CLI stdout shows progress, not the report
867
954
  if PIPE:
868
955
  PIPE.log(text)
869
956
 
@@ -921,8 +1008,10 @@ class Pipeline:
921
1008
  self._post("pipeline-events", {"task": self.task, "stage": self.stage, "status": status,
922
1009
  "run": PIPE_RUN, "source": "laptop", "actor": f"ikanban-{self.stage}",
923
1010
  "detail": detail[:300] or None})
924
- for chunk in log_chunks(mask_report(LAST_RUN["report"])):
925
- self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage, "text": chunk})
1011
+ # Plan 5 X1-T2: the summary only; the report stays on the machine that ran the scan.
1012
+ summary = f"{self.stage} {status}" + (f": {detail[:300]}" if detail else "")
1013
+ self._post("pipeline-logs", {"task": self.task, "run": PIPE_RUN, "stage": self.stage,
1014
+ "text": mask_report(summary) + "\n"})
926
1015
 
927
1016
 
928
1017
  def run_main(stage, main_fn, argv=None):
@@ -1171,7 +1260,7 @@ def main(argv=None):
1171
1260
  skipped=scanned == 0 and not findings,
1172
1261
  )
1173
1262
  write_private(workdir / "report.md", LAST_RUN["report"])
1174
- log(f"scan files: {workdir}")
1263
+ log(scan_files_note(workdir))
1175
1264
 
1176
1265
  coverage = {
1177
1266
  "target": args.target,
@@ -1197,6 +1286,8 @@ def main(argv=None):
1197
1286
  )
1198
1287
  log(f"wrote {args.out}")
1199
1288
 
1289
+ if os.environ.get("IKANBAN_SCAN_ID") or args.print_report:
1290
+ show_report(LAST_RUN["report"], LAST_RUN["detail"], args.print_report)
1200
1291
  if args.dry_run:
1201
1292
  print(json.dumps({"scan_run_id": run_id, "coverage": coverage, "findings": findings}, indent=2))
1202
1293
  elif args.publish: