@secbez-labs/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.
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/index.js ADDED
@@ -0,0 +1,887 @@
1
+ #!/usr/bin/env node
2
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
+ }) : x)(function(x) {
5
+ if (typeof require !== "undefined") return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ // src/index.ts
10
+ import { Command } from "commander";
11
+
12
+ // src/commands/check.ts
13
+ import chalk2 from "chalk";
14
+ import ora from "ora";
15
+
16
+ // src/lib/crawler.ts
17
+ import fs from "fs";
18
+ import path from "path";
19
+ var DEFAULT_IGNORE = [
20
+ "node_modules",
21
+ ".git",
22
+ "dist",
23
+ "build",
24
+ ".next",
25
+ "__pycache__",
26
+ ".venv",
27
+ "venv",
28
+ "vendor",
29
+ "target",
30
+ ".idea",
31
+ ".vscode",
32
+ "coverage",
33
+ ".nyc_output",
34
+ ".turbo",
35
+ ".cache",
36
+ "*.min.js",
37
+ "*.bundle.js",
38
+ "*.map",
39
+ "package-lock.json",
40
+ "pnpm-lock.yaml",
41
+ "yarn.lock"
42
+ ];
43
+ var SUPPORTED_EXTENSIONS = /* @__PURE__ */ new Set([
44
+ ".ts",
45
+ ".tsx",
46
+ ".js",
47
+ ".jsx",
48
+ ".mjs",
49
+ ".cjs",
50
+ ".py",
51
+ ".pyi"
52
+ ]);
53
+ function detectLanguage(filePath) {
54
+ const ext = path.extname(filePath).toLowerCase();
55
+ const map = {
56
+ ".ts": "typescript",
57
+ ".tsx": "typescript",
58
+ ".js": "javascript",
59
+ ".jsx": "javascript",
60
+ ".mjs": "javascript",
61
+ ".cjs": "javascript",
62
+ ".py": "python",
63
+ ".pyi": "python"
64
+ };
65
+ return map[ext] || "unknown";
66
+ }
67
+ function loadIgnorePatterns(rootDir) {
68
+ const patterns = [...DEFAULT_IGNORE];
69
+ const secbezIgnore = path.join(rootDir, ".secbezignore");
70
+ if (fs.existsSync(secbezIgnore)) {
71
+ const lines = fs.readFileSync(secbezIgnore, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
72
+ patterns.push(...lines);
73
+ }
74
+ const gitIgnore = path.join(rootDir, ".gitignore");
75
+ if (fs.existsSync(gitIgnore)) {
76
+ const lines = fs.readFileSync(gitIgnore, "utf-8").split("\n").map((l) => l.trim()).filter((l) => l && !l.startsWith("#"));
77
+ patterns.push(...lines);
78
+ }
79
+ return patterns;
80
+ }
81
+ function shouldIgnore(relativePath, ignorePatterns) {
82
+ const parts = relativePath.split(path.sep);
83
+ for (const pattern of ignorePatterns) {
84
+ if (parts.some((p) => p === pattern)) return true;
85
+ if (pattern.startsWith("*.")) {
86
+ const ext = pattern.slice(1);
87
+ if (relativePath.endsWith(ext)) return true;
88
+ }
89
+ if (relativePath === pattern) return true;
90
+ }
91
+ return false;
92
+ }
93
+ function crawlDirectory(rootDir, maxFiles = 2e3) {
94
+ const absRoot = path.resolve(rootDir);
95
+ const ignorePatterns = loadIgnorePatterns(absRoot);
96
+ const files = [];
97
+ function walk(dir) {
98
+ if (files.length >= maxFiles) return;
99
+ let entries;
100
+ try {
101
+ entries = fs.readdirSync(dir, { withFileTypes: true });
102
+ } catch {
103
+ return;
104
+ }
105
+ for (const entry of entries) {
106
+ if (files.length >= maxFiles) return;
107
+ const fullPath = path.join(dir, entry.name);
108
+ const relativePath = path.relative(absRoot, fullPath);
109
+ if (shouldIgnore(relativePath, ignorePatterns)) continue;
110
+ if (entry.isDirectory()) {
111
+ walk(fullPath);
112
+ } else if (entry.isFile()) {
113
+ const ext = path.extname(entry.name).toLowerCase();
114
+ if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
115
+ try {
116
+ const stat = fs.statSync(fullPath);
117
+ if (stat.size > 1e6) continue;
118
+ const content = fs.readFileSync(fullPath, "utf-8");
119
+ files.push({
120
+ path: fullPath,
121
+ relativePath,
122
+ content,
123
+ language: detectLanguage(fullPath),
124
+ sizeBytes: stat.size
125
+ });
126
+ } catch {
127
+ }
128
+ }
129
+ }
130
+ }
131
+ walk(absRoot);
132
+ return files;
133
+ }
134
+
135
+ // src/lib/config.ts
136
+ import fs2 from "fs";
137
+ import os from "os";
138
+ import path2 from "path";
139
+ var CONFIG_DIR = path2.join(os.homedir(), ".secbez");
140
+ var CONFIG_FILE = path2.join(CONFIG_DIR, "config.json");
141
+ var DEFAULT_CONFIG = {
142
+ apiUrl: "https://api.secbez.com",
143
+ defaultSeverity: "low",
144
+ defaultFormat: "table"
145
+ };
146
+ function ensureConfigDir() {
147
+ if (!fs2.existsSync(CONFIG_DIR)) {
148
+ fs2.mkdirSync(CONFIG_DIR, { recursive: true, mode: 448 });
149
+ }
150
+ }
151
+ function loadConfig() {
152
+ ensureConfigDir();
153
+ if (!fs2.existsSync(CONFIG_FILE)) {
154
+ return { ...DEFAULT_CONFIG };
155
+ }
156
+ try {
157
+ const raw = fs2.readFileSync(CONFIG_FILE, "utf-8");
158
+ return { ...DEFAULT_CONFIG, ...JSON.parse(raw) };
159
+ } catch {
160
+ return { ...DEFAULT_CONFIG };
161
+ }
162
+ }
163
+ function saveConfig(config) {
164
+ ensureConfigDir();
165
+ const existing = loadConfig();
166
+ const merged = { ...existing, ...config };
167
+ fs2.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), {
168
+ mode: 384
169
+ });
170
+ }
171
+ function getApiKey() {
172
+ return process.env.SECBEZ_API_KEY || loadConfig().apiKey;
173
+ }
174
+ function getApiUrl() {
175
+ return process.env.SECBEZ_API_URL || loadConfig().apiUrl;
176
+ }
177
+
178
+ // src/lib/scanner.ts
179
+ import crypto from "crypto";
180
+ var RULES = [
181
+ // --- SQL Injection ---
182
+ {
183
+ id: "sqli/string-concat",
184
+ category: "sqli",
185
+ severity: "critical",
186
+ confidence: "high",
187
+ title: "SQL Injection via string concatenation",
188
+ message: "SQL query built with string concatenation using user-controlled input. Use parameterized queries instead.",
189
+ pattern: /(?:query|prepare|exec|execute|raw)\s*\(\s*(?:`[^`]*\$\{|['"][^'"]*['"]\s*\+|['"][^'"]*['"]\s*\.\s*concat)/gi,
190
+ languages: ["typescript", "javascript"],
191
+ references: ["CWE-89", "https://owasp.org/Top10/A03_2021-Injection/"]
192
+ },
193
+ {
194
+ id: "sqli/template-literal",
195
+ category: "sqli",
196
+ severity: "critical",
197
+ confidence: "high",
198
+ title: "SQL Injection via template literal",
199
+ message: "SQL query uses template literals with embedded expressions. Use parameterized queries.",
200
+ pattern: /(?:SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|JOIN|AND|OR)\s[^;]*\$\{[^}]+\}/gi,
201
+ languages: ["typescript", "javascript"],
202
+ references: ["CWE-89"]
203
+ },
204
+ {
205
+ id: "sqli/f-string",
206
+ category: "sqli",
207
+ severity: "critical",
208
+ confidence: "high",
209
+ title: "SQL Injection via f-string",
210
+ message: "SQL query uses Python f-string with embedded expressions. Use parameterized queries.",
211
+ pattern: /(?:execute|executemany|cursor\.execute)\s*\(\s*f['"][^'"]*\{[^}]+\}/gi,
212
+ languages: ["python"],
213
+ references: ["CWE-89"]
214
+ },
215
+ // --- XSS ---
216
+ {
217
+ id: "xss/dangerously-set-html",
218
+ category: "xss",
219
+ severity: "high",
220
+ confidence: "high",
221
+ title: "Cross-Site Scripting via dangerouslySetInnerHTML",
222
+ message: "Using dangerouslySetInnerHTML with potentially untrusted content enables XSS attacks.",
223
+ pattern: /dangerouslySetInnerHTML\s*=\s*\{\s*\{\s*__html\s*:/gi,
224
+ languages: ["typescript", "javascript"],
225
+ references: ["CWE-79", "https://owasp.org/Top10/A03_2021-Injection/"]
226
+ },
227
+ {
228
+ id: "xss/innerhtml-assignment",
229
+ category: "xss",
230
+ severity: "high",
231
+ confidence: "medium",
232
+ title: "Cross-Site Scripting via innerHTML",
233
+ message: "Direct innerHTML assignment with dynamic content can lead to XSS.",
234
+ pattern: /\.innerHTML\s*=\s*(?!['"`]<)/gi,
235
+ languages: ["typescript", "javascript"],
236
+ references: ["CWE-79"]
237
+ },
238
+ {
239
+ id: "xss/document-write",
240
+ category: "xss",
241
+ severity: "high",
242
+ confidence: "medium",
243
+ title: "Cross-Site Scripting via document.write",
244
+ message: "document.write with dynamic content can lead to DOM-based XSS.",
245
+ pattern: /document\.write(?:ln)?\s*\(/gi,
246
+ languages: ["typescript", "javascript"],
247
+ references: ["CWE-79"]
248
+ },
249
+ // --- Auth ---
250
+ {
251
+ id: "auth/jwt-none-alg",
252
+ category: "auth",
253
+ severity: "critical",
254
+ confidence: "high",
255
+ title: "JWT algorithm none accepted",
256
+ message: 'Accepting JWT tokens with algorithm "none" bypasses signature verification entirely.',
257
+ pattern: /(?:alg(?:orithm)?)\s*===?\s*['"]none['"]/gi,
258
+ languages: ["typescript", "javascript"],
259
+ references: ["CWE-345", "CWE-347"]
260
+ },
261
+ {
262
+ id: "auth/hardcoded-secret",
263
+ category: "secret_leak",
264
+ severity: "high",
265
+ confidence: "medium",
266
+ title: "Hardcoded secret or API key",
267
+ message: "Secrets hardcoded in source code can be extracted from version control.",
268
+ pattern: /(?:secret|api_?key|password|token|auth)\s*[:=]\s*['"][a-zA-Z0-9_\-./+]{16,}['"]/gi,
269
+ languages: ["typescript", "javascript", "python"],
270
+ references: ["CWE-798"],
271
+ suppressIf: [/process\.env/, /env\[/, /os\.environ/]
272
+ },
273
+ {
274
+ id: "auth/weak-password-hash",
275
+ category: "auth",
276
+ severity: "high",
277
+ confidence: "high",
278
+ title: "Weak password hashing algorithm",
279
+ message: "MD5 or SHA1 should not be used for password hashing. Use bcrypt, scrypt, or argon2.",
280
+ pattern: /(?:createHash|hashlib\.)\s*\(\s*['"](?:md5|sha1)['"]\s*\).*(?:password|passwd|pwd)/gi,
281
+ languages: ["typescript", "javascript", "python"],
282
+ references: ["CWE-328"]
283
+ },
284
+ // --- IDOR ---
285
+ {
286
+ id: "idor/param-direct-query",
287
+ category: "idor",
288
+ severity: "high",
289
+ confidence: "medium",
290
+ title: "Potential IDOR \u2014 resource accessed by user-supplied ID without ownership check",
291
+ message: "Resource fetched using request parameter ID without verifying the resource belongs to the authenticated user.",
292
+ pattern: /(?:params|query)\.[a-zA-Z]*[Ii]d\b.*(?:findFirst|findUnique|findOne|prepare|query|get)\s*\(/gi,
293
+ languages: ["typescript", "javascript"],
294
+ references: ["CWE-639"]
295
+ },
296
+ // --- SSRF ---
297
+ {
298
+ id: "ssrf/user-controlled-fetch",
299
+ category: "other",
300
+ severity: "high",
301
+ confidence: "medium",
302
+ title: "Server-Side Request Forgery \u2014 user-controlled URL fetched server-side",
303
+ message: "A URL from user input is fetched server-side without validation, allowing SSRF attacks.",
304
+ pattern: /(?:fetch|axios\.(?:get|post)|got|request|http\.get|urllib\.request)\s*\(\s*(?:req\.(?:body|query|params)\.[a-zA-Z_]+|(?:source_)?url|webhook[._]?url)/gi,
305
+ languages: ["typescript", "javascript", "python"],
306
+ references: ["CWE-918", "https://owasp.org/Top10/A10_2021-Server-Side_Request_Forgery/"]
307
+ },
308
+ // --- Path Traversal ---
309
+ {
310
+ id: "path-traversal/user-path",
311
+ category: "other",
312
+ severity: "high",
313
+ confidence: "medium",
314
+ title: "Path traversal via user-controlled filename",
315
+ message: "File path constructed from user input without sanitization can allow reading/writing arbitrary files.",
316
+ pattern: /path\.(?:join|resolve)\s*\([^)]*(?:req\.(?:params|query|body)\.[a-zA-Z_]+|filename|filePath)/gi,
317
+ languages: ["typescript", "javascript"],
318
+ references: ["CWE-22"],
319
+ suppressIf: [/path\.relative/, /\.startsWith\s*\(/]
320
+ },
321
+ // --- CSRF ---
322
+ {
323
+ id: "csrf/no-csrf-token",
324
+ category: "auth",
325
+ severity: "medium",
326
+ confidence: "low",
327
+ title: "Missing CSRF protection on state-changing endpoint",
328
+ message: "POST/PUT/PATCH/DELETE endpoint does not appear to validate a CSRF token.",
329
+ pattern: /(?:app|router)\.(?:post|put|patch|delete)\s*\(\s*['"][^'"]+['"]/gi,
330
+ languages: ["typescript", "javascript"],
331
+ references: ["CWE-352"],
332
+ suppressIf: [/csrf/, /csrfToken/, /\_csrf/, /xsrf/]
333
+ },
334
+ // --- Race Conditions ---
335
+ {
336
+ id: "race/toctou-check-then-update",
337
+ category: "business_logic",
338
+ severity: "medium",
339
+ confidence: "medium",
340
+ title: "Potential TOCTOU race condition",
341
+ message: "Value is checked then updated in separate operations without a transaction or atomic operation.",
342
+ pattern: /(?:if\s*\([^)]*(?:balance|stock|count|uses|quantity|credits?|amount)[^)]*\)[\s\S]{1,200}(?:UPDATE|update|set)\s*\()/gi,
343
+ languages: ["typescript", "javascript"],
344
+ references: ["CWE-367"]
345
+ },
346
+ // --- Mass Assignment ---
347
+ {
348
+ id: "mass-assignment/unfiltered-body",
349
+ category: "idor",
350
+ severity: "high",
351
+ confidence: "medium",
352
+ title: "Mass assignment \u2014 request body spread directly into database update",
353
+ message: "User-supplied fields are directly used in a database update without allowlisting, enabling privilege escalation.",
354
+ pattern: /(?:Object\.entries|Object\.keys|\.\.\.req\.body|\.\.\.body|\.\.\.input|for\s*\(\s*(?:const|let)\s*\[key|updates\s*=\s*req\.body)[\s\S]{0,300}(?:UPDATE|update|set)\s*\(/gi,
355
+ languages: ["typescript", "javascript"],
356
+ references: ["CWE-915"]
357
+ },
358
+ // --- Eval / Code Injection ---
359
+ {
360
+ id: "rce/eval",
361
+ category: "rce",
362
+ severity: "critical",
363
+ confidence: "high",
364
+ title: "Code injection via eval or Function constructor",
365
+ message: "Using eval() or new Function() with dynamic input enables remote code execution.",
366
+ pattern: /(?:eval|new\s+Function)\s*\(\s*(?!['"`])/gi,
367
+ languages: ["typescript", "javascript"],
368
+ references: ["CWE-94"]
369
+ },
370
+ {
371
+ id: "rce/exec",
372
+ category: "rce",
373
+ severity: "critical",
374
+ confidence: "medium",
375
+ title: "Command injection via child_process",
376
+ message: "Shell command constructed with user input can lead to command injection.",
377
+ pattern: /(?:exec|execSync|spawn|spawnSync)\s*\(\s*(?:`[^`]*\$\{|[^'"`\s]+\s*\+)/gi,
378
+ languages: ["typescript", "javascript"],
379
+ references: ["CWE-78"]
380
+ },
381
+ // --- Python-specific ---
382
+ {
383
+ id: "rce/python-exec",
384
+ category: "rce",
385
+ severity: "critical",
386
+ confidence: "high",
387
+ title: "Code injection via exec/eval",
388
+ message: "Using exec() or eval() with dynamic input enables code execution.",
389
+ pattern: /(?:exec|eval)\s*\(\s*(?!['"])/gi,
390
+ languages: ["python"],
391
+ references: ["CWE-94"]
392
+ },
393
+ {
394
+ id: "rce/python-os-system",
395
+ category: "rce",
396
+ severity: "critical",
397
+ confidence: "medium",
398
+ title: "Command injection via os.system or subprocess",
399
+ message: "Shell command with user input can lead to command injection.",
400
+ pattern: /(?:os\.system|os\.popen|subprocess\.(?:call|run|Popen|check_output))\s*\(\s*(?:f['"]|[^'"`\s]+\s*\+)/gi,
401
+ languages: ["python"],
402
+ references: ["CWE-78"]
403
+ }
404
+ ];
405
+ function fingerprint(ruleId, filePath, lineNumber) {
406
+ const input = `${ruleId}:${filePath}:${lineNumber}`;
407
+ return crypto.createHash("sha256").update(input).digest("hex");
408
+ }
409
+ function getLineNumber(content, matchIndex) {
410
+ return content.substring(0, matchIndex).split("\n").length;
411
+ }
412
+ function getSnippet(content, lineNumber, contextLines = 2) {
413
+ const lines = content.split("\n");
414
+ const start = Math.max(0, lineNumber - contextLines - 1);
415
+ const end = Math.min(lines.length, lineNumber + contextLines);
416
+ return lines.slice(start, end).map((l, i) => {
417
+ const num = start + i + 1;
418
+ const marker = num === lineNumber ? ">" : " ";
419
+ return `${marker} ${num} | ${l}`;
420
+ }).join("\n");
421
+ }
422
+ function isLineSuppressed(content, lineNumber, suppressPatterns) {
423
+ if (!suppressPatterns || suppressPatterns.length === 0) return false;
424
+ const lines = content.split("\n");
425
+ const start = Math.max(0, lineNumber - 4);
426
+ const end = Math.min(lines.length, lineNumber + 2);
427
+ const context = lines.slice(start, end).join("\n");
428
+ return suppressPatterns.some((p) => p.test(context));
429
+ }
430
+ function scanFile(filePath, content, language) {
431
+ const findings = [];
432
+ const applicableRules = RULES.filter((r) => r.languages.includes(language));
433
+ for (const rule of applicableRules) {
434
+ rule.pattern.lastIndex = 0;
435
+ let match;
436
+ while ((match = rule.pattern.exec(content)) !== null) {
437
+ const lineNumber = getLineNumber(content, match.index);
438
+ if (isLineSuppressed(content, lineNumber, rule.suppressIf)) {
439
+ continue;
440
+ }
441
+ const snippet = getSnippet(content, lineNumber);
442
+ const fp = fingerprint(rule.id, filePath, lineNumber);
443
+ if (findings.some((f) => f.fingerprint === fp)) continue;
444
+ findings.push({
445
+ ruleId: rule.id,
446
+ category: rule.category,
447
+ severity: rule.severity,
448
+ confidence: rule.confidence,
449
+ title: rule.title,
450
+ message: rule.message,
451
+ filePath,
452
+ lineNumber,
453
+ snippet,
454
+ fingerprint: fp,
455
+ evidence: { snippet, highlights: [lineNumber] },
456
+ references: rule.references
457
+ });
458
+ }
459
+ }
460
+ return findings;
461
+ }
462
+
463
+ // src/reporters/table.ts
464
+ import chalk from "chalk";
465
+ import Table from "cli-table3";
466
+ var severityColor = {
467
+ critical: chalk.bgRed.white.bold,
468
+ high: chalk.red.bold,
469
+ medium: chalk.yellow,
470
+ low: chalk.cyan,
471
+ info: chalk.gray
472
+ };
473
+ var severityOrder = {
474
+ critical: 0,
475
+ high: 1,
476
+ medium: 2,
477
+ low: 3,
478
+ info: 4
479
+ };
480
+ function renderSummary(findings, filesScanned, durationMs) {
481
+ const counts = {
482
+ critical: 0,
483
+ high: 0,
484
+ medium: 0,
485
+ low: 0,
486
+ info: 0
487
+ };
488
+ for (const f of findings) {
489
+ counts[f.severity] = (counts[f.severity] || 0) + 1;
490
+ }
491
+ console.log();
492
+ console.log(
493
+ chalk.bold("Scan Summary"),
494
+ chalk.gray(`(${filesScanned} files in ${(durationMs / 1e3).toFixed(1)}s)`)
495
+ );
496
+ console.log();
497
+ const parts = [];
498
+ if (counts.critical)
499
+ parts.push(severityColor.critical(` ${counts.critical} critical `));
500
+ if (counts.high) parts.push(severityColor.high(`${counts.high} high`));
501
+ if (counts.medium)
502
+ parts.push(severityColor.medium(`${counts.medium} medium`));
503
+ if (counts.low) parts.push(severityColor.low(`${counts.low} low`));
504
+ if (counts.info) parts.push(severityColor.info(`${counts.info} info`));
505
+ if (parts.length === 0) {
506
+ console.log(chalk.green.bold(" No findings detected"));
507
+ } else {
508
+ console.log(" " + parts.join(chalk.gray(" | ")));
509
+ }
510
+ console.log();
511
+ }
512
+ function renderFindings(findings, minSeverity = "low") {
513
+ const minOrder = severityOrder[minSeverity] ?? 3;
514
+ const filtered = findings.filter((f) => (severityOrder[f.severity] ?? 4) <= minOrder).sort(
515
+ (a, b) => (severityOrder[a.severity] ?? 4) - (severityOrder[b.severity] ?? 4)
516
+ );
517
+ if (filtered.length === 0) return;
518
+ const table = new Table({
519
+ head: [
520
+ chalk.gray("Severity"),
521
+ chalk.gray("Category"),
522
+ chalk.gray("Title"),
523
+ chalk.gray("File"),
524
+ chalk.gray("Line")
525
+ ],
526
+ colWidths: [12, 16, 40, 35, 6],
527
+ wordWrap: true,
528
+ style: { head: [], border: ["gray"] }
529
+ });
530
+ for (const f of filtered) {
531
+ const colorFn = severityColor[f.severity] || chalk.white;
532
+ table.push([
533
+ colorFn(f.severity),
534
+ f.category,
535
+ f.title,
536
+ truncatePath(f.filePath, 33),
537
+ String(f.lineNumber)
538
+ ]);
539
+ }
540
+ console.log(table.toString());
541
+ console.log();
542
+ }
543
+ function renderFindingDetail(finding) {
544
+ const colorFn = severityColor[finding.severity] || chalk.white;
545
+ console.log(chalk.bold("\u2500".repeat(70)));
546
+ console.log(
547
+ colorFn(` [${finding.severity.toUpperCase()}]`),
548
+ chalk.bold(finding.title)
549
+ );
550
+ console.log(chalk.gray(` ${finding.filePath}:${finding.lineNumber}`));
551
+ console.log();
552
+ console.log(chalk.gray(" " + finding.message));
553
+ console.log();
554
+ if (finding.snippet) {
555
+ const lines = finding.snippet.split("\n");
556
+ for (const line of lines) {
557
+ if (line.startsWith(">")) {
558
+ console.log(chalk.yellow(" " + line));
559
+ } else {
560
+ console.log(chalk.gray(" " + line));
561
+ }
562
+ }
563
+ console.log();
564
+ }
565
+ if (finding.references.length > 0) {
566
+ console.log(
567
+ chalk.gray(" References: "),
568
+ finding.references.join(", ")
569
+ );
570
+ }
571
+ }
572
+ function truncatePath(p, maxLen) {
573
+ if (p.length <= maxLen) return p;
574
+ return "..." + p.slice(-(maxLen - 3));
575
+ }
576
+ function renderJsonOutput(findings, stats) {
577
+ const output = {
578
+ version: "1.0.0",
579
+ tool: "secbez-cli",
580
+ stats,
581
+ findings: findings.map((f) => ({
582
+ ruleId: f.ruleId,
583
+ category: f.category,
584
+ severity: f.severity,
585
+ confidence: f.confidence,
586
+ title: f.title,
587
+ message: f.message,
588
+ filePath: f.filePath,
589
+ lineNumber: f.lineNumber,
590
+ fingerprint: f.fingerprint,
591
+ references: f.references
592
+ }))
593
+ };
594
+ console.log(JSON.stringify(output, null, 2));
595
+ }
596
+
597
+ // src/commands/check.ts
598
+ async function checkCommand(targetPath, options) {
599
+ const startTime = Date.now();
600
+ const spinner = options.format === "json" ? null : ora("Crawling files...").start();
601
+ const files = crawlDirectory(targetPath, options.maxFiles);
602
+ const totalBytes = files.reduce((sum, f) => sum + f.sizeBytes, 0);
603
+ if (spinner) {
604
+ spinner.text = `Scanning ${files.length} files (${(totalBytes / 1024).toFixed(0)} KB)...`;
605
+ }
606
+ const allFindings = [];
607
+ for (const file of files) {
608
+ const findings = scanFile(file.relativePath, file.content, file.language);
609
+ allFindings.push(...findings);
610
+ }
611
+ const durationMs = Date.now() - startTime;
612
+ if (spinner) {
613
+ spinner.stop();
614
+ }
615
+ const stats = {
616
+ filesScanned: files.length,
617
+ bytesProcessed: totalBytes,
618
+ durationMs
619
+ };
620
+ if (options.format === "json") {
621
+ renderJsonOutput(allFindings, stats);
622
+ } else {
623
+ renderSummary(allFindings, files.length, durationMs);
624
+ if (allFindings.length > 0) {
625
+ if (options.verbose) {
626
+ for (const f of allFindings) {
627
+ renderFindingDetail(f);
628
+ }
629
+ } else {
630
+ renderFindings(allFindings, options.severity);
631
+ }
632
+ }
633
+ }
634
+ if (options.online && allFindings.length > 0) {
635
+ const apiKey = getApiKey();
636
+ if (!apiKey) {
637
+ console.log(
638
+ chalk2.yellow(
639
+ "\n To upload results, run: secbez login\n"
640
+ )
641
+ );
642
+ } else {
643
+ const uploadSpinner = options.format !== "json" ? ora("Uploading to dashboard...").start() : null;
644
+ try {
645
+ const repoName = detectRepoName(targetPath);
646
+ const apiUrl = getApiUrl();
647
+ const response = await fetch(`${apiUrl}/cli/scan`, {
648
+ method: "POST",
649
+ headers: {
650
+ "Content-Type": "application/json",
651
+ Authorization: `Bearer ${apiKey}`
652
+ },
653
+ body: JSON.stringify({
654
+ repoName,
655
+ findings: allFindings,
656
+ stats
657
+ })
658
+ });
659
+ if (!response.ok) {
660
+ const err = await response.json().catch(() => ({ error: "Unknown error" }));
661
+ throw new Error(err.error || `HTTP ${response.status}`);
662
+ }
663
+ const result = await response.json();
664
+ if (uploadSpinner) {
665
+ uploadSpinner.succeed("Results uploaded to dashboard");
666
+ }
667
+ if (options.format !== "json") {
668
+ console.log(
669
+ chalk2.bold(`
670
+ View full report: ${chalk2.underline.blue(result.dashboardUrl)}
671
+ `)
672
+ );
673
+ }
674
+ if (options.open) {
675
+ const { exec } = await import("child_process");
676
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
677
+ exec(`${cmd} ${result.dashboardUrl}`);
678
+ }
679
+ } catch (err) {
680
+ if (uploadSpinner) {
681
+ uploadSpinner.fail(
682
+ `Upload failed: ${err instanceof Error ? err.message : "Unknown error"}`
683
+ );
684
+ }
685
+ }
686
+ }
687
+ }
688
+ if (options.ci || options.format === "json") {
689
+ const hasCritical = allFindings.some(
690
+ (f) => f.severity === "critical" || f.severity === "high"
691
+ );
692
+ if (hasCritical) {
693
+ process.exit(1);
694
+ }
695
+ }
696
+ }
697
+ function detectRepoName(targetPath) {
698
+ try {
699
+ const { execSync } = __require("child_process");
700
+ const remote = execSync("git remote get-url origin", {
701
+ cwd: targetPath,
702
+ encoding: "utf-8",
703
+ stdio: ["pipe", "pipe", "pipe"]
704
+ }).trim();
705
+ const match = remote.match(/[:/]([^/]+\/[^/.]+?)(?:\.git)?$/);
706
+ if (match) return match[1];
707
+ } catch {
708
+ }
709
+ const path3 = __require("path");
710
+ return path3.basename(path3.resolve(targetPath));
711
+ }
712
+
713
+ // src/commands/config.ts
714
+ import chalk3 from "chalk";
715
+ var VALID_KEYS = [
716
+ "apiUrl",
717
+ "defaultSeverity",
718
+ "defaultFormat"
719
+ ];
720
+ function configSetCommand(key, value) {
721
+ if (!VALID_KEYS.includes(key)) {
722
+ console.log(chalk3.red(`
723
+ Unknown config key: ${key}`));
724
+ console.log(chalk3.gray(` Valid keys: ${VALID_KEYS.join(", ")}
725
+ `));
726
+ process.exit(1);
727
+ }
728
+ saveConfig({ [key]: value });
729
+ console.log(chalk3.green(`
730
+ Set ${key} = ${value}
731
+ `));
732
+ }
733
+ function configGetCommand(key) {
734
+ const config = loadConfig();
735
+ if (key) {
736
+ const value = config[key];
737
+ if (value !== void 0) {
738
+ console.log(`
739
+ ${key} = ${key === "apiKey" ? "****" : value}
740
+ `);
741
+ } else {
742
+ console.log(chalk3.gray(`
743
+ ${key} is not set
744
+ `));
745
+ }
746
+ } else {
747
+ console.log(chalk3.bold("\n Secbez CLI Configuration\n"));
748
+ console.log(` apiUrl = ${config.apiUrl}`);
749
+ console.log(` apiKey = ${config.apiKey ? "sk_live_****" : chalk3.gray("(not set)")}`);
750
+ console.log(` defaultSeverity = ${config.defaultSeverity}`);
751
+ console.log(` defaultFormat = ${config.defaultFormat}`);
752
+ console.log();
753
+ }
754
+ }
755
+ function logoutCommand() {
756
+ saveConfig({ apiKey: void 0 });
757
+ console.log(chalk3.green("\n Logged out. API key removed.\n"));
758
+ }
759
+
760
+ // src/commands/login.ts
761
+ import chalk4 from "chalk";
762
+ import { createInterface } from "readline";
763
+ async function loginCommand() {
764
+ console.log();
765
+ console.log(chalk4.bold(" Secbez CLI Login"));
766
+ console.log(
767
+ chalk4.gray(
768
+ " Get your API key from: https://app.secbez.com/settings/api-keys"
769
+ )
770
+ );
771
+ console.log();
772
+ const apiKey = await promptHidden(" API Key: ");
773
+ if (!apiKey || !apiKey.startsWith("sk_live_")) {
774
+ console.log(
775
+ chalk4.red('\n Invalid API key. Keys start with "sk_live_".\n')
776
+ );
777
+ process.exit(1);
778
+ }
779
+ const apiUrl = getApiUrl();
780
+ try {
781
+ const response = await fetch(`${apiUrl}/me`, {
782
+ headers: { Authorization: `Bearer ${apiKey}` }
783
+ });
784
+ if (!response.ok) {
785
+ console.log(
786
+ chalk4.red(
787
+ "\n Authentication failed. Check your API key and try again.\n"
788
+ )
789
+ );
790
+ process.exit(1);
791
+ }
792
+ const data = await response.json();
793
+ saveConfig({ apiKey });
794
+ console.log(
795
+ chalk4.green(`
796
+ Authenticated as ${chalk4.bold(data.user.email)}`)
797
+ );
798
+ console.log(
799
+ chalk4.gray(" Key saved to ~/.secbez/config.json\n")
800
+ );
801
+ } catch (err) {
802
+ console.log(
803
+ chalk4.red(
804
+ `
805
+ Connection failed: ${err instanceof Error ? err.message : "Unknown error"}`
806
+ )
807
+ );
808
+ console.log(
809
+ chalk4.gray(
810
+ ` API URL: ${apiUrl} \u2014 set SECBEZ_API_URL if using a custom instance.
811
+ `
812
+ )
813
+ );
814
+ process.exit(1);
815
+ }
816
+ }
817
+ function promptHidden(question) {
818
+ return new Promise((resolve) => {
819
+ const rl = createInterface({
820
+ input: process.stdin,
821
+ output: process.stdout
822
+ });
823
+ if (process.stdin.isTTY) {
824
+ process.stdout.write(question);
825
+ const stdin = process.stdin;
826
+ const wasRaw = stdin.isRaw;
827
+ stdin.setRawMode(true);
828
+ stdin.resume();
829
+ let input = "";
830
+ stdin.on("data", function onData(char) {
831
+ const c = char.toString("utf-8");
832
+ if (c === "\n" || c === "\r" || c === "") {
833
+ stdin.setRawMode(wasRaw ?? false);
834
+ stdin.pause();
835
+ stdin.removeListener("data", onData);
836
+ rl.close();
837
+ console.log();
838
+ resolve(input);
839
+ } else if (c === "") {
840
+ process.exit(0);
841
+ } else if (c === "\x7F" || c === "\b") {
842
+ input = input.slice(0, -1);
843
+ } else {
844
+ input += c;
845
+ }
846
+ });
847
+ } else {
848
+ rl.question(question, (answer) => {
849
+ rl.close();
850
+ resolve(answer.trim());
851
+ });
852
+ }
853
+ });
854
+ }
855
+
856
+ // src/index.ts
857
+ var program = new Command();
858
+ program.name("secbez").description("Secbez CLI \u2014 local security scanning for your codebase").version("0.1.0");
859
+ program.command("check").description("Scan a directory for security vulnerabilities").argument("[path]", "Directory to scan", ".").option("-s, --severity <level>", "Minimum severity to report", "low").option(
860
+ "-f, --format <format>",
861
+ "Output format: table, json",
862
+ "table"
863
+ ).option("--online", "Upload results to Secbez dashboard", false).option("--open", "Open dashboard report in browser after upload", false).option("--ci", "CI mode: exit 1 on high/critical findings", false).option("-v, --verbose", "Show detailed findings with code snippets", false).option("--max-files <n>", "Maximum files to scan", "2000").action(async (targetPath, options) => {
864
+ await checkCommand(targetPath, {
865
+ severity: options.severity,
866
+ format: options.format,
867
+ online: options.online,
868
+ open: options.open,
869
+ ci: options.ci,
870
+ verbose: options.verbose,
871
+ maxFiles: parseInt(options.maxFiles)
872
+ });
873
+ });
874
+ program.command("login").description("Authenticate with your Secbez API key").action(async () => {
875
+ await loginCommand();
876
+ });
877
+ program.command("logout").description("Remove stored API key").action(() => {
878
+ logoutCommand();
879
+ });
880
+ var configCmd = program.command("config").description("Manage CLI configuration");
881
+ configCmd.command("set").description("Set a config value").argument("<key>", "Config key").argument("<value>", "Config value").action((key, value) => {
882
+ configSetCommand(key, value);
883
+ });
884
+ configCmd.command("get").description("Show config value(s)").argument("[key]", "Config key (omit to show all)").action((key) => {
885
+ configGetCommand(key);
886
+ });
887
+ program.parse();
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@secbez-labs/cli",
3
+ "publishConfig": {
4
+ "access": "public"
5
+ },
6
+ "version": "0.1.0",
7
+ "description": "Secbez CLI — local security scanning for your codebase",
8
+ "type": "module",
9
+ "bin": {
10
+ "secbez": "./dist/index.js"
11
+ },
12
+ "files": [
13
+ "dist"
14
+ ],
15
+ "scripts": {
16
+ "build": "tsup src/index.ts --format esm --dts --clean",
17
+ "dev": "tsup src/index.ts --format esm --watch",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "vitest run --passWithNoTests",
20
+ "test:watch": "vitest"
21
+ },
22
+ "dependencies": {
23
+ "commander": "^12.1.0",
24
+ "chalk": "^5.3.0",
25
+ "ora": "^8.0.1",
26
+ "cli-table3": "^0.6.5"
27
+ },
28
+ "devDependencies": {
29
+ "@secbez/typescript-config": "workspace:*",
30
+ "@types/node": "^20.19.9",
31
+ "tsup": "^8.3.5",
32
+ "typescript": "^5.9.3",
33
+ "vitest": "^3.2.4"
34
+ }
35
+ }