agent-security-scanner-mcp 4.0.0 → 4.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.
Files changed (71) hide show
  1. package/README.md +47 -58
  2. package/code-review-agent/README.md +25 -4
  3. package/code-review-agent/TODO.md +1 -1
  4. package/code-review-agent/bin/cr-agent.ts +7 -1
  5. package/code-review-agent/dist/bin/cr-agent.js +7 -1
  6. package/code-review-agent/dist/bin/cr-agent.js.map +1 -1
  7. package/code-review-agent/dist/src/analyzer/engine.d.ts +5 -0
  8. package/code-review-agent/dist/src/analyzer/engine.d.ts.map +1 -1
  9. package/code-review-agent/dist/src/analyzer/engine.js +30 -3
  10. package/code-review-agent/dist/src/analyzer/engine.js.map +1 -1
  11. package/code-review-agent/dist/src/analyzer/postprocess.d.ts +15 -0
  12. package/code-review-agent/dist/src/analyzer/postprocess.d.ts.map +1 -0
  13. package/code-review-agent/dist/src/analyzer/postprocess.js +275 -0
  14. package/code-review-agent/dist/src/analyzer/postprocess.js.map +1 -0
  15. package/code-review-agent/dist/src/analyzer/semantic.d.ts +5 -1
  16. package/code-review-agent/dist/src/analyzer/semantic.d.ts.map +1 -1
  17. package/code-review-agent/dist/src/analyzer/semantic.js +80 -20
  18. package/code-review-agent/dist/src/analyzer/semantic.js.map +1 -1
  19. package/code-review-agent/dist/src/context/assembler.d.ts +8 -2
  20. package/code-review-agent/dist/src/context/assembler.d.ts.map +1 -1
  21. package/code-review-agent/dist/src/context/assembler.js +33 -1
  22. package/code-review-agent/dist/src/context/assembler.js.map +1 -1
  23. package/code-review-agent/dist/src/context/file.d.ts.map +1 -1
  24. package/code-review-agent/dist/src/context/file.js +11 -23
  25. package/code-review-agent/dist/src/context/file.js.map +1 -1
  26. package/code-review-agent/dist/src/context/security-summary.d.ts +19 -0
  27. package/code-review-agent/dist/src/context/security-summary.d.ts.map +1 -0
  28. package/code-review-agent/dist/src/context/security-summary.js +199 -0
  29. package/code-review-agent/dist/src/context/security-summary.js.map +1 -0
  30. package/code-review-agent/dist/src/graph/dependency.d.ts.map +1 -1
  31. package/code-review-agent/dist/src/graph/dependency.js +8 -1
  32. package/code-review-agent/dist/src/graph/dependency.js.map +1 -1
  33. package/code-review-agent/dist/src/graph/resolver.d.ts.map +1 -1
  34. package/code-review-agent/dist/src/graph/resolver.js +14 -5
  35. package/code-review-agent/dist/src/graph/resolver.js.map +1 -1
  36. package/code-review-agent/dist/src/index.d.ts +4 -1
  37. package/code-review-agent/dist/src/index.d.ts.map +1 -1
  38. package/code-review-agent/dist/src/index.js +2 -0
  39. package/code-review-agent/dist/src/index.js.map +1 -1
  40. package/code-review-agent/dist/src/types/config.d.ts +3 -0
  41. package/code-review-agent/dist/src/types/config.d.ts.map +1 -1
  42. package/code-review-agent/dist/src/types/config.js +9 -0
  43. package/code-review-agent/dist/src/types/config.js.map +1 -1
  44. package/code-review-agent/src/analyzer/engine.ts +36 -2
  45. package/code-review-agent/src/analyzer/postprocess.ts +311 -0
  46. package/code-review-agent/src/analyzer/semantic.ts +87 -18
  47. package/code-review-agent/src/context/assembler.ts +44 -2
  48. package/code-review-agent/src/context/file.ts +13 -18
  49. package/code-review-agent/src/context/security-summary.ts +225 -0
  50. package/code-review-agent/src/graph/dependency.ts +8 -1
  51. package/code-review-agent/src/graph/resolver.ts +14 -5
  52. package/code-review-agent/src/index.ts +4 -0
  53. package/code-review-agent/src/types/config.ts +16 -0
  54. package/code-review-agent/tests/analyzer/engine.test.ts +5 -0
  55. package/code-review-agent/tests/analyzer/postprocess.test.ts +450 -0
  56. package/code-review-agent/tests/analyzer/prompt-routing.test.ts +137 -0
  57. package/code-review-agent/tests/config-mode.test.ts +71 -0
  58. package/code-review-agent/tests/context/file.test.ts +16 -1
  59. package/code-review-agent/tests/context/security-summary.test.ts +181 -0
  60. package/code-review-agent/tests/fixtures/guarded-agent/router.py +6 -0
  61. package/code-review-agent/tests/fixtures/guarded-agent/tools/executor.py +10 -0
  62. package/code-review-agent/tests/fixtures/guarded-agent/tools/guard.py +4 -0
  63. package/code-review-agent/tests/fixtures/guarded-agent/vuln-tool.py +6 -0
  64. package/code-review-agent/tests/graph/dependency.test.ts +76 -0
  65. package/index.js +18 -18
  66. package/openclaw.plugin.json +1 -1
  67. package/package.json +3 -2
  68. package/scripts/postinstall.js +43 -4
  69. package/server.json +1 -1
  70. package/src/cli/init-hooks.js +3 -3
  71. package/src/cli/init.js +1 -1
@@ -0,0 +1,225 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import type { FileContext, DependencyGraph } from '../types/analysis.js';
4
+
5
+ /**
6
+ * Keywords that indicate security-relevant lines worth including in summaries.
7
+ */
8
+ const SECURITY_RELEVANT_PATTERNS = [
9
+ // Dangerous sinks
10
+ /\b(subprocess|exec|eval|system|popen|spawn|shell_exec|os\.system|os\.popen)\b/,
11
+ /\b(requests?\.(get|post|put|delete|patch|head)|fetch|urllib|http\.request|axios)\b/,
12
+ /\b(query|execute|cursor\.execute|\.raw\(|\.query\(|sequelize|knex)\b/,
13
+ /\b(fs\.(readFile|writeFile|unlink|rmdir|rename)|open\(|os\.remove|shutil)\b/,
14
+ // Guard / policy patterns
15
+ /\b(allowlist|allow_list|whitelist|denylist|deny_list|blocklist|blacklist)\b/,
16
+ /\b(validate|sanitize|authorize|authenticate|check_perm|has_perm)\b/,
17
+ /\b(guard|policy|permission|auth_check|is_allowed\w*|can_access\w*|ALLOWED_\w+)\b/,
18
+ /\b(shell\s*=\s*(True|False)|parameterized|prepared_statement|bind_param)\b/,
19
+ // Routing / dispatching
20
+ /\b(app\.(get|post|put|delete|patch|use)|router\.(get|post|put|delete))\b/,
21
+ /\b(dispatch|handle_request|route_to|forward_to)\b/,
22
+ ];
23
+
24
+ /**
25
+ * Maximum number of nearby files to summarize.
26
+ */
27
+ const MAX_RELATED_FILES = 4;
28
+
29
+ /**
30
+ * Maximum lines to extract per file summary.
31
+ */
32
+ const MAX_SUMMARY_LINES = 15;
33
+
34
+ /**
35
+ * Maximum bytes to read from any related file.
36
+ */
37
+ const MAX_FILE_READ_BYTES = 64 * 1024;
38
+
39
+ export interface RelatedFileSummary {
40
+ filePath: string;
41
+ relationship: 'imports' | 'imported-by' | 'sibling';
42
+ relevantLines: string[];
43
+ }
44
+
45
+ /**
46
+ * Build compact security-relevant summaries of files related to the one
47
+ * being analyzed. This gives the LLM enough context to understand:
48
+ * - Whether a called module has guards (allowlist, validation)
49
+ * - Whether an imported file contains a dangerous sink
50
+ * - Whether sibling files provide auth/policy enforcement
51
+ */
52
+ export function buildRelatedFileSummaries(
53
+ file: FileContext,
54
+ projectRoot: string,
55
+ graph?: DependencyGraph,
56
+ ): RelatedFileSummary[] {
57
+ const summaries: RelatedFileSummary[] = [];
58
+ const seen = new Set<string>();
59
+
60
+ // Priority 1: files this file imports (may contain sinks or guards)
61
+ for (const imp of file.imports) {
62
+ if (summaries.length >= MAX_RELATED_FILES) break;
63
+ const resolved = resolveLocalFile(imp, file.filePath, projectRoot);
64
+ if (!resolved) continue;
65
+ const relativePath = path.relative(projectRoot, resolved);
66
+ if (seen.has(relativePath)) continue;
67
+ seen.add(relativePath);
68
+
69
+ const summary = summarizeFile(resolved, projectRoot, 'imports');
70
+ if (summary) summaries.push(summary);
71
+ }
72
+
73
+ // Priority 2: files that import this file (may be routers/controllers)
74
+ for (const importer of file.importedBy) {
75
+ if (summaries.length >= MAX_RELATED_FILES) break;
76
+ const fullPath = path.resolve(projectRoot, importer);
77
+ const normalized = path.relative(projectRoot, fullPath);
78
+ if (seen.has(normalized)) continue;
79
+ seen.add(normalized);
80
+
81
+ const summary = summarizeFile(fullPath, projectRoot, 'imported-by');
82
+ if (summary) summaries.push(summary);
83
+ }
84
+
85
+ // Priority 3: security-relevant sibling files (guard, policy, tool, etc.)
86
+ const securitySiblingKeywords = /\b(guard|policy|validator|auth|tool|command|executor|service|middleware)\b/i;
87
+ for (const sibling of file.siblingFiles) {
88
+ if (summaries.length >= MAX_RELATED_FILES) break;
89
+ if (!securitySiblingKeywords.test(sibling)) continue;
90
+
91
+ const siblingPath = path.resolve(path.dirname(path.resolve(projectRoot, file.filePath)), sibling);
92
+ const normalized = path.relative(projectRoot, siblingPath);
93
+ if (seen.has(normalized)) continue;
94
+ seen.add(normalized);
95
+
96
+ const summary = summarizeFile(siblingPath, projectRoot, 'sibling');
97
+ if (summary) summaries.push(summary);
98
+ }
99
+
100
+ return summaries;
101
+ }
102
+
103
+ /**
104
+ * Extract security-relevant lines from a file.
105
+ */
106
+ function summarizeFile(
107
+ filePath: string,
108
+ projectRoot: string,
109
+ relationship: RelatedFileSummary['relationship'],
110
+ ): RelatedFileSummary | null {
111
+ try {
112
+ const stat = fs.statSync(filePath);
113
+ if (!stat.isFile() || stat.size > MAX_FILE_READ_BYTES) return null;
114
+ } catch {
115
+ return null;
116
+ }
117
+
118
+ let content: string;
119
+ try {
120
+ content = fs.readFileSync(filePath, 'utf-8');
121
+ } catch {
122
+ return null;
123
+ }
124
+
125
+ const lines = content.split('\n');
126
+ const relevantLines: string[] = [];
127
+
128
+ for (let i = 0; i < lines.length && relevantLines.length < MAX_SUMMARY_LINES; i++) {
129
+ const line = lines[i];
130
+ if (SECURITY_RELEVANT_PATTERNS.some((p) => p.test(line))) {
131
+ relevantLines.push(`L${i + 1}: ${line.trim()}`);
132
+ }
133
+ }
134
+
135
+ // No relevant lines found — skip this file
136
+ if (relevantLines.length === 0) return null;
137
+
138
+ return {
139
+ filePath: path.relative(projectRoot, filePath),
140
+ relationship,
141
+ relevantLines,
142
+ };
143
+ }
144
+
145
+ /**
146
+ * Try to resolve a local import specifier to an actual file path.
147
+ * Handles:
148
+ * - Relative imports: ./foo, ../bar
149
+ * - Python bare module imports: tools.executor → tools/executor.py
150
+ * - Python single-token imports: guard → guard.py, tools → tools/__init__.py
151
+ */
152
+ function resolveLocalFile(
153
+ specifier: string,
154
+ fromFile: string,
155
+ projectRoot: string,
156
+ ): string | null {
157
+ const fromDir = path.dirname(path.resolve(projectRoot, fromFile));
158
+
159
+ let basePath: string;
160
+
161
+ if (specifier.startsWith('.')) {
162
+ // Relative import (JS/TS/Python relative)
163
+ basePath = path.resolve(fromDir, specifier);
164
+ } else if (/^[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*$/.test(specifier) && !specifier.includes('/')) {
165
+ // Python bare module import:
166
+ // tools.executor → tools/executor
167
+ // guard → guard
168
+ // tools → tools
169
+ const asPath = specifier.replace(/\./g, '/');
170
+ basePath = path.resolve(fromDir, asPath);
171
+
172
+ // Also try from project root (Python resolves from project root or cwd)
173
+ const fromRoot = path.resolve(projectRoot, asPath);
174
+ const rootCandidates = [
175
+ `${fromRoot}.py`,
176
+ path.join(fromRoot, '__init__.py'),
177
+ ];
178
+ for (const candidate of rootCandidates) {
179
+ try {
180
+ if (fs.statSync(candidate).isFile()) return candidate;
181
+ } catch { /* not found */ }
182
+ }
183
+ } else {
184
+ // Non-local third-party import
185
+ return null;
186
+ }
187
+
188
+ // Try exact path, then common extensions
189
+ const candidates = [
190
+ basePath,
191
+ `${basePath}.ts`,
192
+ `${basePath}.js`,
193
+ `${basePath}.py`,
194
+ `${basePath}.go`,
195
+ path.join(basePath, 'index.ts'),
196
+ path.join(basePath, 'index.js'),
197
+ `${basePath}.tsx`,
198
+ `${basePath}.jsx`,
199
+ path.join(basePath, '__init__.py'),
200
+ ];
201
+
202
+ for (const candidate of candidates) {
203
+ try {
204
+ if (fs.statSync(candidate).isFile()) {
205
+ return candidate;
206
+ }
207
+ } catch { /* not found, try next */ }
208
+ }
209
+
210
+ return null;
211
+ }
212
+
213
+ /**
214
+ * Format related file summaries for inclusion in the LLM prompt.
215
+ */
216
+ export function formatRelatedFileSummaries(summaries: RelatedFileSummary[]): string {
217
+ if (summaries.length === 0) return '';
218
+
219
+ const parts = summaries.map((s) => {
220
+ const header = `${s.filePath} (${s.relationship}):`;
221
+ return [header, ...s.relevantLines].join('\n ');
222
+ });
223
+
224
+ return parts.join('\n\n');
225
+ }
@@ -64,7 +64,14 @@ export class DependencyGraphBuilder {
64
64
  for (const imp of imports) {
65
65
  if (!imp.isLocal) continue;
66
66
 
67
- const resolved = resolveImportPath(imp.specifier, file, language);
67
+ // Try resolving from the file's directory first, then from project root
68
+ // (Python bare imports resolve from sys.path which includes project root)
69
+ let resolved = resolveImportPath(imp.specifier, file, language);
70
+ if (!resolved && !imp.specifier.startsWith('.')) {
71
+ // Create a synthetic "from project root" path for resolution
72
+ const rootSentinel = path.join(this.projectRoot, '__resolve_root__.py');
73
+ resolved = resolveImportPath(imp.specifier, rootSentinel, language);
74
+ }
68
75
  if (resolved) {
69
76
  const resolvedRel = path.relative(this.projectRoot, resolved);
70
77
  resolvedImports.push(resolvedRel);
@@ -35,10 +35,14 @@ export function extractImports(content: string, language: string): ImportInfo[]
35
35
  imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
36
36
  }
37
37
  } else if (language === 'python') {
38
- // from .module import ... (relative) and from package import ... (absolute)
39
- for (const m of content.matchAll(/from\s+(\S+)\s+import/g)) {
40
- const spec = m[1];
41
- imports.push({ specifier: spec, isLocal: isLocalImport(spec, language), resolved: null });
38
+ // `from package import name` emit both `package` and `package.name`
39
+ // since `name` might be a submodule (file) or a symbol within the package.
40
+ for (const m of content.matchAll(/from\s+(\S+)\s+import\s+(\w+)/g)) {
41
+ const pkg = m[1];
42
+ const name = m[2];
43
+ imports.push({ specifier: pkg, isLocal: isLocalImport(pkg, language), resolved: null });
44
+ const sub = `${pkg}.${name}`;
45
+ imports.push({ specifier: sub, isLocal: isLocalImport(sub, language), resolved: null });
42
46
  }
43
47
  // import module
44
48
  for (const m of content.matchAll(/^import\s+(\S+)/gm)) {
@@ -119,6 +123,7 @@ export function resolveImportPath(
119
123
  const initFile = path.join(base, '__init__.py');
120
124
  if (fs.statSync(initFile).isFile()) return initFile;
121
125
  } catch { /* not found */ }
126
+
122
127
  }
123
128
 
124
129
  return null;
@@ -129,7 +134,11 @@ export function isLocalImport(specifier: string, language: string): boolean {
129
134
  return specifier.startsWith('./') || specifier.startsWith('../');
130
135
  }
131
136
  if (language === 'python') {
132
- return specifier.startsWith('.');
137
+ // Relative imports (starts with .) are always local.
138
+ // Bare imports (tools, tools.executor) may be local — let resolveImportPath
139
+ // do a filesystem check rather than rejecting them outright.
140
+ if (specifier.startsWith('.')) return true;
141
+ return /^[a-zA-Z_]\w*(\.[a-zA-Z_]\w*)*$/.test(specifier);
133
142
  }
134
143
  if (language === 'go') {
135
144
  return !specifier.includes('.');
@@ -2,6 +2,7 @@
2
2
  export { AnalysisEngine, type ProgressCallback } from './analyzer/engine.js';
3
3
  export { IntentProfiler } from './analyzer/intent.js';
4
4
  export { SemanticAnalyzer } from './analyzer/semantic.js';
5
+ export { postFilterFindings, suppressCarrierFindings } from './analyzer/postprocess.js';
5
6
 
6
7
  // LLM providers
7
8
  export { AnthropicProvider } from './llm/anthropic.js';
@@ -16,6 +17,8 @@ export { zodToJsonSchema, zodToAnthropicTool, zodToOpenAIResponseFormat } from '
16
17
  export { buildProjectContext, formatProjectContextForLLM } from './context/project.js';
17
18
  export { buildFileContext, isTestFile, isConfigFile, isGeneratedFile } from './context/file.js';
18
19
  export { ContextAssembler } from './context/assembler.js';
20
+ export { buildRelatedFileSummaries, formatRelatedFileSummaries } from './context/security-summary.js';
21
+ export type { RelatedFileSummary } from './context/security-summary.js';
19
22
 
20
23
  // Graph
21
24
  export { DependencyGraphBuilder } from './graph/dependency.js';
@@ -32,6 +35,7 @@ export type {
32
35
  DependencyGraph,
33
36
  } from './types/analysis.js';
34
37
  export type {
38
+ AnalysisMode,
35
39
  AnalysisOptions,
36
40
  CRAgentConfig,
37
41
  } from './types/config.js';
@@ -1,7 +1,10 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
 
4
+ export type AnalysisMode = 'review' | 'security';
5
+
4
6
  export interface AnalysisOptions {
7
+ mode: AnalysisMode;
5
8
  provider: 'anthropic' | 'openai' | 'claude-cli';
6
9
  model?: string;
7
10
  triageModel?: string;
@@ -15,6 +18,7 @@ export interface AnalysisOptions {
15
18
  }
16
19
 
17
20
  export interface CRAgentConfig {
21
+ mode?: AnalysisMode;
18
22
  provider?: 'anthropic' | 'openai' | 'claude-cli';
19
23
  model?: string;
20
24
  triageModel?: string;
@@ -25,6 +29,7 @@ export interface CRAgentConfig {
25
29
  }
26
30
 
27
31
  const DEFAULTS: AnalysisOptions = {
32
+ mode: 'review',
28
33
  provider: 'anthropic',
29
34
  confidenceThreshold: 0.7,
30
35
  format: 'text',
@@ -50,7 +55,18 @@ export function resolveOptions(
50
55
  config: CRAgentConfig | null,
51
56
  env: Record<string, string | undefined> = process.env,
52
57
  ): AnalysisOptions {
58
+ const mode =
59
+ cliFlags.mode ??
60
+ config?.mode ??
61
+ (env.CR_AGENT_MODE as AnalysisMode | undefined) ??
62
+ DEFAULTS.mode;
63
+
64
+ if (mode !== 'review' && mode !== 'security') {
65
+ throw new Error(`Invalid analysis mode "${mode}". Must be "review" or "security".`);
66
+ }
67
+
53
68
  return {
69
+ mode,
54
70
  provider:
55
71
  cliFlags.provider ??
56
72
  config?.provider ??
@@ -86,6 +86,7 @@ describe('AnalysisEngine', () => {
86
86
 
87
87
  it('analyzes vuln-api-server and finds vulnerabilities', async () => {
88
88
  const options: AnalysisOptions = {
89
+ mode: 'review',
89
90
  provider: 'anthropic',
90
91
  confidenceThreshold: 0.7,
91
92
  format: 'text',
@@ -108,6 +109,7 @@ describe('AnalysisEngine', () => {
108
109
 
109
110
  it('returns sorted findings (critical first)', async () => {
110
111
  const options: AnalysisOptions = {
112
+ mode: 'review',
111
113
  provider: 'anthropic',
112
114
  confidenceThreshold: 0.5,
113
115
  format: 'text',
@@ -133,6 +135,7 @@ describe('AnalysisEngine', () => {
133
135
 
134
136
  it('filters findings below confidence threshold', async () => {
135
137
  const options: AnalysisOptions = {
138
+ mode: 'review',
136
139
  provider: 'anthropic',
137
140
  confidenceThreshold: 0.99,
138
141
  format: 'text',
@@ -154,6 +157,7 @@ describe('AnalysisEngine', () => {
154
157
 
155
158
  it('computes stats correctly', async () => {
156
159
  const options: AnalysisOptions = {
160
+ mode: 'review',
157
161
  provider: 'anthropic',
158
162
  confidenceThreshold: 0.7,
159
163
  format: 'text',
@@ -174,6 +178,7 @@ describe('AnalysisEngine', () => {
174
178
 
175
179
  it('clamps private worker count when concurrency is zero', async () => {
176
180
  const options: AnalysisOptions = {
181
+ mode: 'review',
177
182
  provider: 'anthropic',
178
183
  confidenceThreshold: 0.7,
179
184
  format: 'text',