ikanban 0.4.8 → 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.
- package/README.md +57 -9
- package/bin/ikanban.js +49 -6
- package/hooks/deploy-check.js +221 -0
- package/hooks/ikanban-safe.js +26 -3
- package/hooks/plan-gate.js +206 -0
- package/hooks/trace.js +365 -0
- package/lib/ikanban-safe.js +26 -3
- package/lib/plancheck.js +221 -0
- package/lib/roadmap.js +78 -27
- package/lib/scanstore.js +403 -0
- package/lib/statuswords.js +13 -0
- package/package.json +3 -3
- package/scanners/ikanban-aiscan.py +121 -30
- package/scanners/ikanban-cloudscan.py +110 -41
- package/scanners/ikanban-controlscan.py +783 -0
- package/scanners/ikanban-dastscan.py +108 -40
- package/scanners/ikanban-import.py +597 -0
- package/scanners/ikanban-infrascan.py +116 -51
- package/scanners/ikanban-secscan.py +116 -104
package/lib/scanstore.js
ADDED
|
@@ -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
|
-
"description": "iKanban CLI: AI and security scans of your own repo
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
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
|
|
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
|
-
|
|
925
|
-
|
|
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(
|
|
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:
|