cipher-security 2.0.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/bin/cipher.js +566 -0
- package/lib/api/billing.js +321 -0
- package/lib/api/compliance.js +693 -0
- package/lib/api/controls.js +1401 -0
- package/lib/api/index.js +49 -0
- package/lib/api/marketplace.js +467 -0
- package/lib/api/openai-proxy.js +383 -0
- package/lib/api/server.js +685 -0
- package/lib/autonomous/feedback-loop.js +554 -0
- package/lib/autonomous/framework.js +512 -0
- package/lib/autonomous/index.js +97 -0
- package/lib/autonomous/leaderboard.js +594 -0
- package/lib/autonomous/modes/architect.js +412 -0
- package/lib/autonomous/modes/blue.js +386 -0
- package/lib/autonomous/modes/incident.js +684 -0
- package/lib/autonomous/modes/privacy.js +369 -0
- package/lib/autonomous/modes/purple.js +294 -0
- package/lib/autonomous/modes/recon.js +250 -0
- package/lib/autonomous/parallel.js +587 -0
- package/lib/autonomous/researcher.js +583 -0
- package/lib/autonomous/runner.js +955 -0
- package/lib/autonomous/scheduler.js +615 -0
- package/lib/autonomous/task-parser.js +127 -0
- package/lib/autonomous/validators/forensic.js +266 -0
- package/lib/autonomous/validators/osint.js +216 -0
- package/lib/autonomous/validators/privacy.js +296 -0
- package/lib/autonomous/validators/purple.js +298 -0
- package/lib/autonomous/validators/sigma.js +248 -0
- package/lib/autonomous/validators/threat-model.js +363 -0
- package/lib/benchmark/agent.js +119 -0
- package/lib/benchmark/baselines.js +43 -0
- package/lib/benchmark/builder.js +143 -0
- package/lib/benchmark/config.js +35 -0
- package/lib/benchmark/coordinator.js +91 -0
- package/lib/benchmark/index.js +20 -0
- package/lib/benchmark/llm.js +58 -0
- package/lib/benchmark/models.js +137 -0
- package/lib/benchmark/reporter.js +103 -0
- package/lib/benchmark/runner.js +103 -0
- package/lib/benchmark/sandbox.js +96 -0
- package/lib/benchmark/scorer.js +32 -0
- package/lib/benchmark/solver.js +166 -0
- package/lib/benchmark/tools.js +62 -0
- package/lib/bot/bot.js +238 -0
- package/lib/brand.js +105 -0
- package/lib/commands.js +100 -0
- package/lib/complexity.js +377 -0
- package/lib/config.js +213 -0
- package/lib/gateway/client.js +309 -0
- package/lib/gateway/commands.js +991 -0
- package/lib/gateway/config-validate.js +109 -0
- package/lib/gateway/gateway.js +367 -0
- package/lib/gateway/index.js +62 -0
- package/lib/gateway/mode.js +309 -0
- package/lib/gateway/plugins.js +222 -0
- package/lib/gateway/prompt.js +214 -0
- package/lib/mcp/server.js +262 -0
- package/lib/memory/compressor.js +425 -0
- package/lib/memory/engine.js +763 -0
- package/lib/memory/evolution.js +668 -0
- package/lib/memory/index.js +58 -0
- package/lib/memory/orchestrator.js +506 -0
- package/lib/memory/retriever.js +515 -0
- package/lib/memory/synthesizer.js +333 -0
- package/lib/pipeline/async-scanner.js +510 -0
- package/lib/pipeline/binary-analysis.js +1043 -0
- package/lib/pipeline/dom-xss-scanner.js +435 -0
- package/lib/pipeline/github-actions.js +792 -0
- package/lib/pipeline/index.js +124 -0
- package/lib/pipeline/osint.js +498 -0
- package/lib/pipeline/sarif.js +373 -0
- package/lib/pipeline/scanner.js +880 -0
- package/lib/pipeline/template-manager.js +525 -0
- package/lib/pipeline/xss-scanner.js +353 -0
- package/lib/setup-wizard.js +288 -0
- package/package.json +31 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// Copyright (c) 2026 defconxt. All rights reserved.
|
|
2
|
+
// Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
|
+
// CIPHER is a trademark of defconxt.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Static XSS heuristic scanner — detects DOM XSS sinks and sources without a browser.
|
|
7
|
+
*
|
|
8
|
+
* Covers ~40-60% of reflected/DOM XSS patterns via regex-based sink/source analysis.
|
|
9
|
+
* This is the static layer; full runtime DOM XSS requires Playwright (dom-xss-scanner.js).
|
|
10
|
+
*
|
|
11
|
+
* Ported from pipeline/xss_scanner.py (338 LOC Python).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
15
|
+
import { join, extname } from 'node:path';
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Sink patterns — where untrusted data reaches dangerous APIs
|
|
19
|
+
// Note: No /g flag on any regex to avoid lastIndex state issues.
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
/** @type {Array<[string, string, RegExp]>} [name, severity, pattern] */
|
|
23
|
+
const SINK_PATTERNS = [
|
|
24
|
+
['innerHTML', 'critical', /\.\s*innerHTML\s*=(?!=)/i],
|
|
25
|
+
['outerHTML', 'critical', /\.\s*outerHTML\s*=(?!=)/i],
|
|
26
|
+
['document.write', 'critical', /document\s*\.\s*write(ln)?\s*\(/i],
|
|
27
|
+
['eval', 'critical', /\beval\s*\(/i],
|
|
28
|
+
['setTimeout-string', 'high', /setTimeout\s*\(\s*['"]/i],
|
|
29
|
+
['setInterval-string', 'high', /setInterval\s*\(\s*['"]/i],
|
|
30
|
+
['Function-constructor', 'critical', /\bnew\s+Function\s*\(/i],
|
|
31
|
+
['insertAdjacentHTML', 'high', /\.insertAdjacentHTML\s*\(/i],
|
|
32
|
+
['dangerouslySetInnerHTML', 'critical', /dangerouslySetInnerHTML\s*=\s*\{/i],
|
|
33
|
+
['v-html', 'high', /\bv-html\s*=/i],
|
|
34
|
+
['[innerHTML]', 'high', /\[innerHTML\]\s*=/i],
|
|
35
|
+
['bypassSecurityTrust', 'critical', /bypassSecurityTrust(Html|Script|Url|ResourceUrl|Style)/i],
|
|
36
|
+
['jquery-html', 'high', /\$\([^)]*\)\s*\.\s*html\s*\(/i],
|
|
37
|
+
['jquery-append-raw', 'medium', /\$\([^)]*\)\s*\.\s*append\s*\(/i],
|
|
38
|
+
['document.domain', 'high', /document\s*\.\s*domain\s*=/i],
|
|
39
|
+
['location-assign', 'high', /location\s*\.\s*(href|assign|replace)\s*=/i],
|
|
40
|
+
['srcdoc', 'high', /\bsrcdoc\s*=/i],
|
|
41
|
+
['postMessage-star', 'medium', /\.postMessage\s*\([^,]+,\s*['"]?\*/i],
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Source patterns — where untrusted data enters the application
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
/** @type {Array<[string, RegExp]>} [name, pattern] */
|
|
49
|
+
const SOURCE_PATTERNS = [
|
|
50
|
+
['location.hash', /location\s*\.\s*hash/i],
|
|
51
|
+
['location.search', /location\s*\.\s*search/i],
|
|
52
|
+
['location.href', /location\s*\.\s*href(?!\s*=)/i],
|
|
53
|
+
['document.URL', /document\s*\.\s*URL/i],
|
|
54
|
+
['document.referrer', /document\s*\.\s*referrer/i],
|
|
55
|
+
['document.cookie', /document\s*\.\s*cookie/i],
|
|
56
|
+
['window.name', /window\s*\.\s*name/i],
|
|
57
|
+
['URLSearchParams', /new\s+URLSearchParams/i],
|
|
58
|
+
['postMessage-handler', /addEventListener\s*\(\s*['"]message['"]/i],
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// Template injection patterns (server-side)
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/** @type {Array<[string, string, RegExp]>} [name, severity, pattern] */
|
|
66
|
+
const TEMPLATE_SINKS = [
|
|
67
|
+
['jinja2-raw', 'high', /\{\{.*\|.*safe\s*\}\}/i],
|
|
68
|
+
['jinja2-autoescape-off', 'critical', /\{%\s*autoescape\s+false/i],
|
|
69
|
+
['erb-raw', 'high', /<%=\s*raw\s+/i],
|
|
70
|
+
['php-echo-unescaped', 'high', /<\?=\s*\$_(GET|POST|REQUEST|COOKIE)/i],
|
|
71
|
+
['asp-response-write', 'high', /Response\.Write\s*\(\s*Request/i],
|
|
72
|
+
['thymeleaf-utext', 'high', /th:utext\s*=/i],
|
|
73
|
+
['razor-raw', 'high', /@Html\.Raw\s*\(/i],
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// File extensions to scan
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
const SCANNABLE_EXTENSIONS = new Set([
|
|
81
|
+
'.js', '.jsx', '.ts', '.tsx', '.vue', '.svelte',
|
|
82
|
+
'.html', '.htm', '.php', '.erb', '.ejs', '.hbs',
|
|
83
|
+
'.pug', '.jade', '.py', '.rb', '.java', '.cs',
|
|
84
|
+
'.asp', '.aspx',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
const SKIP_DIRS = new Set(['node_modules', 'vendor', '.venv', '__pycache__']);
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Data classes
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
class XSSFinding {
|
|
94
|
+
/**
|
|
95
|
+
* @param {object} opts
|
|
96
|
+
* @param {string} opts.file
|
|
97
|
+
* @param {number} opts.lineNumber
|
|
98
|
+
* @param {string} opts.lineContent
|
|
99
|
+
* @param {string} opts.sinkName
|
|
100
|
+
* @param {string} opts.severity critical | high | medium
|
|
101
|
+
* @param {string} opts.category dom-sink | dom-source | template-injection
|
|
102
|
+
* @param {string|null} [opts.sourceNearby=null]
|
|
103
|
+
*/
|
|
104
|
+
constructor(opts) {
|
|
105
|
+
this.file = opts.file;
|
|
106
|
+
this.lineNumber = opts.lineNumber;
|
|
107
|
+
this.lineContent = opts.lineContent;
|
|
108
|
+
this.sinkName = opts.sinkName;
|
|
109
|
+
this.severity = opts.severity;
|
|
110
|
+
this.category = opts.category;
|
|
111
|
+
this.sourceNearby = opts.sourceNearby ?? null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
toDict() {
|
|
115
|
+
const d = {
|
|
116
|
+
file: this.file,
|
|
117
|
+
line: this.lineNumber,
|
|
118
|
+
sink: this.sinkName,
|
|
119
|
+
severity: this.severity,
|
|
120
|
+
category: this.category,
|
|
121
|
+
content: this.lineContent.trim().slice(0, 200),
|
|
122
|
+
};
|
|
123
|
+
if (this.sourceNearby) {
|
|
124
|
+
d.source_nearby = this.sourceNearby;
|
|
125
|
+
}
|
|
126
|
+
return d;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
class XSSScanResult {
|
|
131
|
+
/**
|
|
132
|
+
* @param {object} [opts]
|
|
133
|
+
* @param {number} [opts.filesScanned=0]
|
|
134
|
+
* @param {XSSFinding[]} [opts.findings=[]]
|
|
135
|
+
* @param {Array<object>} [opts.sourcesFound=[]]
|
|
136
|
+
*/
|
|
137
|
+
constructor(opts = {}) {
|
|
138
|
+
this.filesScanned = opts.filesScanned ?? 0;
|
|
139
|
+
this.findings = opts.findings ?? [];
|
|
140
|
+
this.sourcesFound = opts.sourcesFound ?? [];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get criticalCount() {
|
|
144
|
+
return this.findings.filter((f) => f.severity === 'critical').length;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
get highCount() {
|
|
148
|
+
return this.findings.filter((f) => f.severity === 'high').length;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
toDict() {
|
|
152
|
+
return {
|
|
153
|
+
files_scanned: this.filesScanned,
|
|
154
|
+
total_findings: this.findings.length,
|
|
155
|
+
critical: this.criticalCount,
|
|
156
|
+
high: this.highCount,
|
|
157
|
+
medium: this.findings.length - this.criticalCount - this.highCount,
|
|
158
|
+
findings: this.findings.map((f) => f.toDict()),
|
|
159
|
+
sources_found: this.sourcesFound,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
summary() {
|
|
164
|
+
if (this.findings.length === 0) {
|
|
165
|
+
return `No XSS sinks detected in ${this.filesScanned} files.`;
|
|
166
|
+
}
|
|
167
|
+
return (
|
|
168
|
+
`${this.findings.length} XSS sink(s) in ${this.filesScanned} files: ` +
|
|
169
|
+
`${this.criticalCount} critical, ${this.highCount} high`
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// XSSScanner
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
class XSSScanner {
|
|
179
|
+
/**
|
|
180
|
+
* Scan a single file for XSS patterns.
|
|
181
|
+
* @param {string} filepath
|
|
182
|
+
* @returns {XSSFinding[]}
|
|
183
|
+
*/
|
|
184
|
+
scanFile(filepath) {
|
|
185
|
+
const ext = extname(filepath).toLowerCase();
|
|
186
|
+
if (!SCANNABLE_EXTENSIONS.has(ext)) return [];
|
|
187
|
+
|
|
188
|
+
let content;
|
|
189
|
+
try {
|
|
190
|
+
content = readFileSync(filepath, 'utf-8');
|
|
191
|
+
} catch {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const lines = content.split('\n');
|
|
196
|
+
const findings = [];
|
|
197
|
+
/** @type {Map<number, string>} lineNumber → sourceName */
|
|
198
|
+
const sourceLines = new Map();
|
|
199
|
+
|
|
200
|
+
// First pass: find all source locations
|
|
201
|
+
for (let i = 0; i < lines.length; i++) {
|
|
202
|
+
for (const [srcName, srcPat] of SOURCE_PATTERNS) {
|
|
203
|
+
if (srcPat.test(lines[i])) {
|
|
204
|
+
sourceLines.set(i + 1, srcName);
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Second pass: find sinks + correlate nearby sources
|
|
211
|
+
for (let i = 0; i < lines.length; i++) {
|
|
212
|
+
const lineNo = i + 1;
|
|
213
|
+
const line = lines[i];
|
|
214
|
+
|
|
215
|
+
for (const [sinkName, severity, sinkPat] of SINK_PATTERNS) {
|
|
216
|
+
if (sinkPat.test(line)) {
|
|
217
|
+
// Check if a source is within 10 lines
|
|
218
|
+
let nearbySource = null;
|
|
219
|
+
let adjustedSeverity = severity;
|
|
220
|
+
for (const [srcLineNo, srcName] of sourceLines) {
|
|
221
|
+
if (Math.abs(srcLineNo - lineNo) <= 10) {
|
|
222
|
+
nearbySource = srcName;
|
|
223
|
+
adjustedSeverity = 'critical'; // Source + sink = confirmed dangerous
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
findings.push(
|
|
228
|
+
new XSSFinding({
|
|
229
|
+
file: filepath,
|
|
230
|
+
lineNumber: lineNo,
|
|
231
|
+
lineContent: line,
|
|
232
|
+
sinkName,
|
|
233
|
+
severity: adjustedSeverity,
|
|
234
|
+
category: 'dom-sink',
|
|
235
|
+
sourceNearby: nearbySource,
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const [sinkName, severity, sinkPat] of TEMPLATE_SINKS) {
|
|
242
|
+
if (sinkPat.test(line)) {
|
|
243
|
+
findings.push(
|
|
244
|
+
new XSSFinding({
|
|
245
|
+
file: filepath,
|
|
246
|
+
lineNumber: lineNo,
|
|
247
|
+
lineContent: line,
|
|
248
|
+
sinkName,
|
|
249
|
+
severity,
|
|
250
|
+
category: 'template-injection',
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return findings;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Scan a directory for XSS patterns.
|
|
262
|
+
* @param {string} directory
|
|
263
|
+
* @param {object} [opts]
|
|
264
|
+
* @param {boolean} [opts.recursive=true]
|
|
265
|
+
* @returns {XSSScanResult}
|
|
266
|
+
*/
|
|
267
|
+
scanDirectory(directory, opts = {}) {
|
|
268
|
+
const recursive = opts.recursive !== false;
|
|
269
|
+
const result = new XSSScanResult();
|
|
270
|
+
|
|
271
|
+
const walk = (dir) => {
|
|
272
|
+
let entries;
|
|
273
|
+
try {
|
|
274
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
275
|
+
} catch {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
for (const ent of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
279
|
+
if (ent.name.startsWith('.') || SKIP_DIRS.has(ent.name)) continue;
|
|
280
|
+
const full = join(dir, ent.name);
|
|
281
|
+
if (ent.isDirectory() && recursive) {
|
|
282
|
+
walk(full);
|
|
283
|
+
} else if (ent.isFile() && SCANNABLE_EXTENSIONS.has(extname(ent.name).toLowerCase())) {
|
|
284
|
+
result.filesScanned++;
|
|
285
|
+
const findings = this.scanFile(full);
|
|
286
|
+
result.findings.push(...findings);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
walk(directory);
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Scan added lines in a unified diff for XSS patterns.
|
|
296
|
+
* @param {string} diffText
|
|
297
|
+
* @param {string} [filename='<diff>']
|
|
298
|
+
* @returns {XSSFinding[]}
|
|
299
|
+
*/
|
|
300
|
+
scanDiff(diffText, filename = '<diff>') {
|
|
301
|
+
const findings = [];
|
|
302
|
+
let lineNo = 0;
|
|
303
|
+
|
|
304
|
+
for (const line of diffText.split('\n')) {
|
|
305
|
+
if (line.startsWith('@@')) {
|
|
306
|
+
const m = line.match(/\+(\d+)/);
|
|
307
|
+
lineNo = m ? parseInt(m[1], 10) : 0;
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
311
|
+
const content = line.slice(1);
|
|
312
|
+
lineNo++;
|
|
313
|
+
|
|
314
|
+
const allSinks = [...SINK_PATTERNS, ...TEMPLATE_SINKS];
|
|
315
|
+
for (const [sinkName, severity, sinkPat] of allSinks) {
|
|
316
|
+
if (sinkPat.test(content)) {
|
|
317
|
+
const isTemplate = TEMPLATE_SINKS.some((t) => t[0] === sinkName);
|
|
318
|
+
findings.push(
|
|
319
|
+
new XSSFinding({
|
|
320
|
+
file: filename,
|
|
321
|
+
lineNumber: lineNo,
|
|
322
|
+
lineContent: content,
|
|
323
|
+
sinkName,
|
|
324
|
+
severity,
|
|
325
|
+
category: isTemplate ? 'template-injection' : 'dom-sink',
|
|
326
|
+
}),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} else if (!line.startsWith('-')) {
|
|
331
|
+
lineNo++;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return findings;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Exports
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
export {
|
|
343
|
+
// Pattern arrays (for testing/inspection)
|
|
344
|
+
SINK_PATTERNS,
|
|
345
|
+
SOURCE_PATTERNS,
|
|
346
|
+
TEMPLATE_SINKS,
|
|
347
|
+
SCANNABLE_EXTENSIONS,
|
|
348
|
+
// Data classes
|
|
349
|
+
XSSFinding,
|
|
350
|
+
XSSScanResult,
|
|
351
|
+
// Scanner
|
|
352
|
+
XSSScanner,
|
|
353
|
+
};
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// Copyright (c) 2026 defconxt. All rights reserved.
|
|
2
|
+
// Licensed under AGPL-3.0 — see LICENSE file for details.
|
|
3
|
+
// CIPHER is a trademark of defconxt.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* setup-wizard.js — Interactive @clack/prompts setup wizard for CIPHER.
|
|
7
|
+
*
|
|
8
|
+
* Implements R002 (interactive wizard) and R006 (craft feel):
|
|
9
|
+
* - Provider selection (Ollama / Claude API / LiteLLM)
|
|
10
|
+
* - Masked API key input
|
|
11
|
+
* - Ollama binary detection
|
|
12
|
+
* - Python availability check
|
|
13
|
+
* - Completion summary with configured settings
|
|
14
|
+
*
|
|
15
|
+
* All prompts write to stderr via @clack/prompts — stdout stays clean
|
|
16
|
+
* for protocol use.
|
|
17
|
+
*
|
|
18
|
+
* @module setup-wizard
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as clack from '@clack/prompts';
|
|
22
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
23
|
+
import { existsSync } from 'node:fs';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { writeConfig, getConfigPaths } from './config.js';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check whether a binary exists on $PATH.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} name — binary name to look up
|
|
31
|
+
* @returns {string | null} — absolute path if found, null otherwise
|
|
32
|
+
*/
|
|
33
|
+
function whichSync(name) {
|
|
34
|
+
try {
|
|
35
|
+
return execFileSync('which', [name], {
|
|
36
|
+
encoding: 'utf-8',
|
|
37
|
+
timeout: 3000,
|
|
38
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
39
|
+
}).trim();
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Handle @clack/prompts cancellation. Call after every prompt.
|
|
47
|
+
* If the user pressed Ctrl+C, shows a cancel message and exits cleanly.
|
|
48
|
+
*
|
|
49
|
+
* @param {any} value — return value from a @clack/prompts call
|
|
50
|
+
*/
|
|
51
|
+
function handleCancel(value) {
|
|
52
|
+
if (clack.isCancel(value)) {
|
|
53
|
+
clack.cancel('Setup cancelled.');
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build the default config sections that every provider path includes.
|
|
60
|
+
* Matches the Python setup wizard's defaults exactly.
|
|
61
|
+
*/
|
|
62
|
+
function defaultOllamaSection(model = 'cipher') {
|
|
63
|
+
return {
|
|
64
|
+
base_url: 'http://127.0.0.1:11434',
|
|
65
|
+
model,
|
|
66
|
+
timeout: 300,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function defaultClaudeSection(apiKey = '', model = 'claude-sonnet-4-5-20250929') {
|
|
71
|
+
return {
|
|
72
|
+
api_key: apiKey,
|
|
73
|
+
model,
|
|
74
|
+
timeout: 120,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Run the interactive setup wizard.
|
|
80
|
+
*
|
|
81
|
+
* Walks the user through provider selection, backend-specific configuration,
|
|
82
|
+
* and writes a valid config.yaml. Uses @clack/prompts for all I/O (stderr).
|
|
83
|
+
*
|
|
84
|
+
* @returns {Promise<void>}
|
|
85
|
+
*/
|
|
86
|
+
export async function runSetupWizard() {
|
|
87
|
+
try {
|
|
88
|
+
// ── Intro ──────────────────────────────────────────────────────────
|
|
89
|
+
clack.intro('CIPHER Setup');
|
|
90
|
+
clack.log.info('Configure your LLM backend to get started with CIPHER.');
|
|
91
|
+
|
|
92
|
+
// ── Python no longer required ────────────────────────────────────
|
|
93
|
+
clack.log.success('CIPHER runs natively on Node.js — no Python required.');
|
|
94
|
+
|
|
95
|
+
// ── Provider selection ─────────────────────────────────────────────
|
|
96
|
+
const provider = await clack.select({
|
|
97
|
+
message: 'Choose your LLM backend',
|
|
98
|
+
options: [
|
|
99
|
+
{ value: 'claude-code', label: 'Claude Code', hint: 'Running inside Claude Code — no API key needed' },
|
|
100
|
+
{ value: 'ollama', label: 'Ollama', hint: 'Local inference, free, private (requires GPU)' },
|
|
101
|
+
{ value: 'claude', label: 'Claude API', hint: 'Standalone use with Anthropic API key' },
|
|
102
|
+
{ value: 'litellm', label: 'LiteLLM', hint: 'Multi-provider (OpenAI, Gemini, llama.cpp, etc.)' },
|
|
103
|
+
],
|
|
104
|
+
});
|
|
105
|
+
handleCancel(provider);
|
|
106
|
+
|
|
107
|
+
let config;
|
|
108
|
+
|
|
109
|
+
// ── Claude Code path ──────────────────────────────────────────────
|
|
110
|
+
if (provider === 'claude-code') {
|
|
111
|
+
// Check if claude CLI is installed and authed
|
|
112
|
+
let claudeAuthed = false;
|
|
113
|
+
try {
|
|
114
|
+
const authJson = execSync('claude auth status', { encoding: 'utf-8', timeout: 5000 });
|
|
115
|
+
const auth = JSON.parse(authJson);
|
|
116
|
+
if (auth.loggedIn) {
|
|
117
|
+
clack.log.success(`Authenticated as ${auth.email} (${auth.subscriptionType})`);
|
|
118
|
+
claudeAuthed = true;
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// claude CLI not found or not authed
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!claudeAuthed) {
|
|
125
|
+
clack.log.warn('Claude Code is not authenticated.');
|
|
126
|
+
clack.log.info('Run this in your terminal to log in:\n\n claude login\n');
|
|
127
|
+
|
|
128
|
+
const proceed = await clack.confirm({
|
|
129
|
+
message: 'Continue setup anyway? (you can log in later)',
|
|
130
|
+
initialValue: false,
|
|
131
|
+
});
|
|
132
|
+
handleCancel(proceed);
|
|
133
|
+
if (!proceed) {
|
|
134
|
+
clack.outro('Run `claude login` first, then `cipher setup` again.');
|
|
135
|
+
process.exit(0);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
clack.log.info(
|
|
140
|
+
'CIPHER activates automatically via CLAUDE.md inside Claude Code.\n' +
|
|
141
|
+
'No API key needed — Claude Code provides the LLM.'
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
// Offer Ollama as fallback for standalone use
|
|
145
|
+
const addOllama = await clack.confirm({
|
|
146
|
+
message: 'Also configure Ollama for standalone CLI use?',
|
|
147
|
+
initialValue: true,
|
|
148
|
+
});
|
|
149
|
+
handleCancel(addOllama);
|
|
150
|
+
|
|
151
|
+
if (addOllama) {
|
|
152
|
+
const ollamaPath = whichSync('ollama');
|
|
153
|
+
if (ollamaPath) {
|
|
154
|
+
clack.log.success(`Ollama found at ${ollamaPath}`);
|
|
155
|
+
} else {
|
|
156
|
+
clack.log.info('Ollama not found — install from https://ollama.com/download when ready.');
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
config = {
|
|
161
|
+
llm_backend: addOllama ? 'ollama' : 'claude-code',
|
|
162
|
+
ollama: defaultOllamaSection(),
|
|
163
|
+
claude: defaultClaudeSection(),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ── Ollama path ────────────────────────────────────────────────────
|
|
168
|
+
else if (provider === 'ollama') {
|
|
169
|
+
const ollamaPath = whichSync('ollama');
|
|
170
|
+
if (ollamaPath) {
|
|
171
|
+
clack.log.success(`Ollama found at ${ollamaPath}`);
|
|
172
|
+
} else {
|
|
173
|
+
clack.log.info(
|
|
174
|
+
'Ollama not found on PATH. Install it from https://ollama.com/download'
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const model = await clack.text({
|
|
179
|
+
message: 'Ollama model name',
|
|
180
|
+
placeholder: 'cipher',
|
|
181
|
+
defaultValue: 'cipher',
|
|
182
|
+
});
|
|
183
|
+
handleCancel(model);
|
|
184
|
+
|
|
185
|
+
config = {
|
|
186
|
+
llm_backend: 'ollama',
|
|
187
|
+
ollama: defaultOllamaSection(model),
|
|
188
|
+
claude: defaultClaudeSection(),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Claude path ────────────────────────────────────────────────────
|
|
193
|
+
else if (provider === 'claude') {
|
|
194
|
+
const apiKey = await clack.password({
|
|
195
|
+
message: 'Anthropic API key',
|
|
196
|
+
mask: '•',
|
|
197
|
+
});
|
|
198
|
+
handleCancel(apiKey);
|
|
199
|
+
|
|
200
|
+
const model = await clack.select({
|
|
201
|
+
message: 'Claude model',
|
|
202
|
+
options: [
|
|
203
|
+
{ value: 'claude-sonnet-4-5-20250929', label: 'Claude Sonnet 4.5', hint: 'Recommended' },
|
|
204
|
+
{ value: 'claude-opus-4-6', label: 'Claude Opus 4' },
|
|
205
|
+
{ value: 'claude-haiku-3-5', label: 'Claude Haiku 3.5' },
|
|
206
|
+
],
|
|
207
|
+
});
|
|
208
|
+
handleCancel(model);
|
|
209
|
+
|
|
210
|
+
config = {
|
|
211
|
+
llm_backend: 'claude',
|
|
212
|
+
ollama: defaultOllamaSection(),
|
|
213
|
+
claude: defaultClaudeSection(apiKey, model),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── LiteLLM path ──────────────────────────────────────────────────
|
|
218
|
+
else if (provider === 'litellm') {
|
|
219
|
+
const model = await clack.text({
|
|
220
|
+
message: 'LiteLLM model string',
|
|
221
|
+
placeholder: 'gpt-4o, gemini/gemini-2.0-flash, ollama/llama3.1',
|
|
222
|
+
});
|
|
223
|
+
handleCancel(model);
|
|
224
|
+
|
|
225
|
+
const apiKey = await clack.password({
|
|
226
|
+
message: 'API key (leave empty if not needed)',
|
|
227
|
+
mask: '•',
|
|
228
|
+
});
|
|
229
|
+
handleCancel(apiKey);
|
|
230
|
+
|
|
231
|
+
const apiBase = await clack.text({
|
|
232
|
+
message: 'Custom API base URL (leave empty for default)',
|
|
233
|
+
placeholder: '',
|
|
234
|
+
defaultValue: '',
|
|
235
|
+
});
|
|
236
|
+
handleCancel(apiBase);
|
|
237
|
+
|
|
238
|
+
config = {
|
|
239
|
+
llm_backend: 'litellm',
|
|
240
|
+
ollama: defaultOllamaSection(),
|
|
241
|
+
claude: defaultClaudeSection(),
|
|
242
|
+
litellm: {
|
|
243
|
+
model: model || '',
|
|
244
|
+
api_key: apiKey || '',
|
|
245
|
+
api_base: apiBase || '',
|
|
246
|
+
timeout: 120,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ── Write config ──────────────────────────────────────────────────
|
|
252
|
+
const configPath = writeConfig(config);
|
|
253
|
+
|
|
254
|
+
// Also write to project root if pyproject.toml exists (dual-write like Python)
|
|
255
|
+
const { projectConfig } = getConfigPaths();
|
|
256
|
+
const projectRoot = join(projectConfig, '..');
|
|
257
|
+
if (existsSync(join(projectRoot, 'pyproject.toml')) && projectConfig !== configPath) {
|
|
258
|
+
try {
|
|
259
|
+
writeConfig(config, projectConfig);
|
|
260
|
+
} catch {
|
|
261
|
+
// Non-fatal — user config was already written
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Completion summary ────────────────────────────────────────────
|
|
266
|
+
const summaryLines = [
|
|
267
|
+
`Provider: ${provider}`,
|
|
268
|
+
];
|
|
269
|
+
|
|
270
|
+
if (provider === 'ollama') {
|
|
271
|
+
summaryLines.push(`Model: ${config.ollama.model}`);
|
|
272
|
+
} else if (provider === 'claude') {
|
|
273
|
+
summaryLines.push(`Model: ${config.claude.model}`);
|
|
274
|
+
} else if (provider === 'litellm') {
|
|
275
|
+
summaryLines.push(`Model: ${config.litellm.model || '(not set)'}`);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
summaryLines.push(`Config path: ${configPath}`);
|
|
279
|
+
|
|
280
|
+
clack.note(summaryLines.join('\n'), 'Configuration Summary');
|
|
281
|
+
clack.outro('Setup complete! Run cipher --help to get started.');
|
|
282
|
+
} catch (err) {
|
|
283
|
+
// isCancel paths call process.exit(0) directly — this catches
|
|
284
|
+
// unexpected errors only.
|
|
285
|
+
clack.log.error(err.message || String(err));
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "cipher-security",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "CIPHER — AI Security Engineering Platform CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=18"
|
|
8
|
+
},
|
|
9
|
+
"license": "AGPL-3.0-only",
|
|
10
|
+
"bin": {
|
|
11
|
+
"cipher": "bin/cipher.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"bin/",
|
|
15
|
+
"lib/"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "vitest run"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"vitest": "^3"
|
|
22
|
+
},
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@anthropic-ai/sdk": "^0.80.0",
|
|
25
|
+
"@clack/prompts": "^1.1.0",
|
|
26
|
+
"better-sqlite3": "^12.8.0",
|
|
27
|
+
"openai": "^6.32.0",
|
|
28
|
+
"ws": "^8.19.0",
|
|
29
|
+
"yaml": "^2.8.2"
|
|
30
|
+
}
|
|
31
|
+
}
|