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 ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true, fileName?.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
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 ts.SyntaxKind.ImportDeclaration: {
109
+ case tsLib.SyntaxKind.ImportDeclaration: {
98
110
  const importDecl = node;
99
- if (importDecl.moduleSpecifier && ts.isStringLiteral(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 ts.SyntaxKind.VariableDeclaration: {
116
+ case tsLib.SyntaxKind.VariableDeclaration: {
105
117
  const varDecl = node;
106
- if (varDecl.name && ts.isIdentifier(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 ts.SyntaxKind.CallExpression: {
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
- ts.forEachChild(node, visit);
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
- ts.forEachChild(node, visit);
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 (ts.isCallExpression(node)) {
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 (ts.isPropertyAccessExpression(node)) {
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 (ts.isCallExpression(node)) {
169
- if (node.expression.kind === ts.SyntaxKind.ImportKeyword) {
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
- // Literal import paths are common and generally safe; focus on non-literals that could be user-controlled.
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; // Higher confidence for dynamic imports
187
+ confidence += 0.1;
178
188
  return { confidence };
179
189
  }
180
190
  }
181
191
  }
182
192
  break;
183
193
  case 'eval-chain':
184
- if (ts.isCallExpression(node) || ts.isNewExpression(node)) {
194
+ if (tsLib.isCallExpression(node) || tsLib.isNewExpression(node)) {
185
195
  const expr = node.expression;
186
196
  const target = pattern.pattern;
187
- // Direct calls: eval(...), Function(...)
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
- // Allow common globals: globalThis.eval(...), window.eval(...)
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 (ts.isIdentifier(receiver) && (receiver.text === 'globalThis' || receiver.text === 'window')) {
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 (ts.isObjectLiteralExpression(node)) {
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: ts.SyntaxKind[node.kind],
226
+ nodeType: tsLib.SyntaxKind[node.kind],
219
227
  ...(nodeName && { name: nodeName }),
220
- ...(node.parent && { parent: ts.SyntaxKind[node.parent.kind] }),
221
- children: node.getChildren(sourceFile).map(child => ts.SyntaxKind[child.kind])
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 (ts.isIdentifier(node)) {
235
+ function getNodeName(tsLib, node) {
236
+ if (tsLib.isIdentifier(node)) {
229
237
  return node.text;
230
238
  }
231
- if (ts.isFunctionDeclaration(node) && node.name) {
239
+ if (tsLib.isFunctionDeclaration(node) && node.name) {
232
240
  return node.name.text;
233
241
  }
234
- if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) {
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',
@@ -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',
@@ -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.0",
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",