bandit-cli 1.0.4 → 1.0.7
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/dist/cli/api.js +1 -1
- package/dist/cli/bench.js +12 -0
- package/dist/cli/doctor.js +6 -0
- package/dist/cli/env.js +7 -2
- package/dist/cli/studio.js +54 -0
- package/dist/index.js +11 -11
- package/dist/rules/index.js +2 -0
- package/dist/rules/mvp.rules.js +103 -0
- package/dist/studio/db.js +96 -0
- package/dist/studio/scanner.js +88 -0
- package/dist/studio/server.js +516 -0
- package/dist/utils/monorepo.js +21 -1
- package/package.json +1 -1
package/dist/cli/api.js
CHANGED
|
@@ -539,7 +539,7 @@ export async function runApiCommand(methodOrPath = ".", routePath, opts = {}) {
|
|
|
539
539
|
// Let the developer confirm and edit the path (highly convenient for prefix additions!)
|
|
540
540
|
const editPath = await clack.text({
|
|
541
541
|
message: "Confirm or edit the route path:",
|
|
542
|
-
|
|
542
|
+
initialValue: selectedRoute.routePath,
|
|
543
543
|
placeholder: selectedRoute.routePath,
|
|
544
544
|
validate: (v) => (!v || !v.startsWith("/") ? "Route path must start with a slash '/'" : undefined),
|
|
545
545
|
});
|
package/dist/cli/bench.js
CHANGED
|
@@ -131,5 +131,17 @@ export async function runBenchCommand(targetUrl, opts) {
|
|
|
131
131
|
console.log(` ${chalk.gray(label)} : [${chalk.green(bar)}] ${count.toString().padStart(4)} (${percentage.toFixed(1)}%)`);
|
|
132
132
|
}
|
|
133
133
|
console.log(chalk.gray("──────────────────────────────────────────────────"));
|
|
134
|
+
try {
|
|
135
|
+
const { StudioDB } = await import("../studio/db.js");
|
|
136
|
+
const db = new StudioDB();
|
|
137
|
+
db.saveBenchmark({
|
|
138
|
+
targetUrl,
|
|
139
|
+
connections: opts.connections,
|
|
140
|
+
requests: opts.requests,
|
|
141
|
+
rps,
|
|
142
|
+
latency: { min, avg, max, p50, p90, p95, p99 },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
catch { }
|
|
134
146
|
clack.outro(chalk.bold.green("Benchmark complete!"));
|
|
135
147
|
}
|
package/dist/cli/doctor.js
CHANGED
|
@@ -396,5 +396,11 @@ export async function runDoctorCommand(targetUrl = "http://localhost:3000") {
|
|
|
396
396
|
}
|
|
397
397
|
console.log("");
|
|
398
398
|
}
|
|
399
|
+
try {
|
|
400
|
+
const { StudioDB } = await import("../studio/db.js");
|
|
401
|
+
const db = new StudioDB();
|
|
402
|
+
db.saveAudit(reports);
|
|
403
|
+
}
|
|
404
|
+
catch { }
|
|
399
405
|
clack.outro(chalk.bold.green("Doctor diagnostic scan completed."));
|
|
400
406
|
}
|
package/dist/cli/env.js
CHANGED
|
@@ -69,7 +69,10 @@ export async function runEnvCommand(projectPath = ".") {
|
|
|
69
69
|
}
|
|
70
70
|
else {
|
|
71
71
|
const val = currentKeysMap.get(expectedKey);
|
|
72
|
-
if (val === "" ||
|
|
72
|
+
if (val === "" ||
|
|
73
|
+
val === "your_key_here" ||
|
|
74
|
+
val === "placeholder" ||
|
|
75
|
+
val.includes("TODO")) {
|
|
73
76
|
emptyKeys.push(expectedKey);
|
|
74
77
|
}
|
|
75
78
|
}
|
|
@@ -81,7 +84,9 @@ export async function runEnvCommand(projectPath = ".") {
|
|
|
81
84
|
}
|
|
82
85
|
}
|
|
83
86
|
// Output report for this file
|
|
84
|
-
if (missingKeys.length === 0 &&
|
|
87
|
+
if (missingKeys.length === 0 &&
|
|
88
|
+
emptyKeys.length === 0 &&
|
|
89
|
+
extraKeys.length === 0) {
|
|
85
90
|
clack.log.success(chalk.green(` ✔ ${envFile} is perfectly in sync with .env.example!`));
|
|
86
91
|
}
|
|
87
92
|
else {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as clack from "@clack/prompts";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { exec } from "node:child_process";
|
|
4
|
+
import { startStudioServer } from "../studio/server.js";
|
|
5
|
+
export async function runStudioCommand(projectPath = ".") {
|
|
6
|
+
clack.intro(chalk.bold.bgBlue.white(" Bandit Studio "));
|
|
7
|
+
const s = clack.spinner();
|
|
8
|
+
s.start("Starting Bandit Studio local server...");
|
|
9
|
+
try {
|
|
10
|
+
const { server, port } = await startStudioServer(projectPath, 4000);
|
|
11
|
+
const url = `http://localhost:${port}`;
|
|
12
|
+
s.stop(`Bandit Studio server running at ${chalk.bold.cyan(url)}`);
|
|
13
|
+
clack.log.info("Press Ctrl+C to stop the studio server.");
|
|
14
|
+
let isShuttingDown = false;
|
|
15
|
+
const shutdown = () => {
|
|
16
|
+
if (isShuttingDown)
|
|
17
|
+
return;
|
|
18
|
+
isShuttingDown = true;
|
|
19
|
+
console.log(chalk.bold.yellow("\nStopping Bandit Studio server... Goodbye!"));
|
|
20
|
+
try {
|
|
21
|
+
server.close();
|
|
22
|
+
}
|
|
23
|
+
catch { }
|
|
24
|
+
process.exit(0);
|
|
25
|
+
};
|
|
26
|
+
process.on("SIGINT", shutdown);
|
|
27
|
+
process.on("SIGTERM", shutdown);
|
|
28
|
+
// Windows PowerShell / Terminal raw input listener for Ctrl+C (\u0003)
|
|
29
|
+
if (process.stdin.isTTY) {
|
|
30
|
+
try {
|
|
31
|
+
process.stdin.setRawMode(true);
|
|
32
|
+
process.stdin.resume();
|
|
33
|
+
process.stdin.on("data", (chunk) => {
|
|
34
|
+
const str = chunk.toString();
|
|
35
|
+
if (str === "\u0003" || str === "\u001b" || str.includes("\u0003")) {
|
|
36
|
+
shutdown();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch { }
|
|
41
|
+
}
|
|
42
|
+
// Attempt auto-opening browser
|
|
43
|
+
const startCmd = process.platform === "win32" ? `start ${url}` : process.platform === "darwin" ? `open ${url}` : `xdg-open ${url}`;
|
|
44
|
+
exec(startCmd, (err) => {
|
|
45
|
+
if (err) {
|
|
46
|
+
clack.log.warn(`Could not open browser automatically. Please open ${url} in your browser.`);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
s.stop("Failed to start Bandit Studio server.");
|
|
52
|
+
clack.log.error(`Server startup error: ${err.message}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -38,17 +38,6 @@ program
|
|
|
38
38
|
const { runPortsCommand } = await import("./cli/ports.js");
|
|
39
39
|
await runPortsCommand();
|
|
40
40
|
});
|
|
41
|
-
// Subcommand: env
|
|
42
|
-
program
|
|
43
|
-
.command("env")
|
|
44
|
-
.description("Audit local .env configurations against .env.example")
|
|
45
|
-
.argument("[path]", "Path to the project", ".")
|
|
46
|
-
.action(async (projectPath) => {
|
|
47
|
-
const { resolveProjectPath } = await import("./utils/monorepo.js");
|
|
48
|
-
const resolvedPath = await resolveProjectPath(projectPath || ".");
|
|
49
|
-
const { runEnvCommand } = await import("./cli/env.js");
|
|
50
|
-
await runEnvCommand(resolvedPath);
|
|
51
|
-
});
|
|
52
41
|
// Subcommand: doctor
|
|
53
42
|
program
|
|
54
43
|
.command("doctor")
|
|
@@ -100,6 +89,17 @@ program
|
|
|
100
89
|
const { runBenchCommand } = await import("./cli/bench.js");
|
|
101
90
|
await runBenchCommand(url, { connections, requests });
|
|
102
91
|
});
|
|
92
|
+
// Subcommand: studio
|
|
93
|
+
program
|
|
94
|
+
.command("studio")
|
|
95
|
+
.description("Launch Bandit Studio local web dashboard")
|
|
96
|
+
.argument("[path]", "Path to the project", ".")
|
|
97
|
+
.action(async (projectPath) => {
|
|
98
|
+
const { resolveProjectPath } = await import("./utils/monorepo.js");
|
|
99
|
+
const resolvedPath = await resolveProjectPath(projectPath || ".");
|
|
100
|
+
const { runStudioCommand } = await import("./cli/studio.js");
|
|
101
|
+
await runStudioCommand(resolvedPath);
|
|
102
|
+
});
|
|
103
103
|
// Default to help if no command specified
|
|
104
104
|
if (!process.argv.slice(2).length) {
|
|
105
105
|
program.outputHelp();
|
package/dist/rules/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { ruleEnvExampleExists } from "./mvp.rules.js";
|
|
|
4
4
|
import { ruleEnvInGitignore } from "./mvp.rules.js";
|
|
5
5
|
import { ruleDockerfileExists } from "./mvp.rules.js";
|
|
6
6
|
import { ruleHasTestScript } from "./mvp.rules.js";
|
|
7
|
+
import { ruleEnvKeysMatch } from "./mvp.rules.js";
|
|
7
8
|
import { ruleSrcFolderExists, ruleDetectFramework, ruleSecurityDeps, ruleGlobalErrorHandler, } from "./phase2.rules.js";
|
|
8
9
|
import { ruleLoggingSetup, ruleEnvValidation, ruleDatabaseSetup, ruleTypeScriptStrict, ruleProductionDeps, } from "./phase3.rules.js";
|
|
9
10
|
export const rules = [
|
|
@@ -12,6 +13,7 @@ export const rules = [
|
|
|
12
13
|
ruleEnvExists,
|
|
13
14
|
ruleEnvExampleExists,
|
|
14
15
|
ruleEnvInGitignore,
|
|
16
|
+
ruleEnvKeysMatch,
|
|
15
17
|
ruleDockerfileExists,
|
|
16
18
|
ruleHasTestScript,
|
|
17
19
|
// Phase 2 - Structure + detection + security
|
package/dist/rules/mvp.rules.js
CHANGED
|
@@ -136,3 +136,106 @@ export const ruleHasTestScript = {
|
|
|
136
136
|
};
|
|
137
137
|
},
|
|
138
138
|
};
|
|
139
|
+
function parseEnvContent(content) {
|
|
140
|
+
const map = new Map();
|
|
141
|
+
const lines = content.split("\n");
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
const trimmed = line.trim();
|
|
144
|
+
if (!trimmed || trimmed.startsWith("#"))
|
|
145
|
+
continue;
|
|
146
|
+
const firstEqual = trimmed.indexOf("=");
|
|
147
|
+
if (firstEqual === -1)
|
|
148
|
+
continue;
|
|
149
|
+
const key = trimmed.slice(0, firstEqual).trim();
|
|
150
|
+
let val = trimmed.slice(firstEqual + 1).trim();
|
|
151
|
+
if ((val.startsWith('"') && val.endsWith('"')) ||
|
|
152
|
+
(val.startsWith("'") && val.endsWith("'"))) {
|
|
153
|
+
val = val.slice(1, -1);
|
|
154
|
+
}
|
|
155
|
+
map.set(key, val);
|
|
156
|
+
}
|
|
157
|
+
return map;
|
|
158
|
+
}
|
|
159
|
+
export const ruleEnvKeysMatch = {
|
|
160
|
+
id: "env-keys-match",
|
|
161
|
+
title: ".env aligns with .env.example",
|
|
162
|
+
severity: "warn",
|
|
163
|
+
async run(ctx) {
|
|
164
|
+
const envPath = path.join(ctx.projectPath, ".env");
|
|
165
|
+
const envExamplePath = path.join(ctx.projectPath, ".env.example");
|
|
166
|
+
if (!exists(envPath) || !exists(envExamplePath)) {
|
|
167
|
+
return {
|
|
168
|
+
id: this.id,
|
|
169
|
+
title: this.title,
|
|
170
|
+
severity: this.severity,
|
|
171
|
+
status: "skip",
|
|
172
|
+
details: "Requires both .env and .env.example files to run comparison.",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const envContent = readText(envPath) || "";
|
|
177
|
+
const exampleContent = readText(envExamplePath) || "";
|
|
178
|
+
const envKeys = parseEnvContent(envContent);
|
|
179
|
+
const exampleKeys = parseEnvContent(exampleContent);
|
|
180
|
+
const missing = [];
|
|
181
|
+
const extra = [];
|
|
182
|
+
const placeholders = [];
|
|
183
|
+
for (const key of exampleKeys.keys()) {
|
|
184
|
+
if (!envKeys.has(key)) {
|
|
185
|
+
missing.push(key);
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
const val = envKeys.get(key);
|
|
189
|
+
if (val === "" ||
|
|
190
|
+
val === "your_key_here" ||
|
|
191
|
+
val === "placeholder" ||
|
|
192
|
+
val.includes("TODO")) {
|
|
193
|
+
placeholders.push(key);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const key of envKeys.keys()) {
|
|
198
|
+
if (!exampleKeys.has(key)) {
|
|
199
|
+
extra.push(key);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (missing.length === 0 && extra.length === 0 && placeholders.length === 0) {
|
|
203
|
+
return {
|
|
204
|
+
id: this.id,
|
|
205
|
+
title: this.title,
|
|
206
|
+
severity: this.severity,
|
|
207
|
+
status: "pass",
|
|
208
|
+
details: ".env has all keys defined in .env.example with active values.",
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const issues = [];
|
|
212
|
+
if (missing.length > 0) {
|
|
213
|
+
issues.push(`Missing keys: ${missing.join(", ")}`);
|
|
214
|
+
}
|
|
215
|
+
if (placeholders.length > 0) {
|
|
216
|
+
issues.push(`Placeholder/Empty values: ${placeholders.join(", ")}`);
|
|
217
|
+
}
|
|
218
|
+
if (extra.length > 0) {
|
|
219
|
+
issues.push(`Extra keys: ${extra.join(", ")}`);
|
|
220
|
+
}
|
|
221
|
+
return {
|
|
222
|
+
id: this.id,
|
|
223
|
+
title: this.title,
|
|
224
|
+
severity: this.severity,
|
|
225
|
+
status: "fail",
|
|
226
|
+
details: issues.join(" | "),
|
|
227
|
+
suggestion: "Update your .env or .env.example to keep them in sync and replace any placeholders.",
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
catch (err) {
|
|
231
|
+
return {
|
|
232
|
+
id: this.id,
|
|
233
|
+
title: this.title,
|
|
234
|
+
severity: this.severity,
|
|
235
|
+
status: "fail",
|
|
236
|
+
details: `Failed to compare env files: ${err.message}`,
|
|
237
|
+
suggestion: "Ensure env files are valid text files.",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { execSync } from "node:child_process";
|
|
4
|
+
export class StudioDB {
|
|
5
|
+
baseDir;
|
|
6
|
+
constructor(projectPath = ".") {
|
|
7
|
+
this.baseDir = path.join(path.resolve(projectPath), ".bandit");
|
|
8
|
+
this.ensureDir();
|
|
9
|
+
}
|
|
10
|
+
ensureDir() {
|
|
11
|
+
if (!fs.existsSync(this.baseDir)) {
|
|
12
|
+
fs.mkdirSync(this.baseDir, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
getFilePath(filename) {
|
|
16
|
+
return path.join(this.baseDir, filename);
|
|
17
|
+
}
|
|
18
|
+
readJson(filename, defaultValue) {
|
|
19
|
+
const file = this.getFilePath(filename);
|
|
20
|
+
if (!fs.existsSync(file))
|
|
21
|
+
return defaultValue;
|
|
22
|
+
try {
|
|
23
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
24
|
+
return JSON.parse(content);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return defaultValue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
writeJson(filename, data) {
|
|
31
|
+
const file = this.getFilePath(filename);
|
|
32
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2), "utf-8");
|
|
33
|
+
}
|
|
34
|
+
getGitMetadata() {
|
|
35
|
+
try {
|
|
36
|
+
const commit = execSync("git rev-parse --short HEAD", { stdio: ["ignore", "pipe", "ignore"] })
|
|
37
|
+
.toString()
|
|
38
|
+
.trim();
|
|
39
|
+
const branch = execSync("git branch --show-current", { stdio: ["ignore", "pipe", "ignore"] })
|
|
40
|
+
.toString()
|
|
41
|
+
.trim();
|
|
42
|
+
const author = execSync('git log -1 --pretty=format:"%an"', { stdio: ["ignore", "pipe", "ignore"] })
|
|
43
|
+
.toString()
|
|
44
|
+
.trim();
|
|
45
|
+
return { commit, branch, author };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
saveBenchmark(data) {
|
|
52
|
+
const records = this.readJson("benchmarks.json", []);
|
|
53
|
+
const git = this.getGitMetadata();
|
|
54
|
+
const newRecord = {
|
|
55
|
+
id: Math.random().toString(36).substring(2, 10),
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
gitCommit: git.commit,
|
|
58
|
+
gitBranch: git.branch,
|
|
59
|
+
gitAuthor: git.author,
|
|
60
|
+
...data,
|
|
61
|
+
};
|
|
62
|
+
records.unshift(newRecord);
|
|
63
|
+
this.writeJson("benchmarks.json", records.slice(0, 200));
|
|
64
|
+
return newRecord;
|
|
65
|
+
}
|
|
66
|
+
getBenchmarks() {
|
|
67
|
+
return this.readJson("benchmarks.json", []);
|
|
68
|
+
}
|
|
69
|
+
saveAudit(items) {
|
|
70
|
+
const records = this.readJson("audits.json", []);
|
|
71
|
+
const git = this.getGitMetadata();
|
|
72
|
+
const newRecord = {
|
|
73
|
+
id: Math.random().toString(36).substring(2, 10),
|
|
74
|
+
timestamp: new Date().toISOString(),
|
|
75
|
+
gitCommit: git.commit,
|
|
76
|
+
items,
|
|
77
|
+
};
|
|
78
|
+
records.unshift(newRecord);
|
|
79
|
+
this.writeJson("audits.json", records.slice(0, 50));
|
|
80
|
+
return newRecord;
|
|
81
|
+
}
|
|
82
|
+
getAudits() {
|
|
83
|
+
return this.readJson("audits.json", []);
|
|
84
|
+
}
|
|
85
|
+
getConfig() {
|
|
86
|
+
return this.readJson("config.json", { dismissedRules: [] });
|
|
87
|
+
}
|
|
88
|
+
dismissRule(ruleTitle) {
|
|
89
|
+
const config = this.getConfig();
|
|
90
|
+
if (!config.dismissedRules.includes(ruleTitle)) {
|
|
91
|
+
config.dismissedRules.push(ruleTitle);
|
|
92
|
+
this.writeJson("config.json", config);
|
|
93
|
+
}
|
|
94
|
+
return config;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { scanForRoutes } from "../cli/api.js";
|
|
4
|
+
import { StudioDB } from "./db.js";
|
|
5
|
+
export async function generateApiBlueprint(projectPath) {
|
|
6
|
+
const rawRoutes = await scanForRoutes(projectPath);
|
|
7
|
+
const db = new StudioDB(projectPath);
|
|
8
|
+
const benchmarks = db.getBenchmarks();
|
|
9
|
+
// Filter out non-route scans (like test files or CLI internal strings)
|
|
10
|
+
const routes = rawRoutes.filter(r => !r.sourceFile.includes("cli/") &&
|
|
11
|
+
!r.sourceFile.includes("doctor.") &&
|
|
12
|
+
!r.sourceFile.includes("api.") &&
|
|
13
|
+
r.routePath.startsWith("/") &&
|
|
14
|
+
!r.routePath.includes("-") // exclude header test strings like /access-control-allow-origin
|
|
15
|
+
);
|
|
16
|
+
const nodes = [];
|
|
17
|
+
const edges = [];
|
|
18
|
+
// Infrastructure Nodes
|
|
19
|
+
nodes.push({ id: "db:pg", label: "PostgreSQL Database", type: "database", details: "Primary Data Store & Connection Pool" });
|
|
20
|
+
nodes.push({ id: "cache:redis", label: "Redis Cache Layer", type: "cache", details: "In-Memory Response & Session Cache" });
|
|
21
|
+
const addedControllers = new Set();
|
|
22
|
+
const addedServices = new Set();
|
|
23
|
+
routes.forEach((r, idx) => {
|
|
24
|
+
const routeId = `route:${idx}`;
|
|
25
|
+
const routeLabel = `[${r.method}] ${r.routePath}`;
|
|
26
|
+
// Find matching telemetry if benchmarked
|
|
27
|
+
const matchBench = benchmarks.find(b => b.targetUrl.endsWith(r.routePath));
|
|
28
|
+
const metrics = matchBench ? { rps: matchBench.rps, p99: matchBench.latency.p99, avg: matchBench.latency.avg } : undefined;
|
|
29
|
+
// Static Auth Status Detection
|
|
30
|
+
let authStatus = "Public";
|
|
31
|
+
try {
|
|
32
|
+
const fullPath = path.resolve(projectPath, r.sourceFile);
|
|
33
|
+
if (fs.existsSync(fullPath)) {
|
|
34
|
+
const fileContent = fs.readFileSync(fullPath, "utf-8");
|
|
35
|
+
const lines = fileContent.split("\n");
|
|
36
|
+
// Check if any line defining this path contains auth keywords
|
|
37
|
+
const matchingLine = lines.find(line => line.includes(r.routePath) ||
|
|
38
|
+
(line.includes(r.method.toLowerCase()) && line.includes(r.routePath.split("/").pop() || "___")));
|
|
39
|
+
if (matchingLine) {
|
|
40
|
+
const authKeywords = ["auth", "protect", "require", "jwt", "session", "admin", "vendor", "guard", "cookie"];
|
|
41
|
+
const hasAuth = authKeywords.some(kw => matchingLine.toLowerCase().includes(kw));
|
|
42
|
+
if (hasAuth)
|
|
43
|
+
authStatus = "Protected";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
nodes.push({
|
|
49
|
+
id: routeId,
|
|
50
|
+
label: routeLabel,
|
|
51
|
+
type: "route",
|
|
52
|
+
details: r.sourceFile,
|
|
53
|
+
authStatus,
|
|
54
|
+
metrics,
|
|
55
|
+
});
|
|
56
|
+
const controllerName = r.sourceFile.split(/[/\\]/).pop() || "Controller";
|
|
57
|
+
const controllerId = `controller:${controllerName}`;
|
|
58
|
+
if (!addedControllers.has(controllerId)) {
|
|
59
|
+
addedControllers.add(controllerId);
|
|
60
|
+
nodes.push({
|
|
61
|
+
id: controllerId,
|
|
62
|
+
label: controllerName,
|
|
63
|
+
type: "controller",
|
|
64
|
+
details: r.sourceFile,
|
|
65
|
+
});
|
|
66
|
+
// Map controller to corresponding service layer
|
|
67
|
+
const serviceName = controllerName.replace(".controller.", ".service.").replace("controller", "service");
|
|
68
|
+
const serviceId = `service:${serviceName}`;
|
|
69
|
+
if (!addedServices.has(serviceId)) {
|
|
70
|
+
addedServices.add(serviceId);
|
|
71
|
+
nodes.push({
|
|
72
|
+
id: serviceId,
|
|
73
|
+
label: serviceName,
|
|
74
|
+
type: "service",
|
|
75
|
+
details: `Business logic & DB ORM mapping`,
|
|
76
|
+
});
|
|
77
|
+
// Link Service to Infrastructure
|
|
78
|
+
edges.push({ from: serviceId, to: "db:pg", label: "queries" });
|
|
79
|
+
if (serviceName.toLowerCase().includes("product") || serviceName.toLowerCase().includes("catalog") || serviceName.toLowerCase().includes("cache")) {
|
|
80
|
+
edges.push({ from: serviceId, to: "cache:redis", label: "hits cache" });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
edges.push({ from: controllerId, to: serviceId, label: "delegates" });
|
|
84
|
+
}
|
|
85
|
+
edges.push({ from: routeId, to: controllerId, label: "handles" });
|
|
86
|
+
});
|
|
87
|
+
return { nodes, edges };
|
|
88
|
+
}
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { StudioDB } from "./db.js";
|
|
5
|
+
import { generateApiBlueprint } from "./scanner.js";
|
|
6
|
+
export async function startStudioServer(projectPath, port = 4000) {
|
|
7
|
+
const db = new StudioDB(projectPath);
|
|
8
|
+
const getClientHtml = () => {
|
|
9
|
+
return `<!DOCTYPE html>
|
|
10
|
+
<html lang="en">
|
|
11
|
+
<head>
|
|
12
|
+
<meta charset="UTF-8">
|
|
13
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
14
|
+
<title>Bandit Studio - Backend Developer Platform</title>
|
|
15
|
+
<style>
|
|
16
|
+
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
|
17
|
+
|
|
18
|
+
:root {
|
|
19
|
+
--bg: #050507;
|
|
20
|
+
--card-bg: #0c0c0e;
|
|
21
|
+
--card-hover: #121215;
|
|
22
|
+
--border: #1f1f23;
|
|
23
|
+
--border-bright: #2f2f37;
|
|
24
|
+
--text: #f4f4f5;
|
|
25
|
+
--text-dim: #71717a;
|
|
26
|
+
--primary: #eab308;
|
|
27
|
+
--primary-hover: #facc15;
|
|
28
|
+
--primary-dim: rgba(234, 179, 8, 0.08);
|
|
29
|
+
--success: #22c55e;
|
|
30
|
+
--success-dim: rgba(34, 197, 94, 0.08);
|
|
31
|
+
--warning: #f59e0b;
|
|
32
|
+
--warning-dim: rgba(245, 158, 11, 0.08);
|
|
33
|
+
--danger: #ef4444;
|
|
34
|
+
--danger-dim: rgba(239, 68, 68, 0.08);
|
|
35
|
+
--accent: #a855f7;
|
|
36
|
+
}
|
|
37
|
+
* { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Inter', -apple-system, sans-serif; }
|
|
38
|
+
body { background-color: var(--bg); color: var(--text); display: flex; flex-direction: column; min-height: 100vh; }
|
|
39
|
+
header { background: #000000; border-bottom: 1px solid var(--border); padding: 0.8rem 2rem; display: flex; justify-content: space-between; align-items: center; }
|
|
40
|
+
|
|
41
|
+
.logo-container { display: flex; align-items: center; gap: 0.65rem; }
|
|
42
|
+
.logo-container img { height: 32px; width: auto; object-fit: contain; }
|
|
43
|
+
.logo-text { font-size: 1.45rem; font-weight: 800; color: #ffffff; letter-spacing: -0.03em; }
|
|
44
|
+
.logo-beta { font-size: 0.65rem; font-weight: 700; background: #131316; border: 1px solid var(--border); color: var(--text-dim); padding: 0.15rem 0.5rem; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.05em; margin-left: 0.1rem; }
|
|
45
|
+
|
|
46
|
+
nav { display: flex; gap: 0.5rem; background: #0c0c0e; padding: 0.3rem; border-radius: 0.5rem; border: 1px solid var(--border); }
|
|
47
|
+
nav button { background: none; border: none; color: var(--text-dim); font-size: 0.875rem; font-weight: 600; padding: 0.5rem 1rem; cursor: pointer; border-radius: 0.375rem; transition: all 0.15s ease; }
|
|
48
|
+
nav button.active { color: #000000; background: var(--primary); font-weight: 700; }
|
|
49
|
+
nav button:hover:not(.active) { color: var(--text); background: var(--card-hover); }
|
|
50
|
+
|
|
51
|
+
main { flex: 1; padding: 2rem; max-width: 1280px; margin: 0 auto; width: 100%; }
|
|
52
|
+
.tab-content { display: none; }
|
|
53
|
+
.tab-content.active { display: block; }
|
|
54
|
+
|
|
55
|
+
.section-header { margin-bottom: 2rem; }
|
|
56
|
+
.section-header h2 { font-size: 1.6rem; font-weight: 800; color: var(--text); letter-spacing: -0.02em; }
|
|
57
|
+
.section-header p { color: var(--text-dim); font-size: 0.9rem; margin-top: 0.25rem; }
|
|
58
|
+
|
|
59
|
+
.analytics-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem; margin-bottom: 2rem; }
|
|
60
|
+
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.75rem; padding: 1.5rem; transition: border-color 0.2s; }
|
|
61
|
+
.card:hover { border-color: var(--border-bright); }
|
|
62
|
+
.card h3 { font-size: 0.95rem; font-weight: 700; margin-bottom: 1.25rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; }
|
|
63
|
+
|
|
64
|
+
/* Progress Rings style */
|
|
65
|
+
.rings-row { display: flex; justify-content: space-around; align-items: center; height: 100px; }
|
|
66
|
+
.ring-container { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; }
|
|
67
|
+
.ring-svg { transform: rotate(-90deg); }
|
|
68
|
+
.ring-bg { fill: none; stroke: var(--border); stroke-width: 8; }
|
|
69
|
+
.ring-bar { fill: none; stroke: var(--primary); stroke-width: 8; stroke-dasharray: 220; stroke-dashoffset: 220; transition: stroke-dashoffset 1s ease-out; stroke-linecap: round; }
|
|
70
|
+
.ring-text { font-size: 0.85rem; font-weight: 800; color: var(--text); font-family: 'JetBrains Mono', monospace; }
|
|
71
|
+
|
|
72
|
+
/* Exposure Bar */
|
|
73
|
+
.exposure-box { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 0.5rem; }
|
|
74
|
+
.progress-bar-bg { height: 10px; background: var(--border); border-radius: 999px; overflow: hidden; display: flex; }
|
|
75
|
+
.progress-bar-val { height: 100%; background: var(--primary); transition: width 0.8s ease-out; }
|
|
76
|
+
|
|
77
|
+
/* Latency Radar (Slowest Endpoints Chart) */
|
|
78
|
+
.radar-list { display: flex; flex-direction: column; gap: 0.75rem; }
|
|
79
|
+
.radar-item { display: flex; flex-direction: column; gap: 0.25rem; }
|
|
80
|
+
.radar-labels { display: flex; justify-content: space-between; font-size: 0.8rem; }
|
|
81
|
+
.radar-labels span { font-family: 'JetBrains Mono', monospace; }
|
|
82
|
+
.radar-bar-bg { height: 8px; background: var(--border); border-radius: 999px; overflow: hidden; }
|
|
83
|
+
.radar-bar-val { height: 100%; background: var(--danger); border-radius: 999px; transition: width 0.8s ease-out; }
|
|
84
|
+
|
|
85
|
+
/* Search Bar & Matrix Layout */
|
|
86
|
+
.controls-row { display: flex; gap: 1rem; align-items: center; margin-bottom: 1.25rem; }
|
|
87
|
+
.search-input { flex: 1; background: #000; border: 1px solid var(--border); padding: 0.75rem 1rem; border-radius: 0.5rem; color: var(--text); font-size: 0.9rem; transition: border-color 0.15s; }
|
|
88
|
+
.search-input:focus { outline: none; border-color: var(--primary); }
|
|
89
|
+
|
|
90
|
+
.matrix-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 0.75rem; padding: 1.5rem; }
|
|
91
|
+
|
|
92
|
+
table { width: 100%; border-collapse: collapse; text-align: left; }
|
|
93
|
+
th, td { padding: 1rem; border-bottom: 1px solid var(--border); font-size: 0.875rem; }
|
|
94
|
+
th { color: var(--text-dim); font-weight: 600; background: #000; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
95
|
+
tr:hover td { background: var(--card-hover); }
|
|
96
|
+
|
|
97
|
+
.badge { padding: 0.25rem 0.5rem; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 700; text-transform: uppercase; display: inline-block; border: 1px solid transparent; }
|
|
98
|
+
.badge-success { background: var(--success-dim); color: var(--success); border-color: rgba(34, 197, 94, 0.2); }
|
|
99
|
+
.badge-warning { background: var(--warning-dim); color: var(--warning); border-color: rgba(245, 158, 11, 0.2); }
|
|
100
|
+
.badge-danger { background: var(--danger-dim); color: var(--danger); border-color: rgba(239, 68, 68, 0.2); }
|
|
101
|
+
.badge-yellow { background: var(--primary-dim); color: var(--primary); border-color: rgba(234, 179, 8, 0.2); }
|
|
102
|
+
|
|
103
|
+
.actions-cell { display: flex; gap: 0.5rem; }
|
|
104
|
+
.btn-action { background: var(--border); border: 1px solid var(--border); color: var(--text-dim); padding: 0.4rem 0.75rem; border-radius: 0.375rem; cursor: pointer; font-size: 0.75rem; font-weight: 600; display: inline-flex; align-items: center; gap: 0.35rem; transition: all 0.15s; }
|
|
105
|
+
.btn-action:hover { color: var(--text); background: var(--border-bright); }
|
|
106
|
+
|
|
107
|
+
code, td strong, .radar-labels span, .ring-text, td code { font-family: 'JetBrains Mono', monospace; }
|
|
108
|
+
code { background: #000; padding: 0.15rem 0.35rem; border-radius: 0.2rem; font-size: 0.8rem; color: var(--primary); }
|
|
109
|
+
</style>
|
|
110
|
+
</head>
|
|
111
|
+
<body>
|
|
112
|
+
<header>
|
|
113
|
+
<div class="logo-container">
|
|
114
|
+
<img src="https://res.cloudinary.com/dv7iah7yv/image/upload/v1783707247/Gemini_Generated_Image_6xf1oi6xf1oi6xf1-removebg-preview_znn27s.png" alt="Bandit Logo" />
|
|
115
|
+
<span class="logo-text">Bandit CLI <span style="color: var(--primary);">Studio</span></span>
|
|
116
|
+
<span class="logo-beta">BETA</span>
|
|
117
|
+
</div>
|
|
118
|
+
<nav>
|
|
119
|
+
<button onclick="showTab('blueprint', this)" class="active" style="display: inline-flex; align-items: center; gap: 0.45rem;">
|
|
120
|
+
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"></path><line x1="4" y1="22" x2="4" y2="15"></line></svg>
|
|
121
|
+
API Health Matrix
|
|
122
|
+
</button>
|
|
123
|
+
<button onclick="showTab('performance', this)" style="display: inline-flex; align-items: center; gap: 0.45rem;">
|
|
124
|
+
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>
|
|
125
|
+
Performance History
|
|
126
|
+
</button>
|
|
127
|
+
<button onclick="showTab('audits', this)" style="display: inline-flex; align-items: center; gap: 0.45rem;">
|
|
128
|
+
<svg viewBox="0 0 24 24" width="14" height="14" stroke="currentColor" stroke-width="2.5" fill="none"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
|
|
129
|
+
Security Inspector
|
|
130
|
+
</button>
|
|
131
|
+
</nav>
|
|
132
|
+
</header>
|
|
133
|
+
<main>
|
|
134
|
+
<!-- API Health Matrix (Tab 1) -->
|
|
135
|
+
<div id="blueprint" class="tab-content active">
|
|
136
|
+
<div class="section-header">
|
|
137
|
+
<h2>API Health & Performance Matrix</h2>
|
|
138
|
+
<p>Real-time endpoint registry tracking authentication scopes, load test latencies, and active vulnerability states.</p>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<!-- Analytics Row -->
|
|
142
|
+
<div class="analytics-grid">
|
|
143
|
+
<div class="card">
|
|
144
|
+
<h3>Testing Coverage</h3>
|
|
145
|
+
<div class="rings-row">
|
|
146
|
+
<div class="ring-container">
|
|
147
|
+
<svg width="70" height="70" class="ring-svg">
|
|
148
|
+
<circle cx="35" cy="35" r="30" class="ring-bg"></circle>
|
|
149
|
+
<circle cx="35" cy="35" r="30" class="ring-bar" id="ring-bench"></circle>
|
|
150
|
+
</svg>
|
|
151
|
+
<span class="ring-text" id="ring-bench-text">0%</span>
|
|
152
|
+
<span style="font-size: 0.75rem; color: var(--text-dim);">Load Tested</span>
|
|
153
|
+
</div>
|
|
154
|
+
<div class="ring-container">
|
|
155
|
+
<svg width="70" height="70" class="ring-svg">
|
|
156
|
+
<circle cx="35" cy="35" r="30" class="ring-bg"></circle>
|
|
157
|
+
<circle cx="35" cy="35" r="30" class="ring-bar" id="ring-security" style="stroke: var(--success);"></circle>
|
|
158
|
+
</svg>
|
|
159
|
+
<span class="ring-text" id="ring-security-text">0%</span>
|
|
160
|
+
<span style="font-size: 0.75rem; color: var(--text-dim);">Secured</span>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<div class="card">
|
|
166
|
+
<h3>Public Route Exposure</h3>
|
|
167
|
+
<div class="exposure-box">
|
|
168
|
+
<div style="display: flex; justify-content: space-between; font-size: 0.85rem;">
|
|
169
|
+
<span>🌐 Public: <strong id="exposure-public">0</strong></span>
|
|
170
|
+
<span>🔑 Protected: <strong id="exposure-private">0</strong></span>
|
|
171
|
+
</div>
|
|
172
|
+
<div class="progress-bar-bg">
|
|
173
|
+
<div class="progress-bar-val" id="exposure-bar-val" style="width: 0%;"></div>
|
|
174
|
+
</div>
|
|
175
|
+
<span style="font-size: 0.75rem; color: var(--text-dim);" id="exposure-ratio-desc">Analyzing exposure...</span>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<div class="card">
|
|
180
|
+
<h3>Latency Hotspots (Avg ms)</h3>
|
|
181
|
+
<div class="radar-list" id="radar-list">
|
|
182
|
+
<p style="color: var(--text-dim); font-size: 0.85rem; padding-top: 0.5rem;">No benchmark data available.</p>
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
<!-- Matrix Table -->
|
|
188
|
+
<div class="controls-row">
|
|
189
|
+
<input type="text" class="search-input" id="route-search" placeholder="Filter endpoints by path, method, or controller file..." oninput="filterRoutes()" />
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div class="matrix-card">
|
|
193
|
+
<h3>API Endpoint Registry</h3>
|
|
194
|
+
<div style="overflow-x: auto; margin-top: 1rem;">
|
|
195
|
+
<table id="matrix-table">
|
|
196
|
+
<thead>
|
|
197
|
+
<tr>
|
|
198
|
+
<th>Endpoint</th>
|
|
199
|
+
<th>Auth Status</th>
|
|
200
|
+
<th>Performance (RPS / Latency)</th>
|
|
201
|
+
<th>Security Scan</th>
|
|
202
|
+
<th>Quick Actions</th>
|
|
203
|
+
</tr>
|
|
204
|
+
</thead>
|
|
205
|
+
<tbody id="matrix-table-body">
|
|
206
|
+
<tr>
|
|
207
|
+
<td colspan="5" style="color: var(--text-dim); text-align: center; padding: 2rem;">Loading API blueprint...</td>
|
|
208
|
+
</tr>
|
|
209
|
+
</tbody>
|
|
210
|
+
</table>
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
</div>
|
|
214
|
+
|
|
215
|
+
<!-- Performance History (Tab 2) -->
|
|
216
|
+
<div id="performance" class="tab-content">
|
|
217
|
+
<div class="section-header">
|
|
218
|
+
<h2>Performance & Regression History</h2>
|
|
219
|
+
<p>Tracks benchmark latency and throughput trends linked with Git commits.</p>
|
|
220
|
+
</div>
|
|
221
|
+
<div class="card">
|
|
222
|
+
<h3>Recorded Load Test Runs</h3>
|
|
223
|
+
<div id="benchmarks-table-container">Loading metrics...</div>
|
|
224
|
+
</div>
|
|
225
|
+
</div>
|
|
226
|
+
|
|
227
|
+
<!-- Security Inspector (Tab 3) -->
|
|
228
|
+
<div id="audits" class="tab-content">
|
|
229
|
+
<div class="section-header">
|
|
230
|
+
<h2>Security & Codebase Inspector</h2>
|
|
231
|
+
<p>Active penetration test findings (SQLi, XSS, Header Security) and diagnostic logs.</p>
|
|
232
|
+
</div>
|
|
233
|
+
<div class="card">
|
|
234
|
+
<h3>Active Penetration Test Results</h3>
|
|
235
|
+
<div id="audits-container">Loading diagnostics...</div>
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</main>
|
|
239
|
+
|
|
240
|
+
<script>
|
|
241
|
+
let globalRoutes = [];
|
|
242
|
+
let globalAudits = [];
|
|
243
|
+
|
|
244
|
+
function showTab(tabId, btn) {
|
|
245
|
+
document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
|
|
246
|
+
document.querySelectorAll('nav button').forEach(el => el.classList.remove('active'));
|
|
247
|
+
document.getElementById(tabId).classList.add('active');
|
|
248
|
+
btn.classList.add('active');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function copyToClipboard(text) {
|
|
252
|
+
navigator.clipboard.writeText(text).then(() => {
|
|
253
|
+
alert("Command copied to clipboard: " + text);
|
|
254
|
+
}).catch(err => {
|
|
255
|
+
console.error("Could not copy text: ", err);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function filterRoutes() {
|
|
260
|
+
const q = document.getElementById('route-search').value.toLowerCase();
|
|
261
|
+
const filtered = globalRoutes.filter(r =>
|
|
262
|
+
r.label.toLowerCase().includes(q) ||
|
|
263
|
+
(r.details && r.details.toLowerCase().includes(q))
|
|
264
|
+
);
|
|
265
|
+
renderMatrixTable(filtered);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function renderMatrixTable(routes) {
|
|
269
|
+
const tbody = document.getElementById('matrix-table-body');
|
|
270
|
+
if (routes.length === 0) {
|
|
271
|
+
tbody.innerHTML = '<tr><td colspan="5" style="color: var(--text-dim); text-align: center; padding: 2rem;">No matching endpoints found.</td></tr>';
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
let html = '';
|
|
276
|
+
routes.forEach(r => {
|
|
277
|
+
const method = r.label.match(/\\[([A-Z]+)\\]/)[1];
|
|
278
|
+
const path = r.label.replace(/\\[([A-Z]+)\\]\\s*/, '');
|
|
279
|
+
|
|
280
|
+
// Performance
|
|
281
|
+
let perfHtml = '<span style="color: var(--text-dim); display: inline-flex; align-items: center;"><svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" stroke-width="2.5" fill="none" style="vertical-align: middle; margin-right: 3px;"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="16"></line><line x1="8" y1="12" x2="16" y2="12"></line></svg>Untested</span>';
|
|
282
|
+
if (r.metrics) {
|
|
283
|
+
const speedIcon = r.metrics.avg > 200
|
|
284
|
+
? '<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" stroke-width="2.5" fill="none" style="vertical-align: middle; margin-right: 3.5px; color: var(--warning);"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>'
|
|
285
|
+
: '<svg viewBox="0 0 24 24" width="12" height="12" fill="currentColor" style="vertical-align: middle; margin-right: 3.5px; color: var(--primary);"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>';
|
|
286
|
+
perfHtml = \`<div style="font-weight: 600; color: var(--primary); display: inline-flex; align-items: center;">\${speedIcon}\${r.metrics.rps.toFixed(0)} req/s</div>
|
|
287
|
+
<div style="font-size: 0.75rem; color: var(--text-dim);">Avg: \${r.metrics.avg.toFixed(1)}ms | p99: \${r.metrics.p99.toFixed(1)}ms</div>\`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Security
|
|
291
|
+
let secHtml = '<span class="badge badge-warning" style="display: inline-flex; align-items: center;"><svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" stroke-width="2.5" fill="none" style="vertical-align: middle; margin-right: 3px;"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></svg>Unscanned</span>';
|
|
292
|
+
if (globalAudits.length > 0 && globalAudits[0].items) {
|
|
293
|
+
const failures = globalAudits[0].items.filter(item => item.status === 'fail');
|
|
294
|
+
if (failures.length > 0) {
|
|
295
|
+
secHtml = '<span class="badge badge-danger" style="display: inline-flex; align-items: center;"><svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" stroke-width="2.5" fill="none" style="vertical-align: middle; margin-right: 3px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>Vulnerable</span>';
|
|
296
|
+
} else {
|
|
297
|
+
secHtml = '<span class="badge badge-success" style="display: inline-flex; align-items: center;"><svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" stroke-width="2.5" fill="none" style="vertical-align: middle; margin-right: 3px;"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path><polyline points="9 11 11 13 15 9"></polyline></svg>Secure</span>';
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Auth
|
|
302
|
+
const authBadgeClass = r.authStatus === 'Protected' ? 'badge-success' : 'badge-yellow';
|
|
303
|
+
const authIcon = r.authStatus === 'Protected'
|
|
304
|
+
? '<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" stroke-width="2.5" fill="none" style="vertical-align: middle; margin-right: 3.5px;"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>'
|
|
305
|
+
: '<svg viewBox="0 0 24 24" width="12" height="12" stroke="currentColor" stroke-width="2.5" fill="none" style="vertical-align: middle; margin-right: 3.5px;"><circle cx="12" cy="12" r="10"></circle><line x1="2" y1="12" x2="22" y2="12"></line><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path></svg>';
|
|
306
|
+
|
|
307
|
+
// Copy command helpers
|
|
308
|
+
const host = window.location.origin;
|
|
309
|
+
const benchCmd = \`bandit bench \${host}\${path} -c 50 -r 2000\`;
|
|
310
|
+
const docCmd = \`bandit doctor \${host}\`;
|
|
311
|
+
|
|
312
|
+
html += \`<tr>
|
|
313
|
+
<td>
|
|
314
|
+
<strong style="color: var(--text);">\${r.label}</strong>
|
|
315
|
+
<div style="font-size: 0.75rem; color: var(--text-dim);">\${r.details || '-'}</div>
|
|
316
|
+
</td>
|
|
317
|
+
<td><span class="badge \${authBadgeClass}" style="display: inline-flex; align-items: center;">\${authIcon}\${r.authStatus || 'Public'}</span></td>
|
|
318
|
+
<td>\${perfHtml}</td>
|
|
319
|
+
<td>\${secHtml}</td>
|
|
320
|
+
<td class="actions-cell">
|
|
321
|
+
<button class="btn-action" onclick="copyToClipboard('\${benchCmd}')" title="Copy bench command">
|
|
322
|
+
<svg viewBox="0 0 24 24" width="11" height="11" fill="currentColor"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon></svg>
|
|
323
|
+
Bench
|
|
324
|
+
</button>
|
|
325
|
+
<button class="btn-action" onclick="copyToClipboard('\${docCmd}')" title="Copy doctor command">
|
|
326
|
+
<svg viewBox="0 0 24 24" width="11" height="11" stroke="currentColor" stroke-width="2.5" fill="none"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>
|
|
327
|
+
Doctor
|
|
328
|
+
</button>
|
|
329
|
+
</td>
|
|
330
|
+
</tr>\`;
|
|
331
|
+
});
|
|
332
|
+
tbody.innerHTML = html;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function loadData() {
|
|
336
|
+
// Load Audits
|
|
337
|
+
try {
|
|
338
|
+
const res = await fetch('/api/audits');
|
|
339
|
+
globalAudits = await res.json();
|
|
340
|
+
const container = document.getElementById('audits-container');
|
|
341
|
+
if (!globalAudits || globalAudits.length === 0 || !globalAudits[0].items) {
|
|
342
|
+
container.innerHTML = '<p style="color: var(--text-dim); padding: 1rem 0;">No active audits logged yet. Run <code>bandit doctor</code> in terminal to execute security probes.</p>';
|
|
343
|
+
} else {
|
|
344
|
+
let html = '<table><thead><tr><th>Status</th><th>Probe Title</th><th>Diagnostic Details</th><th>Remediation Suggestion</th></tr></thead><tbody>';
|
|
345
|
+
globalAudits[0].items.forEach(item => {
|
|
346
|
+
const badgeClass = item.status === 'pass' ? 'badge-success' : item.status === 'warn' ? 'badge-warning' : 'badge-danger';
|
|
347
|
+
html += \`<tr>
|
|
348
|
+
<td><span class="badge \${badgeClass}">\${item.status}</span></td>
|
|
349
|
+
<td><strong>\${item.title}</strong></td>
|
|
350
|
+
<td>\${item.details || '-'}</td>
|
|
351
|
+
<td style="color: var(--primary);">\${item.suggestion || 'No action needed'}</td>
|
|
352
|
+
</tr>\`;
|
|
353
|
+
});
|
|
354
|
+
html += '</tbody></table>';
|
|
355
|
+
container.innerHTML = html;
|
|
356
|
+
}
|
|
357
|
+
} catch (err) { console.error(err); }
|
|
358
|
+
|
|
359
|
+
// Load Blueprint Graph
|
|
360
|
+
try {
|
|
361
|
+
const res = await fetch('/api/blueprint');
|
|
362
|
+
const graph = await res.json();
|
|
363
|
+
globalRoutes = graph.nodes.filter(n => n.type === 'route');
|
|
364
|
+
|
|
365
|
+
// Renders Table
|
|
366
|
+
renderMatrixTable(globalRoutes);
|
|
367
|
+
|
|
368
|
+
// Process Analytics
|
|
369
|
+
const total = globalRoutes.length;
|
|
370
|
+
if (total > 0) {
|
|
371
|
+
// 1. Bench Ring
|
|
372
|
+
const benched = globalRoutes.filter(r => r.metrics).length;
|
|
373
|
+
const benchPct = Math.round((benched / total) * 100);
|
|
374
|
+
document.getElementById('ring-bench-text').innerText = benchPct + '%';
|
|
375
|
+
const benchOffset = 220 - (220 * benchPct) / 100;
|
|
376
|
+
document.getElementById('ring-bench').style.strokeDashoffset = benchOffset;
|
|
377
|
+
|
|
378
|
+
// 2. Security Ring
|
|
379
|
+
const securityPct = globalAudits.length > 0 ? 100 : 0;
|
|
380
|
+
document.getElementById('ring-security-text').innerText = securityPct + '%';
|
|
381
|
+
const secOffset = 220 - (220 * securityPct) / 100;
|
|
382
|
+
document.getElementById('ring-security').style.strokeDashoffset = secOffset;
|
|
383
|
+
|
|
384
|
+
// 3. Exposure Progress Bar
|
|
385
|
+
const privCount = globalRoutes.filter(r => r.authStatus === 'Protected').length;
|
|
386
|
+
const pubCount = total - privCount;
|
|
387
|
+
document.getElementById('exposure-public').innerText = pubCount;
|
|
388
|
+
document.getElementById('exposure-private').innerText = privCount;
|
|
389
|
+
const privatePct = Math.round((privCount / total) * 100);
|
|
390
|
+
document.getElementById('exposure-bar-val').style.width = privatePct + '%';
|
|
391
|
+
document.getElementById('exposure-ratio-desc').innerText = \`\${privatePct}% of your API routes are protected by middleware.\`;
|
|
392
|
+
|
|
393
|
+
// 4. Latency Hotspots (Slowest top 3)
|
|
394
|
+
const benchedRoutes = globalRoutes.filter(r => r.metrics);
|
|
395
|
+
const radarList = document.getElementById('radar-list');
|
|
396
|
+
if (benchedRoutes.length === 0) {
|
|
397
|
+
radarList.innerHTML = '<p style="color: var(--text-dim); font-size: 0.85rem; padding-top: 0.5rem;">No benchmark data available.</p>';
|
|
398
|
+
} else {
|
|
399
|
+
benchedRoutes.sort((a, b) => b.metrics.avg - a.metrics.avg);
|
|
400
|
+
const maxAvg = benchedRoutes[0].metrics.avg || 1;
|
|
401
|
+
let radarHtml = '';
|
|
402
|
+
benchedRoutes.slice(0, 3).forEach(br => {
|
|
403
|
+
const widthPct = Math.round((br.metrics.avg / maxAvg) * 100);
|
|
404
|
+
radarHtml += \`<div class="radar-item">
|
|
405
|
+
<div class="radar-labels">
|
|
406
|
+
<span style="font-weight: 500;">\${br.label}</span>
|
|
407
|
+
<span style="color: var(--danger); font-weight: 600;">\${br.metrics.avg.toFixed(0)} ms</span>
|
|
408
|
+
</div>
|
|
409
|
+
<div class="radar-bar-bg">
|
|
410
|
+
<div class="radar-bar-val" style="width: \${widthPct}%;"></div>
|
|
411
|
+
</div>
|
|
412
|
+
</div>\`;
|
|
413
|
+
});
|
|
414
|
+
radarList.innerHTML = radarHtml;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
} catch (err) { console.error(err); }
|
|
418
|
+
|
|
419
|
+
// Load Benchmarks
|
|
420
|
+
try {
|
|
421
|
+
const res = await fetch('/api/benchmarks');
|
|
422
|
+
const data = await res.json();
|
|
423
|
+
const container = document.getElementById('benchmarks-table-container');
|
|
424
|
+
if (!data || data.length === 0) {
|
|
425
|
+
container.innerHTML = '<p style="color: var(--text-dim); padding: 1rem 0;">No benchmark history recorded yet. Run <code>bandit bench <url></code> in your terminal.</p>';
|
|
426
|
+
} else {
|
|
427
|
+
let html = '<table><thead><tr><th>Timestamp</th><th>Commit</th><th>Target URL</th><th>Reqs / Conn</th><th>Throughput (RPS)</th><th>Avg Latency</th><th>p99 Tail Latency</th></tr></thead><tbody>';
|
|
428
|
+
data.forEach(b => {
|
|
429
|
+
html += \`<tr>
|
|
430
|
+
<td>\${new Date(b.timestamp).toLocaleString()}</td>
|
|
431
|
+
<td><code>\${b.gitCommit || 'HEAD'}</code></td>
|
|
432
|
+
<td><strong>\${b.targetUrl}</strong></td>
|
|
433
|
+
<td>\${b.requests} / \${b.connections}</td>
|
|
434
|
+
<td style="color: var(--primary); font-weight: 700;">\${b.rps.toFixed(1)} req/s</td>
|
|
435
|
+
<td>\${b.latency.avg.toFixed(1)} ms</td>
|
|
436
|
+
<td style="color: \${b.latency.p99 > 200 ? 'var(--warning)' : 'var(--success)'}; font-weight: 600;">\${b.latency.p99.toFixed(1)} ms</td>
|
|
437
|
+
</tr>\`;
|
|
438
|
+
});
|
|
439
|
+
html += '</tbody></table>';
|
|
440
|
+
container.innerHTML = html;
|
|
441
|
+
}
|
|
442
|
+
} catch (err) { console.error(err); }
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
loadData();
|
|
446
|
+
</script>
|
|
447
|
+
</body>
|
|
448
|
+
</html>`;
|
|
449
|
+
};
|
|
450
|
+
const server = http.createServer(async (req, res) => {
|
|
451
|
+
const url = req.url || "/";
|
|
452
|
+
// Serve custom logo if saved in project root
|
|
453
|
+
if (url === "/api/logo" && req.method === "GET") {
|
|
454
|
+
const logoPath = path.join(projectPath, "logo.png");
|
|
455
|
+
if (fs.existsSync(logoPath)) {
|
|
456
|
+
res.writeHead(200, { "Content-Type": "image/png" });
|
|
457
|
+
res.end(fs.readFileSync(logoPath));
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
res.writeHead(404);
|
|
461
|
+
res.end();
|
|
462
|
+
}
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (url === "/api/benchmarks" && req.method === "GET") {
|
|
466
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
467
|
+
res.end(JSON.stringify(db.getBenchmarks()));
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
if (url === "/api/audits" && req.method === "GET") {
|
|
471
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
472
|
+
res.end(JSON.stringify(db.getAudits()));
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (url === "/api/blueprint" && req.method === "GET") {
|
|
476
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
477
|
+
const blueprint = await generateApiBlueprint(projectPath);
|
|
478
|
+
res.end(JSON.stringify(blueprint));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (url === "/api/config/dismiss" && req.method === "POST") {
|
|
482
|
+
let body = "";
|
|
483
|
+
req.on("data", chunk => { body += chunk; });
|
|
484
|
+
req.on("end", () => {
|
|
485
|
+
try {
|
|
486
|
+
const { ruleTitle } = JSON.parse(body);
|
|
487
|
+
const updated = db.dismissRule(ruleTitle);
|
|
488
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
489
|
+
res.end(JSON.stringify(updated));
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
493
|
+
res.end(JSON.stringify({ error: "Invalid payload" }));
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
499
|
+
res.end(getClientHtml());
|
|
500
|
+
});
|
|
501
|
+
return new Promise((resolve, reject) => {
|
|
502
|
+
server.listen(port, () => {
|
|
503
|
+
resolve({ server, port });
|
|
504
|
+
});
|
|
505
|
+
server.on("error", (err) => {
|
|
506
|
+
if (err.code === "EADDRINUSE") {
|
|
507
|
+
server.listen(port + 1, () => {
|
|
508
|
+
resolve({ server, port: port + 1 });
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
else {
|
|
512
|
+
reject(err);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
}
|
package/dist/utils/monorepo.js
CHANGED
|
@@ -3,6 +3,22 @@ import path from "node:path";
|
|
|
3
3
|
import fg from "fast-glob";
|
|
4
4
|
import * as clack from "@clack/prompts";
|
|
5
5
|
import chalk from "chalk";
|
|
6
|
+
function detectFramework(pkg) {
|
|
7
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
8
|
+
const frameworks = {
|
|
9
|
+
express: "Express",
|
|
10
|
+
"@nestjs/core": "NestJS",
|
|
11
|
+
fastify: "Fastify",
|
|
12
|
+
koa: "Koa",
|
|
13
|
+
hapi: "Hapi",
|
|
14
|
+
};
|
|
15
|
+
for (const [dep, label] of Object.entries(frameworks)) {
|
|
16
|
+
if (deps[dep]) {
|
|
17
|
+
return label;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return "gRPC / Library";
|
|
21
|
+
}
|
|
6
22
|
export async function resolveProjectPath(inputPath = ".") {
|
|
7
23
|
const absoluteRoot = path.resolve(inputPath);
|
|
8
24
|
// Find all package.json files, ignoring dependency and output directories
|
|
@@ -24,6 +40,7 @@ export async function resolveProjectPath(inputPath = ".") {
|
|
|
24
40
|
services.push({
|
|
25
41
|
name: pkg.name || path.basename(dir),
|
|
26
42
|
path: dir,
|
|
43
|
+
framework: detectFramework(pkg),
|
|
27
44
|
});
|
|
28
45
|
}
|
|
29
46
|
catch { }
|
|
@@ -33,9 +50,12 @@ export async function resolveProjectPath(inputPath = ".") {
|
|
|
33
50
|
clack.log.info(`${chalk.bold.yellow("Monorepo detected!")} Found ${chalk.bold.cyan(services.length)} nested packages.`);
|
|
34
51
|
const options = services.map((s) => {
|
|
35
52
|
const relPath = path.relative(absoluteRoot, s.path) || ".";
|
|
53
|
+
const fwLabel = s.framework === "gRPC / Library"
|
|
54
|
+
? chalk.dim(s.framework)
|
|
55
|
+
: chalk.cyan(s.framework);
|
|
36
56
|
return {
|
|
37
57
|
value: s.path,
|
|
38
|
-
label: `${chalk.bold.green(s.name)} ${chalk.gray(`(${relPath})`)}`,
|
|
58
|
+
label: `${chalk.bold.green(s.name)} ${chalk.gray(`(${relPath})`)} - ${fwLabel}`,
|
|
39
59
|
};
|
|
40
60
|
});
|
|
41
61
|
const selectedPath = await clack.select({
|