agent-security-scanner-mcp 3.20.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -43
- package/code-review-agent/.env.example +8 -0
- package/code-review-agent/README.md +142 -0
- package/code-review-agent/TODO.md +149 -0
- package/code-review-agent/bin/cr-agent.ts +313 -0
- package/code-review-agent/dist/bin/cr-agent.d.ts +3 -0
- package/code-review-agent/dist/bin/cr-agent.d.ts.map +1 -0
- package/code-review-agent/dist/bin/cr-agent.js +299 -0
- package/code-review-agent/dist/bin/cr-agent.js.map +1 -0
- package/code-review-agent/dist/src/analyzer/engine.d.ts +16 -0
- package/code-review-agent/dist/src/analyzer/engine.d.ts.map +1 -0
- package/code-review-agent/dist/src/analyzer/engine.js +298 -0
- package/code-review-agent/dist/src/analyzer/engine.js.map +1 -0
- package/code-review-agent/dist/src/analyzer/intent.d.ts +10 -0
- package/code-review-agent/dist/src/analyzer/intent.d.ts.map +1 -0
- package/code-review-agent/dist/src/analyzer/intent.js +40 -0
- package/code-review-agent/dist/src/analyzer/intent.js.map +1 -0
- package/code-review-agent/dist/src/analyzer/semantic.d.ts +19 -0
- package/code-review-agent/dist/src/analyzer/semantic.d.ts.map +1 -0
- package/code-review-agent/dist/src/analyzer/semantic.js +150 -0
- package/code-review-agent/dist/src/analyzer/semantic.js.map +1 -0
- package/code-review-agent/dist/src/context/assembler.d.ts +16 -0
- package/code-review-agent/dist/src/context/assembler.d.ts.map +1 -0
- package/code-review-agent/dist/src/context/assembler.js +135 -0
- package/code-review-agent/dist/src/context/assembler.js.map +1 -0
- package/code-review-agent/dist/src/context/file.d.ts +6 -0
- package/code-review-agent/dist/src/context/file.d.ts.map +1 -0
- package/code-review-agent/dist/src/context/file.js +139 -0
- package/code-review-agent/dist/src/context/file.js.map +1 -0
- package/code-review-agent/dist/src/context/project.d.ts +4 -0
- package/code-review-agent/dist/src/context/project.d.ts.map +1 -0
- package/code-review-agent/dist/src/context/project.js +252 -0
- package/code-review-agent/dist/src/context/project.js.map +1 -0
- package/code-review-agent/dist/src/graph/dependency.d.ts +11 -0
- package/code-review-agent/dist/src/graph/dependency.d.ts.map +1 -0
- package/code-review-agent/dist/src/graph/dependency.js +102 -0
- package/code-review-agent/dist/src/graph/dependency.js.map +1 -0
- package/code-review-agent/dist/src/graph/resolver.d.ts +9 -0
- package/code-review-agent/dist/src/graph/resolver.d.ts.map +1 -0
- package/code-review-agent/dist/src/graph/resolver.js +124 -0
- package/code-review-agent/dist/src/graph/resolver.js.map +1 -0
- package/code-review-agent/dist/src/index.d.ts +21 -0
- package/code-review-agent/dist/src/index.d.ts.map +1 -0
- package/code-review-agent/dist/src/index.js +21 -0
- package/code-review-agent/dist/src/index.js.map +1 -0
- package/code-review-agent/dist/src/llm/anthropic.d.ts +13 -0
- package/code-review-agent/dist/src/llm/anthropic.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/anthropic.js +83 -0
- package/code-review-agent/dist/src/llm/anthropic.js.map +1 -0
- package/code-review-agent/dist/src/llm/claude-cli.d.ts +13 -0
- package/code-review-agent/dist/src/llm/claude-cli.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/claude-cli.js +142 -0
- package/code-review-agent/dist/src/llm/claude-cli.js.map +1 -0
- package/code-review-agent/dist/src/llm/openai.d.ts +13 -0
- package/code-review-agent/dist/src/llm/openai.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/openai.js +78 -0
- package/code-review-agent/dist/src/llm/openai.js.map +1 -0
- package/code-review-agent/dist/src/llm/provider.d.ts +18 -0
- package/code-review-agent/dist/src/llm/provider.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/provider.js +11 -0
- package/code-review-agent/dist/src/llm/provider.js.map +1 -0
- package/code-review-agent/dist/src/llm/router.d.ts +14 -0
- package/code-review-agent/dist/src/llm/router.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/router.js +67 -0
- package/code-review-agent/dist/src/llm/router.js.map +1 -0
- package/code-review-agent/dist/src/llm/schemas.d.ts +18 -0
- package/code-review-agent/dist/src/llm/schemas.d.ts.map +1 -0
- package/code-review-agent/dist/src/llm/schemas.js +91 -0
- package/code-review-agent/dist/src/llm/schemas.js.map +1 -0
- package/code-review-agent/dist/src/types/analysis.d.ts +56 -0
- package/code-review-agent/dist/src/types/analysis.d.ts.map +1 -0
- package/code-review-agent/dist/src/types/analysis.js +2 -0
- package/code-review-agent/dist/src/types/analysis.js.map +1 -0
- package/code-review-agent/dist/src/types/config.d.ts +24 -0
- package/code-review-agent/dist/src/types/config.d.ts.map +1 -0
- package/code-review-agent/dist/src/types/config.js +42 -0
- package/code-review-agent/dist/src/types/config.js.map +1 -0
- package/code-review-agent/dist/src/types/findings.d.ts +236 -0
- package/code-review-agent/dist/src/types/findings.d.ts.map +1 -0
- package/code-review-agent/dist/src/types/findings.js +64 -0
- package/code-review-agent/dist/src/types/findings.js.map +1 -0
- package/code-review-agent/package.json +36 -0
- package/code-review-agent/src/analyzer/engine.ts +374 -0
- package/code-review-agent/src/analyzer/intent.ts +49 -0
- package/code-review-agent/src/analyzer/semantic.ts +222 -0
- package/code-review-agent/src/context/assembler.ts +165 -0
- package/code-review-agent/src/context/file.ts +145 -0
- package/code-review-agent/src/context/project.ts +253 -0
- package/code-review-agent/src/graph/dependency.ts +116 -0
- package/code-review-agent/src/graph/resolver.ts +138 -0
- package/code-review-agent/src/index.ts +58 -0
- package/code-review-agent/src/llm/anthropic.ts +106 -0
- package/code-review-agent/src/llm/claude-cli.ts +188 -0
- package/code-review-agent/src/llm/openai.ts +95 -0
- package/code-review-agent/src/llm/provider.ts +33 -0
- package/code-review-agent/src/llm/router.ts +86 -0
- package/code-review-agent/src/llm/schemas.ts +125 -0
- package/code-review-agent/src/types/analysis.ts +62 -0
- package/code-review-agent/src/types/config.ts +72 -0
- package/code-review-agent/src/types/findings.ts +81 -0
- package/code-review-agent/tests/analyzer/engine.test.ts +194 -0
- package/code-review-agent/tests/analyzer/intent.test.ts +76 -0
- package/code-review-agent/tests/analyzer/semantic.test.ts +131 -0
- package/code-review-agent/tests/context/file.test.ts +21 -0
- package/code-review-agent/tests/context/project.test.ts +20 -0
- package/code-review-agent/tests/fixtures/safe-build-tool/README.md +19 -0
- package/code-review-agent/tests/fixtures/safe-build-tool/builder.js +52 -0
- package/code-review-agent/tests/fixtures/safe-file-manager/README.md +16 -0
- package/code-review-agent/tests/fixtures/safe-file-manager/organizer.py +70 -0
- package/code-review-agent/tests/fixtures/vuln-api-server/README.md +17 -0
- package/code-review-agent/tests/fixtures/vuln-api-server/server.js +52 -0
- package/code-review-agent/tests/fixtures/vuln-ecommerce/README.md +18 -0
- package/code-review-agent/tests/fixtures/vuln-ecommerce/checkout.js +63 -0
- package/code-review-agent/tests/graph/dependency.test.ts +136 -0
- package/code-review-agent/tests/helpers/mock-provider.ts +48 -0
- package/code-review-agent/tests/llm/claude-cli.test.ts +251 -0
- package/code-review-agent/tests/llm/router.test.ts +77 -0
- package/code-review-agent/tests/llm/schemas.test.ts +142 -0
- package/code-review-agent/tsconfig.json +20 -0
- package/code-review-agent/vitest.config.ts +11 -0
- package/index.js +18 -18
- package/openclaw.plugin.json +2 -2
- package/package.json +13 -3
- package/server.json +3 -3
- package/src/cli/init-hooks.js +3 -3
- package/src/cli/init.js +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { AnalysisOptions } from '../types/config.js';
|
|
2
|
+
import { AnthropicProvider } from './anthropic.js';
|
|
3
|
+
import { ClaudeCliProvider } from './claude-cli.js';
|
|
4
|
+
import { OpenAIProvider } from './openai.js';
|
|
5
|
+
import type { LLMProvider } from './provider.js';
|
|
6
|
+
|
|
7
|
+
const TRIAGE_MODELS: Record<string, string> = {
|
|
8
|
+
anthropic: 'claude-haiku-4-5-20251001',
|
|
9
|
+
openai: 'gpt-4o-mini',
|
|
10
|
+
'claude-cli': 'haiku',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const ANALYSIS_MODELS: Record<string, string> = {
|
|
14
|
+
anthropic: 'claude-sonnet-4-20250514',
|
|
15
|
+
openai: 'gpt-4o',
|
|
16
|
+
'claude-cli': 'sonnet',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// Approximate USD per million tokens (input + output averaged)
|
|
20
|
+
const COST_PER_MILLION: Record<string, number> = {
|
|
21
|
+
'claude-sonnet-4-20250514': 9,
|
|
22
|
+
'claude-haiku-4-5-20251001': 1.25,
|
|
23
|
+
'gpt-4o': 7.5,
|
|
24
|
+
'gpt-4o-mini': 0.3,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export class ModelRouter {
|
|
28
|
+
private triageProvider: LLMProvider | null = null;
|
|
29
|
+
private analysisProvider: LLMProvider | null = null;
|
|
30
|
+
private options: AnalysisOptions;
|
|
31
|
+
|
|
32
|
+
constructor(options: AnalysisOptions) {
|
|
33
|
+
this.options = options;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getTriageProvider(): LLMProvider {
|
|
37
|
+
if (!this.triageProvider) {
|
|
38
|
+
const model =
|
|
39
|
+
this.options.triageModel ?? TRIAGE_MODELS[this.options.provider];
|
|
40
|
+
this.triageProvider = this.createProvider(model);
|
|
41
|
+
}
|
|
42
|
+
return this.triageProvider;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
getAnalysisProvider(): LLMProvider {
|
|
46
|
+
if (!this.analysisProvider) {
|
|
47
|
+
const model =
|
|
48
|
+
this.options.model ?? ANALYSIS_MODELS[this.options.provider];
|
|
49
|
+
this.analysisProvider = this.createProvider(model);
|
|
50
|
+
}
|
|
51
|
+
return this.analysisProvider;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
estimateCost(tokens: number, model?: string): number {
|
|
55
|
+
const modelId =
|
|
56
|
+
model ?? this.options.model ?? ANALYSIS_MODELS[this.options.provider];
|
|
57
|
+
const rate = COST_PER_MILLION[modelId] ?? 5;
|
|
58
|
+
return (tokens / 1_000_000) * rate;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private createProvider(model: string): LLMProvider {
|
|
62
|
+
const provider = this.options.provider;
|
|
63
|
+
|
|
64
|
+
if (provider === 'claude-cli') {
|
|
65
|
+
return new ClaudeCliProvider(model);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const apiKey = this.getApiKey(provider);
|
|
69
|
+
if (provider === 'anthropic') {
|
|
70
|
+
return new AnthropicProvider(apiKey, model);
|
|
71
|
+
}
|
|
72
|
+
return new OpenAIProvider(apiKey, model);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private getApiKey(provider: string): string {
|
|
76
|
+
const envVar =
|
|
77
|
+
provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
|
|
78
|
+
const key = process.env[envVar];
|
|
79
|
+
if (!key) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`Missing API key. Set ${envVar} environment variable or pass it in options.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return key;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
type JsonSchema = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
export function zodToJsonSchema(schema: z.ZodTypeAny): JsonSchema {
|
|
6
|
+
return convertType(schema);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function convertType(schema: z.ZodTypeAny): JsonSchema {
|
|
10
|
+
const def = schema._def;
|
|
11
|
+
const typeName = def.typeName as string;
|
|
12
|
+
|
|
13
|
+
switch (typeName) {
|
|
14
|
+
case 'ZodString':
|
|
15
|
+
return { type: 'string' };
|
|
16
|
+
|
|
17
|
+
case 'ZodNumber': {
|
|
18
|
+
const result: JsonSchema = { type: 'number' };
|
|
19
|
+
for (const check of def.checks ?? []) {
|
|
20
|
+
if (check.kind === 'min') result.minimum = check.value;
|
|
21
|
+
if (check.kind === 'max') result.maximum = check.value;
|
|
22
|
+
}
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
case 'ZodBoolean':
|
|
27
|
+
return { type: 'boolean' };
|
|
28
|
+
|
|
29
|
+
case 'ZodLiteral':
|
|
30
|
+
return { type: typeof def.value, const: def.value };
|
|
31
|
+
|
|
32
|
+
case 'ZodEnum':
|
|
33
|
+
return { type: 'string', enum: def.values };
|
|
34
|
+
|
|
35
|
+
case 'ZodArray':
|
|
36
|
+
return { type: 'array', items: convertType(def.type) };
|
|
37
|
+
|
|
38
|
+
case 'ZodObject': {
|
|
39
|
+
const shape = def.shape();
|
|
40
|
+
const properties: Record<string, JsonSchema> = {};
|
|
41
|
+
const required: string[] = [];
|
|
42
|
+
|
|
43
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
44
|
+
const fieldSchema = value as z.ZodTypeAny;
|
|
45
|
+
const isOptional = fieldSchema.isOptional();
|
|
46
|
+
properties[key] = convertType(fieldSchema);
|
|
47
|
+
if (!isOptional) {
|
|
48
|
+
required.push(key);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const result: JsonSchema = {
|
|
53
|
+
type: 'object',
|
|
54
|
+
properties,
|
|
55
|
+
additionalProperties: false,
|
|
56
|
+
};
|
|
57
|
+
if (required.length > 0) {
|
|
58
|
+
result.required = required;
|
|
59
|
+
}
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
case 'ZodOptional':
|
|
64
|
+
return convertType(def.innerType);
|
|
65
|
+
|
|
66
|
+
case 'ZodNullable': {
|
|
67
|
+
const inner = convertType(def.innerType);
|
|
68
|
+
return { anyOf: [inner, { type: 'null' }] };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case 'ZodDefault':
|
|
72
|
+
return convertType(def.innerType);
|
|
73
|
+
|
|
74
|
+
case 'ZodEffects':
|
|
75
|
+
return convertType(def.schema);
|
|
76
|
+
|
|
77
|
+
case 'ZodUnion': {
|
|
78
|
+
const options = (def.options as z.ZodTypeAny[]).map(convertType);
|
|
79
|
+
return { anyOf: options };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'ZodRecord':
|
|
83
|
+
return {
|
|
84
|
+
type: 'object',
|
|
85
|
+
additionalProperties: convertType(def.valueType),
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
default:
|
|
89
|
+
// Fail loud instead of silently producing invalid schema
|
|
90
|
+
throw new Error(`zodToJsonSchema: unsupported Zod type "${typeName}". Add explicit handling for this type.`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function zodToAnthropicTool(
|
|
95
|
+
schema: z.ZodTypeAny,
|
|
96
|
+
name: string,
|
|
97
|
+
description: string,
|
|
98
|
+
): {
|
|
99
|
+
name: string;
|
|
100
|
+
description: string;
|
|
101
|
+
input_schema: JsonSchema;
|
|
102
|
+
} {
|
|
103
|
+
return {
|
|
104
|
+
name,
|
|
105
|
+
description,
|
|
106
|
+
input_schema: zodToJsonSchema(schema),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function zodToOpenAIResponseFormat(
|
|
111
|
+
schema: z.ZodTypeAny,
|
|
112
|
+
name: string,
|
|
113
|
+
): {
|
|
114
|
+
type: 'json_schema';
|
|
115
|
+
json_schema: { name: string; strict: boolean; schema: JsonSchema };
|
|
116
|
+
} {
|
|
117
|
+
return {
|
|
118
|
+
type: 'json_schema',
|
|
119
|
+
json_schema: {
|
|
120
|
+
name,
|
|
121
|
+
strict: true,
|
|
122
|
+
schema: zodToJsonSchema(schema),
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Finding, IntentProfile, TriageDecision } from './findings.js';
|
|
2
|
+
|
|
3
|
+
export interface AnalysisResult {
|
|
4
|
+
findings: Finding[];
|
|
5
|
+
intentProfile: IntentProfile | null;
|
|
6
|
+
fileResults: FileAnalysisResult[];
|
|
7
|
+
stats: AnalysisStats;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FileAnalysisResult {
|
|
11
|
+
file: string;
|
|
12
|
+
findings: Finding[];
|
|
13
|
+
triageDecision: TriageDecision | null;
|
|
14
|
+
tokensUsed: number;
|
|
15
|
+
skipped: boolean;
|
|
16
|
+
truncated: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AnalysisStats {
|
|
20
|
+
filesAnalyzed: number;
|
|
21
|
+
filesSkipped: number;
|
|
22
|
+
totalFindings: number;
|
|
23
|
+
findingsBySeverity: Record<string, number>;
|
|
24
|
+
totalTokensUsed: number;
|
|
25
|
+
estimatedCost: number;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProjectContext {
|
|
30
|
+
readme: string;
|
|
31
|
+
packageMeta: Record<string, unknown> | null;
|
|
32
|
+
directoryTree: string;
|
|
33
|
+
envVars: string[];
|
|
34
|
+
hasDockerfile: boolean;
|
|
35
|
+
hasCI: boolean;
|
|
36
|
+
language: string;
|
|
37
|
+
framework: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface FileContext {
|
|
41
|
+
filePath: string;
|
|
42
|
+
content: string;
|
|
43
|
+
language: string;
|
|
44
|
+
lineCount: number;
|
|
45
|
+
imports: string[];
|
|
46
|
+
importedBy: string[];
|
|
47
|
+
siblingFiles: string[];
|
|
48
|
+
isTestFile: boolean;
|
|
49
|
+
isConfigFile: boolean;
|
|
50
|
+
isGenerated: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DependencyNode {
|
|
54
|
+
file: string;
|
|
55
|
+
imports: string[];
|
|
56
|
+
importedBy: string[];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface DependencyGraph {
|
|
60
|
+
nodes: Map<string, DependencyNode>;
|
|
61
|
+
entryPoints: string[];
|
|
62
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export interface AnalysisOptions {
|
|
5
|
+
provider: 'anthropic' | 'openai' | 'claude-cli';
|
|
6
|
+
model?: string;
|
|
7
|
+
triageModel?: string;
|
|
8
|
+
confidenceThreshold: number;
|
|
9
|
+
format: 'text' | 'json' | 'sarif';
|
|
10
|
+
verbose: boolean;
|
|
11
|
+
projectRoot: string;
|
|
12
|
+
exclude: string[];
|
|
13
|
+
concurrencyLimit: number;
|
|
14
|
+
maxFileSize: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CRAgentConfig {
|
|
18
|
+
provider?: 'anthropic' | 'openai' | 'claude-cli';
|
|
19
|
+
model?: string;
|
|
20
|
+
triageModel?: string;
|
|
21
|
+
confidenceThreshold?: number;
|
|
22
|
+
exclude?: string[];
|
|
23
|
+
concurrencyLimit?: number;
|
|
24
|
+
maxFileSize?: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const DEFAULTS: AnalysisOptions = {
|
|
28
|
+
provider: 'anthropic',
|
|
29
|
+
confidenceThreshold: 0.7,
|
|
30
|
+
format: 'text',
|
|
31
|
+
verbose: false,
|
|
32
|
+
projectRoot: process.cwd(),
|
|
33
|
+
exclude: ['node_modules', 'dist', 'build', '.git', 'vendor', '__pycache__', '.venv', 'venv', 'env', '.env', 'site-packages', '.tox', '.mypy_cache', '.pytest_cache', 'coverage', '.nyc_output', '.next', 'target'],
|
|
34
|
+
concurrencyLimit: 5,
|
|
35
|
+
maxFileSize: 512 * 1024,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export function loadConfig(projectRoot: string): CRAgentConfig | null {
|
|
39
|
+
const configPath = path.join(projectRoot, '.cr-agent.json');
|
|
40
|
+
try {
|
|
41
|
+
const raw = fs.readFileSync(configPath, 'utf-8');
|
|
42
|
+
return JSON.parse(raw) as CRAgentConfig;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resolveOptions(
|
|
49
|
+
cliFlags: Partial<AnalysisOptions>,
|
|
50
|
+
config: CRAgentConfig | null,
|
|
51
|
+
env: Record<string, string | undefined> = process.env,
|
|
52
|
+
): AnalysisOptions {
|
|
53
|
+
return {
|
|
54
|
+
provider:
|
|
55
|
+
cliFlags.provider ??
|
|
56
|
+
config?.provider ??
|
|
57
|
+
(env.CR_AGENT_PROVIDER as AnalysisOptions['provider'] | undefined) ??
|
|
58
|
+
DEFAULTS.provider,
|
|
59
|
+
model: cliFlags.model ?? config?.model ?? env.CR_AGENT_MODEL ?? undefined,
|
|
60
|
+
triageModel: cliFlags.triageModel ?? config?.triageModel ?? undefined,
|
|
61
|
+
confidenceThreshold:
|
|
62
|
+
cliFlags.confidenceThreshold ??
|
|
63
|
+
config?.confidenceThreshold ??
|
|
64
|
+
(env.CR_AGENT_CONFIDENCE ? parseFloat(env.CR_AGENT_CONFIDENCE) : DEFAULTS.confidenceThreshold),
|
|
65
|
+
format: cliFlags.format ?? DEFAULTS.format,
|
|
66
|
+
verbose: cliFlags.verbose ?? DEFAULTS.verbose,
|
|
67
|
+
projectRoot: cliFlags.projectRoot ?? DEFAULTS.projectRoot,
|
|
68
|
+
exclude: cliFlags.exclude ?? config?.exclude ?? DEFAULTS.exclude,
|
|
69
|
+
concurrencyLimit: Math.max(1, cliFlags.concurrencyLimit ?? config?.concurrencyLimit ?? DEFAULTS.concurrencyLimit),
|
|
70
|
+
maxFileSize: cliFlags.maxFileSize ?? config?.maxFileSize ?? DEFAULTS.maxFileSize,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
|
|
3
|
+
export const SeveritySchema = z.enum(['critical', 'high', 'medium', 'low', 'info']);
|
|
4
|
+
export type Severity = z.infer<typeof SeveritySchema>;
|
|
5
|
+
|
|
6
|
+
export const CategorySchema = z.enum([
|
|
7
|
+
'logic-bug',
|
|
8
|
+
'security',
|
|
9
|
+
'race-condition',
|
|
10
|
+
'null-ref',
|
|
11
|
+
'boundary',
|
|
12
|
+
'type-error',
|
|
13
|
+
'unhandled-exception',
|
|
14
|
+
'other',
|
|
15
|
+
]);
|
|
16
|
+
export type Category = z.infer<typeof CategorySchema>;
|
|
17
|
+
|
|
18
|
+
export const IntentAlignmentSchema = z.enum([
|
|
19
|
+
'violates-intent',
|
|
20
|
+
'matches-intent',
|
|
21
|
+
'unclear',
|
|
22
|
+
]);
|
|
23
|
+
export type IntentAlignment = z.infer<typeof IntentAlignmentSchema>;
|
|
24
|
+
|
|
25
|
+
export const RiskDomainSchema = z.enum([
|
|
26
|
+
'web-api',
|
|
27
|
+
'cli-tool',
|
|
28
|
+
'library',
|
|
29
|
+
'build-tool',
|
|
30
|
+
'data-pipeline',
|
|
31
|
+
'desktop-app',
|
|
32
|
+
'unknown',
|
|
33
|
+
]);
|
|
34
|
+
export type RiskDomain = z.infer<typeof RiskDomainSchema>;
|
|
35
|
+
|
|
36
|
+
export const LocationSchema = z.object({
|
|
37
|
+
file: z.string(),
|
|
38
|
+
startLine: z.number().int().min(1),
|
|
39
|
+
endLine: z.number().int().min(1),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const FindingSchema = z.object({
|
|
43
|
+
title: z.string(),
|
|
44
|
+
severity: SeveritySchema,
|
|
45
|
+
category: CategorySchema,
|
|
46
|
+
location: LocationSchema,
|
|
47
|
+
reasoning: z.string(),
|
|
48
|
+
intentAlignment: IntentAlignmentSchema,
|
|
49
|
+
confidence: z.number().min(0).max(1),
|
|
50
|
+
suggestedAction: z.string(),
|
|
51
|
+
cwe: z.string().optional(),
|
|
52
|
+
owasp: z.string().optional(),
|
|
53
|
+
});
|
|
54
|
+
export type Finding = z.infer<typeof FindingSchema>;
|
|
55
|
+
|
|
56
|
+
export const FileAnalysisResponseSchema = z.object({
|
|
57
|
+
findings: z.array(FindingSchema),
|
|
58
|
+
});
|
|
59
|
+
export type FileAnalysisResponse = z.infer<typeof FileAnalysisResponseSchema>;
|
|
60
|
+
|
|
61
|
+
export const IntentProfileSchema = z.object({
|
|
62
|
+
purpose: z.string(),
|
|
63
|
+
expectedBehaviors: z.array(z.string()),
|
|
64
|
+
unexpectedBehaviors: z.array(z.string()),
|
|
65
|
+
framework: z.string(),
|
|
66
|
+
riskDomain: RiskDomainSchema,
|
|
67
|
+
});
|
|
68
|
+
export type IntentProfile = z.infer<typeof IntentProfileSchema>;
|
|
69
|
+
|
|
70
|
+
export const AreaOfInterestSchema = z.object({
|
|
71
|
+
startLine: z.number(),
|
|
72
|
+
endLine: z.number(),
|
|
73
|
+
reason: z.string(),
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
export const TriageDecisionSchema = z.object({
|
|
77
|
+
action: z.enum(['analyze', 'skip']),
|
|
78
|
+
reason: z.string(),
|
|
79
|
+
areasOfInterest: z.array(AreaOfInterestSchema),
|
|
80
|
+
});
|
|
81
|
+
export type TriageDecision = z.infer<typeof TriageDecisionSchema>;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { AnalysisEngine } from '../../src/analyzer/engine.js';
|
|
4
|
+
import type { AnalysisOptions } from '../../src/types/config.js';
|
|
5
|
+
|
|
6
|
+
// We need to mock the ModelRouter to return our MockLLMProvider
|
|
7
|
+
// Since the engine creates its own router, we mock at the module level
|
|
8
|
+
const mockAnalysisResponse = {
|
|
9
|
+
findings: [
|
|
10
|
+
{
|
|
11
|
+
title: 'SQL Injection via string concatenation',
|
|
12
|
+
severity: 'critical' as const,
|
|
13
|
+
category: 'security' as const,
|
|
14
|
+
location: { file: 'server.js', startLine: 15, endLine: 15 },
|
|
15
|
+
reasoning: 'User input directly concatenated into SQL query',
|
|
16
|
+
intentAlignment: 'violates-intent' as const,
|
|
17
|
+
confidence: 0.95,
|
|
18
|
+
suggestedAction: 'Use parameterized queries',
|
|
19
|
+
cwe: 'CWE-89',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
title: 'eval() on user input',
|
|
23
|
+
severity: 'critical' as const,
|
|
24
|
+
category: 'security' as const,
|
|
25
|
+
location: { file: 'server.js', startLine: 23, endLine: 23 },
|
|
26
|
+
reasoning: 'eval() with user-controlled input',
|
|
27
|
+
intentAlignment: 'violates-intent' as const,
|
|
28
|
+
confidence: 0.92,
|
|
29
|
+
suggestedAction: 'Remove eval, use a safe parser',
|
|
30
|
+
cwe: 'CWE-94',
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const mockIntentResponse = {
|
|
36
|
+
purpose: 'REST API server for user management',
|
|
37
|
+
expectedBehaviors: ['Handle HTTP requests', 'Query database'],
|
|
38
|
+
unexpectedBehaviors: ['Execute eval on user input', 'Write arbitrary files'],
|
|
39
|
+
framework: 'express',
|
|
40
|
+
riskDomain: 'web-api' as const,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const mockTriageAnalyze = {
|
|
44
|
+
action: 'analyze' as const,
|
|
45
|
+
reason: 'Contains HTTP handlers with database queries',
|
|
46
|
+
areasOfInterest: [{ startLine: 1, endLine: 50, reason: 'HTTP handler code' }],
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Mock the LLM modules to avoid needing real API keys
|
|
50
|
+
vi.mock('../../src/llm/anthropic.js', () => ({
|
|
51
|
+
AnthropicProvider: class {
|
|
52
|
+
modelId = 'mock-model';
|
|
53
|
+
providerName = 'anthropic';
|
|
54
|
+
async chat() { return 'mock'; }
|
|
55
|
+
async chatStructured(_msgs: unknown, schema: { safeParse: (data: unknown) => { success: boolean; data: unknown } }, schemaName: string) {
|
|
56
|
+
const responses: Record<string, unknown> = {
|
|
57
|
+
intent_profile: mockIntentResponse,
|
|
58
|
+
file_analysis: mockAnalysisResponse,
|
|
59
|
+
triage_decision: mockTriageAnalyze,
|
|
60
|
+
};
|
|
61
|
+
const response = responses[schemaName];
|
|
62
|
+
const result = schema.safeParse(response);
|
|
63
|
+
if (!result.success) throw new Error('Schema validation failed');
|
|
64
|
+
return result.data;
|
|
65
|
+
}
|
|
66
|
+
countTokens(text: string) { return Math.ceil(text.length / 4); }
|
|
67
|
+
},
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
vi.mock('../../src/llm/openai.js', () => ({
|
|
71
|
+
OpenAIProvider: class {
|
|
72
|
+
modelId = 'mock-model';
|
|
73
|
+
providerName = 'openai';
|
|
74
|
+
async chat() { return 'mock'; }
|
|
75
|
+
async chatStructured() { return {}; }
|
|
76
|
+
countTokens(text: string) { return Math.ceil(text.length / 4); }
|
|
77
|
+
},
|
|
78
|
+
}));
|
|
79
|
+
|
|
80
|
+
const FIXTURES_DIR = path.resolve(__dirname, '../fixtures');
|
|
81
|
+
|
|
82
|
+
describe('AnalysisEngine', () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
vi.stubEnv('ANTHROPIC_API_KEY', 'test-key');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('analyzes vuln-api-server and finds vulnerabilities', async () => {
|
|
88
|
+
const options: AnalysisOptions = {
|
|
89
|
+
provider: 'anthropic',
|
|
90
|
+
confidenceThreshold: 0.7,
|
|
91
|
+
format: 'text',
|
|
92
|
+
verbose: false,
|
|
93
|
+
projectRoot: path.join(FIXTURES_DIR, 'vuln-api-server'),
|
|
94
|
+
exclude: ['node_modules', 'dist', '.git'],
|
|
95
|
+
concurrencyLimit: 5,
|
|
96
|
+
maxFileSize: 512 * 1024,
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const engine = new AnalysisEngine(options);
|
|
100
|
+
const result = await engine.analyze('.');
|
|
101
|
+
|
|
102
|
+
expect(result.intentProfile).toBeTruthy();
|
|
103
|
+
expect(result.intentProfile?.riskDomain).toBe('web-api');
|
|
104
|
+
expect(result.findings.length).toBeGreaterThan(0);
|
|
105
|
+
expect(result.stats.filesAnalyzed).toBeGreaterThan(0);
|
|
106
|
+
expect(result.stats.durationMs).toBeGreaterThan(0);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns sorted findings (critical first)', async () => {
|
|
110
|
+
const options: AnalysisOptions = {
|
|
111
|
+
provider: 'anthropic',
|
|
112
|
+
confidenceThreshold: 0.5,
|
|
113
|
+
format: 'text',
|
|
114
|
+
verbose: false,
|
|
115
|
+
projectRoot: path.join(FIXTURES_DIR, 'vuln-api-server'),
|
|
116
|
+
exclude: ['node_modules', 'dist', '.git'],
|
|
117
|
+
concurrencyLimit: 5,
|
|
118
|
+
maxFileSize: 512 * 1024,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const engine = new AnalysisEngine(options);
|
|
122
|
+
const result = await engine.analyze('.');
|
|
123
|
+
|
|
124
|
+
if (result.findings.length >= 2) {
|
|
125
|
+
const severityOrder = ['critical', 'high', 'medium', 'low', 'info'];
|
|
126
|
+
for (let i = 1; i < result.findings.length; i++) {
|
|
127
|
+
const prevIdx = severityOrder.indexOf(result.findings[i - 1].severity);
|
|
128
|
+
const currIdx = severityOrder.indexOf(result.findings[i].severity);
|
|
129
|
+
expect(currIdx).toBeGreaterThanOrEqual(prevIdx);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('filters findings below confidence threshold', async () => {
|
|
135
|
+
const options: AnalysisOptions = {
|
|
136
|
+
provider: 'anthropic',
|
|
137
|
+
confidenceThreshold: 0.99,
|
|
138
|
+
format: 'text',
|
|
139
|
+
verbose: false,
|
|
140
|
+
projectRoot: path.join(FIXTURES_DIR, 'vuln-api-server'),
|
|
141
|
+
exclude: ['node_modules', 'dist', '.git'],
|
|
142
|
+
concurrencyLimit: 5,
|
|
143
|
+
maxFileSize: 512 * 1024,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const engine = new AnalysisEngine(options);
|
|
147
|
+
const result = await engine.analyze('.');
|
|
148
|
+
|
|
149
|
+
// All remaining findings should be above threshold
|
|
150
|
+
for (const f of result.findings) {
|
|
151
|
+
expect(f.confidence).toBeGreaterThanOrEqual(0.99);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('computes stats correctly', async () => {
|
|
156
|
+
const options: AnalysisOptions = {
|
|
157
|
+
provider: 'anthropic',
|
|
158
|
+
confidenceThreshold: 0.7,
|
|
159
|
+
format: 'text',
|
|
160
|
+
verbose: false,
|
|
161
|
+
projectRoot: path.join(FIXTURES_DIR, 'vuln-api-server'),
|
|
162
|
+
exclude: ['node_modules', 'dist', '.git'],
|
|
163
|
+
concurrencyLimit: 5,
|
|
164
|
+
maxFileSize: 512 * 1024,
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const engine = new AnalysisEngine(options);
|
|
168
|
+
const result = await engine.analyze('.');
|
|
169
|
+
|
|
170
|
+
expect(result.stats.filesAnalyzed + result.stats.filesSkipped).toBeGreaterThan(0);
|
|
171
|
+
expect(result.stats.totalFindings).toBe(result.findings.length);
|
|
172
|
+
expect(typeof result.stats.estimatedCost).toBe('number');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('clamps private worker count when concurrency is zero', async () => {
|
|
176
|
+
const options: AnalysisOptions = {
|
|
177
|
+
provider: 'anthropic',
|
|
178
|
+
confidenceThreshold: 0.7,
|
|
179
|
+
format: 'text',
|
|
180
|
+
verbose: false,
|
|
181
|
+
projectRoot: path.join(FIXTURES_DIR, 'vuln-api-server'),
|
|
182
|
+
exclude: ['node_modules', 'dist', '.git'],
|
|
183
|
+
concurrencyLimit: 0,
|
|
184
|
+
maxFileSize: 512 * 1024,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const engine = new AnalysisEngine(options);
|
|
188
|
+
const results = await (engine as unknown as {
|
|
189
|
+
runParallel<T, R>(items: T[], fn: (item: T) => Promise<R>, limit: number): Promise<R[]>;
|
|
190
|
+
}).runParallel([1, 2, 3], async (value) => value * 2, 0);
|
|
191
|
+
|
|
192
|
+
expect(results).toEqual([2, 4, 6]);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { IntentProfiler } from '../../src/analyzer/intent.js';
|
|
3
|
+
import { MockLLMProvider } from '../helpers/mock-provider.js';
|
|
4
|
+
import type { ProjectContext } from '../../src/types/analysis.js';
|
|
5
|
+
|
|
6
|
+
const mockProjectContext: ProjectContext = {
|
|
7
|
+
readme: '# Test Project\nA REST API for user management.',
|
|
8
|
+
packageMeta: { name: 'test-api', dependencies: { express: '^4.18.0' } },
|
|
9
|
+
directoryTree: 'src/\n server.js\n routes/\n users.js',
|
|
10
|
+
envVars: ['DATABASE_URL', 'JWT_SECRET'],
|
|
11
|
+
hasDockerfile: true,
|
|
12
|
+
hasCI: true,
|
|
13
|
+
language: 'javascript/typescript',
|
|
14
|
+
framework: 'express',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const mockIntentResponse = {
|
|
18
|
+
purpose: 'REST API for user management with authentication',
|
|
19
|
+
expectedBehaviors: [
|
|
20
|
+
'Handle HTTP requests',
|
|
21
|
+
'Read/write to database',
|
|
22
|
+
'Validate user input',
|
|
23
|
+
'Generate JWT tokens',
|
|
24
|
+
],
|
|
25
|
+
unexpectedBehaviors: [
|
|
26
|
+
'Execute system commands',
|
|
27
|
+
'Write arbitrary files to disk',
|
|
28
|
+
'Eval user-controlled strings',
|
|
29
|
+
],
|
|
30
|
+
framework: 'express',
|
|
31
|
+
riskDomain: 'web-api' as const,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe('IntentProfiler', () => {
|
|
35
|
+
it('profiles a project and returns intent', async () => {
|
|
36
|
+
const provider = new MockLLMProvider({
|
|
37
|
+
intent_profile: mockIntentResponse,
|
|
38
|
+
});
|
|
39
|
+
const profiler = new IntentProfiler(provider);
|
|
40
|
+
const result = await profiler.profile(mockProjectContext);
|
|
41
|
+
|
|
42
|
+
expect(result.purpose).toBe(mockIntentResponse.purpose);
|
|
43
|
+
expect(result.riskDomain).toBe('web-api');
|
|
44
|
+
expect(result.expectedBehaviors).toHaveLength(4);
|
|
45
|
+
expect(result.unexpectedBehaviors).toHaveLength(3);
|
|
46
|
+
expect(result.framework).toBe('express');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('caches intent profile for same project', async () => {
|
|
50
|
+
const provider = new MockLLMProvider({
|
|
51
|
+
intent_profile: mockIntentResponse,
|
|
52
|
+
});
|
|
53
|
+
const profiler = new IntentProfiler(provider);
|
|
54
|
+
|
|
55
|
+
await profiler.profile(mockProjectContext);
|
|
56
|
+
await profiler.profile(mockProjectContext);
|
|
57
|
+
|
|
58
|
+
expect(provider.structuredCalls).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('passes project context in the prompt', async () => {
|
|
62
|
+
const provider = new MockLLMProvider({
|
|
63
|
+
intent_profile: mockIntentResponse,
|
|
64
|
+
});
|
|
65
|
+
const profiler = new IntentProfiler(provider);
|
|
66
|
+
|
|
67
|
+
await profiler.profile(mockProjectContext);
|
|
68
|
+
|
|
69
|
+
const call = provider.structuredCalls[0];
|
|
70
|
+
expect(call.schemaName).toBe('intent_profile');
|
|
71
|
+
|
|
72
|
+
const userMessage = call.messages.find((m) => m.role === 'user');
|
|
73
|
+
expect(userMessage?.content).toContain('Test Project');
|
|
74
|
+
expect(userMessage?.content).toContain('express');
|
|
75
|
+
});
|
|
76
|
+
});
|