aisec-cli 0.1.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 +54 -0
- package/bin/aisec.mjs +53 -0
- package/lib/api.mjs +31 -0
- package/lib/config.mjs +24 -0
- package/lib/scan.mjs +313 -0
- package/lib/scans.mjs +62 -0
- package/lib/status.mjs +28 -0
- package/package.json +33 -0
package/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# @aisec-foundation/cli
|
|
2
|
+
|
|
3
|
+
CLI for **aisec** — AI-powered web security scanner.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npx @aisec-foundation/cli scan https://target.com --token YOUR_TOKEN
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or install globally:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm i -g @aisec-foundation/cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Authentication
|
|
18
|
+
|
|
19
|
+
Get your token at [app.aisec.tools/developer](https://app.aisec.tools/developer).
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
export AISEC_TOKEN=ask_...
|
|
23
|
+
aisec scan https://target.com
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
### `aisec scan <target>`
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
aisec scan https://target.com # Default balanced scan
|
|
32
|
+
aisec scan https://target.com --full # Aggressive + subdomains
|
|
33
|
+
aisec scan https://target.com --aggressive # Full port scan, sqlmap
|
|
34
|
+
aisec scan https://target.com --stealth # WAF evasion, slow
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Options: `--engine`, `--model`, `--temperature`, `--max-iterations`, `--scope`, `--timeout`, `--skip-recon`, `--skip-browser`, `--username`, `--password`, `--cookies`, `--proxy`, `--headers`
|
|
38
|
+
|
|
39
|
+
### `aisec scans`
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
aisec scans # Last 10 scans
|
|
43
|
+
aisec scans -l 20 # Last 20 scans
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### `aisec status`
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
aisec status # Check connection & auth
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## License
|
|
53
|
+
|
|
54
|
+
MIT
|
package/bin/aisec.mjs
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { program } from "commander";
|
|
4
|
+
import { cmdScan } from "../lib/scan.mjs";
|
|
5
|
+
import { cmdScans } from "../lib/scans.mjs";
|
|
6
|
+
import { cmdStatus } from "../lib/status.mjs";
|
|
7
|
+
|
|
8
|
+
program
|
|
9
|
+
.name("aisec")
|
|
10
|
+
.description("AI-powered web security scanner")
|
|
11
|
+
.version("0.1.0");
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.command("scan <target>")
|
|
15
|
+
.description("Launch a security scan against a target URL")
|
|
16
|
+
.option("--stealth", "Stealth profile — slower, WAF evasion")
|
|
17
|
+
.option("--aggressive", "Aggressive — full port scan, brute force, sqlmap")
|
|
18
|
+
.option("--full", "Full — aggressive + subdomain scope + 50 iterations")
|
|
19
|
+
.option("-e, --engine <engine>", "AI engine: claude or ollama", "claude")
|
|
20
|
+
.option("-m, --model <model>", "Model name")
|
|
21
|
+
.option("--temperature <temp>", "AI temperature 0.0-1.0", parseFloat)
|
|
22
|
+
.option("-n, --max-iterations <n>", "Max AI iterations", parseInt)
|
|
23
|
+
.option("--scope <scope>", "Scan scope: target, domain, subdomain")
|
|
24
|
+
.option("-t, --timeout <minutes>", "Timeout in minutes, 0=unlimited", parseInt)
|
|
25
|
+
.option("--skip-recon", "Skip infrastructure recon")
|
|
26
|
+
.option("--skip-browser", "Skip browser-based recon")
|
|
27
|
+
.option("-u, --username <user>", "Username for auth scanning")
|
|
28
|
+
.option("-p, --password <pass>", "Password for auth scanning")
|
|
29
|
+
.option("--cookies <json>", "Session cookies as JSON or @file")
|
|
30
|
+
.option("--proxy <url>", "Proxy URL")
|
|
31
|
+
.option("--headers <headers>", "Custom headers: 'Key:Val,Key2:Val2' or @file")
|
|
32
|
+
.option("--fail-on <severity>", "Exit 1 if findings at this severity or above (critical, high, medium, low)")
|
|
33
|
+
.option("--source <source>", "Scan source identifier (cli, ci, api)", "cli")
|
|
34
|
+
.option("--token <token>", "API token (or AISEC_TOKEN env)")
|
|
35
|
+
.option("--api <url>", "API URL override")
|
|
36
|
+
.action(cmdScan);
|
|
37
|
+
|
|
38
|
+
program
|
|
39
|
+
.command("scans")
|
|
40
|
+
.description("List recent scans")
|
|
41
|
+
.option("-l, --limit <n>", "Number of scans to show", parseInt, 10)
|
|
42
|
+
.option("--token <token>", "API token (or AISEC_TOKEN env)")
|
|
43
|
+
.option("--api <url>", "API URL override")
|
|
44
|
+
.action(cmdScans);
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command("status")
|
|
48
|
+
.description("Check API connection and authentication")
|
|
49
|
+
.option("--token <token>", "API token (or AISEC_TOKEN env)")
|
|
50
|
+
.option("--api <url>", "API URL override")
|
|
51
|
+
.action(cmdStatus);
|
|
52
|
+
|
|
53
|
+
program.parse();
|
package/lib/api.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
export async function request(apiUrl, path, token, opts = {}) {
|
|
4
|
+
const url = `${apiUrl}${path}`;
|
|
5
|
+
const headers = {
|
|
6
|
+
"Authorization": `Bearer ${token}`,
|
|
7
|
+
"Content-Type": "application/json",
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const res = await fetch(url, { ...opts, headers: { ...headers, ...opts.headers } });
|
|
11
|
+
|
|
12
|
+
if (!res.ok) {
|
|
13
|
+
if (res.status === 401) {
|
|
14
|
+
console.error(chalk.red("Invalid API token."));
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const body = await res.text();
|
|
18
|
+
throw new Error(`${res.status} ${res.statusText}: ${body}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return res.json();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function healthCheck(apiUrl) {
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(`${apiUrl}/health`, { signal: AbortSignal.timeout(5000) });
|
|
27
|
+
return res.ok;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
|
|
3
|
+
const DEFAULT_API = "https://api.aisec.tools";
|
|
4
|
+
|
|
5
|
+
export function resolveAuth(opts) {
|
|
6
|
+
const token = opts.token || process.env.AISEC_TOKEN;
|
|
7
|
+
if (!token) {
|
|
8
|
+
console.error(chalk.red("No API token provided."));
|
|
9
|
+
console.error(
|
|
10
|
+
`Set ${chalk.cyan("AISEC_TOKEN")} env var or pass ${chalk.cyan("--token")}\n` +
|
|
11
|
+
`Get your token at ${chalk.underline("https://app.aisec.tools/developer")}`
|
|
12
|
+
);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
return token;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function resolveApi(opts) {
|
|
19
|
+
return (opts.api || process.env.AISEC_API || DEFAULT_API).replace(/\/$/, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function wsUrl(apiUrl) {
|
|
23
|
+
return apiUrl.replace(/^http/, "ws");
|
|
24
|
+
}
|
package/lib/scan.mjs
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import WebSocket from "ws";
|
|
3
|
+
import { readFileSync } from "fs";
|
|
4
|
+
import { resolveAuth, resolveApi, wsUrl } from "./config.mjs";
|
|
5
|
+
import { request, healthCheck } from "./api.mjs";
|
|
6
|
+
|
|
7
|
+
function resolveProfile(opts) {
|
|
8
|
+
if (opts.full) return "full";
|
|
9
|
+
if (opts.aggressive) return "aggressive";
|
|
10
|
+
if (opts.stealth) return "stealth";
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseHeaders(raw) {
|
|
15
|
+
if (!raw) return undefined;
|
|
16
|
+
if (raw.startsWith("@")) {
|
|
17
|
+
raw = readFileSync(raw.slice(1), "utf-8").trim();
|
|
18
|
+
}
|
|
19
|
+
const headers = {};
|
|
20
|
+
for (const pair of raw.split(",")) {
|
|
21
|
+
const idx = pair.indexOf(":");
|
|
22
|
+
if (idx > 0) headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
|
|
23
|
+
}
|
|
24
|
+
return headers;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseCookies(raw) {
|
|
28
|
+
if (!raw) return undefined;
|
|
29
|
+
if (raw.startsWith("@")) {
|
|
30
|
+
return readFileSync(raw.slice(1), "utf-8").trim();
|
|
31
|
+
}
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildBody(target, opts) {
|
|
36
|
+
const body = { target, source: opts.source || "cli" };
|
|
37
|
+
const profile = resolveProfile(opts);
|
|
38
|
+
if (profile) body.profile = profile;
|
|
39
|
+
if (opts.engine !== "claude") body.engine = opts.engine;
|
|
40
|
+
if (opts.model) body.model = opts.model;
|
|
41
|
+
if (opts.temperature != null) body.temperature = opts.temperature;
|
|
42
|
+
if (opts.maxIterations) body.max_iterations = opts.maxIterations;
|
|
43
|
+
if (opts.scope) body.scope = opts.scope;
|
|
44
|
+
if (opts.timeout != null) body.timeout_minutes = opts.timeout;
|
|
45
|
+
if (opts.skipRecon) body.skip_recon = true;
|
|
46
|
+
if (opts.skipBrowser) body.skip_browser = true;
|
|
47
|
+
if (opts.username) body.username = opts.username;
|
|
48
|
+
if (opts.password) body.password = opts.password;
|
|
49
|
+
if (opts.proxy) body.proxy = opts.proxy;
|
|
50
|
+
const cookies = parseCookies(opts.cookies);
|
|
51
|
+
if (cookies) body.cookies_json = cookies;
|
|
52
|
+
const headers = parseHeaders(opts.headers);
|
|
53
|
+
if (headers) body.custom_headers = headers;
|
|
54
|
+
return body;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function formatDuration(seconds) {
|
|
58
|
+
const m = Math.floor(seconds / 60);
|
|
59
|
+
const s = seconds % 60;
|
|
60
|
+
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const SEV_RANK = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
64
|
+
|
|
65
|
+
function sevAboveThreshold(finding, threshold) {
|
|
66
|
+
if (!threshold) return false;
|
|
67
|
+
const fRank = SEV_RANK[(finding || "").toLowerCase()] ?? -1;
|
|
68
|
+
const tRank = SEV_RANK[threshold.toLowerCase()] ?? 99;
|
|
69
|
+
return fRank >= tRank;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const THINKING_VERBS = [
|
|
73
|
+
"Thinking", "Analyzing", "Probing", "Investigating", "Evaluating",
|
|
74
|
+
"Inspecting", "Scanning", "Crafting", "Assessing", "Examining",
|
|
75
|
+
"Mapping", "Enumerating", "Fingerprinting", "Strategizing",
|
|
76
|
+
];
|
|
77
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
78
|
+
|
|
79
|
+
function createThinkingSpinner() {
|
|
80
|
+
let interval = null;
|
|
81
|
+
let frame = 0;
|
|
82
|
+
let verb = THINKING_VERBS[Math.floor(Math.random() * THINKING_VERBS.length)];
|
|
83
|
+
let verbInterval = null;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
start() {
|
|
87
|
+
this.stop();
|
|
88
|
+
verb = THINKING_VERBS[Math.floor(Math.random() * THINKING_VERBS.length)];
|
|
89
|
+
frame = 0;
|
|
90
|
+
interval = setInterval(() => {
|
|
91
|
+
const f = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
|
|
92
|
+
process.stdout.write(`\r${chalk.cyan(f)} ${chalk.dim.italic(verb)} `);
|
|
93
|
+
frame++;
|
|
94
|
+
}, 80);
|
|
95
|
+
verbInterval = setInterval(() => {
|
|
96
|
+
verb = THINKING_VERBS[Math.floor(Math.random() * THINKING_VERBS.length)];
|
|
97
|
+
}, 3000 + Math.random() * 2000);
|
|
98
|
+
},
|
|
99
|
+
stop() {
|
|
100
|
+
if (interval) {
|
|
101
|
+
clearInterval(interval);
|
|
102
|
+
interval = null;
|
|
103
|
+
process.stdout.write("\r" + " ".repeat(40) + "\r");
|
|
104
|
+
}
|
|
105
|
+
if (verbInterval) {
|
|
106
|
+
clearInterval(verbInterval);
|
|
107
|
+
verbInterval = null;
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function cmdScan(target, opts) {
|
|
114
|
+
if (!/^https?:\/\//i.test(target)) {
|
|
115
|
+
console.error(chalk.red(`Target must start with http:// or https://`));
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const token = resolveAuth(opts);
|
|
120
|
+
const apiUrl = resolveApi(opts);
|
|
121
|
+
|
|
122
|
+
const ok = await healthCheck(apiUrl);
|
|
123
|
+
if (!ok) {
|
|
124
|
+
console.error(chalk.red(`API unreachable at ${apiUrl}`));
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const body = buildBody(target, opts);
|
|
129
|
+
const profile = resolveProfile(opts) || "default";
|
|
130
|
+
|
|
131
|
+
// Fetch account info before scan
|
|
132
|
+
let accountPlan = "?";
|
|
133
|
+
let accountCredits = "?";
|
|
134
|
+
try {
|
|
135
|
+
const me = await request(apiUrl, "/api/v1/auth/me", token);
|
|
136
|
+
accountPlan = me.plan || "free";
|
|
137
|
+
accountCredits = parseFloat(me.credits_balance || 0).toFixed(1);
|
|
138
|
+
} catch {}
|
|
139
|
+
|
|
140
|
+
console.log(
|
|
141
|
+
chalk.red("━".repeat(50)) + "\n" +
|
|
142
|
+
chalk.bold.red(" aisec") + chalk.dim(" — AI security scanner\n") +
|
|
143
|
+
chalk.dim(` Target: `) + chalk.white(target) + "\n" +
|
|
144
|
+
chalk.dim(` Account: `) + chalk.white(accountPlan) + chalk.dim(" · ") + chalk.yellow.bold(accountCredits) + chalk.dim(" credits") + "\n" +
|
|
145
|
+
chalk.dim(` Profile: `) + chalk.cyan(profile) + "\n" +
|
|
146
|
+
chalk.dim(` Engine: `) + chalk.cyan(opts.engine || "claude") + "\n" +
|
|
147
|
+
chalk.red("━".repeat(50))
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
let scan;
|
|
151
|
+
try {
|
|
152
|
+
scan = await request(apiUrl, "/api/v1/scans", token, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
body: JSON.stringify(body),
|
|
155
|
+
});
|
|
156
|
+
} catch (err) {
|
|
157
|
+
console.error(chalk.red(`Failed to create scan: ${err.message}`));
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const scanId = scan.id;
|
|
162
|
+
console.log(chalk.dim(`Scan ${scanId.slice(0, 8)}... created`));
|
|
163
|
+
|
|
164
|
+
// Expose scan ID for CI (GitHub Actions, etc.)
|
|
165
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
166
|
+
const { appendFileSync } = await import("fs");
|
|
167
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `scan-id=${scanId}\n`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (scan.queue_position && scan.queue_position > 0) {
|
|
171
|
+
console.log(chalk.yellow(`Queued at position ${scan.queue_position}`));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// WebSocket streaming
|
|
175
|
+
const wsBase = wsUrl(apiUrl);
|
|
176
|
+
const wsUrlFull = `${wsBase}/ws/scans/${scanId}?token=${token}`;
|
|
177
|
+
|
|
178
|
+
const ws = new WebSocket(wsUrlFull);
|
|
179
|
+
let cancelled = false;
|
|
180
|
+
const foundFindings = []; // track severities for --fail-on
|
|
181
|
+
let exitCode = 0;
|
|
182
|
+
|
|
183
|
+
const cancel = async () => {
|
|
184
|
+
if (cancelled) process.exit(1);
|
|
185
|
+
cancelled = true;
|
|
186
|
+
console.log(chalk.yellow("\nCancelling scan..."));
|
|
187
|
+
try {
|
|
188
|
+
await request(apiUrl, `/api/v1/scans/${scanId}/cancel`, token, { method: "POST" });
|
|
189
|
+
} catch {}
|
|
190
|
+
setTimeout(() => process.exit(0), 2000);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
process.on("SIGINT", cancel);
|
|
194
|
+
process.on("SIGTERM", cancel);
|
|
195
|
+
|
|
196
|
+
ws.on("open", () => {
|
|
197
|
+
// keepalive
|
|
198
|
+
const ping = setInterval(() => {
|
|
199
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
200
|
+
ws.send(JSON.stringify({ type: "ping" }));
|
|
201
|
+
} else {
|
|
202
|
+
clearInterval(ping);
|
|
203
|
+
}
|
|
204
|
+
}, 30_000);
|
|
205
|
+
ws.once("close", () => clearInterval(ping));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const spinner = createThinkingSpinner();
|
|
209
|
+
|
|
210
|
+
ws.on("message", async (data) => {
|
|
211
|
+
let msg;
|
|
212
|
+
try {
|
|
213
|
+
msg = JSON.parse(data.toString());
|
|
214
|
+
} catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
switch (msg.type) {
|
|
219
|
+
case "thinking":
|
|
220
|
+
if (msg.data?.status === "start") spinner.start();
|
|
221
|
+
else spinner.stop();
|
|
222
|
+
break;
|
|
223
|
+
|
|
224
|
+
case "console":
|
|
225
|
+
spinner.stop();
|
|
226
|
+
if (msg.data?.text) process.stdout.write(msg.data.text + "\n");
|
|
227
|
+
break;
|
|
228
|
+
|
|
229
|
+
case "finding":
|
|
230
|
+
spinner.stop();
|
|
231
|
+
console.log(chalk.bold.yellow(`\n[FINDING] ${msg.data?.title || "Vulnerability found"}`));
|
|
232
|
+
if (msg.data?.severity) {
|
|
233
|
+
console.log(chalk.dim(` Severity: ${msg.data.severity}`));
|
|
234
|
+
foundFindings.push(msg.data.severity);
|
|
235
|
+
}
|
|
236
|
+
break;
|
|
237
|
+
|
|
238
|
+
case "credits_update":
|
|
239
|
+
case "cost_update":
|
|
240
|
+
{
|
|
241
|
+
const cr = msg.data?.credits_used ?? msg.data?.cost;
|
|
242
|
+
if (cr != null) {
|
|
243
|
+
process.stdout.write(chalk.dim(` [${cr.toFixed(1)} credits]\r`));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
break;
|
|
247
|
+
|
|
248
|
+
case "error":
|
|
249
|
+
spinner.stop();
|
|
250
|
+
console.error(chalk.red(`\nError: ${msg.data?.message || "Unknown error"}`));
|
|
251
|
+
break;
|
|
252
|
+
|
|
253
|
+
case "scan_complete": {
|
|
254
|
+
spinner.stop();
|
|
255
|
+
const d = msg.data || {};
|
|
256
|
+
const duration = d.duration ? formatDuration(d.duration) : "?";
|
|
257
|
+
const creditsUsed = (d.credits_used ?? d.cost ?? 0).toFixed(1);
|
|
258
|
+
|
|
259
|
+
// Fetch remaining credits
|
|
260
|
+
let remaining = "?";
|
|
261
|
+
try {
|
|
262
|
+
const me = await request(apiUrl, "/api/v1/auth/me", token);
|
|
263
|
+
remaining = parseFloat(me.credits_balance || 0).toFixed(1);
|
|
264
|
+
} catch {}
|
|
265
|
+
|
|
266
|
+
const reportUrl = `https://app.aisec.tools/scans/${scanId}`;
|
|
267
|
+
console.log(
|
|
268
|
+
"\n" + chalk.green("━".repeat(50)) + "\n" +
|
|
269
|
+
chalk.bold.green(" Scan complete\n") +
|
|
270
|
+
chalk.dim(` Findings: `) + chalk.white(d.findings ?? 0) + "\n" +
|
|
271
|
+
chalk.dim(` Credits: `) + chalk.white(creditsUsed) + chalk.dim(" used · ") + chalk.yellow.bold(remaining) + chalk.dim(" remaining") + "\n" +
|
|
272
|
+
chalk.dim(` Duration: `) + chalk.white(duration) + "\n" +
|
|
273
|
+
chalk.dim(` Report: `) + chalk.underline.cyan(reportUrl) + "\n" +
|
|
274
|
+
chalk.green("━".repeat(50))
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
// CI outputs
|
|
278
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
279
|
+
const { appendFileSync } = await import("fs");
|
|
280
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `findings=${d.findings ?? 0}\n`);
|
|
281
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `report-url=${reportUrl}\n`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// --fail-on check
|
|
285
|
+
if (opts.failOn) {
|
|
286
|
+
const failed = foundFindings.some(s => sevAboveThreshold(s, opts.failOn));
|
|
287
|
+
if (failed) {
|
|
288
|
+
console.log(chalk.red(`\n✗ Findings at ${opts.failOn}+ severity detected — exiting with code 1`));
|
|
289
|
+
exitCode = 1;
|
|
290
|
+
} else {
|
|
291
|
+
console.log(chalk.green(`\n✓ No findings at ${opts.failOn}+ severity`));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
ws.close();
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
case "scan_started":
|
|
300
|
+
console.log(chalk.cyan("Scan started, streaming output...\n"));
|
|
301
|
+
break;
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
ws.on("error", (err) => {
|
|
306
|
+
console.error(chalk.red(`WebSocket error: ${err.message}`));
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
ws.on("close", () => {
|
|
310
|
+
spinner.stop();
|
|
311
|
+
if (!cancelled) process.exit(exitCode);
|
|
312
|
+
});
|
|
313
|
+
}
|
package/lib/scans.mjs
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { resolveAuth, resolveApi } from "./config.mjs";
|
|
3
|
+
import { request } from "./api.mjs";
|
|
4
|
+
|
|
5
|
+
const STATUS_COLORS = {
|
|
6
|
+
running: chalk.green,
|
|
7
|
+
completed: chalk.blue,
|
|
8
|
+
failed: chalk.red,
|
|
9
|
+
cancelled: chalk.dim,
|
|
10
|
+
pending: chalk.yellow,
|
|
11
|
+
queued: chalk.yellow,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export async function cmdScans(opts) {
|
|
15
|
+
const token = resolveAuth(opts);
|
|
16
|
+
const apiUrl = resolveApi(opts);
|
|
17
|
+
const limit = opts.limit || 10;
|
|
18
|
+
|
|
19
|
+
let data;
|
|
20
|
+
try {
|
|
21
|
+
data = await request(apiUrl, `/api/v1/scans?limit=${limit}`, token);
|
|
22
|
+
} catch (err) {
|
|
23
|
+
console.error(chalk.red(`Failed to fetch scans: ${err.message}`));
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const scans = Array.isArray(data) ? data : data.items || data.scans || [];
|
|
28
|
+
|
|
29
|
+
if (scans.length === 0) {
|
|
30
|
+
console.log(chalk.dim("No scans found."));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
console.log(
|
|
35
|
+
chalk.dim("Status".padEnd(12)) +
|
|
36
|
+
chalk.dim("Domain".padEnd(30)) +
|
|
37
|
+
chalk.dim("Finds".padStart(6)) +
|
|
38
|
+
chalk.dim("Cost".padStart(9)) +
|
|
39
|
+
chalk.dim("Date".padStart(13)) +
|
|
40
|
+
chalk.dim("ID".padStart(11))
|
|
41
|
+
);
|
|
42
|
+
console.log(chalk.dim("─".repeat(81)));
|
|
43
|
+
|
|
44
|
+
for (const s of scans) {
|
|
45
|
+
const color = STATUS_COLORS[s.status] || chalk.white;
|
|
46
|
+
const domain = (s.target || "").replace(/^https?:\/\//, "").slice(0, 28);
|
|
47
|
+
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
|
+
const date = s.created_at ? s.created_at.slice(0, 10) : "-";
|
|
51
|
+
const id = (s.id || "").slice(0, 8);
|
|
52
|
+
|
|
53
|
+
console.log(
|
|
54
|
+
color(s.status.padEnd(12)) +
|
|
55
|
+
chalk.white(domain.padEnd(30)) +
|
|
56
|
+
chalk.white(findings.padStart(6)) +
|
|
57
|
+
chalk.white(cost.padStart(9)) +
|
|
58
|
+
chalk.dim(date.padStart(13)) +
|
|
59
|
+
chalk.dim(id.padStart(11))
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
package/lib/status.mjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { resolveAuth, resolveApi } from "./config.mjs";
|
|
3
|
+
import { request, healthCheck } from "./api.mjs";
|
|
4
|
+
|
|
5
|
+
export async function cmdStatus(opts) {
|
|
6
|
+
const token = resolveAuth(opts);
|
|
7
|
+
const apiUrl = resolveApi(opts);
|
|
8
|
+
|
|
9
|
+
const ok = await healthCheck(apiUrl);
|
|
10
|
+
if (!ok) {
|
|
11
|
+
console.error(chalk.red(`API unreachable at ${apiUrl}`));
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
console.log(chalk.green(`✓ API reachable at ${apiUrl}`));
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const stats = await request(apiUrl, "/api/v1/stats", token);
|
|
18
|
+
console.log(chalk.green("✓ Authenticated"));
|
|
19
|
+
const scans = stats.total_scans ?? "?";
|
|
20
|
+
const findings = stats.total_findings ?? "?";
|
|
21
|
+
const cr = stats.credits_used ?? stats.total_cost;
|
|
22
|
+
const credits = cr != null ? cr.toFixed(1) : "?";
|
|
23
|
+
console.log(chalk.dim(`Scans: ${scans} | Findings: ${findings} | Credits: ${credits}`));
|
|
24
|
+
} catch (err) {
|
|
25
|
+
console.error(chalk.red(`Auth check failed: ${err.message}`));
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "aisec-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for aisec — AI-powered web security scanner",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"aisec": "./bin/aisec.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin/",
|
|
11
|
+
"lib/"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"security",
|
|
18
|
+
"scanner",
|
|
19
|
+
"pentest",
|
|
20
|
+
"ai",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "https://github.com/aisec-foundation/cli-node.git"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"commander": "^12.1.0",
|
|
30
|
+
"chalk": "^5.3.0",
|
|
31
|
+
"ws": "^8.18.0"
|
|
32
|
+
}
|
|
33
|
+
}
|