@zksecurity/zkao-cli 0.2.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/LICENSE +21 -0
- package/README.md +30 -0
- package/dist/index.js +594 -0
- package/package.json +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zkSecurity
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# @zksecurity/zkao-cli
|
|
2
|
+
|
|
3
|
+
Command-line interface for the [zkao](https://zkao.io) Project API. Launch and
|
|
4
|
+
poll scans, read and triage findings, and publish results from your terminal or
|
|
5
|
+
CI. Installs a `zkao` command.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @zksecurity/zkao-cli
|
|
9
|
+
# or run without installing:
|
|
10
|
+
npx @zksecurity/zkao-cli --help
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
zkao login # browser approval; saves credentials
|
|
15
|
+
zkao repos
|
|
16
|
+
zkao scans launch --repo <repoId> --budget 500 --preset <ref>
|
|
17
|
+
zkao scans wait <scanId> # block until the scan finishes
|
|
18
|
+
zkao scans cancel <scanId> # cancel a running or queued scan
|
|
19
|
+
zkao findings list --scan <scanId>
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
All output is JSON. `zkao --help` lists every command. Credentials come from
|
|
23
|
+
`zkao login`, `zkao config set`, or the `ZKAO_API_TOKEN` / `ZKAO_PROJECT_ID`
|
|
24
|
+
environment variables. Point at another environment with `ZKAO_URL`.
|
|
25
|
+
|
|
26
|
+
Full docs and the SDK (`@zksecurity/zkao-sdk`): **https://github.com/zksecurity/zkao-sdk**
|
|
27
|
+
|
|
28
|
+
## License
|
|
29
|
+
|
|
30
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
5
|
+
import { createRequire } from "module";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
import {
|
|
8
|
+
ZkaoApiError,
|
|
9
|
+
ZkaoClient
|
|
10
|
+
} from "@zksecurity/zkao-sdk";
|
|
11
|
+
|
|
12
|
+
// src/config.ts
|
|
13
|
+
import {
|
|
14
|
+
chmodSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
unlinkSync,
|
|
18
|
+
writeFileSync
|
|
19
|
+
} from "fs";
|
|
20
|
+
import { homedir } from "os";
|
|
21
|
+
import { dirname, join } from "path";
|
|
22
|
+
import { resolveBaseUrlFromEnv } from "@zksecurity/zkao-sdk";
|
|
23
|
+
var CONFIG_PATH = join(homedir(), ".zkao", "config.json");
|
|
24
|
+
function readFile() {
|
|
25
|
+
let raw;
|
|
26
|
+
try {
|
|
27
|
+
raw = readFileSync(CONFIG_PATH, "utf8");
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err.code === "ENOENT") {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(raw);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Could not parse ${CONFIG_PATH}: ${err.message}. Fix or delete the file, or overwrite it with \`zkao config set\`.`
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function writeConfig(update) {
|
|
43
|
+
let existing;
|
|
44
|
+
try {
|
|
45
|
+
existing = readFile();
|
|
46
|
+
} catch {
|
|
47
|
+
console.error(`zkao: replacing unparseable config at ${CONFIG_PATH}`);
|
|
48
|
+
existing = {};
|
|
49
|
+
}
|
|
50
|
+
const merged = { ...existing, ...stripUndefined(update) };
|
|
51
|
+
writeSecretFile(CONFIG_PATH, `${JSON.stringify(merged, null, 2)}
|
|
52
|
+
`);
|
|
53
|
+
return merged;
|
|
54
|
+
}
|
|
55
|
+
function writeSecretFile(path, contents) {
|
|
56
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
57
|
+
writeFileSync(path, contents, { mode: 384 });
|
|
58
|
+
chmodSync(path, 384);
|
|
59
|
+
}
|
|
60
|
+
function configPath() {
|
|
61
|
+
return CONFIG_PATH;
|
|
62
|
+
}
|
|
63
|
+
function stripUndefined(obj) {
|
|
64
|
+
return Object.fromEntries(
|
|
65
|
+
Object.entries(obj).filter(([, v]) => v !== void 0)
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
function resolveConfig(flags) {
|
|
69
|
+
const file = readFile();
|
|
70
|
+
return {
|
|
71
|
+
token: flags.token ?? process.env.ZKAO_API_TOKEN ?? file.token,
|
|
72
|
+
projectId: flags.projectId ?? process.env.ZKAO_PROJECT_ID ?? file.projectId,
|
|
73
|
+
baseUrl: flags.baseUrl ?? resolveBaseUrlFromEnv() ?? file.baseUrl
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
var PENDING_PATH = join(homedir(), ".zkao", "pending-login.json");
|
|
77
|
+
function writePendingLogin(pending) {
|
|
78
|
+
writeSecretFile(PENDING_PATH, `${JSON.stringify(pending, null, 2)}
|
|
79
|
+
`);
|
|
80
|
+
}
|
|
81
|
+
function readPendingLogin() {
|
|
82
|
+
let raw;
|
|
83
|
+
try {
|
|
84
|
+
raw = readFileSync(PENDING_PATH, "utf8");
|
|
85
|
+
} catch (err) {
|
|
86
|
+
if (err.code === "ENOENT") {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
throw err;
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
return JSON.parse(raw);
|
|
93
|
+
} catch {
|
|
94
|
+
console.error(
|
|
95
|
+
`zkao: ignoring unparseable pending login at ${PENDING_PATH}`
|
|
96
|
+
);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function clearPendingLogin() {
|
|
101
|
+
try {
|
|
102
|
+
unlinkSync(PENDING_PATH);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err.code !== "ENOENT") {
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// src/login.ts
|
|
111
|
+
import { spawn } from "child_process";
|
|
112
|
+
import { hostname } from "os";
|
|
113
|
+
import { DEFAULT_BASE_URL } from "@zksecurity/zkao-sdk";
|
|
114
|
+
function appOrigin(baseUrl) {
|
|
115
|
+
const url = new URL(baseUrl);
|
|
116
|
+
const prefix = url.pathname.replace(/\/+$/, "").replace(/\/api\/v\d+$/, "");
|
|
117
|
+
return `${url.origin}${prefix}`;
|
|
118
|
+
}
|
|
119
|
+
function openBrowser(url) {
|
|
120
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
121
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
122
|
+
try {
|
|
123
|
+
const child = spawn(command, args, { stdio: "ignore", detached: true });
|
|
124
|
+
child.on("error", () => {
|
|
125
|
+
});
|
|
126
|
+
child.unref();
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
function sleep(ms) {
|
|
131
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
132
|
+
}
|
|
133
|
+
async function postJson(url, body) {
|
|
134
|
+
const res = await fetch(url, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "content-type": "application/json" },
|
|
137
|
+
body: JSON.stringify(body)
|
|
138
|
+
});
|
|
139
|
+
if (!res.ok) {
|
|
140
|
+
let detail = res.statusText;
|
|
141
|
+
try {
|
|
142
|
+
const data = await res.json();
|
|
143
|
+
detail = data.error?.message ?? detail;
|
|
144
|
+
} catch {
|
|
145
|
+
}
|
|
146
|
+
throw new Error(`Device authorization failed (${res.status}): ${detail}`);
|
|
147
|
+
}
|
|
148
|
+
return await res.json();
|
|
149
|
+
}
|
|
150
|
+
function printInstructions(start) {
|
|
151
|
+
console.log("\nTo authorize this CLI, open this URL and approve:\n");
|
|
152
|
+
console.log(` ${start.verificationUriComplete}`);
|
|
153
|
+
console.log(
|
|
154
|
+
`
|
|
155
|
+
Confirm the page shows this code: ${start.userCode}
|
|
156
|
+
Approve only if you started this login.
|
|
157
|
+
`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
function persistCredentials(poll, baseUrl) {
|
|
161
|
+
const saved = writeConfig({
|
|
162
|
+
token: poll.token,
|
|
163
|
+
projectId: poll.projectId,
|
|
164
|
+
baseUrl
|
|
165
|
+
});
|
|
166
|
+
clearPendingLogin();
|
|
167
|
+
console.log("Authorized. Credentials saved.\n");
|
|
168
|
+
console.log(` project: ${saved.projectId}`);
|
|
169
|
+
}
|
|
170
|
+
async function login(flags) {
|
|
171
|
+
const cfg = resolveConfig({
|
|
172
|
+
token: flags.token,
|
|
173
|
+
projectId: flags.project,
|
|
174
|
+
baseUrl: flags.baseUrl
|
|
175
|
+
});
|
|
176
|
+
const apiBase = cfg.baseUrl ?? DEFAULT_BASE_URL;
|
|
177
|
+
const origin = appOrigin(apiBase);
|
|
178
|
+
const start = await postJson(`${origin}/api/auth/device`, {
|
|
179
|
+
label: hostname()
|
|
180
|
+
});
|
|
181
|
+
printInstructions(start);
|
|
182
|
+
if (!flags.noBrowser) {
|
|
183
|
+
openBrowser(start.verificationUriComplete);
|
|
184
|
+
}
|
|
185
|
+
writePendingLogin({
|
|
186
|
+
deviceCode: start.deviceCode,
|
|
187
|
+
origin,
|
|
188
|
+
interval: Math.max(1, start.interval),
|
|
189
|
+
expiresAtMs: Date.now() + start.expiresIn * 1e3,
|
|
190
|
+
baseUrl: cfg.baseUrl
|
|
191
|
+
});
|
|
192
|
+
if (flags.wait === false) {
|
|
193
|
+
console.log(
|
|
194
|
+
"Once you approve in the browser, run `zkao login --resume` to finish.\n"
|
|
195
|
+
);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
await pollUntilDone({
|
|
199
|
+
origin,
|
|
200
|
+
deviceCode: start.deviceCode,
|
|
201
|
+
intervalMs: Math.max(1, start.interval) * 1e3,
|
|
202
|
+
deadlineMs: Date.now() + start.expiresIn * 1e3,
|
|
203
|
+
baseUrl: cfg.baseUrl
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
async function resumeLogin(flags) {
|
|
207
|
+
const pending = readPendingLogin();
|
|
208
|
+
if (!pending) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
"No pending login. Run `zkao login --no-wait` to start one first."
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (Date.now() >= pending.expiresAtMs) {
|
|
214
|
+
clearPendingLogin();
|
|
215
|
+
throw new Error("The login request expired. Run `zkao login` again.");
|
|
216
|
+
}
|
|
217
|
+
const waitMs = (flags.timeout ?? 0) * 1e3;
|
|
218
|
+
await pollUntilDone({
|
|
219
|
+
origin: pending.origin,
|
|
220
|
+
deviceCode: pending.deviceCode,
|
|
221
|
+
intervalMs: Math.max(1, pending.interval) * 1e3,
|
|
222
|
+
deadlineMs: Math.min(pending.expiresAtMs, Date.now() + waitMs),
|
|
223
|
+
baseUrl: pending.baseUrl,
|
|
224
|
+
reportStillPending: true
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
async function pollUntilDone(opts) {
|
|
228
|
+
let intervalMs = opts.intervalMs;
|
|
229
|
+
let polled = false;
|
|
230
|
+
for (; ; ) {
|
|
231
|
+
if (polled) {
|
|
232
|
+
const remaining = opts.deadlineMs - Date.now();
|
|
233
|
+
if (remaining <= 0) {
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
await sleep(Math.min(intervalMs, remaining));
|
|
237
|
+
}
|
|
238
|
+
polled = true;
|
|
239
|
+
const poll = await postJson(
|
|
240
|
+
`${opts.origin}/api/auth/device/token`,
|
|
241
|
+
{ deviceCode: opts.deviceCode }
|
|
242
|
+
);
|
|
243
|
+
switch (poll.status) {
|
|
244
|
+
case "pending":
|
|
245
|
+
case "slow_down":
|
|
246
|
+
if (poll.status === "slow_down") {
|
|
247
|
+
intervalMs += 2e3;
|
|
248
|
+
}
|
|
249
|
+
break;
|
|
250
|
+
case "denied":
|
|
251
|
+
clearPendingLogin();
|
|
252
|
+
throw new Error("Authorization was denied in the browser.");
|
|
253
|
+
case "expired":
|
|
254
|
+
clearPendingLogin();
|
|
255
|
+
throw new Error("The login request expired. Run `zkao login` again.");
|
|
256
|
+
case "approved":
|
|
257
|
+
persistCredentials(poll, opts.baseUrl);
|
|
258
|
+
return;
|
|
259
|
+
default: {
|
|
260
|
+
const _exhaustive = poll;
|
|
261
|
+
throw new Error(
|
|
262
|
+
`Unexpected device status: ${JSON.stringify(_exhaustive)}`
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (opts.reportStillPending) {
|
|
268
|
+
console.log(
|
|
269
|
+
"Still waiting for approval. Approve in the browser, then run `zkao login --resume` again."
|
|
270
|
+
);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
throw new Error("The login request expired. Run `zkao login` again.");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// src/update-check.ts
|
|
277
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
278
|
+
import { dirname as dirname2, join as join2 } from "path";
|
|
279
|
+
var CACHE_PATH = join2(dirname2(configPath()), "update-check.json");
|
|
280
|
+
var CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
281
|
+
var REGISTRY_TIMEOUT_MS = 1500;
|
|
282
|
+
var PACKAGE = "@zksecurity/zkao-cli";
|
|
283
|
+
function debug(message, err) {
|
|
284
|
+
if (process.env.ZKAO_DEBUG) {
|
|
285
|
+
console.error(`zkao: ${message}: ${err instanceof Error ? err.message : String(err)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
function disabled() {
|
|
289
|
+
return Boolean(process.env.ZKAO_NO_UPDATE_CHECK);
|
|
290
|
+
}
|
|
291
|
+
function readCache() {
|
|
292
|
+
let raw;
|
|
293
|
+
try {
|
|
294
|
+
raw = readFileSync2(CACHE_PATH, "utf8");
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (err.code !== "ENOENT") {
|
|
297
|
+
debug("could not read the update cache", err);
|
|
298
|
+
}
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const parsed = JSON.parse(raw);
|
|
303
|
+
return typeof parsed.latest === "string" && typeof parsed.checkedAtMs === "number" ? parsed : null;
|
|
304
|
+
} catch (err) {
|
|
305
|
+
debug("ignoring an unparseable update cache", err);
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function writeCache(cache) {
|
|
310
|
+
try {
|
|
311
|
+
mkdirSync2(dirname2(CACHE_PATH), { recursive: true, mode: 448 });
|
|
312
|
+
writeFileSync2(CACHE_PATH, `${JSON.stringify(cache)}
|
|
313
|
+
`);
|
|
314
|
+
} catch (err) {
|
|
315
|
+
debug("could not write the update cache", err);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
function isNewerVersion(latest, current) {
|
|
319
|
+
const parse = (v) => {
|
|
320
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(v.trim());
|
|
321
|
+
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
322
|
+
};
|
|
323
|
+
const a = parse(latest);
|
|
324
|
+
const b = parse(current);
|
|
325
|
+
if (!(a && b)) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
const [aMajor, aMinor, aPatch] = a;
|
|
329
|
+
const [bMajor, bMinor, bPatch] = b;
|
|
330
|
+
if (aMajor !== bMajor) {
|
|
331
|
+
return aMajor > bMajor;
|
|
332
|
+
}
|
|
333
|
+
if (aMinor !== bMinor) {
|
|
334
|
+
return aMinor > bMinor;
|
|
335
|
+
}
|
|
336
|
+
return aPatch > bPatch;
|
|
337
|
+
}
|
|
338
|
+
function printUpdateNotice(currentVersion) {
|
|
339
|
+
if (disabled()) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const cache = readCache();
|
|
343
|
+
if (!(cache && isNewerVersion(cache.latest, currentVersion))) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
console.error(
|
|
347
|
+
`zkao: version ${cache.latest} is available (running ${currentVersion}). Update with \`npm install -g ${PACKAGE}\`. If you are an agent working from a zkao skill file, update that too: it may describe fewer commands than the API now offers.`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
async function refreshUpdateCache() {
|
|
351
|
+
if (disabled()) {
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const cache = readCache();
|
|
355
|
+
if (cache && Date.now() - cache.checkedAtMs < CHECK_INTERVAL_MS) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
const keepPrevious = () => writeCache({ checkedAtMs: Date.now(), latest: cache?.latest ?? "" });
|
|
359
|
+
try {
|
|
360
|
+
const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
|
|
361
|
+
headers: { accept: "application/json" },
|
|
362
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS)
|
|
363
|
+
});
|
|
364
|
+
if (!res.ok) {
|
|
365
|
+
debug("registry rejected the version lookup", new Error(`HTTP ${res.status}`));
|
|
366
|
+
keepPrevious();
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const body = await res.json();
|
|
370
|
+
if (typeof body.version !== "string") {
|
|
371
|
+
debug("registry returned no version", new Error("missing `version`"));
|
|
372
|
+
keepPrevious();
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
writeCache({ checkedAtMs: Date.now(), latest: body.version });
|
|
376
|
+
} catch (err) {
|
|
377
|
+
debug("could not reach the npm registry", err);
|
|
378
|
+
keepPrevious();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// src/index.ts
|
|
383
|
+
var { version } = createRequire(import.meta.url)("../package.json");
|
|
384
|
+
var program = new Command();
|
|
385
|
+
program.name("zkao").description("Control a zkao project from the command line.").version(version).option("--token <token>", "project API token (or env ZKAO_API_TOKEN)").option("--project <id>", "project id (or env ZKAO_PROJECT_ID)").option("--base-url <url>", "API base URL, used verbatim (env: ZKAO_URL sets the environment)");
|
|
386
|
+
function out(data) {
|
|
387
|
+
console.log(JSON.stringify(data, null, 2));
|
|
388
|
+
}
|
|
389
|
+
function fail(message) {
|
|
390
|
+
console.error(message);
|
|
391
|
+
process.exit(1);
|
|
392
|
+
}
|
|
393
|
+
function client() {
|
|
394
|
+
const g = program.opts();
|
|
395
|
+
const cfg = resolveConfig({ token: g.token, projectId: g.project, baseUrl: g.baseUrl });
|
|
396
|
+
if (!cfg.token) {
|
|
397
|
+
fail("No API token. Pass --token, set ZKAO_API_TOKEN, or run `zkao config set --token <token>`.");
|
|
398
|
+
}
|
|
399
|
+
if (!cfg.projectId) {
|
|
400
|
+
fail("No project id. Pass --project, set ZKAO_PROJECT_ID, or run `zkao config set --project <id>`.");
|
|
401
|
+
}
|
|
402
|
+
return new ZkaoClient({ token: cfg.token, projectId: cfg.projectId, baseUrl: cfg.baseUrl });
|
|
403
|
+
}
|
|
404
|
+
async function run(fn) {
|
|
405
|
+
try {
|
|
406
|
+
out(await fn(client()));
|
|
407
|
+
} catch (err) {
|
|
408
|
+
if (err instanceof ZkaoApiError) {
|
|
409
|
+
fail(`Error ${err.status} (${err.code}): ${err.message}`);
|
|
410
|
+
}
|
|
411
|
+
throw err;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
function readContentArg(source) {
|
|
415
|
+
try {
|
|
416
|
+
return readFileSync3(source === "-" ? 0 : source, "utf8");
|
|
417
|
+
} catch (err) {
|
|
418
|
+
fail(
|
|
419
|
+
`Could not read ${source === "-" ? "stdin" : `"${source}"`}: ${err instanceof Error ? err.message : String(err)}`
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
var collect = (value, prev) => [...prev, value];
|
|
424
|
+
var toInt = (value) => {
|
|
425
|
+
const n = Number.parseInt(value, 10);
|
|
426
|
+
if (!Number.isInteger(n)) {
|
|
427
|
+
fail(`Expected an integer, got "${value}"`);
|
|
428
|
+
}
|
|
429
|
+
return n;
|
|
430
|
+
};
|
|
431
|
+
program.command("login").description(
|
|
432
|
+
"Authorize this CLI by approving it in your browser (device flow)"
|
|
433
|
+
).option("--no-browser", "print the URL instead of opening a browser").option(
|
|
434
|
+
"--no-wait",
|
|
435
|
+
"start the flow, print the URL/code, and exit; finish with --resume"
|
|
436
|
+
).option("--resume", "poll a login previously started with --no-wait").option(
|
|
437
|
+
"--timeout <seconds>",
|
|
438
|
+
"with --resume, wait up to this long for approval (default: poll once)",
|
|
439
|
+
toInt
|
|
440
|
+
).action(
|
|
441
|
+
async (opts) => {
|
|
442
|
+
if (opts.resume) {
|
|
443
|
+
await resumeLogin({ timeout: opts.timeout });
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
const g = program.opts();
|
|
447
|
+
await login({
|
|
448
|
+
token: g.token,
|
|
449
|
+
project: g.project,
|
|
450
|
+
baseUrl: g.baseUrl,
|
|
451
|
+
noBrowser: opts.browser === false,
|
|
452
|
+
wait: opts.wait !== false
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
);
|
|
456
|
+
var config = program.command("config").description("Manage stored credentials");
|
|
457
|
+
config.command("set").description(`Persist settings to ${configPath()}`).option("--token <token>", "project API token").option("--project <id>", "project id").option("--base-url <url>", "API base URL").action((opts) => {
|
|
458
|
+
const g = program.opts();
|
|
459
|
+
const update = {
|
|
460
|
+
token: opts.token ?? g.token,
|
|
461
|
+
projectId: opts.project ?? g.project,
|
|
462
|
+
baseUrl: opts.baseUrl ?? g.baseUrl
|
|
463
|
+
};
|
|
464
|
+
if (update.token === void 0 && update.projectId === void 0 && update.baseUrl === void 0) {
|
|
465
|
+
fail("Nothing to save. Pass --token, --project, or --base-url.");
|
|
466
|
+
}
|
|
467
|
+
writeConfig(update);
|
|
468
|
+
console.log(`Saved to ${configPath()}`);
|
|
469
|
+
});
|
|
470
|
+
config.command("show").description("Show the resolved settings (token masked)").action(() => {
|
|
471
|
+
const cfg = resolveConfig({});
|
|
472
|
+
out({
|
|
473
|
+
token: cfg.token ? `${cfg.token.slice(0, 14)}\u2026` : null,
|
|
474
|
+
projectId: cfg.projectId ?? null,
|
|
475
|
+
baseUrl: cfg.baseUrl ?? "https://zkao.io/api/v1 (default)"
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
program.command("repos").description("List repositories in the project").action(() => run((c) => c.listRepositories()));
|
|
479
|
+
var guidance = program.command("guidance").description("Read and update a repository's guidance");
|
|
480
|
+
guidance.command("get <repoId>").description("Show a repository's configured guidance").action((repoId) => run((c) => c.getRepositoryGuidance(repoId)));
|
|
481
|
+
guidance.command("set <repoId> <file|->").description("Set a repository's guidance from a file (or - for stdin)").option(
|
|
482
|
+
"--force",
|
|
483
|
+
"overwrite without the safety check that guidance did not change since last read"
|
|
484
|
+
).action((repoId, source, opts) => {
|
|
485
|
+
const content = readContentArg(source);
|
|
486
|
+
return run(async (c) => {
|
|
487
|
+
if (opts.force) {
|
|
488
|
+
return c.setRepositoryGuidance(repoId, content);
|
|
489
|
+
}
|
|
490
|
+
const current = await c.getRepositoryGuidance(repoId);
|
|
491
|
+
return c.setRepositoryGuidance(repoId, content, {
|
|
492
|
+
expectedContent: current.content
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
guidance.command("clear <repoId>").description("Remove a repository's guidance").option("--force", "overwrite without the concurrent-change safety check").action(
|
|
497
|
+
(repoId, opts) => run(async (c) => {
|
|
498
|
+
if (opts.force) {
|
|
499
|
+
return c.setRepositoryGuidance(repoId, null);
|
|
500
|
+
}
|
|
501
|
+
const current = await c.getRepositoryGuidance(repoId);
|
|
502
|
+
return c.setRepositoryGuidance(repoId, null, {
|
|
503
|
+
expectedContent: current.content
|
|
504
|
+
});
|
|
505
|
+
})
|
|
506
|
+
);
|
|
507
|
+
var scans = program.command("scans").description("Launch, list, and poll scans");
|
|
508
|
+
scans.command("list").option("--page <n>", "page number", toInt).option("--limit <n>", "page size (max 100)", toInt).action(
|
|
509
|
+
(opts) => run((c) => c.listScans({ page: opts.page, limit: opts.limit }))
|
|
510
|
+
);
|
|
511
|
+
scans.command("get <scanId>").description("Get a scan's status and detail").action((scanId) => run((c) => c.getScan(scanId)));
|
|
512
|
+
scans.command("wait <scanId>").description("Poll a scan until it finishes (honors server backoff)").option("--timeout <seconds>", "give up after this long", toInt).action((scanId, opts) => {
|
|
513
|
+
const controller = new AbortController();
|
|
514
|
+
const onSigint = () => controller.abort(new Error("Interrupted"));
|
|
515
|
+
process.once("SIGINT", onSigint);
|
|
516
|
+
return run(
|
|
517
|
+
(c) => c.waitForScan(scanId, {
|
|
518
|
+
timeoutMs: opts.timeout ? opts.timeout * 1e3 : void 0,
|
|
519
|
+
signal: controller.signal,
|
|
520
|
+
onPoll: (scan) => console.error(` scan ${scan.id}: ${scan.status}`)
|
|
521
|
+
}).finally(() => process.removeListener("SIGINT", onSigint))
|
|
522
|
+
);
|
|
523
|
+
});
|
|
524
|
+
scans.command("launch").description("Launch a scan").requiredOption("--repo <id>", "repository id").requiredOption("--budget <credits>", "max budget, in credits", toInt).option("--preset <ref>", "scan preset ref (see `zkao presets`)").option("--branch <name>", "branch to scan").option("--commit <sha>", "specific commit to scan").option("--message <text>", "commit message (metadata)").option("--flow <id>", "optional-flow id to opt into (repeatable)", collect, []).option(
|
|
525
|
+
"--guidance <file|->",
|
|
526
|
+
"per-scan guidance from a file (or - for stdin); replaces the repo's guidance for this scan"
|
|
527
|
+
).action(
|
|
528
|
+
(opts) => {
|
|
529
|
+
const body = {
|
|
530
|
+
repositoryId: opts.repo,
|
|
531
|
+
creditBudget: opts.budget,
|
|
532
|
+
presetRef: opts.preset,
|
|
533
|
+
branch: opts.branch ?? null,
|
|
534
|
+
commitHash: opts.commit ?? null,
|
|
535
|
+
commitMessage: opts.message ?? null,
|
|
536
|
+
optInFlowIds: opts.flow.length > 0 ? opts.flow : void 0,
|
|
537
|
+
...opts.guidance !== void 0 ? { guidance: readContentArg(opts.guidance) } : {}
|
|
538
|
+
};
|
|
539
|
+
return run((c) => c.launchScan(body));
|
|
540
|
+
}
|
|
541
|
+
);
|
|
542
|
+
scans.command("cancel <scanId>").description("Cancel a running or queued scan").action((scanId) => run((c) => c.cancelScan(scanId)));
|
|
543
|
+
scans.command("publish <scanId>").description("Publish a completed scan as a public artifact").option("--password", "generate an access password").action(
|
|
544
|
+
(scanId, opts) => run((c) => c.publishScan(scanId, { withPassword: opts.password }))
|
|
545
|
+
);
|
|
546
|
+
var findings = program.command("findings").description("Read and triage findings");
|
|
547
|
+
findings.command("list").option("--scan <id>", "only findings for this scan").option("--page <n>", "page number", toInt).option("--limit <n>", "page size (max 100)", toInt).action(
|
|
548
|
+
(opts) => run((c) => c.listFindings({ scanId: opts.scan, page: opts.page, limit: opts.limit }))
|
|
549
|
+
);
|
|
550
|
+
findings.command("get <findingId>").description("Get a finding's full detail").action((findingId) => run((c) => c.getFinding(findingId)));
|
|
551
|
+
findings.command("comment <findingId> <text>").description("Add a comment to a finding").action((findingId, text) => run((c) => c.addFindingNote(findingId, text)));
|
|
552
|
+
findings.command("severity <findingId> <level>").description("Override severity (CRITICAL|HIGH|MEDIUM|LOW|INFO, or 'none' to clear)").action((findingId, level) => {
|
|
553
|
+
const value = level.toLowerCase() === "none" ? null : level.toUpperCase();
|
|
554
|
+
return run((c) => c.setFindingSeverity(findingId, value));
|
|
555
|
+
});
|
|
556
|
+
findings.command("resolution <findingId> <status>").description("Set resolution status (e.g. RESOLVED, WONT_FIX, FALSE_POSITIVE)").option("--note <text>", "record the why as a comment in the same call").option(
|
|
557
|
+
"--reason <code>",
|
|
558
|
+
"record the why as a catalog code instead (codes depend on the status; a wrong one is rejected with the valid list)"
|
|
559
|
+
).action(
|
|
560
|
+
(findingId, status, opts) => run(
|
|
561
|
+
(c) => c.setFindingResolution(findingId, status.toUpperCase(), {
|
|
562
|
+
note: opts.note ? { content: opts.note } : void 0,
|
|
563
|
+
reason: opts.reason
|
|
564
|
+
})
|
|
565
|
+
)
|
|
566
|
+
);
|
|
567
|
+
findings.command("publish <findingId>").description("Publish a finding as a public artifact").option("--note <noteId>", "note to pin as the published note").option("--password", "generate an access password").action(
|
|
568
|
+
(findingId, opts) => run((c) => c.publishFinding(findingId, { noteId: opts.note, withPassword: opts.password }))
|
|
569
|
+
);
|
|
570
|
+
var billing = program.command("billing").description("Read the project's credit balance and usage");
|
|
571
|
+
billing.command("balance").description("Credit balance, what active scans hold, and what is available").action(() => run((c) => c.getBillingBalance()));
|
|
572
|
+
billing.command("usage").description("Credit ledger movements (defaults to the last 30 days)").option("--from <date>", "ISO date or timestamp to start from").option("--to <date>", "ISO date or timestamp to stop at").option("--limit <n>", "max events to return", toInt).action(
|
|
573
|
+
(opts) => run((c) => c.getBillingUsage(opts))
|
|
574
|
+
);
|
|
575
|
+
billing.command("summary").description("Per-month credits spent and purchased, newest first").option("--months <n>", "how many months to return", toInt).action(
|
|
576
|
+
(opts) => run((c) => c.getBillingSummary({ months: opts.months }))
|
|
577
|
+
);
|
|
578
|
+
program.command("presets").description("List scan presets the project can launch").action(() => run((c) => c.listScanPresets()));
|
|
579
|
+
program.command("flows").description("List opt-in flows available at launch").action(() => run((c) => c.listOptionalFlows()));
|
|
580
|
+
function errorMessage(err) {
|
|
581
|
+
if (err instanceof AggregateError && err.errors.length > 0) {
|
|
582
|
+
return err.errors.map(errorMessage).join("; ");
|
|
583
|
+
}
|
|
584
|
+
if (!(err instanceof Error)) {
|
|
585
|
+
return String(err);
|
|
586
|
+
}
|
|
587
|
+
const message = err.message || String(err);
|
|
588
|
+
return err.cause ? `${message}: ${errorMessage(err.cause)}` : message;
|
|
589
|
+
}
|
|
590
|
+
printUpdateNotice(version);
|
|
591
|
+
program.parseAsync().then(() => refreshUpdateCache()).catch((err) => {
|
|
592
|
+
console.error(errorMessage(err));
|
|
593
|
+
process.exit(1);
|
|
594
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zksecurity/zkao-cli",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Command-line interface for the zkao Project API",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "zkSecurity",
|
|
7
|
+
"homepage": "https://github.com/zksecurity/zkao-sdk#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/zksecurity/zkao-sdk.git",
|
|
11
|
+
"directory": "packages/cli"
|
|
12
|
+
},
|
|
13
|
+
"bugs": "https://github.com/zksecurity/zkao-sdk/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"zkao",
|
|
16
|
+
"security",
|
|
17
|
+
"audit",
|
|
18
|
+
"cli",
|
|
19
|
+
"scanner"
|
|
20
|
+
],
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"zkao": "./dist/index.js"
|
|
24
|
+
},
|
|
25
|
+
"files": ["dist", "LICENSE", "README.md"],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=18"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup",
|
|
34
|
+
"typecheck": "tsc --noEmit",
|
|
35
|
+
"prepublishOnly": "node -e \"if (!(process.env.npm_config_user_agent || '').includes('bun/')) { console.error('Publish with bun publish: npm ships the literal workspace:* dependency and breaks installs.'); process.exit(1); }\""
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@zksecurity/zkao-sdk": "0.2.0",
|
|
39
|
+
"commander": "^12.1.0"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.0.0",
|
|
43
|
+
"tsup": "^8.3.0",
|
|
44
|
+
"typescript": "^5.7.0"
|
|
45
|
+
}
|
|
46
|
+
}
|