aisec-cli 0.2.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,22 +1,22 @@
1
- # @aisec-foundation/cli
1
+ # aisec-cli
2
2
 
3
- CLI for **aisec** — AI-powered web security scanner.
3
+ CLI for [AISEC](https://aisec.tools) — AI-powered web application security scanner. Autonomous pentesting from your terminal.
4
4
 
5
5
  ## Quick Start
6
6
 
7
7
  ```bash
8
- npx @aisec-foundation/cli scan https://target.com --token YOUR_TOKEN
8
+ npx aisec-cli scan https://target.com --token YOUR_TOKEN
9
9
  ```
10
10
 
11
11
  Or install globally:
12
12
 
13
13
  ```bash
14
- npm i -g @aisec-foundation/cli
14
+ npm i -g aisec-cli
15
15
  ```
16
16
 
17
17
  ## Authentication
18
18
 
19
- Get your token at [app.aisec.tools/developer](https://app.aisec.tools/developer).
19
+ Get your token at the [AISEC Dashboard](https://app.aisec.tools/developer).
20
20
 
21
21
  ```bash
22
22
  export AISEC_TOKEN=ask_...
@@ -49,6 +49,14 @@ aisec scans -l 20 # Last 20 scans
49
49
  aisec status # Check connection & auth
50
50
  ```
51
51
 
52
+ ## Links
53
+
54
+ - [AISEC Website](https://aisec.tools) — AI-powered penetration testing platform
55
+ - [Dashboard](https://app.aisec.tools) — manage scans and findings
56
+ - [How It Works](https://aisec.tools/how-it-works) — platform documentation
57
+ - [Pricing](https://aisec.tools/pricing) — plans and credits
58
+ - [Python CLI](https://github.com/aisec-foundation/cli-python) — alternative CLI
59
+
52
60
  ## License
53
61
 
54
62
  MIT
package/lib/api.mjs CHANGED
@@ -15,6 +15,26 @@ export async function request(apiUrl, path, token, opts = {}) {
15
15
  process.exit(1);
16
16
  }
17
17
  const body = await res.text();
18
+ if (res.status === 403) {
19
+ try {
20
+ const parsed = JSON.parse(body);
21
+ const err = parsed?.detail || parsed;
22
+ if (err?.error === "target_not_verified") {
23
+ const root = err.root_domain || "your target";
24
+ console.error(chalk.bold.red("\nTarget not verified"));
25
+ console.error(`To scan ${chalk.bold(root)}, publish a DNS TXT record:`);
26
+ console.error(` ${chalk.cyan("Host")}: _aisec-verify.${root}`);
27
+ console.error(` ${chalk.cyan("Type")}: TXT`);
28
+ console.error(` ${chalk.cyan("Value")}: use the dashboard to get your per-user challenge token`);
29
+ // Prefer the backend-provided per-project verification path; fall
30
+ // back to /projects (verification is per-project — there is no
31
+ // account-wide /verifications route).
32
+ const vpath = err.verification_url || "/projects";
33
+ console.error(`\nDashboard: ${chalk.cyan(`https://app.aisec.tools${vpath}`)}`);
34
+ process.exit(3);
35
+ }
36
+ } catch {}
37
+ }
18
38
  throw new Error(`${res.status} ${res.statusText}: ${body}`);
19
39
  }
20
40
 
package/lib/scan.mjs CHANGED
@@ -4,6 +4,19 @@ import { readFileSync } from "fs";
4
4
  import { resolveAuth, resolveApi, wsUrl } from "./config.mjs";
5
5
  import { request, healthCheck } from "./api.mjs";
6
6
 
7
+ // Map a known API host to its dashboard host. Returns null for unknown
8
+ // hosts (staging/local/other tenant) so we never print a production URL
9
+ // for a scan that didn't run on production.
10
+ function dashboardUrl(apiUrl, scanId) {
11
+ try {
12
+ const host = new URL(apiUrl).hostname;
13
+ if (host === "api.aisec.tools") return `https://app.aisec.tools/scans/${scanId}`;
14
+ return null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
7
20
  function resolveProfile(opts) {
8
21
  if (opts.full) return "full";
9
22
  if (opts.bounty) return "bounty";
@@ -202,10 +215,15 @@ export async function cmdScan(target, opts) {
202
215
 
203
216
  // WebSocket streaming
204
217
  const wsBase = wsUrl(apiUrl);
205
- const wsUrlFull = `${wsBase}/ws/scans/${scanId}?token=${token}`;
218
+ const wsUrlFull = `${wsBase}/ws/scans/${scanId}`;
206
219
 
207
- const ws = new WebSocket(wsUrlFull);
220
+ // The API authenticates the socket via the `aisec-token` subprotocol
221
+ // (same as the dashboard + Python CLI). It does NOT read ?token= from the
222
+ // URL — passing it there both fails auth and risks leaking the token into
223
+ // logs. The ws lib sends the second protocol entry as the token value.
224
+ const ws = new WebSocket(wsUrlFull, ["aisec-token", token]);
208
225
  let cancelled = false;
226
+ let completed = false; // set true on scan_complete
209
227
  const foundFindings = []; // track severities for --fail-on
210
228
  let exitCode = 0;
211
229
 
@@ -264,12 +282,13 @@ export async function cmdScan(target, opts) {
264
282
  }
265
283
  break;
266
284
 
267
- case "credits_update":
268
285
  case "cost_update":
269
286
  {
270
- const cr = msg.data?.credits_used ?? msg.data?.cost;
271
- if (cr != null) {
272
- process.stdout.write(chalk.dim(` [${cr.toFixed(1)} credits]\r`));
287
+ // Backend emits token counts now (cost/credits were removed).
288
+ const ti = msg.data?.tokens_in;
289
+ const to = msg.data?.tokens_out;
290
+ if (ti != null || to != null) {
291
+ process.stdout.write(chalk.dim(` [tokens ${ti ?? 0} in · ${to ?? 0} out]\r`));
273
292
  }
274
293
  }
275
294
  break;
@@ -281,25 +300,19 @@ export async function cmdScan(target, opts) {
281
300
 
282
301
  case "scan_complete": {
283
302
  spinner.stop();
303
+ completed = true;
284
304
  const d = msg.data || {};
285
- const duration = d.duration ? formatDuration(d.duration) : "?";
286
- const creditsUsed = (d.credits_used ?? d.cost ?? 0).toFixed(1);
287
-
288
- // Fetch remaining credits
289
- let remaining = "?";
290
- try {
291
- const me = await request(apiUrl, "/api/v1/auth/me", token);
292
- remaining = parseFloat(me.credits_balance || 0).toFixed(1);
293
- } catch {}
294
-
295
- const reportUrl = `https://app.aisec.tools/scans/${scanId}`;
305
+ const tokensIn = d.tokens_in ?? 0;
306
+ const tokensOut = d.tokens_out ?? 0;
307
+ const reportUrl = dashboardUrl(apiUrl, scanId);
296
308
  console.log(
297
309
  "\n" + chalk.green("━".repeat(50)) + "\n" +
298
310
  chalk.bold.green(" Scan complete\n") +
299
311
  chalk.dim(` Findings: `) + chalk.white(d.findings ?? 0) + "\n" +
300
- chalk.dim(` Credits: `) + chalk.white(creditsUsed) + chalk.dim(" used · ") + chalk.yellow.bold(remaining) + chalk.dim(" remaining") + "\n" +
301
- chalk.dim(` Duration: `) + chalk.white(duration) + "\n" +
302
- chalk.dim(` Report: `) + chalk.underline.cyan(reportUrl) + "\n" +
312
+ chalk.dim(` Tokens: `) + chalk.white(`${tokensIn} in · ${tokensOut} out`) + "\n" +
313
+ (reportUrl
314
+ ? chalk.dim(` Report: `) + chalk.underline.cyan(reportUrl)
315
+ : chalk.dim(` Scan ID: `) + chalk.white(scanId)) + "\n" +
303
316
  chalk.green("━".repeat(50))
304
317
  );
305
318
 
@@ -307,7 +320,7 @@ export async function cmdScan(target, opts) {
307
320
  if (process.env.GITHUB_OUTPUT) {
308
321
  const { appendFileSync } = await import("fs");
309
322
  appendFileSync(process.env.GITHUB_OUTPUT, `findings=${d.findings ?? 0}\n`);
310
- appendFileSync(process.env.GITHUB_OUTPUT, `report-url=${reportUrl}\n`);
323
+ if (reportUrl) appendFileSync(process.env.GITHUB_OUTPUT, `report-url=${reportUrl}\n`);
311
324
  }
312
325
 
313
326
  // --fail-on check
@@ -335,8 +348,19 @@ export async function cmdScan(target, opts) {
335
348
  console.error(chalk.red(`WebSocket error: ${err.message}`));
336
349
  });
337
350
 
338
- ws.on("close", () => {
351
+ ws.on("close", (code) => {
339
352
  spinner.stop();
340
- if (!cancelled) process.exit(exitCode);
353
+ if (cancelled) return;
354
+ // Closing before scan_complete means we never saw the result (auth
355
+ // failure, network drop, server restart). Don't let CI read that as a
356
+ // pass — exit non-zero so the pipeline surfaces it.
357
+ if (!completed && exitCode === 0) {
358
+ console.error(
359
+ chalk.red(`\n✗ Stream closed before the scan completed (ws code ${code ?? "?"}). `) +
360
+ chalk.dim("Check the dashboard for status; the scan may still be running.")
361
+ );
362
+ exitCode = 1;
363
+ }
364
+ process.exit(exitCode);
341
365
  });
342
366
  }
package/lib/scans.mjs CHANGED
@@ -35,18 +35,15 @@ export async function cmdScans(opts) {
35
35
  chalk.dim("Status".padEnd(12)) +
36
36
  chalk.dim("Domain".padEnd(30)) +
37
37
  chalk.dim("Finds".padStart(6)) +
38
- chalk.dim("Cost".padStart(9)) +
39
38
  chalk.dim("Date".padStart(13)) +
40
39
  chalk.dim("ID".padStart(11))
41
40
  );
42
- console.log(chalk.dim("─".repeat(81)));
41
+ console.log(chalk.dim("─".repeat(72)));
43
42
 
44
43
  for (const s of scans) {
45
44
  const color = STATUS_COLORS[s.status] || chalk.white;
46
45
  const domain = (s.target || "").replace(/^https?:\/\//, "").slice(0, 28);
47
46
  const findings = String(s.findings_count ?? s.total_findings ?? 0);
48
- const cr = s.credits_used ?? s.total_cost;
49
- const cost = cr != null ? `${cr.toFixed(1)}` : "-";
50
47
  const date = s.created_at ? s.created_at.slice(0, 10) : "-";
51
48
  const id = (s.id || "").slice(0, 8);
52
49
 
@@ -54,7 +51,6 @@ export async function cmdScans(opts) {
54
51
  color(s.status.padEnd(12)) +
55
52
  chalk.white(domain.padEnd(30)) +
56
53
  chalk.white(findings.padStart(6)) +
57
- chalk.white(cost.padStart(9)) +
58
54
  chalk.dim(date.padStart(13)) +
59
55
  chalk.dim(id.padStart(11))
60
56
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aisec-cli",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "CLI for aisec — AI-powered web security scanner",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,9 @@
10
10
  "bin/",
11
11
  "lib/"
12
12
  ],
13
+ "scripts": {
14
+ "test": "node test/unit.mjs"
15
+ },
13
16
  "engines": {
14
17
  "node": ">=18"
15
18
  },