pinata-security-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/dist/cli/index.d.ts +1 -0
- package/dist/cli/index.js +6473 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/index.d.ts +1188 -0
- package/dist/index.js +1622 -0
- package/dist/index.js.map +1 -0
- package/package.json +91 -0
- package/wasm/tree-sitter-javascript.wasm +0 -0
- package/wasm/tree-sitter-python.wasm +0 -0
- package/wasm/tree-sitter-typescript.wasm +0 -0
- package/wasm/web-tree-sitter.wasm +0 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1622 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import fs from 'fs/promises';
|
|
3
|
+
import path, { dirname } from 'path';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
import chalk from 'chalk';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import 'web-tree-sitter';
|
|
8
|
+
import 'minimatch';
|
|
9
|
+
|
|
10
|
+
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
|
11
|
+
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
|
12
|
+
}) : x)(function(x) {
|
|
13
|
+
if (typeof require !== "undefined") return require.apply(this, arguments);
|
|
14
|
+
throw Error('Dynamic require of "' + x + '" is not supported');
|
|
15
|
+
});
|
|
16
|
+
var RiskDomainSchema = z.enum([
|
|
17
|
+
"security",
|
|
18
|
+
"data",
|
|
19
|
+
"concurrency",
|
|
20
|
+
"input",
|
|
21
|
+
"resource",
|
|
22
|
+
"reliability",
|
|
23
|
+
"performance",
|
|
24
|
+
"platform",
|
|
25
|
+
"business",
|
|
26
|
+
"compliance"
|
|
27
|
+
]);
|
|
28
|
+
var TestLevelSchema = z.enum([
|
|
29
|
+
"unit",
|
|
30
|
+
"integration",
|
|
31
|
+
"system",
|
|
32
|
+
"chaos"
|
|
33
|
+
]);
|
|
34
|
+
var PrioritySchema = z.enum(["P0", "P1", "P2"]);
|
|
35
|
+
var SeveritySchema = z.enum(["critical", "high", "medium", "low"]);
|
|
36
|
+
var ConfidenceSchema = z.enum(["high", "medium", "low"]);
|
|
37
|
+
var LanguageSchema = z.enum([
|
|
38
|
+
"python",
|
|
39
|
+
"typescript",
|
|
40
|
+
"javascript",
|
|
41
|
+
"go",
|
|
42
|
+
"java",
|
|
43
|
+
"rust"
|
|
44
|
+
]);
|
|
45
|
+
var ID_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
46
|
+
var CategoryBaseSchema = z.object({
|
|
47
|
+
id: z.string().regex(ID_PATTERN, "ID must start with lowercase letter and contain only lowercase letters, numbers, and hyphens"),
|
|
48
|
+
version: z.number().int().positive(),
|
|
49
|
+
name: z.string().min(1, "Name is required").max(100, "Name too long"),
|
|
50
|
+
description: z.string().min(10, "Description must be at least 10 characters").max(2e3, "Description too long"),
|
|
51
|
+
domain: RiskDomainSchema,
|
|
52
|
+
level: TestLevelSchema,
|
|
53
|
+
priority: PrioritySchema,
|
|
54
|
+
severity: SeveritySchema,
|
|
55
|
+
applicableLanguages: z.array(LanguageSchema).min(1, "At least one language required"),
|
|
56
|
+
cves: z.array(z.string()).optional(),
|
|
57
|
+
references: z.array(z.string().url("Invalid URL")).optional(),
|
|
58
|
+
createdAt: z.coerce.date(),
|
|
59
|
+
updatedAt: z.coerce.date()
|
|
60
|
+
});
|
|
61
|
+
var RISK_DOMAINS = RiskDomainSchema.options;
|
|
62
|
+
var TEST_LEVELS = TestLevelSchema.options;
|
|
63
|
+
var LANGUAGES = LanguageSchema.options;
|
|
64
|
+
var ID_PATTERN2 = /^[a-z][a-z0-9-]*$/;
|
|
65
|
+
var ExampleSchema = z.object({
|
|
66
|
+
/** Unique identifier for this example */
|
|
67
|
+
name: z.string().regex(ID_PATTERN2, "Name must start with lowercase letter and contain only lowercase letters, numbers, and hyphens"),
|
|
68
|
+
/** Explanation of the vulnerability/edge case concept */
|
|
69
|
+
concept: z.string().min(20, "Concept must be at least 20 characters"),
|
|
70
|
+
/** Example of vulnerable or problematic code */
|
|
71
|
+
vulnerableCode: z.string().min(10, "Vulnerable code must be at least 10 characters"),
|
|
72
|
+
/** Example test code that catches this vulnerability */
|
|
73
|
+
testCode: z.string().min(50, "Test code must be at least 50 characters"),
|
|
74
|
+
/** Programming language of the example */
|
|
75
|
+
language: LanguageSchema,
|
|
76
|
+
/** Severity if this vulnerability is exploited */
|
|
77
|
+
severity: SeveritySchema,
|
|
78
|
+
/** Optional related CVE identifier */
|
|
79
|
+
cve: z.string().optional(),
|
|
80
|
+
/** Optional link to more information */
|
|
81
|
+
reference: z.string().url().optional()
|
|
82
|
+
});
|
|
83
|
+
var PatternTypeSchema = z.enum(["ast", "regex", "semantic"]);
|
|
84
|
+
var ID_PATTERN3 = /^[a-z][a-z0-9-]*$/;
|
|
85
|
+
var DetectionPatternSchema = z.object({
|
|
86
|
+
/** Unique identifier for this pattern */
|
|
87
|
+
id: z.string().regex(ID_PATTERN3, "ID must start with lowercase letter and contain only lowercase letters, numbers, and hyphens"),
|
|
88
|
+
/** Type of pattern matching to use */
|
|
89
|
+
type: PatternTypeSchema,
|
|
90
|
+
/** Target programming language */
|
|
91
|
+
language: LanguageSchema,
|
|
92
|
+
/** The pattern string (AST query, regex, or semantic description) */
|
|
93
|
+
pattern: z.string().min(1, "Pattern is required"),
|
|
94
|
+
/** How confident we are when this pattern matches */
|
|
95
|
+
confidence: ConfidenceSchema,
|
|
96
|
+
/** Human-readable description of what this pattern detects */
|
|
97
|
+
description: z.string().min(10, "Description must be at least 10 characters"),
|
|
98
|
+
/** Optional pattern that indicates code is NOT vulnerable (false positive filter) */
|
|
99
|
+
negativePattern: z.string().optional(),
|
|
100
|
+
/** Optional list of framework contexts where this pattern applies */
|
|
101
|
+
frameworks: z.array(z.string()).optional()
|
|
102
|
+
});
|
|
103
|
+
z.object({
|
|
104
|
+
/** ID of the pattern that matched */
|
|
105
|
+
patternId: z.string(),
|
|
106
|
+
/** Category this detection belongs to */
|
|
107
|
+
categoryId: z.string(),
|
|
108
|
+
/** File path where detection occurred */
|
|
109
|
+
filePath: z.string(),
|
|
110
|
+
/** Starting line number (1-indexed) */
|
|
111
|
+
lineStart: z.number().int().positive(),
|
|
112
|
+
/** Ending line number (1-indexed) */
|
|
113
|
+
lineEnd: z.number().int().positive(),
|
|
114
|
+
/** Code snippet that matched */
|
|
115
|
+
codeSnippet: z.string(),
|
|
116
|
+
/** Confidence of this specific match */
|
|
117
|
+
confidence: ConfidenceSchema,
|
|
118
|
+
/** Optional additional context */
|
|
119
|
+
context: z.record(z.unknown()).optional()
|
|
120
|
+
});
|
|
121
|
+
var PATTERN_TYPES = PatternTypeSchema.options;
|
|
122
|
+
var TestFrameworkSchema = z.enum([
|
|
123
|
+
"pytest",
|
|
124
|
+
"unittest",
|
|
125
|
+
"jest",
|
|
126
|
+
"vitest",
|
|
127
|
+
"mocha",
|
|
128
|
+
"go-test",
|
|
129
|
+
"junit"
|
|
130
|
+
]);
|
|
131
|
+
var VariableTypeSchema = z.enum([
|
|
132
|
+
"string",
|
|
133
|
+
"number",
|
|
134
|
+
"boolean",
|
|
135
|
+
"array",
|
|
136
|
+
"object"
|
|
137
|
+
]);
|
|
138
|
+
var VARIABLE_NAME_PATTERN = /^[a-z][a-zA-Z0-9_]*$/;
|
|
139
|
+
var TemplateVariableSchema = z.object({
|
|
140
|
+
/** Variable name (used in template as {{name}}) */
|
|
141
|
+
name: z.string().regex(VARIABLE_NAME_PATTERN, "Variable name must be camelCase"),
|
|
142
|
+
/** Type of the variable value */
|
|
143
|
+
type: VariableTypeSchema,
|
|
144
|
+
/** Human-readable description */
|
|
145
|
+
description: z.string().min(1, "Description is required"),
|
|
146
|
+
/** Whether this variable must be provided */
|
|
147
|
+
required: z.boolean().default(true),
|
|
148
|
+
/** Default value if not provided */
|
|
149
|
+
defaultValue: z.unknown().optional()
|
|
150
|
+
});
|
|
151
|
+
var ID_PATTERN4 = /^[a-z][a-z0-9-]*$/;
|
|
152
|
+
var TestTemplateSchema = z.object({
|
|
153
|
+
/** Unique identifier for this template */
|
|
154
|
+
id: z.string().regex(ID_PATTERN4, "ID must start with lowercase letter and contain only lowercase letters, numbers, and hyphens"),
|
|
155
|
+
/** Target programming language */
|
|
156
|
+
language: LanguageSchema,
|
|
157
|
+
/** Target test framework */
|
|
158
|
+
framework: TestFrameworkSchema,
|
|
159
|
+
/** Template content with {{variable}} placeholders */
|
|
160
|
+
template: z.string().min(50, "Template must be at least 50 characters"),
|
|
161
|
+
/** Variables that can be substituted in the template */
|
|
162
|
+
variables: z.array(TemplateVariableSchema),
|
|
163
|
+
/** Required imports for the generated test */
|
|
164
|
+
imports: z.array(z.string()).optional(),
|
|
165
|
+
/** Required fixtures or setup code */
|
|
166
|
+
fixtures: z.array(z.string()).optional(),
|
|
167
|
+
/** Description of what this template tests */
|
|
168
|
+
description: z.string().optional()
|
|
169
|
+
});
|
|
170
|
+
var TEST_FRAMEWORKS = TestFrameworkSchema.options;
|
|
171
|
+
|
|
172
|
+
// src/categories/schema/index.ts
|
|
173
|
+
var CategorySchema = CategoryBaseSchema.extend({
|
|
174
|
+
detectionPatterns: z.array(DetectionPatternSchema).min(1, "At least one detection pattern required"),
|
|
175
|
+
testTemplates: z.array(TestTemplateSchema).min(1, "At least one test template required"),
|
|
176
|
+
examples: z.array(ExampleSchema).min(1, "At least one example required")
|
|
177
|
+
});
|
|
178
|
+
var CategorySummarySchema = CategoryBaseSchema.pick({
|
|
179
|
+
id: true,
|
|
180
|
+
name: true,
|
|
181
|
+
domain: true,
|
|
182
|
+
level: true,
|
|
183
|
+
priority: true,
|
|
184
|
+
severity: true,
|
|
185
|
+
description: true
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// src/lib/errors.ts
|
|
189
|
+
var PinataError = class extends Error {
|
|
190
|
+
constructor(message, code, context) {
|
|
191
|
+
super(message);
|
|
192
|
+
this.code = code;
|
|
193
|
+
this.context = context;
|
|
194
|
+
this.name = "PinataError";
|
|
195
|
+
Error.captureStackTrace?.(this, this.constructor);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Serialize error for logging or API responses
|
|
199
|
+
*/
|
|
200
|
+
toJSON() {
|
|
201
|
+
return {
|
|
202
|
+
name: this.name,
|
|
203
|
+
code: this.code,
|
|
204
|
+
message: this.message,
|
|
205
|
+
context: this.context
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
var ValidationError = class extends PinataError {
|
|
210
|
+
constructor(message, context) {
|
|
211
|
+
super(message, "VALIDATION_ERROR", context);
|
|
212
|
+
this.name = "ValidationError";
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
var ParseError = class extends PinataError {
|
|
216
|
+
constructor(message, filePath, line, context) {
|
|
217
|
+
super(message, "PARSE_ERROR", { ...context, filePath, line });
|
|
218
|
+
this.filePath = filePath;
|
|
219
|
+
this.line = line;
|
|
220
|
+
this.name = "ParseError";
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
var ConfigError = class extends PinataError {
|
|
224
|
+
constructor(message, context) {
|
|
225
|
+
super(message, "CONFIG_ERROR", context);
|
|
226
|
+
this.name = "ConfigError";
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
var AnalysisError = class extends PinataError {
|
|
230
|
+
constructor(message, context) {
|
|
231
|
+
super(message, "ANALYSIS_ERROR", context);
|
|
232
|
+
this.name = "AnalysisError";
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
var GenerationError = class extends PinataError {
|
|
236
|
+
constructor(message, context) {
|
|
237
|
+
super(message, "GENERATION_ERROR", context);
|
|
238
|
+
this.name = "GenerationError";
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
var CategoryNotFoundError = class extends PinataError {
|
|
242
|
+
constructor(categoryId) {
|
|
243
|
+
super(`Category not found: ${categoryId}`, "CATEGORY_NOT_FOUND", { categoryId });
|
|
244
|
+
this.name = "CategoryNotFoundError";
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
var PatternNotFoundError = class extends PinataError {
|
|
248
|
+
constructor(patternId) {
|
|
249
|
+
super(`Pattern not found: ${patternId}`, "PATTERN_NOT_FOUND", { patternId });
|
|
250
|
+
this.name = "PatternNotFoundError";
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
// src/lib/result.ts
|
|
255
|
+
function ok(data) {
|
|
256
|
+
return { success: true, data };
|
|
257
|
+
}
|
|
258
|
+
function err(error) {
|
|
259
|
+
return { success: false, error };
|
|
260
|
+
}
|
|
261
|
+
function unwrap(result) {
|
|
262
|
+
if (result.success) {
|
|
263
|
+
return result.data;
|
|
264
|
+
}
|
|
265
|
+
throw result.error;
|
|
266
|
+
}
|
|
267
|
+
function unwrapOr(result, defaultValue) {
|
|
268
|
+
if (result.success) {
|
|
269
|
+
return result.data;
|
|
270
|
+
}
|
|
271
|
+
return defaultValue;
|
|
272
|
+
}
|
|
273
|
+
function map(result, fn) {
|
|
274
|
+
if (result.success) {
|
|
275
|
+
return ok(fn(result.data));
|
|
276
|
+
}
|
|
277
|
+
return result;
|
|
278
|
+
}
|
|
279
|
+
function mapErr(result, fn) {
|
|
280
|
+
if (!result.success) {
|
|
281
|
+
return err(fn(result.error));
|
|
282
|
+
}
|
|
283
|
+
return result;
|
|
284
|
+
}
|
|
285
|
+
function andThen(result, fn) {
|
|
286
|
+
if (result.success) {
|
|
287
|
+
return fn(result.data);
|
|
288
|
+
}
|
|
289
|
+
return result;
|
|
290
|
+
}
|
|
291
|
+
function all(results) {
|
|
292
|
+
const values = [];
|
|
293
|
+
for (const result of results) {
|
|
294
|
+
if (!result.success) {
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
values.push(result.data);
|
|
298
|
+
}
|
|
299
|
+
return ok(values);
|
|
300
|
+
}
|
|
301
|
+
function tryCatch(fn) {
|
|
302
|
+
try {
|
|
303
|
+
return ok(fn());
|
|
304
|
+
} catch (e) {
|
|
305
|
+
return err(e instanceof Error ? e : new Error(String(e)));
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async function tryCatchAsync(fn) {
|
|
309
|
+
try {
|
|
310
|
+
return ok(await fn());
|
|
311
|
+
} catch (e) {
|
|
312
|
+
return err(e instanceof Error ? e : new Error(String(e)));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/categories/store/category-store.ts
|
|
317
|
+
var CategoryStore = class {
|
|
318
|
+
/** All loaded categories by ID */
|
|
319
|
+
categories = /* @__PURE__ */ new Map();
|
|
320
|
+
/** Index by domain */
|
|
321
|
+
domainIndex = /* @__PURE__ */ new Map();
|
|
322
|
+
/** Index by level */
|
|
323
|
+
levelIndex = /* @__PURE__ */ new Map();
|
|
324
|
+
/** Index by language */
|
|
325
|
+
languageIndex = /* @__PURE__ */ new Map();
|
|
326
|
+
/** Index by priority */
|
|
327
|
+
priorityIndex = /* @__PURE__ */ new Map();
|
|
328
|
+
/** Search index: word -> category IDs */
|
|
329
|
+
searchIndex = /* @__PURE__ */ new Map();
|
|
330
|
+
/** Version tracking for loaded categories */
|
|
331
|
+
versions = /* @__PURE__ */ new Map();
|
|
332
|
+
/**
|
|
333
|
+
* Get total number of loaded categories
|
|
334
|
+
*/
|
|
335
|
+
get size() {
|
|
336
|
+
return this.categories.size;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Load a single category into the store
|
|
340
|
+
*/
|
|
341
|
+
add(category) {
|
|
342
|
+
const validation = CategorySchema.safeParse(category);
|
|
343
|
+
if (!validation.success) {
|
|
344
|
+
return err(
|
|
345
|
+
new ValidationError("Invalid category", {
|
|
346
|
+
categoryId: category.id,
|
|
347
|
+
issues: validation.error.issues
|
|
348
|
+
})
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
const validated = validation.data;
|
|
352
|
+
const existing = this.categories.get(validated.id);
|
|
353
|
+
if (existing !== void 0) {
|
|
354
|
+
const existingVersion = this.versions.get(validated.id) ?? 0;
|
|
355
|
+
if (validated.version <= existingVersion) {
|
|
356
|
+
return err(
|
|
357
|
+
new ValidationError(`Category ${validated.id} already exists with same or higher version`, {
|
|
358
|
+
categoryId: validated.id,
|
|
359
|
+
existingVersion,
|
|
360
|
+
newVersion: validated.version
|
|
361
|
+
})
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
this.removeFromIndexes(existing);
|
|
365
|
+
}
|
|
366
|
+
this.categories.set(validated.id, validated);
|
|
367
|
+
this.versions.set(validated.id, validated.version);
|
|
368
|
+
this.addToIndexes(validated);
|
|
369
|
+
return ok(validated);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Get a category by ID
|
|
373
|
+
*/
|
|
374
|
+
get(id) {
|
|
375
|
+
const category = this.categories.get(id);
|
|
376
|
+
if (category === void 0) {
|
|
377
|
+
return err(new CategoryNotFoundError(id));
|
|
378
|
+
}
|
|
379
|
+
return ok(category);
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Check if a category exists
|
|
383
|
+
*/
|
|
384
|
+
has(id) {
|
|
385
|
+
return this.categories.has(id);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Remove a category by ID
|
|
389
|
+
*/
|
|
390
|
+
remove(id) {
|
|
391
|
+
const category = this.categories.get(id);
|
|
392
|
+
if (category === void 0) {
|
|
393
|
+
return err(new CategoryNotFoundError(id));
|
|
394
|
+
}
|
|
395
|
+
this.removeFromIndexes(category);
|
|
396
|
+
this.categories.delete(id);
|
|
397
|
+
this.versions.delete(id);
|
|
398
|
+
return ok(category);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* List all categories, optionally filtered
|
|
402
|
+
*/
|
|
403
|
+
list(filter) {
|
|
404
|
+
let ids;
|
|
405
|
+
if (filter?.domain !== void 0) {
|
|
406
|
+
const domainIds = this.domainIndex.get(filter.domain);
|
|
407
|
+
if (domainIds === void 0) return [];
|
|
408
|
+
ids = this.intersect(ids, domainIds);
|
|
409
|
+
}
|
|
410
|
+
if (filter?.level !== void 0) {
|
|
411
|
+
const levelIds = this.levelIndex.get(filter.level);
|
|
412
|
+
if (levelIds === void 0) return [];
|
|
413
|
+
ids = this.intersect(ids, levelIds);
|
|
414
|
+
}
|
|
415
|
+
if (filter?.language !== void 0) {
|
|
416
|
+
const langIds = this.languageIndex.get(filter.language);
|
|
417
|
+
if (langIds === void 0) return [];
|
|
418
|
+
ids = this.intersect(ids, langIds);
|
|
419
|
+
}
|
|
420
|
+
if (filter?.priority !== void 0) {
|
|
421
|
+
const priorityIds = this.priorityIndex.get(filter.priority);
|
|
422
|
+
if (priorityIds === void 0) return [];
|
|
423
|
+
ids = this.intersect(ids, priorityIds);
|
|
424
|
+
}
|
|
425
|
+
const categories = [];
|
|
426
|
+
const targetIds = ids ?? this.categories.keys();
|
|
427
|
+
for (const id of targetIds) {
|
|
428
|
+
const category = this.categories.get(id);
|
|
429
|
+
if (category !== void 0) {
|
|
430
|
+
if (filter?.severity !== void 0 && category.severity !== filter.severity) {
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
categories.push(this.toSummary(category));
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return categories.sort((a, b) => {
|
|
437
|
+
const priorityOrder = { P0: 0, P1: 1, P2: 2 };
|
|
438
|
+
const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
439
|
+
const priorityDiff = priorityOrder[a.priority] - priorityOrder[b.priority];
|
|
440
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
441
|
+
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity];
|
|
442
|
+
if (severityDiff !== 0) return severityDiff;
|
|
443
|
+
return a.name.localeCompare(b.name);
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Get all categories in a specific domain
|
|
448
|
+
*/
|
|
449
|
+
byDomain(domain) {
|
|
450
|
+
return this.list({ domain });
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Get all categories at a specific test level
|
|
454
|
+
*/
|
|
455
|
+
byLevel(level) {
|
|
456
|
+
return this.list({ level });
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Get all categories applicable to a language
|
|
460
|
+
*/
|
|
461
|
+
byLanguage(language) {
|
|
462
|
+
return this.list({ language });
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Full-text search across categories
|
|
466
|
+
*/
|
|
467
|
+
search(options) {
|
|
468
|
+
const { query, filter, limit = 20 } = options;
|
|
469
|
+
const queryTokens = this.tokenize(query.toLowerCase());
|
|
470
|
+
if (queryTokens.length === 0) {
|
|
471
|
+
return [];
|
|
472
|
+
}
|
|
473
|
+
const scores = /* @__PURE__ */ new Map();
|
|
474
|
+
for (const token of queryTokens) {
|
|
475
|
+
const exactMatches = this.searchIndex.get(token);
|
|
476
|
+
if (exactMatches !== void 0) {
|
|
477
|
+
for (const id of exactMatches) {
|
|
478
|
+
const current = scores.get(id) ?? { score: 0, matches: [] };
|
|
479
|
+
current.score += 10;
|
|
480
|
+
current.matches.push(token);
|
|
481
|
+
scores.set(id, current);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
for (const [indexToken, ids] of this.searchIndex) {
|
|
485
|
+
if (indexToken.startsWith(token) && indexToken !== token) {
|
|
486
|
+
for (const id of ids) {
|
|
487
|
+
const current = scores.get(id) ?? { score: 0, matches: [] };
|
|
488
|
+
current.score += 5;
|
|
489
|
+
if (!current.matches.includes(token)) {
|
|
490
|
+
current.matches.push(token);
|
|
491
|
+
}
|
|
492
|
+
scores.set(id, current);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
const results = [];
|
|
498
|
+
for (const [id, { score, matches }] of scores) {
|
|
499
|
+
const category = this.categories.get(id);
|
|
500
|
+
if (category === void 0) continue;
|
|
501
|
+
if (filter !== void 0) {
|
|
502
|
+
if (filter.domain !== void 0 && category.domain !== filter.domain) continue;
|
|
503
|
+
if (filter.level !== void 0 && category.level !== filter.level) continue;
|
|
504
|
+
if (filter.priority !== void 0 && category.priority !== filter.priority) continue;
|
|
505
|
+
if (filter.severity !== void 0 && category.severity !== filter.severity) continue;
|
|
506
|
+
if (filter.language !== void 0 && !category.applicableLanguages.includes(filter.language)) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
results.push({
|
|
511
|
+
category: this.toSummary(category),
|
|
512
|
+
score,
|
|
513
|
+
matches: [...new Set(matches)]
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
results.sort((a, b) => {
|
|
517
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
518
|
+
const priorityOrder = { P0: 0, P1: 1, P2: 2 };
|
|
519
|
+
return priorityOrder[a.category.priority] - priorityOrder[b.category.priority];
|
|
520
|
+
});
|
|
521
|
+
return results.slice(0, limit);
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Load categories from a directory of YAML files
|
|
525
|
+
*/
|
|
526
|
+
async loadFromDirectory(dirPath) {
|
|
527
|
+
const results = await this.loadYamlFilesRecursive(dirPath);
|
|
528
|
+
const combined = all(results);
|
|
529
|
+
if (!combined.success) {
|
|
530
|
+
return combined;
|
|
531
|
+
}
|
|
532
|
+
return ok(combined.data.length);
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Load a single category from a YAML file
|
|
536
|
+
*/
|
|
537
|
+
async loadFromFile(filePath) {
|
|
538
|
+
const result = await tryCatchAsync(async () => {
|
|
539
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
540
|
+
return YAML.parse(content);
|
|
541
|
+
});
|
|
542
|
+
if (!result.success) {
|
|
543
|
+
return err(
|
|
544
|
+
new ValidationError(`Failed to read category file: ${filePath}`, {
|
|
545
|
+
filePath,
|
|
546
|
+
cause: result.error.message
|
|
547
|
+
})
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
const validation = CategorySchema.safeParse(result.data);
|
|
551
|
+
if (!validation.success) {
|
|
552
|
+
return err(
|
|
553
|
+
new ValidationError(`Invalid category in ${filePath}`, {
|
|
554
|
+
filePath,
|
|
555
|
+
issues: validation.error.issues
|
|
556
|
+
})
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
return this.add(validation.data);
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Export all categories as an array
|
|
563
|
+
*/
|
|
564
|
+
toArray() {
|
|
565
|
+
return Array.from(this.categories.values());
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Clear all categories and indexes
|
|
569
|
+
*/
|
|
570
|
+
clear() {
|
|
571
|
+
this.categories.clear();
|
|
572
|
+
this.domainIndex.clear();
|
|
573
|
+
this.levelIndex.clear();
|
|
574
|
+
this.languageIndex.clear();
|
|
575
|
+
this.priorityIndex.clear();
|
|
576
|
+
this.searchIndex.clear();
|
|
577
|
+
this.versions.clear();
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Get statistics about loaded categories
|
|
581
|
+
*/
|
|
582
|
+
stats() {
|
|
583
|
+
const byDomain = {};
|
|
584
|
+
const byLevel = {};
|
|
585
|
+
const byPriority = {};
|
|
586
|
+
for (const [domain, ids] of this.domainIndex) {
|
|
587
|
+
byDomain[domain] = ids.size;
|
|
588
|
+
}
|
|
589
|
+
for (const [level, ids] of this.levelIndex) {
|
|
590
|
+
byLevel[level] = ids.size;
|
|
591
|
+
}
|
|
592
|
+
for (const [priority, ids] of this.priorityIndex) {
|
|
593
|
+
byPriority[priority] = ids.size;
|
|
594
|
+
}
|
|
595
|
+
return {
|
|
596
|
+
total: this.categories.size,
|
|
597
|
+
byDomain,
|
|
598
|
+
byLevel,
|
|
599
|
+
byPriority
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
603
|
+
// Private methods
|
|
604
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
605
|
+
/**
|
|
606
|
+
* Add category to all indexes
|
|
607
|
+
*/
|
|
608
|
+
addToIndexes(category) {
|
|
609
|
+
const id = category.id;
|
|
610
|
+
this.addToIndex(this.domainIndex, category.domain, id);
|
|
611
|
+
this.addToIndex(this.levelIndex, category.level, id);
|
|
612
|
+
for (const lang of category.applicableLanguages) {
|
|
613
|
+
this.addToIndex(this.languageIndex, lang, id);
|
|
614
|
+
}
|
|
615
|
+
this.addToIndex(this.priorityIndex, category.priority, id);
|
|
616
|
+
this.indexForSearch(category);
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Remove category from all indexes
|
|
620
|
+
*/
|
|
621
|
+
removeFromIndexes(category) {
|
|
622
|
+
const id = category.id;
|
|
623
|
+
this.removeFromIndex(this.domainIndex, category.domain, id);
|
|
624
|
+
this.removeFromIndex(this.levelIndex, category.level, id);
|
|
625
|
+
for (const lang of category.applicableLanguages) {
|
|
626
|
+
this.removeFromIndex(this.languageIndex, lang, id);
|
|
627
|
+
}
|
|
628
|
+
this.removeFromIndex(this.priorityIndex, category.priority, id);
|
|
629
|
+
this.removeFromSearchIndex(id);
|
|
630
|
+
}
|
|
631
|
+
/**
|
|
632
|
+
* Add ID to an index map
|
|
633
|
+
*/
|
|
634
|
+
addToIndex(index, key, id) {
|
|
635
|
+
let set = index.get(key);
|
|
636
|
+
if (set === void 0) {
|
|
637
|
+
set = /* @__PURE__ */ new Set();
|
|
638
|
+
index.set(key, set);
|
|
639
|
+
}
|
|
640
|
+
set.add(id);
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Remove ID from an index map
|
|
644
|
+
*/
|
|
645
|
+
removeFromIndex(index, key, id) {
|
|
646
|
+
const set = index.get(key);
|
|
647
|
+
if (set !== void 0) {
|
|
648
|
+
set.delete(id);
|
|
649
|
+
if (set.size === 0) {
|
|
650
|
+
index.delete(key);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Index category text for search
|
|
656
|
+
*/
|
|
657
|
+
indexForSearch(category) {
|
|
658
|
+
const id = category.id;
|
|
659
|
+
const textToIndex = [
|
|
660
|
+
category.id,
|
|
661
|
+
category.name,
|
|
662
|
+
category.description,
|
|
663
|
+
category.domain,
|
|
664
|
+
category.level,
|
|
665
|
+
...category.applicableLanguages,
|
|
666
|
+
...category.cves ?? []
|
|
667
|
+
].join(" ");
|
|
668
|
+
const tokens = this.tokenize(textToIndex.toLowerCase());
|
|
669
|
+
for (const token of tokens) {
|
|
670
|
+
this.addToIndex(this.searchIndex, token, id);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
/**
|
|
674
|
+
* Remove category from search index
|
|
675
|
+
*/
|
|
676
|
+
removeFromSearchIndex(id) {
|
|
677
|
+
for (const [token, ids] of this.searchIndex) {
|
|
678
|
+
ids.delete(id);
|
|
679
|
+
if (ids.size === 0) {
|
|
680
|
+
this.searchIndex.delete(token);
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Tokenize text for search indexing
|
|
686
|
+
*/
|
|
687
|
+
tokenize(text) {
|
|
688
|
+
return text.split(/[\s\-_.,;:!?'"()\[\]{}]+/).filter((token) => token.length >= 2).map((token) => token.toLowerCase());
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Intersect two sets, handling undefined
|
|
692
|
+
* Returns empty set if either input is empty set (filter found no matches)
|
|
693
|
+
*/
|
|
694
|
+
intersect(a, b) {
|
|
695
|
+
if (a === void 0) return b;
|
|
696
|
+
if (b === void 0) return a;
|
|
697
|
+
if (b.size === 0) return /* @__PURE__ */ new Set();
|
|
698
|
+
return new Set([...a].filter((x) => b.has(x)));
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Convert full category to summary
|
|
702
|
+
*/
|
|
703
|
+
toSummary(category) {
|
|
704
|
+
return CategorySummarySchema.parse({
|
|
705
|
+
id: category.id,
|
|
706
|
+
name: category.name,
|
|
707
|
+
domain: category.domain,
|
|
708
|
+
level: category.level,
|
|
709
|
+
priority: category.priority,
|
|
710
|
+
severity: category.severity,
|
|
711
|
+
description: category.description
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* Recursively load YAML files from directory
|
|
716
|
+
*/
|
|
717
|
+
async loadYamlFilesRecursive(dirPath) {
|
|
718
|
+
const results = [];
|
|
719
|
+
const loadResult = await tryCatchAsync(async () => {
|
|
720
|
+
const entries = await fs.readdir(dirPath, { withFileTypes: true });
|
|
721
|
+
return entries;
|
|
722
|
+
});
|
|
723
|
+
if (!loadResult.success) {
|
|
724
|
+
return [
|
|
725
|
+
err(
|
|
726
|
+
new ValidationError(`Failed to read directory: ${dirPath}`, {
|
|
727
|
+
dirPath,
|
|
728
|
+
cause: loadResult.error.message
|
|
729
|
+
})
|
|
730
|
+
)
|
|
731
|
+
];
|
|
732
|
+
}
|
|
733
|
+
for (const entry of loadResult.data) {
|
|
734
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
735
|
+
if (entry.isDirectory()) {
|
|
736
|
+
const subResults = await this.loadYamlFilesRecursive(fullPath);
|
|
737
|
+
results.push(...subResults);
|
|
738
|
+
} else if (entry.isFile() && (entry.name.endsWith(".yml") || entry.name.endsWith(".yaml"))) {
|
|
739
|
+
const result = await this.loadFromFile(fullPath);
|
|
740
|
+
results.push(result);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return results;
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
function createCategoryStore() {
|
|
747
|
+
return new CategoryStore();
|
|
748
|
+
}
|
|
749
|
+
var LOG_LEVELS = {
|
|
750
|
+
debug: 0,
|
|
751
|
+
info: 1,
|
|
752
|
+
warn: 2,
|
|
753
|
+
error: 3,
|
|
754
|
+
silent: 4
|
|
755
|
+
};
|
|
756
|
+
var Logger = class _Logger {
|
|
757
|
+
level = "info";
|
|
758
|
+
prefix = "";
|
|
759
|
+
/**
|
|
760
|
+
* Configure the logger
|
|
761
|
+
*/
|
|
762
|
+
configure(config) {
|
|
763
|
+
if (config.level !== void 0) {
|
|
764
|
+
this.level = config.level;
|
|
765
|
+
}
|
|
766
|
+
if (config.prefix !== void 0) {
|
|
767
|
+
this.prefix = config.prefix;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Check if a log level should be output
|
|
772
|
+
*/
|
|
773
|
+
shouldLog(level) {
|
|
774
|
+
return LOG_LEVELS[level] >= LOG_LEVELS[this.level];
|
|
775
|
+
}
|
|
776
|
+
/**
|
|
777
|
+
* Format a message with optional prefix
|
|
778
|
+
*/
|
|
779
|
+
format(message) {
|
|
780
|
+
return this.prefix ? `${this.prefix} ${message}` : message;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Debug level logging (gray)
|
|
784
|
+
*/
|
|
785
|
+
debug(message, ...args) {
|
|
786
|
+
if (this.shouldLog("debug")) {
|
|
787
|
+
console.debug(chalk.gray(this.format(message)), ...args);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
/**
|
|
791
|
+
* Info level logging (default color)
|
|
792
|
+
*/
|
|
793
|
+
info(message, ...args) {
|
|
794
|
+
if (this.shouldLog("info")) {
|
|
795
|
+
console.info(this.format(message), ...args);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
/**
|
|
799
|
+
* Warning level logging (yellow)
|
|
800
|
+
*/
|
|
801
|
+
warn(message, ...args) {
|
|
802
|
+
if (this.shouldLog("warn")) {
|
|
803
|
+
console.warn(chalk.yellow(this.format(message)), ...args);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Error level logging (red)
|
|
808
|
+
*/
|
|
809
|
+
error(message, ...args) {
|
|
810
|
+
if (this.shouldLog("error")) {
|
|
811
|
+
console.error(chalk.red(this.format(message)), ...args);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
/**
|
|
815
|
+
* Success message (green)
|
|
816
|
+
*/
|
|
817
|
+
success(message, ...args) {
|
|
818
|
+
if (this.shouldLog("info")) {
|
|
819
|
+
console.info(chalk.green(this.format(message)), ...args);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Create a child logger with a prefix
|
|
824
|
+
*/
|
|
825
|
+
child(prefix) {
|
|
826
|
+
const child = new _Logger();
|
|
827
|
+
child.level = this.level;
|
|
828
|
+
child.prefix = this.prefix ? `${this.prefix} ${prefix}` : prefix;
|
|
829
|
+
return child;
|
|
830
|
+
}
|
|
831
|
+
};
|
|
832
|
+
var logger = new Logger();
|
|
833
|
+
var __filename$1 = fileURLToPath(import.meta.url);
|
|
834
|
+
dirname(__filename$1);
|
|
835
|
+
|
|
836
|
+
// src/core/index.ts
|
|
837
|
+
var VERSION = "0.1.0";
|
|
838
|
+
|
|
839
|
+
// src/ai/service.ts
|
|
840
|
+
var DEFAULT_CONFIG = {
|
|
841
|
+
provider: "anthropic",
|
|
842
|
+
apiKey: "",
|
|
843
|
+
model: "claude-sonnet-4-20250514",
|
|
844
|
+
maxTokens: 1024,
|
|
845
|
+
temperature: 0.3,
|
|
846
|
+
timeoutMs: 3e4
|
|
847
|
+
};
|
|
848
|
+
var PROVIDER_MODELS = {
|
|
849
|
+
anthropic: "claude-sonnet-4-20250514",
|
|
850
|
+
openai: "gpt-4o",
|
|
851
|
+
mock: "mock-model"
|
|
852
|
+
};
|
|
853
|
+
var PROVIDER_ENDPOINTS = {
|
|
854
|
+
anthropic: "https://api.anthropic.com/v1/messages",
|
|
855
|
+
openai: "https://api.openai.com/v1/chat/completions"};
|
|
856
|
+
var AIService = class {
|
|
857
|
+
config;
|
|
858
|
+
constructor(config = {}) {
|
|
859
|
+
this.config = {
|
|
860
|
+
...DEFAULT_CONFIG,
|
|
861
|
+
...config,
|
|
862
|
+
apiKey: config.apiKey ?? this.getApiKeyFromEnv(config.provider ?? "anthropic"),
|
|
863
|
+
model: config.model ?? PROVIDER_MODELS[config.provider ?? "anthropic"]
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Get API key from environment variable
|
|
868
|
+
* For config file support, use the sync version below
|
|
869
|
+
*/
|
|
870
|
+
getApiKeyFromEnv(provider) {
|
|
871
|
+
if (provider === "mock") return "mock-key";
|
|
872
|
+
const envVar = provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY";
|
|
873
|
+
const envValue = process.env[envVar];
|
|
874
|
+
if (envValue !== void 0 && envValue.length > 0) {
|
|
875
|
+
return envValue;
|
|
876
|
+
}
|
|
877
|
+
return this.getApiKeyFromConfig(provider);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Read API key from config file synchronously
|
|
881
|
+
* Uses require() for sync file access in constructor context
|
|
882
|
+
*/
|
|
883
|
+
getApiKeyFromConfig(provider) {
|
|
884
|
+
try {
|
|
885
|
+
const { existsSync, readFileSync } = __require("fs");
|
|
886
|
+
const { homedir } = __require("os");
|
|
887
|
+
const { join: join2 } = __require("path");
|
|
888
|
+
const configPath = join2(homedir(), ".pinata", "config.json");
|
|
889
|
+
if (!existsSync(configPath)) {
|
|
890
|
+
return "";
|
|
891
|
+
}
|
|
892
|
+
const content = readFileSync(configPath, "utf-8");
|
|
893
|
+
const config = JSON.parse(content);
|
|
894
|
+
return (provider === "anthropic" ? config.anthropicApiKey : config.openaiApiKey) ?? "";
|
|
895
|
+
} catch {
|
|
896
|
+
return "";
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
/**
|
|
900
|
+
* Check if the service is configured with an API key
|
|
901
|
+
*/
|
|
902
|
+
isConfigured() {
|
|
903
|
+
return this.config.provider === "mock" || this.config.apiKey.length > 0;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Get the current provider
|
|
907
|
+
*/
|
|
908
|
+
getProvider() {
|
|
909
|
+
return this.config.provider;
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* Generate a completion
|
|
913
|
+
*/
|
|
914
|
+
async complete(request) {
|
|
915
|
+
const startTime = Date.now();
|
|
916
|
+
if (!this.isConfigured()) {
|
|
917
|
+
return {
|
|
918
|
+
success: false,
|
|
919
|
+
error: `API key not configured for ${this.config.provider}. Set ${this.config.provider === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"} environment variable.`,
|
|
920
|
+
durationMs: Date.now() - startTime
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
if (this.config.provider === "mock") {
|
|
924
|
+
return this.mockComplete(request, startTime);
|
|
925
|
+
}
|
|
926
|
+
try {
|
|
927
|
+
const response = await this.callProvider(request);
|
|
928
|
+
return {
|
|
929
|
+
success: true,
|
|
930
|
+
data: response.content,
|
|
931
|
+
usage: response.usage,
|
|
932
|
+
durationMs: Date.now() - startTime
|
|
933
|
+
};
|
|
934
|
+
} catch (error) {
|
|
935
|
+
return {
|
|
936
|
+
success: false,
|
|
937
|
+
error: error instanceof Error ? error.message : "Unknown error",
|
|
938
|
+
durationMs: Date.now() - startTime
|
|
939
|
+
};
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Generate a JSON completion (parses response as JSON)
|
|
944
|
+
*/
|
|
945
|
+
async completeJSON(request) {
|
|
946
|
+
const response = await this.complete({
|
|
947
|
+
...request,
|
|
948
|
+
messages: [
|
|
949
|
+
...request.messages,
|
|
950
|
+
{
|
|
951
|
+
role: "user",
|
|
952
|
+
content: "\n\nRespond with valid JSON only. No markdown, no explanation."
|
|
953
|
+
}
|
|
954
|
+
]
|
|
955
|
+
});
|
|
956
|
+
if (!response.success || response.data === void 0) {
|
|
957
|
+
return {
|
|
958
|
+
success: false,
|
|
959
|
+
error: response.error ?? "No response data",
|
|
960
|
+
durationMs: response.durationMs
|
|
961
|
+
};
|
|
962
|
+
}
|
|
963
|
+
try {
|
|
964
|
+
let jsonStr = response.data;
|
|
965
|
+
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
966
|
+
if (jsonMatch) {
|
|
967
|
+
jsonStr = jsonMatch[1] ?? jsonStr;
|
|
968
|
+
}
|
|
969
|
+
const objectMatch = jsonStr.match(/\{[\s\S]*\}/);
|
|
970
|
+
const arrayMatch = jsonStr.match(/\[[\s\S]*\]/);
|
|
971
|
+
jsonStr = objectMatch?.[0] ?? arrayMatch?.[0] ?? jsonStr;
|
|
972
|
+
const parsed = JSON.parse(jsonStr.trim());
|
|
973
|
+
const result = {
|
|
974
|
+
success: true,
|
|
975
|
+
data: parsed,
|
|
976
|
+
durationMs: response.durationMs
|
|
977
|
+
};
|
|
978
|
+
if (response.usage) {
|
|
979
|
+
result.usage = response.usage;
|
|
980
|
+
}
|
|
981
|
+
return result;
|
|
982
|
+
} catch (error) {
|
|
983
|
+
return {
|
|
984
|
+
success: false,
|
|
985
|
+
error: `Failed to parse JSON response: ${error instanceof Error ? error.message : "Unknown error"}`,
|
|
986
|
+
durationMs: response.durationMs
|
|
987
|
+
};
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Call the AI provider API
|
|
992
|
+
*/
|
|
993
|
+
async callProvider(request) {
|
|
994
|
+
const controller = new AbortController();
|
|
995
|
+
const timeout = setTimeout(() => controller.abort(), this.config.timeoutMs);
|
|
996
|
+
try {
|
|
997
|
+
if (this.config.provider === "anthropic") {
|
|
998
|
+
return await this.callAnthropic(request, controller.signal);
|
|
999
|
+
} else {
|
|
1000
|
+
return await this.callOpenAI(request, controller.signal);
|
|
1001
|
+
}
|
|
1002
|
+
} finally {
|
|
1003
|
+
clearTimeout(timeout);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Call Anthropic API
|
|
1008
|
+
*/
|
|
1009
|
+
async callAnthropic(request, signal) {
|
|
1010
|
+
const messages = request.messages.filter((m) => m.role !== "system");
|
|
1011
|
+
const response = await fetch(PROVIDER_ENDPOINTS.anthropic, {
|
|
1012
|
+
method: "POST",
|
|
1013
|
+
headers: {
|
|
1014
|
+
"Content-Type": "application/json",
|
|
1015
|
+
"x-api-key": this.config.apiKey,
|
|
1016
|
+
"anthropic-version": "2023-06-01"
|
|
1017
|
+
},
|
|
1018
|
+
body: JSON.stringify({
|
|
1019
|
+
model: this.config.model,
|
|
1020
|
+
max_tokens: request.maxTokens ?? this.config.maxTokens,
|
|
1021
|
+
temperature: request.temperature ?? this.config.temperature,
|
|
1022
|
+
system: request.systemPrompt,
|
|
1023
|
+
messages: messages.map((m) => ({
|
|
1024
|
+
role: m.role,
|
|
1025
|
+
content: m.content
|
|
1026
|
+
}))
|
|
1027
|
+
}),
|
|
1028
|
+
signal
|
|
1029
|
+
});
|
|
1030
|
+
if (!response.ok) {
|
|
1031
|
+
const error = await response.text();
|
|
1032
|
+
throw new Error(`Anthropic API error: ${response.status} - ${error}`);
|
|
1033
|
+
}
|
|
1034
|
+
const data = await response.json();
|
|
1035
|
+
return {
|
|
1036
|
+
content: data.content[0]?.text ?? "",
|
|
1037
|
+
usage: {
|
|
1038
|
+
inputTokens: data.usage.input_tokens,
|
|
1039
|
+
outputTokens: data.usage.output_tokens
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Call OpenAI API
|
|
1045
|
+
*/
|
|
1046
|
+
async callOpenAI(request, signal) {
|
|
1047
|
+
const messages = [];
|
|
1048
|
+
if (request.systemPrompt !== void 0 && request.systemPrompt.length > 0) {
|
|
1049
|
+
messages.push({ role: "system", content: request.systemPrompt });
|
|
1050
|
+
}
|
|
1051
|
+
for (const m of request.messages) {
|
|
1052
|
+
messages.push({ role: m.role, content: m.content });
|
|
1053
|
+
}
|
|
1054
|
+
const response = await fetch(PROVIDER_ENDPOINTS.openai, {
|
|
1055
|
+
method: "POST",
|
|
1056
|
+
headers: {
|
|
1057
|
+
"Content-Type": "application/json",
|
|
1058
|
+
Authorization: `Bearer ${this.config.apiKey}`
|
|
1059
|
+
},
|
|
1060
|
+
body: JSON.stringify({
|
|
1061
|
+
model: this.config.model,
|
|
1062
|
+
max_tokens: request.maxTokens ?? this.config.maxTokens,
|
|
1063
|
+
temperature: request.temperature ?? this.config.temperature,
|
|
1064
|
+
messages
|
|
1065
|
+
}),
|
|
1066
|
+
signal
|
|
1067
|
+
});
|
|
1068
|
+
if (!response.ok) {
|
|
1069
|
+
const error = await response.text();
|
|
1070
|
+
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
|
|
1071
|
+
}
|
|
1072
|
+
const data = await response.json();
|
|
1073
|
+
return {
|
|
1074
|
+
content: data.choices[0]?.message.content ?? "",
|
|
1075
|
+
usage: {
|
|
1076
|
+
inputTokens: data.usage.prompt_tokens,
|
|
1077
|
+
outputTokens: data.usage.completion_tokens
|
|
1078
|
+
}
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
/**
|
|
1082
|
+
* Mock completion for testing
|
|
1083
|
+
*/
|
|
1084
|
+
mockComplete(request, startTime) {
|
|
1085
|
+
const lastMessage = request.messages[request.messages.length - 1];
|
|
1086
|
+
const content = lastMessage?.content ?? "";
|
|
1087
|
+
let response = "Mock AI response";
|
|
1088
|
+
if (content.includes("explain") || content.includes("explanation")) {
|
|
1089
|
+
response = JSON.stringify({
|
|
1090
|
+
summary: "This code pattern may introduce a security vulnerability.",
|
|
1091
|
+
explanation: "The detected pattern suggests potential security risk.",
|
|
1092
|
+
risk: "An attacker could exploit this vulnerability to compromise the system.",
|
|
1093
|
+
remediation: "Use parameterized queries or proper input validation.",
|
|
1094
|
+
safeExample: "cursor.execute('SELECT * FROM users WHERE id = ?', (user_id,))"
|
|
1095
|
+
});
|
|
1096
|
+
} else if (content.includes("variable") || content.includes("template")) {
|
|
1097
|
+
response = JSON.stringify({
|
|
1098
|
+
suggestions: [
|
|
1099
|
+
{
|
|
1100
|
+
name: "className",
|
|
1101
|
+
value: "UserService",
|
|
1102
|
+
reasoning: "Based on the file name and context",
|
|
1103
|
+
confidence: 0.8
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
name: "functionName",
|
|
1107
|
+
value: "get_user",
|
|
1108
|
+
reasoning: "Extracted from the code snippet",
|
|
1109
|
+
confidence: 0.9
|
|
1110
|
+
}
|
|
1111
|
+
]
|
|
1112
|
+
});
|
|
1113
|
+
} else if (content.includes("pattern") || content.includes("regex")) {
|
|
1114
|
+
response = JSON.stringify({
|
|
1115
|
+
suggestions: [
|
|
1116
|
+
{
|
|
1117
|
+
id: "custom-sql-pattern",
|
|
1118
|
+
pattern: "execute\\s*\\(.*\\+",
|
|
1119
|
+
description: "Detects SQL execution with string concatenation",
|
|
1120
|
+
confidence: "medium",
|
|
1121
|
+
matchExample: "cursor.execute(query + user_input)",
|
|
1122
|
+
safeExample: "cursor.execute(query, (user_input,))",
|
|
1123
|
+
reasoning: "String concatenation in SQL queries is a common injection vector"
|
|
1124
|
+
}
|
|
1125
|
+
]
|
|
1126
|
+
});
|
|
1127
|
+
}
|
|
1128
|
+
return {
|
|
1129
|
+
success: true,
|
|
1130
|
+
data: response,
|
|
1131
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
1132
|
+
durationMs: Date.now() - startTime
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
function createAIService(config) {
|
|
1137
|
+
return new AIService(config);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// src/ai/explainer.ts
|
|
1141
|
+
var SYSTEM_PROMPT = `You are a security expert explaining code vulnerabilities to developers.
|
|
1142
|
+
Your explanations should be:
|
|
1143
|
+
- Clear and actionable
|
|
1144
|
+
- Focused on the specific code pattern
|
|
1145
|
+
- Include concrete remediation steps
|
|
1146
|
+
- Reference relevant security standards (OWASP, CWE) when applicable
|
|
1147
|
+
|
|
1148
|
+
Always respond with valid JSON matching this structure:
|
|
1149
|
+
{
|
|
1150
|
+
"summary": "1-2 sentence summary",
|
|
1151
|
+
"explanation": "Detailed explanation of the vulnerability",
|
|
1152
|
+
"risk": "What an attacker could do if this is exploited",
|
|
1153
|
+
"remediation": "Step-by-step instructions to fix",
|
|
1154
|
+
"safeExample": "Code example showing the safe pattern",
|
|
1155
|
+
"references": ["optional array of CVE/CWE/OWASP references"]
|
|
1156
|
+
}`;
|
|
1157
|
+
async function explainGap(gap, category, config) {
|
|
1158
|
+
const ai = createAIService(config);
|
|
1159
|
+
if (!ai.isConfigured()) {
|
|
1160
|
+
return {
|
|
1161
|
+
success: false,
|
|
1162
|
+
error: "AI service not configured",
|
|
1163
|
+
durationMs: 0
|
|
1164
|
+
};
|
|
1165
|
+
}
|
|
1166
|
+
const prompt = buildExplainPrompt(gap, category);
|
|
1167
|
+
const response = await ai.completeJSON({
|
|
1168
|
+
systemPrompt: SYSTEM_PROMPT,
|
|
1169
|
+
messages: [{ role: "user", content: prompt }],
|
|
1170
|
+
maxTokens: 1024,
|
|
1171
|
+
temperature: 0.3
|
|
1172
|
+
});
|
|
1173
|
+
return response;
|
|
1174
|
+
}
|
|
1175
|
+
async function explainGaps(gaps, categories, config) {
|
|
1176
|
+
const results = /* @__PURE__ */ new Map();
|
|
1177
|
+
const BATCH_SIZE = 5;
|
|
1178
|
+
for (let i = 0; i < gaps.length; i += BATCH_SIZE) {
|
|
1179
|
+
const batch = gaps.slice(i, i + BATCH_SIZE);
|
|
1180
|
+
const promises = batch.map(async (gap) => {
|
|
1181
|
+
const category = categories?.get(gap.categoryId);
|
|
1182
|
+
const result = await explainGap(gap, category, config);
|
|
1183
|
+
return { key: `${gap.filePath}:${gap.lineStart}:${gap.categoryId}`, result };
|
|
1184
|
+
});
|
|
1185
|
+
const batchResults = await Promise.all(promises);
|
|
1186
|
+
for (const { key, result } of batchResults) {
|
|
1187
|
+
results.set(key, result);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return results;
|
|
1191
|
+
}
|
|
1192
|
+
function buildExplainPrompt(gap, category) {
|
|
1193
|
+
const parts = [];
|
|
1194
|
+
parts.push(`Explain this security finding:
|
|
1195
|
+
`);
|
|
1196
|
+
parts.push(`**Category:** ${gap.categoryName} (${gap.categoryId})`);
|
|
1197
|
+
parts.push(`**Severity:** ${gap.severity}`);
|
|
1198
|
+
parts.push(`**Confidence:** ${gap.confidence}`);
|
|
1199
|
+
parts.push(`**File:** ${gap.filePath}`);
|
|
1200
|
+
parts.push(`**Line:** ${gap.lineStart}`);
|
|
1201
|
+
if (gap.codeSnippet) {
|
|
1202
|
+
parts.push(`
|
|
1203
|
+
**Code:**
|
|
1204
|
+
\`\`\`
|
|
1205
|
+
${gap.codeSnippet}
|
|
1206
|
+
\`\`\``);
|
|
1207
|
+
}
|
|
1208
|
+
parts.push(`
|
|
1209
|
+
**Pattern:** ${gap.patternId}`);
|
|
1210
|
+
parts.push(`**Detection Type:** ${gap.patternType}`);
|
|
1211
|
+
if (category) {
|
|
1212
|
+
parts.push(`
|
|
1213
|
+
**Category Description:** ${category.description}`);
|
|
1214
|
+
if (category.cves && category.cves.length > 0) {
|
|
1215
|
+
parts.push(`**Related CVEs:** ${category.cves.join(", ")}`);
|
|
1216
|
+
}
|
|
1217
|
+
if (category.references && category.references.length > 0) {
|
|
1218
|
+
parts.push(`**References:** ${category.references.slice(0, 3).join(", ")}`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
parts.push(`
|
|
1222
|
+
Provide a clear, actionable explanation for a developer.`);
|
|
1223
|
+
return parts.join("\n");
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
// src/ai/template-filler.ts
|
|
1227
|
+
var SYSTEM_PROMPT2 = `You are an expert at analyzing code and extracting meaningful variable values for test generation.
|
|
1228
|
+
Given a code snippet and a list of template variables, suggest appropriate values for each variable.
|
|
1229
|
+
|
|
1230
|
+
For each variable, analyze:
|
|
1231
|
+
1. The code snippet to extract relevant information (class names, function names, etc.)
|
|
1232
|
+
2. The variable description to understand what's needed
|
|
1233
|
+
3. The variable type to ensure correct formatting
|
|
1234
|
+
|
|
1235
|
+
Always respond with valid JSON matching this structure:
|
|
1236
|
+
{
|
|
1237
|
+
"suggestions": [
|
|
1238
|
+
{
|
|
1239
|
+
"name": "variableName",
|
|
1240
|
+
"value": "suggested value",
|
|
1241
|
+
"reasoning": "why this value was chosen",
|
|
1242
|
+
"confidence": 0.0-1.0
|
|
1243
|
+
}
|
|
1244
|
+
]
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
For arrays, use: "value": ["item1", "item2"]
|
|
1248
|
+
For booleans, use: "value": true or "value": false
|
|
1249
|
+
For numbers, use: "value": 42`;
|
|
1250
|
+
async function suggestVariables(request, config) {
|
|
1251
|
+
const ai = createAIService(config);
|
|
1252
|
+
if (!ai.isConfigured()) {
|
|
1253
|
+
return {
|
|
1254
|
+
success: true,
|
|
1255
|
+
data: extractVariablesFromCode(request),
|
|
1256
|
+
durationMs: 0
|
|
1257
|
+
};
|
|
1258
|
+
}
|
|
1259
|
+
const prompt = buildVariablePrompt(request);
|
|
1260
|
+
const startTime = Date.now();
|
|
1261
|
+
const response = await ai.completeJSON({
|
|
1262
|
+
systemPrompt: SYSTEM_PROMPT2,
|
|
1263
|
+
messages: [{ role: "user", content: prompt }],
|
|
1264
|
+
maxTokens: 1024,
|
|
1265
|
+
temperature: 0.2
|
|
1266
|
+
});
|
|
1267
|
+
if (!response.success || !response.data) {
|
|
1268
|
+
return {
|
|
1269
|
+
success: true,
|
|
1270
|
+
data: extractVariablesFromCode(request),
|
|
1271
|
+
durationMs: Date.now() - startTime
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
const suggestions = /* @__PURE__ */ new Map();
|
|
1275
|
+
const unfilled = [];
|
|
1276
|
+
const values = { ...request.existingValues };
|
|
1277
|
+
const suggestionsList = response.data.suggestions ?? [];
|
|
1278
|
+
for (const suggestion of suggestionsList) {
|
|
1279
|
+
suggestions.set(suggestion.name, suggestion);
|
|
1280
|
+
if (!(suggestion.name in values)) {
|
|
1281
|
+
values[suggestion.name] = suggestion.value;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
for (const variable of request.variables) {
|
|
1285
|
+
if (!suggestions.has(variable.name) && !(variable.name in values)) {
|
|
1286
|
+
if (variable.defaultValue !== void 0) {
|
|
1287
|
+
values[variable.name] = variable.defaultValue;
|
|
1288
|
+
} else {
|
|
1289
|
+
unfilled.push(variable.name);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
const result = {
|
|
1294
|
+
success: true,
|
|
1295
|
+
data: { suggestions, unfilled, values },
|
|
1296
|
+
durationMs: response.durationMs
|
|
1297
|
+
};
|
|
1298
|
+
if (response.usage) {
|
|
1299
|
+
result.usage = response.usage;
|
|
1300
|
+
}
|
|
1301
|
+
return result;
|
|
1302
|
+
}
|
|
1303
|
+
function buildVariablePrompt(request) {
|
|
1304
|
+
const parts = [];
|
|
1305
|
+
parts.push("Analyze this code and suggest values for the template variables:\n");
|
|
1306
|
+
parts.push("**Code:**");
|
|
1307
|
+
parts.push("```");
|
|
1308
|
+
parts.push(request.codeSnippet);
|
|
1309
|
+
parts.push("```\n");
|
|
1310
|
+
parts.push(`**File:** ${request.filePath}
|
|
1311
|
+
`);
|
|
1312
|
+
if (request.gap) {
|
|
1313
|
+
parts.push(`**Category:** ${request.gap.categoryName}`);
|
|
1314
|
+
parts.push(`**Line:** ${request.gap.lineStart}
|
|
1315
|
+
`);
|
|
1316
|
+
}
|
|
1317
|
+
parts.push("**Variables to fill:**");
|
|
1318
|
+
for (const variable of request.variables) {
|
|
1319
|
+
const required = variable.required ? " (required)" : " (optional)";
|
|
1320
|
+
const defaultVal = variable.defaultValue !== void 0 ? ` [default: ${JSON.stringify(variable.defaultValue)}]` : "";
|
|
1321
|
+
parts.push(`- ${variable.name} (${variable.type})${required}${defaultVal}: ${variable.description}`);
|
|
1322
|
+
}
|
|
1323
|
+
if (request.existingValues && Object.keys(request.existingValues).length > 0) {
|
|
1324
|
+
parts.push("\n**Already provided:**");
|
|
1325
|
+
for (const [name, value] of Object.entries(request.existingValues)) {
|
|
1326
|
+
parts.push(`- ${name}: ${JSON.stringify(value)}`);
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
parts.push("\nExtract appropriate values from the code context.");
|
|
1330
|
+
return parts.join("\n");
|
|
1331
|
+
}
|
|
1332
|
+
function extractVariablesFromCode(request) {
|
|
1333
|
+
const suggestions = /* @__PURE__ */ new Map();
|
|
1334
|
+
const unfilled = [];
|
|
1335
|
+
const values = { ...request.existingValues };
|
|
1336
|
+
const code = request.codeSnippet;
|
|
1337
|
+
const filePath = request.filePath;
|
|
1338
|
+
for (const variable of request.variables) {
|
|
1339
|
+
if (variable.name in values) continue;
|
|
1340
|
+
let value = void 0;
|
|
1341
|
+
let reasoning = "";
|
|
1342
|
+
let confidence = 0;
|
|
1343
|
+
switch (variable.name.toLowerCase()) {
|
|
1344
|
+
case "classname":
|
|
1345
|
+
case "class_name": {
|
|
1346
|
+
const classMatch = code.match(/class\s+(\w+)/);
|
|
1347
|
+
if (classMatch) {
|
|
1348
|
+
value = classMatch[1];
|
|
1349
|
+
reasoning = "Extracted from class definition in code";
|
|
1350
|
+
confidence = 0.9;
|
|
1351
|
+
} else {
|
|
1352
|
+
const fileName = filePath.split("/").pop()?.replace(/\.\w+$/, "") ?? "";
|
|
1353
|
+
value = toPascalCase(fileName);
|
|
1354
|
+
reasoning = "Inferred from file name";
|
|
1355
|
+
confidence = 0.6;
|
|
1356
|
+
}
|
|
1357
|
+
break;
|
|
1358
|
+
}
|
|
1359
|
+
case "functionname":
|
|
1360
|
+
case "function_name":
|
|
1361
|
+
case "methodname": {
|
|
1362
|
+
const funcMatch = code.match(/(?:def|function|async function)\s+(\w+)/);
|
|
1363
|
+
if (funcMatch) {
|
|
1364
|
+
value = funcMatch[1];
|
|
1365
|
+
reasoning = "Extracted from function definition";
|
|
1366
|
+
confidence = 0.9;
|
|
1367
|
+
}
|
|
1368
|
+
break;
|
|
1369
|
+
}
|
|
1370
|
+
case "modulepath":
|
|
1371
|
+
case "module_path": {
|
|
1372
|
+
value = filePath.replace(/\.[jt]sx?$/, "").replace(/\.py$/, "").replace(/\//g, ".").replace(/^\.+/, "");
|
|
1373
|
+
reasoning = "Derived from file path";
|
|
1374
|
+
confidence = 0.7;
|
|
1375
|
+
break;
|
|
1376
|
+
}
|
|
1377
|
+
case "tablename":
|
|
1378
|
+
case "table_name": {
|
|
1379
|
+
const tableMatch = code.match(/(?:FROM|INTO|UPDATE)\s+(\w+)/i);
|
|
1380
|
+
if (tableMatch) {
|
|
1381
|
+
value = tableMatch[1];
|
|
1382
|
+
reasoning = "Extracted from SQL statement";
|
|
1383
|
+
confidence = 0.8;
|
|
1384
|
+
} else {
|
|
1385
|
+
value = "users";
|
|
1386
|
+
reasoning = "Default table name";
|
|
1387
|
+
confidence = 0.3;
|
|
1388
|
+
}
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
case "exceptionclass":
|
|
1392
|
+
case "exception_class": {
|
|
1393
|
+
value = "ValueError";
|
|
1394
|
+
reasoning = "Common exception for input validation";
|
|
1395
|
+
confidence = 0.5;
|
|
1396
|
+
break;
|
|
1397
|
+
}
|
|
1398
|
+
case "dbclient":
|
|
1399
|
+
case "db_client": {
|
|
1400
|
+
const clientMatch = code.match(/(db|conn|connection|client|cursor)\s*[=.]/i);
|
|
1401
|
+
if (clientMatch) {
|
|
1402
|
+
value = clientMatch[1];
|
|
1403
|
+
reasoning = "Extracted from code";
|
|
1404
|
+
confidence = 0.7;
|
|
1405
|
+
} else {
|
|
1406
|
+
value = "db";
|
|
1407
|
+
reasoning = "Default database client name";
|
|
1408
|
+
confidence = 0.4;
|
|
1409
|
+
}
|
|
1410
|
+
break;
|
|
1411
|
+
}
|
|
1412
|
+
case "functioncall":
|
|
1413
|
+
case "function_call": {
|
|
1414
|
+
const funcName = values["functionName"] ?? values["function_name"];
|
|
1415
|
+
if (typeof funcName === "string" && funcName.length > 0) {
|
|
1416
|
+
value = `${funcName}(user_input)`;
|
|
1417
|
+
reasoning = "Constructed from function name";
|
|
1418
|
+
confidence = 0.6;
|
|
1419
|
+
}
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
case "fixtures": {
|
|
1423
|
+
value = "db_session";
|
|
1424
|
+
reasoning = "Common pytest fixture";
|
|
1425
|
+
confidence = 0.5;
|
|
1426
|
+
break;
|
|
1427
|
+
}
|
|
1428
|
+
default:
|
|
1429
|
+
if (variable.defaultValue !== void 0) {
|
|
1430
|
+
value = variable.defaultValue;
|
|
1431
|
+
reasoning = "Using default value";
|
|
1432
|
+
confidence = 1;
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (value !== void 0) {
|
|
1436
|
+
suggestions.set(variable.name, {
|
|
1437
|
+
name: variable.name,
|
|
1438
|
+
value,
|
|
1439
|
+
reasoning,
|
|
1440
|
+
confidence
|
|
1441
|
+
});
|
|
1442
|
+
values[variable.name] = value;
|
|
1443
|
+
} else if (variable.required) {
|
|
1444
|
+
unfilled.push(variable.name);
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return { suggestions, unfilled, values };
|
|
1448
|
+
}
|
|
1449
|
+
function toPascalCase(str) {
|
|
1450
|
+
return str.split(/[-_\s]+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()).join("");
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
// src/ai/pattern-suggester.ts
|
|
1454
|
+
var SYSTEM_PROMPT3 = `You are an expert at creating regex patterns for detecting security vulnerabilities in code.
|
|
1455
|
+
Given vulnerable code samples, generate regex patterns that will detect similar vulnerabilities.
|
|
1456
|
+
|
|
1457
|
+
Your patterns should:
|
|
1458
|
+
1. Be specific enough to avoid false positives
|
|
1459
|
+
2. Be general enough to catch variations
|
|
1460
|
+
3. Use standard regex syntax (no lookbehind for compatibility)
|
|
1461
|
+
4. Include examples of what matches and what doesn't
|
|
1462
|
+
|
|
1463
|
+
Always respond with valid JSON matching this structure:
|
|
1464
|
+
{
|
|
1465
|
+
"suggestions": [
|
|
1466
|
+
{
|
|
1467
|
+
"id": "pattern-id-kebab-case",
|
|
1468
|
+
"pattern": "regex pattern here",
|
|
1469
|
+
"description": "What this pattern detects",
|
|
1470
|
+
"confidence": "high|medium|low",
|
|
1471
|
+
"matchExample": "code that should match",
|
|
1472
|
+
"safeExample": "similar code that should NOT match",
|
|
1473
|
+
"reasoning": "Why this pattern works"
|
|
1474
|
+
}
|
|
1475
|
+
]
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
Important:
|
|
1479
|
+
- Escape backslashes properly for JSON (use \\\\s not \\s)
|
|
1480
|
+
- Test your patterns mentally against the examples
|
|
1481
|
+
- Prefer simpler patterns that are less likely to cause ReDoS`;
|
|
1482
|
+
async function suggestPatterns(request, config) {
|
|
1483
|
+
const ai = createAIService(config);
|
|
1484
|
+
if (!ai.isConfigured()) {
|
|
1485
|
+
return {
|
|
1486
|
+
success: false,
|
|
1487
|
+
error: "AI service not configured. Set ANTHROPIC_API_KEY or OPENAI_API_KEY.",
|
|
1488
|
+
durationMs: 0
|
|
1489
|
+
};
|
|
1490
|
+
}
|
|
1491
|
+
const prompt = buildPatternPrompt(request);
|
|
1492
|
+
const response = await ai.completeJSON({
|
|
1493
|
+
systemPrompt: SYSTEM_PROMPT3,
|
|
1494
|
+
messages: [{ role: "user", content: prompt }],
|
|
1495
|
+
maxTokens: 2048,
|
|
1496
|
+
temperature: 0.3
|
|
1497
|
+
});
|
|
1498
|
+
if (!response.success || !response.data) {
|
|
1499
|
+
return {
|
|
1500
|
+
success: false,
|
|
1501
|
+
error: response.error ?? "Failed to generate patterns",
|
|
1502
|
+
durationMs: response.durationMs
|
|
1503
|
+
};
|
|
1504
|
+
}
|
|
1505
|
+
const validated = validatePatterns(
|
|
1506
|
+
response.data.suggestions ?? [],
|
|
1507
|
+
request.vulnerableCode,
|
|
1508
|
+
request.safeCode ?? []
|
|
1509
|
+
);
|
|
1510
|
+
const result = {
|
|
1511
|
+
success: true,
|
|
1512
|
+
data: validated,
|
|
1513
|
+
durationMs: response.durationMs
|
|
1514
|
+
};
|
|
1515
|
+
if (response.usage) {
|
|
1516
|
+
result.usage = response.usage;
|
|
1517
|
+
}
|
|
1518
|
+
return result;
|
|
1519
|
+
}
|
|
1520
|
+
function buildPatternPrompt(request) {
|
|
1521
|
+
const parts = [];
|
|
1522
|
+
parts.push(`Generate regex patterns to detect ${request.category} vulnerabilities in ${request.language} code.
|
|
1523
|
+
`);
|
|
1524
|
+
parts.push("**Vulnerable code samples (patterns SHOULD match these):**");
|
|
1525
|
+
for (let i = 0; i < request.vulnerableCode.length; i++) {
|
|
1526
|
+
parts.push(`
|
|
1527
|
+
Example ${i + 1}:`);
|
|
1528
|
+
parts.push("```");
|
|
1529
|
+
parts.push(request.vulnerableCode[i] ?? "");
|
|
1530
|
+
parts.push("```");
|
|
1531
|
+
}
|
|
1532
|
+
if (request.safeCode && request.safeCode.length > 0) {
|
|
1533
|
+
parts.push("\n**Safe code samples (patterns should NOT match these):**");
|
|
1534
|
+
for (let i = 0; i < request.safeCode.length; i++) {
|
|
1535
|
+
parts.push(`
|
|
1536
|
+
Safe ${i + 1}:`);
|
|
1537
|
+
parts.push("```");
|
|
1538
|
+
parts.push(request.safeCode[i] ?? "");
|
|
1539
|
+
parts.push("```");
|
|
1540
|
+
}
|
|
1541
|
+
}
|
|
1542
|
+
if (request.existingPatterns && request.existingPatterns.length > 0) {
|
|
1543
|
+
parts.push("\n**Existing patterns (avoid duplicating):**");
|
|
1544
|
+
for (const pattern of request.existingPatterns) {
|
|
1545
|
+
parts.push(`- ${pattern}`);
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
parts.push(`
|
|
1549
|
+
Generate up to ${request.maxSuggestions ?? 3} distinct patterns.`);
|
|
1550
|
+
parts.push("Focus on patterns that will have high precision (low false positives).");
|
|
1551
|
+
return parts.join("\n");
|
|
1552
|
+
}
|
|
1553
|
+
function validatePatterns(suggestions, vulnerableCode, safeCode) {
|
|
1554
|
+
const validated = [];
|
|
1555
|
+
const rejected = [];
|
|
1556
|
+
for (const suggestion of suggestions) {
|
|
1557
|
+
try {
|
|
1558
|
+
const regex = new RegExp(suggestion.pattern, "gm");
|
|
1559
|
+
let matchCount = 0;
|
|
1560
|
+
for (const code of vulnerableCode) {
|
|
1561
|
+
regex.lastIndex = 0;
|
|
1562
|
+
if (regex.test(code)) {
|
|
1563
|
+
matchCount++;
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
let falsePositives = 0;
|
|
1567
|
+
for (const code of safeCode) {
|
|
1568
|
+
regex.lastIndex = 0;
|
|
1569
|
+
if (regex.test(code)) {
|
|
1570
|
+
falsePositives++;
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
if (hasRedosPotential(suggestion.pattern)) {
|
|
1574
|
+
rejected.push({
|
|
1575
|
+
pattern: suggestion.pattern,
|
|
1576
|
+
reason: "Pattern may be vulnerable to ReDoS"
|
|
1577
|
+
});
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
if (matchCount > 0) {
|
|
1581
|
+
if (falsePositives > 0 && suggestion.confidence === "high") {
|
|
1582
|
+
suggestion.confidence = "medium";
|
|
1583
|
+
}
|
|
1584
|
+
if (matchCount < vulnerableCode.length / 2 && suggestion.confidence === "high") {
|
|
1585
|
+
suggestion.confidence = "medium";
|
|
1586
|
+
}
|
|
1587
|
+
validated.push(suggestion);
|
|
1588
|
+
} else {
|
|
1589
|
+
rejected.push({
|
|
1590
|
+
pattern: suggestion.pattern,
|
|
1591
|
+
reason: `Pattern did not match any vulnerable samples (0/${vulnerableCode.length})`
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
} catch (error) {
|
|
1595
|
+
rejected.push({
|
|
1596
|
+
pattern: suggestion.pattern,
|
|
1597
|
+
reason: `Invalid regex: ${error instanceof Error ? error.message : "Unknown error"}`
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
return { suggestions: validated, rejected };
|
|
1602
|
+
}
|
|
1603
|
+
function hasRedosPotential(pattern) {
|
|
1604
|
+
if (/\([^)]*[+*][^)]*\)[+*]/.test(pattern)) {
|
|
1605
|
+
return true;
|
|
1606
|
+
}
|
|
1607
|
+
if (/\([^)]*\|[^)]*\)[+*]/.test(pattern)) {
|
|
1608
|
+
const alternationMatch = pattern.match(/\(([^)]+)\)/g);
|
|
1609
|
+
if (alternationMatch) {
|
|
1610
|
+
for (const alt of alternationMatch) {
|
|
1611
|
+
if (/\w+\|\w*\w/.test(alt) && /[+*]/.test(alt)) {
|
|
1612
|
+
return true;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
return false;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
export { AIService, AnalysisError, CategoryNotFoundError, CategorySchema, CategoryStore, CategorySummarySchema, ConfidenceSchema, ConfigError, DetectionPatternSchema, ExampleSchema, GenerationError, LANGUAGES, LanguageSchema, PATTERN_TYPES, ParseError, PatternNotFoundError, PatternTypeSchema, PinataError, PrioritySchema, RISK_DOMAINS, RiskDomainSchema, SeveritySchema, TEST_FRAMEWORKS, TEST_LEVELS, TestFrameworkSchema, TestLevelSchema, TestTemplateSchema, VERSION, ValidationError, all, andThen, createAIService, createCategoryStore, err, explainGap, explainGaps, logger, map, mapErr, ok, suggestPatterns, suggestVariables, tryCatch, tryCatchAsync, unwrap, unwrapOr };
|
|
1621
|
+
//# sourceMappingURL=index.js.map
|
|
1622
|
+
//# sourceMappingURL=index.js.map
|