ferret-scan 2.1.0 → 2.1.2
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.
|
@@ -6,7 +6,7 @@ import type { SemanticFinding, DiscoveredFile, Rule } from '../types.js';
|
|
|
6
6
|
/**
|
|
7
7
|
* Analyze a single file for semantic patterns
|
|
8
8
|
*/
|
|
9
|
-
export declare function analyzeFile(file: DiscoveredFile, content: string, rules: Rule[]): SemanticFinding[]
|
|
9
|
+
export declare function analyzeFile(file: DiscoveredFile, content: string, rules: Rule[]): Promise<SemanticFinding[]>;
|
|
10
10
|
/**
|
|
11
11
|
* Check if semantic analysis should be performed
|
|
12
12
|
*/
|
|
@@ -2,8 +2,20 @@
|
|
|
2
2
|
* AST Analyzer - TypeScript/JavaScript semantic analysis for security scanning
|
|
3
3
|
* Analyzes code blocks in markdown and TypeScript configurations for complex patterns
|
|
4
4
|
*/
|
|
5
|
-
import * as ts from 'typescript';
|
|
6
5
|
import logger from '../utils/logger.js';
|
|
6
|
+
let _ts;
|
|
7
|
+
async function getTS() {
|
|
8
|
+
if (!_ts) {
|
|
9
|
+
try {
|
|
10
|
+
_ts = await import('typescript');
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
throw new Error('The "typescript" package is required for semantic analysis. ' +
|
|
14
|
+
'Install it with: npm install -D typescript');
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return _ts;
|
|
18
|
+
}
|
|
7
19
|
function escapeRegExp(input) {
|
|
8
20
|
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
9
21
|
}
|
|
@@ -80,13 +92,13 @@ function isAnalyzableLanguage(language) {
|
|
|
80
92
|
/**
|
|
81
93
|
* Create TypeScript AST from code
|
|
82
94
|
*/
|
|
83
|
-
function createAST(code, fileName = 'analysis.ts') {
|
|
84
|
-
return
|
|
95
|
+
function createAST(tsLib, code, fileName = 'analysis.ts') {
|
|
96
|
+
return tsLib.createSourceFile(fileName, code, tsLib.ScriptTarget.Latest, true, fileName?.endsWith('.tsx') ? tsLib.ScriptKind.TSX : tsLib.ScriptKind.TS);
|
|
85
97
|
}
|
|
86
98
|
/**
|
|
87
99
|
* Extract semantic context from AST
|
|
88
100
|
*/
|
|
89
|
-
function extractSemanticContext(sourceFile) {
|
|
101
|
+
function extractSemanticContext(tsLib, sourceFile) {
|
|
90
102
|
const context = {
|
|
91
103
|
variables: [],
|
|
92
104
|
imports: [],
|
|
@@ -94,28 +106,28 @@ function extractSemanticContext(sourceFile) {
|
|
|
94
106
|
};
|
|
95
107
|
function visit(node) {
|
|
96
108
|
switch (node.kind) {
|
|
97
|
-
case
|
|
109
|
+
case tsLib.SyntaxKind.ImportDeclaration: {
|
|
98
110
|
const importDecl = node;
|
|
99
|
-
if (importDecl.moduleSpecifier &&
|
|
111
|
+
if (importDecl.moduleSpecifier && tsLib.isStringLiteral(importDecl.moduleSpecifier)) {
|
|
100
112
|
context.imports.push(importDecl.moduleSpecifier.text);
|
|
101
113
|
}
|
|
102
114
|
break;
|
|
103
115
|
}
|
|
104
|
-
case
|
|
116
|
+
case tsLib.SyntaxKind.VariableDeclaration: {
|
|
105
117
|
const varDecl = node;
|
|
106
|
-
if (varDecl.name &&
|
|
118
|
+
if (varDecl.name && tsLib.isIdentifier(varDecl.name)) {
|
|
107
119
|
context.variables.push(varDecl.name.text);
|
|
108
120
|
}
|
|
109
121
|
break;
|
|
110
122
|
}
|
|
111
|
-
case
|
|
123
|
+
case tsLib.SyntaxKind.CallExpression: {
|
|
112
124
|
const callExpr = node;
|
|
113
125
|
const callText = callExpr.expression.getText(sourceFile);
|
|
114
126
|
context.callChain.push(callText);
|
|
115
127
|
break;
|
|
116
128
|
}
|
|
117
129
|
}
|
|
118
|
-
|
|
130
|
+
tsLib.forEachChild(node, visit);
|
|
119
131
|
}
|
|
120
132
|
visit(sourceFile);
|
|
121
133
|
return context;
|
|
@@ -123,11 +135,11 @@ function extractSemanticContext(sourceFile) {
|
|
|
123
135
|
/**
|
|
124
136
|
* Find security patterns in AST
|
|
125
137
|
*/
|
|
126
|
-
function findSecurityPatterns(sourceFile, patterns) {
|
|
138
|
+
function findSecurityPatterns(tsLib, sourceFile, patterns) {
|
|
127
139
|
const matches = [];
|
|
128
140
|
function visit(node) {
|
|
129
141
|
for (const pattern of patterns) {
|
|
130
|
-
const match = matchSemanticPattern(node, pattern, sourceFile);
|
|
142
|
+
const match = matchSemanticPattern(tsLib, node, pattern, sourceFile);
|
|
131
143
|
if (match) {
|
|
132
144
|
matches.push({
|
|
133
145
|
pattern,
|
|
@@ -136,7 +148,7 @@ function findSecurityPatterns(sourceFile, patterns) {
|
|
|
136
148
|
});
|
|
137
149
|
}
|
|
138
150
|
}
|
|
139
|
-
|
|
151
|
+
tsLib.forEachChild(node, visit);
|
|
140
152
|
}
|
|
141
153
|
visit(sourceFile);
|
|
142
154
|
return matches;
|
|
@@ -144,12 +156,12 @@ function findSecurityPatterns(sourceFile, patterns) {
|
|
|
144
156
|
/**
|
|
145
157
|
* Match a semantic pattern against an AST node
|
|
146
158
|
*/
|
|
147
|
-
function matchSemanticPattern(node, pattern, sourceFile) {
|
|
159
|
+
function matchSemanticPattern(tsLib, node, pattern, sourceFile) {
|
|
148
160
|
const nodeText = node.getText(sourceFile);
|
|
149
161
|
let confidence = pattern.confidence ?? 0.8;
|
|
150
162
|
switch (pattern.type) {
|
|
151
163
|
case 'function-call':
|
|
152
|
-
if (
|
|
164
|
+
if (tsLib.isCallExpression(node)) {
|
|
153
165
|
const functionName = node.expression.getText(sourceFile);
|
|
154
166
|
if (matchesSymbolLike(functionName, pattern.pattern)) {
|
|
155
167
|
return { confidence };
|
|
@@ -157,7 +169,7 @@ function matchSemanticPattern(node, pattern, sourceFile) {
|
|
|
157
169
|
}
|
|
158
170
|
break;
|
|
159
171
|
case 'property-access':
|
|
160
|
-
if (
|
|
172
|
+
if (tsLib.isPropertyAccessExpression(node)) {
|
|
161
173
|
const fullAccess = node.getText(sourceFile);
|
|
162
174
|
if (matchesSymbolLike(fullAccess, pattern.pattern)) {
|
|
163
175
|
return { confidence };
|
|
@@ -165,34 +177,30 @@ function matchSemanticPattern(node, pattern, sourceFile) {
|
|
|
165
177
|
}
|
|
166
178
|
break;
|
|
167
179
|
case 'dynamic-import':
|
|
168
|
-
if (
|
|
169
|
-
if (node.expression.kind ===
|
|
170
|
-
// Dynamic import detected - pattern for security analysis only
|
|
180
|
+
if (tsLib.isCallExpression(node)) {
|
|
181
|
+
if (node.expression.kind === tsLib.SyntaxKind.ImportKeyword) {
|
|
171
182
|
const arg = node.arguments[0];
|
|
172
|
-
|
|
173
|
-
if (arg && (ts.isStringLiteralLike(arg) || ts.isNoSubstitutionTemplateLiteral(arg))) {
|
|
183
|
+
if (arg && (tsLib.isStringLiteralLike(arg) || tsLib.isNoSubstitutionTemplateLiteral(arg))) {
|
|
174
184
|
break;
|
|
175
185
|
}
|
|
176
186
|
if (pattern.pattern === 'dynamic-import' || nodeText.includes(pattern.pattern)) {
|
|
177
|
-
confidence += 0.1;
|
|
187
|
+
confidence += 0.1;
|
|
178
188
|
return { confidence };
|
|
179
189
|
}
|
|
180
190
|
}
|
|
181
191
|
}
|
|
182
192
|
break;
|
|
183
193
|
case 'eval-chain':
|
|
184
|
-
if (
|
|
194
|
+
if (tsLib.isCallExpression(node) || tsLib.isNewExpression(node)) {
|
|
185
195
|
const expr = node.expression;
|
|
186
196
|
const target = pattern.pattern;
|
|
187
|
-
|
|
188
|
-
if (ts.isIdentifier(expr) && expr.text === target) {
|
|
197
|
+
if (tsLib.isIdentifier(expr) && expr.text === target) {
|
|
189
198
|
confidence += 0.2;
|
|
190
199
|
return { confidence };
|
|
191
200
|
}
|
|
192
|
-
|
|
193
|
-
if (ts.isPropertyAccessExpression(expr) && expr.name.text === target) {
|
|
201
|
+
if (tsLib.isPropertyAccessExpression(expr) && expr.name.text === target) {
|
|
194
202
|
const receiver = expr.expression;
|
|
195
|
-
if (
|
|
203
|
+
if (tsLib.isIdentifier(receiver) && (receiver.text === 'globalThis' || receiver.text === 'window')) {
|
|
196
204
|
confidence += 0.2;
|
|
197
205
|
return { confidence };
|
|
198
206
|
}
|
|
@@ -200,7 +208,7 @@ function matchSemanticPattern(node, pattern, sourceFile) {
|
|
|
200
208
|
}
|
|
201
209
|
break;
|
|
202
210
|
case 'object-structure':
|
|
203
|
-
if (
|
|
211
|
+
if (tsLib.isObjectLiteralExpression(node)) {
|
|
204
212
|
if (nodeText.includes(pattern.pattern)) {
|
|
205
213
|
return { confidence };
|
|
206
214
|
}
|
|
@@ -212,26 +220,26 @@ function matchSemanticPattern(node, pattern, sourceFile) {
|
|
|
212
220
|
/**
|
|
213
221
|
* Create AST node info
|
|
214
222
|
*/
|
|
215
|
-
function createASTNodeInfo(node, sourceFile) {
|
|
216
|
-
const nodeName = getNodeName(node);
|
|
223
|
+
function createASTNodeInfo(tsLib, node, sourceFile) {
|
|
224
|
+
const nodeName = getNodeName(tsLib, node);
|
|
217
225
|
return {
|
|
218
|
-
nodeType:
|
|
226
|
+
nodeType: tsLib.SyntaxKind[node.kind],
|
|
219
227
|
...(nodeName && { name: nodeName }),
|
|
220
|
-
...(node.parent && { parent:
|
|
221
|
-
children: node.getChildren(sourceFile).map(child =>
|
|
228
|
+
...(node.parent && { parent: tsLib.SyntaxKind[node.parent.kind] }),
|
|
229
|
+
children: node.getChildren(sourceFile).map(child => tsLib.SyntaxKind[child.kind])
|
|
222
230
|
};
|
|
223
231
|
}
|
|
224
232
|
/**
|
|
225
233
|
* Get node name/identifier
|
|
226
234
|
*/
|
|
227
|
-
function getNodeName(node) {
|
|
228
|
-
if (
|
|
235
|
+
function getNodeName(tsLib, node) {
|
|
236
|
+
if (tsLib.isIdentifier(node)) {
|
|
229
237
|
return node.text;
|
|
230
238
|
}
|
|
231
|
-
if (
|
|
239
|
+
if (tsLib.isFunctionDeclaration(node) && node.name) {
|
|
232
240
|
return node.name.text;
|
|
233
241
|
}
|
|
234
|
-
if (
|
|
242
|
+
if (tsLib.isVariableDeclaration(node) && tsLib.isIdentifier(node.name)) {
|
|
235
243
|
return node.name.text;
|
|
236
244
|
}
|
|
237
245
|
return undefined;
|
|
@@ -269,7 +277,7 @@ function createContextLines(sourceFile, node, contextLines = 3) {
|
|
|
269
277
|
/**
|
|
270
278
|
* Analyze a single file for semantic patterns
|
|
271
279
|
*/
|
|
272
|
-
export function analyzeFile(file, content, rules) {
|
|
280
|
+
export async function analyzeFile(file, content, rules) {
|
|
273
281
|
const findings = [];
|
|
274
282
|
try {
|
|
275
283
|
// Get rules with semantic patterns
|
|
@@ -277,6 +285,7 @@ export function analyzeFile(file, content, rules) {
|
|
|
277
285
|
if (semanticRules.length === 0) {
|
|
278
286
|
return findings;
|
|
279
287
|
}
|
|
288
|
+
const tsLib = await getTS();
|
|
280
289
|
logger.debug(`AST analysis for ${file.relativePath} with ${semanticRules.length} semantic rules`);
|
|
281
290
|
let codeBlocksToAnalyze = [];
|
|
282
291
|
// Extract code blocks from markdown files
|
|
@@ -290,16 +299,16 @@ export function analyzeFile(file, content, rules) {
|
|
|
290
299
|
// Analyze each code block
|
|
291
300
|
for (const codeBlock of codeBlocksToAnalyze) {
|
|
292
301
|
try {
|
|
293
|
-
const sourceFile = createAST(codeBlock.code, `${file.relativePath}_block_${codeBlock.line}.${codeBlock.language}`);
|
|
294
|
-
const semanticContext = extractSemanticContext(sourceFile);
|
|
302
|
+
const sourceFile = createAST(tsLib, codeBlock.code, `${file.relativePath}_block_${codeBlock.line}.${codeBlock.language}`);
|
|
303
|
+
const semanticContext = extractSemanticContext(tsLib, sourceFile);
|
|
295
304
|
// Check each semantic rule
|
|
296
305
|
for (const rule of semanticRules) {
|
|
297
306
|
if (!rule.semanticPatterns)
|
|
298
307
|
continue;
|
|
299
|
-
const patternMatches = findSecurityPatterns(sourceFile, rule.semanticPatterns);
|
|
308
|
+
const patternMatches = findSecurityPatterns(tsLib, sourceFile, rule.semanticPatterns);
|
|
300
309
|
for (const match of patternMatches) {
|
|
301
310
|
const position = getPositionFromNode(match.node, sourceFile);
|
|
302
|
-
const astNodeInfo = createASTNodeInfo(match.node, sourceFile);
|
|
311
|
+
const astNodeInfo = createASTNodeInfo(tsLib, match.node, sourceFile);
|
|
303
312
|
const contextLines = createContextLines(sourceFile, match.node, 3);
|
|
304
313
|
const finding = {
|
|
305
314
|
ruleId: rule.id,
|
|
@@ -215,6 +215,27 @@ export const aiSpecificRules = [
|
|
|
215
215
|
'https://owasp.org/www-project-top-10-for-large-language-model-applications/',
|
|
216
216
|
],
|
|
217
217
|
enabled: true,
|
|
218
|
+
// Mirror INJ-003 semantic context suppression: a skill that discusses,
|
|
219
|
+
// documents, detects, or provides examples of these techniques is not
|
|
220
|
+
// itself a jailbreak attempt.
|
|
221
|
+
excludePatterns: [
|
|
222
|
+
// Line discusses detection/blocking rather than deployment
|
|
223
|
+
/\b(detect|catch|flag|block|prevent|scan\s+for|identify|reject|report)\b[^\n]{0,80}(jailbreak|DAN|bypass)/gi,
|
|
224
|
+
/\b(jailbreak|DAN|bypass)\b[^\n]{0,80}\b(detect|catch|flag|block|prevent|found|identified)/gi,
|
|
225
|
+
// Term appears inside a quoted string
|
|
226
|
+
/["'][^"'\n]{0,120}\b(jailbreak|DAN)\b[^"'\n]{0,120}["']/gi,
|
|
227
|
+
// Scanner rule-ID reference on the same line
|
|
228
|
+
/\[(?:INJ|AI|SEC|CRED)-\d+\]/gi,
|
|
229
|
+
// Markdown example label
|
|
230
|
+
/^\s*\*\*(?:Input|Output|Example|Finding|Result)\*\*\s*:/i,
|
|
231
|
+
],
|
|
232
|
+
excludeContext: [
|
|
233
|
+
/\b(security\s+(rule|finding|scan|check|gate|scanner|score)|ferret.?scan|scan\s+result)/gi,
|
|
234
|
+
/\b(example\s+of|this\s+detects|used\s+to\s+(bypass|attack)|common\s+(attack|technique)|known\s+(jailbreak|attack))/gi,
|
|
235
|
+
/\b(security\s+scanner|vulnerability\s+scanner|threat\s+detect|scan\s+for\s+(injection|jailbreak))/gi,
|
|
236
|
+
/^\s*##\s+Example/im,
|
|
237
|
+
/publication\s+blocked/gi,
|
|
238
|
+
],
|
|
218
239
|
},
|
|
219
240
|
{
|
|
220
241
|
id: 'AI-011',
|
package/dist/rules/injection.js
CHANGED
|
@@ -59,6 +59,31 @@ export const injectionRules = [
|
|
|
59
59
|
remediation: 'Remove jailbreak attempts. These patterns attempt to bypass safety measures.',
|
|
60
60
|
references: [],
|
|
61
61
|
enabled: true,
|
|
62
|
+
// Suppress findings when the matched term appears in security-discussion context:
|
|
63
|
+
// documentation explaining what these attacks are, scanner output examples,
|
|
64
|
+
// or skill files that detect/block these patterns rather than deploy them.
|
|
65
|
+
excludePatterns: [
|
|
66
|
+
// Line explicitly discusses detection/blocking of the pattern
|
|
67
|
+
/\b(detect|catch|flag|block|prevent|scan\s+for|identify|reject|report)\b[^\n]{0,80}(jailbreak|DAN|bypass)/gi,
|
|
68
|
+
/\b(jailbreak|DAN|bypass)\b[^\n]{0,80}\b(detect|catch|flag|block|prevent|found|identified)/gi,
|
|
69
|
+
// Term appears inside a quoted string (example output / documentation)
|
|
70
|
+
/["'][^"'\n]{0,120}\b(jailbreak|DAN)\b[^"'\n]{0,120}["']/gi,
|
|
71
|
+
// Markdown rule-ID reference on the same line (scanner output example)
|
|
72
|
+
/\[(?:INJ|AI|SEC|CRED)-\d+\]/gi,
|
|
73
|
+
// Line is a markdown example label
|
|
74
|
+
/^\s*\*\*(?:Input|Output|Example|Finding|Result)\*\*\s*:/i,
|
|
75
|
+
],
|
|
76
|
+
excludeContext: [
|
|
77
|
+
// Surrounding text discusses security scanning, rules, or findings
|
|
78
|
+
/\b(security\s+(rule|finding|scan|check|gate|scanner|score)|ferret.?scan|scan\s+result)/gi,
|
|
79
|
+
// Surrounding text is clearly educational / explanatory
|
|
80
|
+
/\b(example\s+of|this\s+detects|used\s+to\s+(bypass|attack)|common\s+(attack|technique)|known\s+(jailbreak|attack))/gi,
|
|
81
|
+
// Context indicates the skill is a security tool or scanner itself
|
|
82
|
+
/\b(security\s+scanner|vulnerability\s+scanner|threat\s+detect|scan\s+for\s+(injection|jailbreak))/gi,
|
|
83
|
+
// Markdown example blocks
|
|
84
|
+
/^\s*##\s+Example/im,
|
|
85
|
+
/publication\s+blocked/gi,
|
|
86
|
+
],
|
|
62
87
|
},
|
|
63
88
|
{
|
|
64
89
|
id: 'INJ-004',
|
package/dist/scanner/Scanner.js
CHANGED
|
@@ -344,7 +344,7 @@ async function scanFile(file, config, rules, llmProvider, llmRuntime) {
|
|
|
344
344
|
else {
|
|
345
345
|
try {
|
|
346
346
|
logger.debug(`Running semantic analysis on ${file.relativePath}`);
|
|
347
|
-
const semanticFindings = analyzeFileSemantics(file, content, rules);
|
|
347
|
+
const semanticFindings = await analyzeFileSemantics(file, content, rules);
|
|
348
348
|
// Convert SemanticFinding to Finding for compatibility
|
|
349
349
|
allFindings.push(...semanticFindings);
|
|
350
350
|
const memAfter = getMemoryUsage();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ferret-scan",
|
|
3
|
-
"version": "2.1.
|
|
3
|
+
"version": "2.1.2",
|
|
4
4
|
"description": "Comprehensive AI Agent Security Platform - scan, monitor, and secure AI CLI configurations with IDE integrations, behavior analysis, and compliance frameworks",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -111,6 +111,14 @@
|
|
|
111
111
|
"yaml": "^2.3.4",
|
|
112
112
|
"zod": "^3.22.4"
|
|
113
113
|
},
|
|
114
|
+
"peerDependencies": {
|
|
115
|
+
"typescript": ">=5.0.0"
|
|
116
|
+
},
|
|
117
|
+
"peerDependenciesMeta": {
|
|
118
|
+
"typescript": {
|
|
119
|
+
"optional": true
|
|
120
|
+
}
|
|
121
|
+
},
|
|
114
122
|
"devDependencies": {
|
|
115
123
|
"@eslint/js": "^9.26.0",
|
|
116
124
|
"@types/jest": "^29.5.11",
|