@nerviq/cli 1.29.0 → 1.29.1
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/CHANGELOG.md +1527 -1493
- package/README.md +550 -538
- package/SECURITY.md +82 -82
- package/bin/cli.js +2562 -2558
- package/docs/api-reference.md +356 -356
- package/docs/audit-fix.md +109 -0
- package/docs/autofix.md +3 -62
- package/docs/getting-started.md +1 -1
- package/docs/index.html +592 -592
- package/docs/integration-contracts.md +287 -287
- package/docs/maintenance.md +128 -128
- package/docs/new-platform-guide.md +202 -202
- package/docs/release-process.md +63 -0
- package/docs/shallow-risk.md +244 -244
- package/docs/why-nerviq.md +82 -82
- package/package.json +67 -67
- package/src/aider/activity.js +226 -226
- package/src/aider/context.js +162 -162
- package/src/aider/freshness.js +123 -123
- package/src/aider/techniques.js +3465 -3465
- package/src/audit/layers.js +180 -180
- package/src/audit.js +1032 -1032
- package/src/benchmark.js +299 -299
- package/src/codex/activity.js +324 -324
- package/src/codex/freshness.js +142 -142
- package/src/codex/techniques.js +4895 -4895
- package/src/context.js +326 -326
- package/src/continuous-ops.js +11 -1
- package/src/convert.js +340 -340
- package/src/copilot/config-parser.js +280 -280
- package/src/copilot/context.js +218 -218
- package/src/copilot/freshness.js +177 -177
- package/src/copilot/patch.js +238 -238
- package/src/copilot/techniques.js +3578 -3578
- package/src/cursor/freshness.js +194 -194
- package/src/cursor/patch.js +243 -243
- package/src/cursor/techniques.js +3735 -3735
- package/src/doctor.js +201 -201
- package/src/fix-engine.js +511 -8
- package/src/formatters/csv.js +86 -86
- package/src/formatters/junit.js +123 -123
- package/src/formatters/markdown.js +164 -164
- package/src/formatters/otel.js +151 -151
- package/src/freshness.js +156 -156
- package/src/gemini/activity.js +402 -402
- package/src/gemini/context.js +290 -290
- package/src/gemini/freshness.js +183 -183
- package/src/gemini/patch.js +229 -229
- package/src/gemini/techniques.js +3811 -3811
- package/src/governance.js +533 -533
- package/src/harmony/audit.js +306 -306
- package/src/i18n.js +63 -63
- package/src/insights.js +119 -119
- package/src/integrations.js +134 -134
- package/src/locales/en.json +33 -33
- package/src/locales/es.json +33 -33
- package/src/migrate.js +354 -354
- package/src/opencode/activity.js +286 -286
- package/src/opencode/freshness.js +137 -137
- package/src/opencode/techniques.js +3450 -3450
- package/src/setup/analysis.js +12 -12
- package/src/setup.js +7 -6
- package/src/shallow-risk/index.js +56 -56
- package/src/shallow-risk/patterns/agent-config-cross-platform-drift.js +50 -50
- package/src/shallow-risk/patterns/agent-config-dangerous-autoapprove.js +46 -46
- package/src/shallow-risk/patterns/agent-config-deprecated-keys.js +46 -46
- package/src/shallow-risk/patterns/agent-config-missing-file.js +317 -317
- package/src/shallow-risk/patterns/agent-config-secret-literal.js +49 -49
- package/src/shallow-risk/patterns/agent-config-stack-contradiction.js +34 -34
- package/src/shallow-risk/patterns/hook-script-missing.js +70 -70
- package/src/shallow-risk/patterns/mcp-server-no-allowlist.js +52 -52
- package/src/shallow-risk/shared.js +648 -648
- package/src/source-urls.js +295 -295
- package/src/state-paths.js +85 -85
- package/src/supplemental-checks.js +805 -805
- package/src/telemetry.js +160 -160
- package/src/windsurf/context.js +359 -359
- package/src/windsurf/freshness.js +194 -194
- package/src/windsurf/patch.js +231 -231
- package/src/windsurf/techniques.js +3779 -3779
|
@@ -1,648 +1,648 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const fs = require('fs');
|
|
4
|
-
const path = require('path');
|
|
5
|
-
const { resolveEvidence } = require('../audit/evidence');
|
|
6
|
-
const { LAYERS } = require('../audit/layers');
|
|
7
|
-
const { STACKS } = require('../techniques');
|
|
8
|
-
const { P0_SOURCES: AIDER_P0_SOURCES } = require('../aider/freshness');
|
|
9
|
-
|
|
10
|
-
const SHALLOW_RISK_DOC_URL = 'https://github.com/nerviq/nerviq/blob/main/docs/shallow-risk.md';
|
|
11
|
-
const SHALLOW_RISK_BANNER_LINES = [
|
|
12
|
-
'Shallow Risk mode (experimental, opt-in). NERVIQ checks 8 patterns',
|
|
13
|
-
'that sit at the intersection of your AI agent configuration and',
|
|
14
|
-
'your codebase - the kind of issues no generic scanner can find',
|
|
15
|
-
'because they require understanding CLAUDE.md, .claude/settings.json,',
|
|
16
|
-
'and similar files. For broader code-level security coverage, pair',
|
|
17
|
-
'this with Semgrep, CodeQL, or a dedicated secret scanner.',
|
|
18
|
-
];
|
|
19
|
-
const SHALLOW_RISK_BANNER = SHALLOW_RISK_BANNER_LINES.join('\n');
|
|
20
|
-
|
|
21
|
-
const ROOT_AGENT_FILES = [
|
|
22
|
-
'CLAUDE.md',
|
|
23
|
-
'AGENTS.md',
|
|
24
|
-
'GEMINI.md',
|
|
25
|
-
'.cursorrules',
|
|
26
|
-
'.windsurfrules',
|
|
27
|
-
'.aider.conf.yml',
|
|
28
|
-
'.aider.conf.yaml',
|
|
29
|
-
'.mcp.json',
|
|
30
|
-
'.claude/settings.json',
|
|
31
|
-
'.claude/CLAUDE.md',
|
|
32
|
-
'.gemini/settings.json',
|
|
33
|
-
'.github/copilot-instructions.md',
|
|
34
|
-
'.vscode/mcp.json',
|
|
35
|
-
'.vscode/settings.json',
|
|
36
|
-
'.codex/config.toml',
|
|
37
|
-
'opencode.json',
|
|
38
|
-
];
|
|
39
|
-
|
|
40
|
-
const ROOT_AGENT_DIRS = [
|
|
41
|
-
'.claude/agents',
|
|
42
|
-
'.claude/commands',
|
|
43
|
-
'.claude/hooks',
|
|
44
|
-
'.claude/rules',
|
|
45
|
-
'.claude/skills',
|
|
46
|
-
'.cursor/rules',
|
|
47
|
-
'.windsurf/rules',
|
|
48
|
-
'.codex/agents',
|
|
49
|
-
'.codex/hooks',
|
|
50
|
-
'.codex/skills',
|
|
51
|
-
'.github/instructions',
|
|
52
|
-
];
|
|
53
|
-
|
|
54
|
-
const EXCLUDED_DIRS = new Set([
|
|
55
|
-
'.git',
|
|
56
|
-
'node_modules',
|
|
57
|
-
'coverage',
|
|
58
|
-
'dist',
|
|
59
|
-
'build',
|
|
60
|
-
'.next',
|
|
61
|
-
'.turbo',
|
|
62
|
-
'.cache',
|
|
63
|
-
'__pycache__',
|
|
64
|
-
]);
|
|
65
|
-
|
|
66
|
-
const SPECIAL_FILE_BASENAMES = new Set([
|
|
67
|
-
'AGENTS.md',
|
|
68
|
-
'CLAUDE.md',
|
|
69
|
-
'GEMINI.md',
|
|
70
|
-
'SECURITY.md',
|
|
71
|
-
'README.md',
|
|
72
|
-
'CONTRIBUTING.md',
|
|
73
|
-
'CODEOWNERS',
|
|
74
|
-
'Dockerfile',
|
|
75
|
-
'Makefile',
|
|
76
|
-
'justfile',
|
|
77
|
-
'manifest.json',
|
|
78
|
-
'package.json',
|
|
79
|
-
'pyproject.toml',
|
|
80
|
-
'go.mod',
|
|
81
|
-
'Cargo.toml',
|
|
82
|
-
]);
|
|
83
|
-
|
|
84
|
-
const COMMON_DOTFILE_BASENAMES = new Set([
|
|
85
|
-
'.editorconfig',
|
|
86
|
-
'.env',
|
|
87
|
-
'.env.example',
|
|
88
|
-
'.env.sample',
|
|
89
|
-
'.env.template',
|
|
90
|
-
'.gitattributes',
|
|
91
|
-
'.gitignore',
|
|
92
|
-
'.npmrc',
|
|
93
|
-
'.nvmrc',
|
|
94
|
-
'.prettierrc',
|
|
95
|
-
'.python-version',
|
|
96
|
-
'.tool-versions',
|
|
97
|
-
]);
|
|
98
|
-
|
|
99
|
-
const KNOWN_CONVENTION_PATHS = new Set([
|
|
100
|
-
'CODEOWNERS',
|
|
101
|
-
'.github/CODEOWNERS',
|
|
102
|
-
]);
|
|
103
|
-
|
|
104
|
-
const FILE_REFERENCE_EXTENSION_RE = /\.(?:md|mdc|txt|rst|json|jsonc|ya?ml|toml|conf|sh|ps1|js|cjs|mjs|ts|tsx|jsx|cts|mts|py|go|rs|java|kt|kts|gradle|cs|rb|php|swift|pbxproj|xcconfig|xcworkspace|xcodeproj|h|hpp|c|cc|cpp|m|mm|sql|ini|cfg|properties|xml|html|css|scss|sass|lock)$/i;
|
|
105
|
-
const KNOWN_DOMAIN_TLDS = new Set([
|
|
106
|
-
'ai',
|
|
107
|
-
'app',
|
|
108
|
-
'co',
|
|
109
|
-
'com',
|
|
110
|
-
'dev',
|
|
111
|
-
'io',
|
|
112
|
-
'net',
|
|
113
|
-
'org',
|
|
114
|
-
'sh',
|
|
115
|
-
]);
|
|
116
|
-
const KNOWN_HIDDEN_PATH_SEGMENTS = new Set([
|
|
117
|
-
'.claude',
|
|
118
|
-
'.codex',
|
|
119
|
-
'.cursor',
|
|
120
|
-
'.gemini',
|
|
121
|
-
'.github',
|
|
122
|
-
'.opencode',
|
|
123
|
-
'.vscode',
|
|
124
|
-
'.windsurf',
|
|
125
|
-
]);
|
|
126
|
-
const FRAMEWORK_LABEL_TOKENS = new Set([
|
|
127
|
-
'd3.js',
|
|
128
|
-
'go',
|
|
129
|
-
'golang',
|
|
130
|
-
'javascript',
|
|
131
|
-
'kotlin',
|
|
132
|
-
'next',
|
|
133
|
-
'next.js',
|
|
134
|
-
'node',
|
|
135
|
-
'node.js',
|
|
136
|
-
'python',
|
|
137
|
-
'rust',
|
|
138
|
-
'swift',
|
|
139
|
-
'typescript',
|
|
140
|
-
]);
|
|
141
|
-
|
|
142
|
-
const LOCAL_MCP_BINARIES = new Set([
|
|
143
|
-
'context7-mcp',
|
|
144
|
-
'nerviq-mcp',
|
|
145
|
-
]);
|
|
146
|
-
|
|
147
|
-
const STACK_CLAIMS = [
|
|
148
|
-
{ key: 'go', label: 'Go', stackKeys: ['go'], patterns: [/\bprimary (?:language|stack)\s*:\s*(?:go|golang)\b/i, /\bthis (?:repo|project|service|codebase|app|microservice)\b[\s\S]{0,40}\b(?:go|golang)\b/i, /\bwritten in\s+(?:go|golang)\b/i] },
|
|
149
|
-
{ key: 'python', label: 'Python', stackKeys: ['python', 'django', 'fastapi'], patterns: [/\bprimary (?:language|stack)\s*:\s*python\b/i, /\bthis (?:repo|project|service|codebase|app|microservice)\b[\s\S]{0,40}\bpython\b/i, /\bwritten in\s+python\b/i] },
|
|
150
|
-
{ key: 'node', label: 'Node.js', stackKeys: ['node'], patterns: [/\bprimary (?:language|stack)\s*:\s*(?:node|node\.js)\b/i, /\bthis (?:repo|project|service|codebase|app|microservice)\b[\s\S]{0,40}\bnode(?:\.js)?\b/i] },
|
|
151
|
-
{ key: 'javascript', label: 'JavaScript', stackKeys: ['node'], patterns: [/\bprimary (?:language|stack)\s*:\s*javascript\b/i, /\bpure javascript project\b/i, /\bthis (?:repo|project|codebase|app)\b[\s\S]{0,40}\bjavascript\b/i] },
|
|
152
|
-
{ key: 'typescript', label: 'TypeScript', stackKeys: ['typescript', 'node'], patterns: [/\bprimary (?:language|stack)\s*:\s*typescript\b/i, /\buse\s+typescript\b/i, /\btypescript strict mode\b/i, /\bthis (?:repo|project|codebase|app)\b[\s\S]{0,40}\btypescript\b/i] },
|
|
153
|
-
{ key: 'rust', label: 'Rust', stackKeys: ['rust'], patterns: [/\bprimary (?:language|stack)\s*:\s*rust\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\brust\b/i] },
|
|
154
|
-
{ key: 'java', label: 'Java', stackKeys: ['java'], patterns: [/\bprimary (?:language|stack)\s*:\s*java\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bjava\b/i] },
|
|
155
|
-
{ key: 'kotlin', label: 'Kotlin', stackKeys: ['kotlin'], patterns: [/\bprimary (?:language|stack)\s*:\s*kotlin\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bkotlin\b/i] },
|
|
156
|
-
{ key: 'ruby', label: 'Ruby', stackKeys: ['ruby'], patterns: [/\bprimary (?:language|stack)\s*:\s*ruby\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bruby\b/i] },
|
|
157
|
-
{ key: 'php', label: 'PHP', stackKeys: ['php', 'laravel'], patterns: [/\bprimary (?:language|stack)\s*:\s*php\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bphp\b/i] },
|
|
158
|
-
{ key: 'dotnet', label: '.NET', stackKeys: ['dotnet'], patterns: [/\bprimary (?:language|stack)\s*:\s*(?:\.net|dotnet|c#)\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\b(?:\.net|dotnet|c#)\b/i] },
|
|
159
|
-
{ key: 'swift', label: 'Swift', stackKeys: ['swift'], patterns: [/\bprimary (?:language|stack)\s*:\s*swift\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bswift\b/i] },
|
|
160
|
-
{ key: 'flutter', label: 'Flutter', stackKeys: ['flutter'], patterns: [/\bprimary (?:language|stack)\s*:\s*flutter\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bflutter\b/i] },
|
|
161
|
-
];
|
|
162
|
-
|
|
163
|
-
const STACK_CLAIM_BY_KEY = new Map(STACK_CLAIMS.map((claim) => [claim.key, claim]));
|
|
164
|
-
|
|
165
|
-
function toPosix(filePath) {
|
|
166
|
-
return String(filePath || '').replace(/\\/g, '/');
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function escapeRegExp(value) {
|
|
170
|
-
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function existsSyncSafe(targetPath) {
|
|
174
|
-
try {
|
|
175
|
-
return fs.existsSync(targetPath);
|
|
176
|
-
} catch {
|
|
177
|
-
return false;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function isLikelyTextFile(relPath) {
|
|
182
|
-
const base = path.posix.basename(toPosix(relPath));
|
|
183
|
-
if (SPECIAL_FILE_BASENAMES.has(base)) return true;
|
|
184
|
-
if (COMMON_DOTFILE_BASENAMES.has(base)) return true;
|
|
185
|
-
if (base === '.cursorrules' || base === '.windsurfrules') return true;
|
|
186
|
-
return hasKnownFileExtension(base);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function fileExists(ctx, relPath) {
|
|
190
|
-
return existsSyncSafe(path.join(ctx.dir, relPath));
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function listFilesRecursive(rootDir, relDir = '', output = []) {
|
|
194
|
-
const absDir = path.join(rootDir, relDir);
|
|
195
|
-
let entries = [];
|
|
196
|
-
try {
|
|
197
|
-
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
198
|
-
} catch {
|
|
199
|
-
return output;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
for (const entry of entries) {
|
|
203
|
-
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
|
204
|
-
const nextRel = toPosix(path.join(relDir, entry.name));
|
|
205
|
-
if (entry.isDirectory()) {
|
|
206
|
-
listFilesRecursive(rootDir, nextRel, output);
|
|
207
|
-
continue;
|
|
208
|
-
}
|
|
209
|
-
if (entry.isFile() && isLikelyTextFile(nextRel)) {
|
|
210
|
-
output.push(nextRel);
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
return output;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
function getAgentConfigFiles(ctx) {
|
|
217
|
-
if (Array.isArray(ctx.__nerviqShallowRiskFiles)) {
|
|
218
|
-
return ctx.__nerviqShallowRiskFiles;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const files = new Set();
|
|
222
|
-
|
|
223
|
-
for (const relPath of ROOT_AGENT_FILES) {
|
|
224
|
-
if (fileExists(ctx, relPath)) {
|
|
225
|
-
files.add(toPosix(relPath));
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
for (const relDir of ROOT_AGENT_DIRS) {
|
|
230
|
-
if (!existsSyncSafe(path.join(ctx.dir, relDir))) continue;
|
|
231
|
-
for (const relPath of listFilesRecursive(ctx.dir, relDir)) {
|
|
232
|
-
files.add(toPosix(relPath));
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
ctx.__nerviqShallowRiskFiles = [...files]
|
|
237
|
-
.filter((relPath) => {
|
|
238
|
-
try {
|
|
239
|
-
const size = fs.statSync(path.join(ctx.dir, relPath)).size;
|
|
240
|
-
return Number.isFinite(size) && size <= 512 * 1024;
|
|
241
|
-
} catch {
|
|
242
|
-
return false;
|
|
243
|
-
}
|
|
244
|
-
})
|
|
245
|
-
.sort();
|
|
246
|
-
|
|
247
|
-
return ctx.__nerviqShallowRiskFiles;
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function platformForFile(relPath) {
|
|
251
|
-
const normalized = toPosix(relPath);
|
|
252
|
-
if (normalized === 'CLAUDE.md' || normalized.startsWith('.claude/')) return 'claude';
|
|
253
|
-
if (normalized === 'AGENTS.md' || normalized.startsWith('.codex/')) return 'codex';
|
|
254
|
-
if (normalized === 'GEMINI.md' || normalized.startsWith('.gemini/')) return 'gemini';
|
|
255
|
-
if (normalized === '.cursorrules' || normalized.startsWith('.cursor/')) return 'cursor';
|
|
256
|
-
if (normalized === '.windsurfrules' || normalized.startsWith('.windsurf/')) return 'windsurf';
|
|
257
|
-
if (normalized.startsWith('.aider.')) return 'aider';
|
|
258
|
-
if (normalized.startsWith('.github/') || normalized.startsWith('.vscode/')) return 'copilot';
|
|
259
|
-
if (normalized === 'opencode.json' || normalized.startsWith('.opencode/')) return 'opencode';
|
|
260
|
-
if (normalized === '.mcp.json') return 'claude';
|
|
261
|
-
return 'agent';
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
function getAgentConfigEntries(ctx) {
|
|
265
|
-
if (Array.isArray(ctx.__nerviqShallowRiskEntries)) {
|
|
266
|
-
return ctx.__nerviqShallowRiskEntries;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
ctx.__nerviqShallowRiskEntries = getAgentConfigFiles(ctx)
|
|
270
|
-
.map((file) => {
|
|
271
|
-
const content = ctx.fileContent(file);
|
|
272
|
-
if (!content || !content.trim()) return null;
|
|
273
|
-
return {
|
|
274
|
-
path: file,
|
|
275
|
-
platform: platformForFile(file),
|
|
276
|
-
content,
|
|
277
|
-
};
|
|
278
|
-
})
|
|
279
|
-
.filter(Boolean);
|
|
280
|
-
|
|
281
|
-
return ctx.__nerviqShallowRiskEntries;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function stripWrapperChars(value) {
|
|
285
|
-
return String(value || '')
|
|
286
|
-
.replace(/^[`"'(<\[]+/, '')
|
|
287
|
-
.replace(/[`"')>\].,:;!?]+$/, '');
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function normalizeCandidatePath(rawValue) {
|
|
291
|
-
let value = stripWrapperChars(rawValue);
|
|
292
|
-
if (value.startsWith('@')) value = value.slice(1);
|
|
293
|
-
if (/^mdc:/i.test(value)) value = value.slice(4);
|
|
294
|
-
return value;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
function hasKnownFileExtension(baseName) {
|
|
298
|
-
return FILE_REFERENCE_EXTENSION_RE.test(baseName || '');
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
function isVersionLikeToken(candidate) {
|
|
302
|
-
return /^v?\d+(?:\.\d+)+(?:[a-z]+\d*|\.[xX*])?$/i.test(candidate || '');
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function isFrameworkLabelToken(candidate) {
|
|
306
|
-
return FRAMEWORK_LABEL_TOKENS.has(String(candidate || '').toLowerCase());
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
function isDomainLikeToken(candidate) {
|
|
310
|
-
if (!candidate || candidate.includes('/')) return false;
|
|
311
|
-
const parts = String(candidate).split('.');
|
|
312
|
-
if (parts.length < 2) return false;
|
|
313
|
-
const tld = parts[parts.length - 1].toLowerCase();
|
|
314
|
-
if (!KNOWN_DOMAIN_TLDS.has(tld)) return false;
|
|
315
|
-
return parts.slice(0, -1).every((part) => /^[A-Za-z0-9-]+$/.test(part));
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function lineHasExampleContext(line) {
|
|
319
|
-
const text = String(line || '');
|
|
320
|
-
if (/^\s*\|/.test(text)) return true;
|
|
321
|
-
if (/^\s*#{1,6}\s+/.test(text)) return true;
|
|
322
|
-
return /\b(?:e\.g\.?|for example|examples?|sample|placeholder|template|snippet|user request|problem|solution)\b/i.test(text);
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
function looksLikeRelativeFileReference(candidate) {
|
|
326
|
-
if (!candidate) return false;
|
|
327
|
-
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) return false;
|
|
328
|
-
if (candidate.startsWith('#')) return false;
|
|
329
|
-
if (/[<>{}|]/.test(candidate)) return false;
|
|
330
|
-
|
|
331
|
-
const normalized = candidate.replace(/^\.\//, '');
|
|
332
|
-
const base = path.posix.basename(normalized);
|
|
333
|
-
const lowered = normalized.toLowerCase();
|
|
334
|
-
|
|
335
|
-
if (isDomainLikeToken(normalized)) return false;
|
|
336
|
-
if (isVersionLikeToken(normalized)) return false;
|
|
337
|
-
if (isFrameworkLabelToken(normalized)) return false;
|
|
338
|
-
if (base.startsWith('.') && !COMMON_DOTFILE_BASENAMES.has(base) && !COMMON_DOTFILE_BASENAMES.has(lowered)) {
|
|
339
|
-
return false;
|
|
340
|
-
}
|
|
341
|
-
if (normalized.split('/').some((segment) => /^\.[A-Za-z0-9_-]+$/.test(segment) && !COMMON_DOTFILE_BASENAMES.has(segment.toLowerCase()) && !KNOWN_HIDDEN_PATH_SEGMENTS.has(segment.toLowerCase()))) {
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
if (/^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*){2,}$/i.test(normalized) && !hasKnownFileExtension(base)) {
|
|
345
|
-
return false;
|
|
346
|
-
}
|
|
347
|
-
if (/^\.[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+$/.test(base) && !COMMON_DOTFILE_BASENAMES.has(base) && !COMMON_DOTFILE_BASENAMES.has(lowered)) {
|
|
348
|
-
return false;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (KNOWN_CONVENTION_PATHS.has(normalized) || SPECIAL_FILE_BASENAMES.has(base) || COMMON_DOTFILE_BASENAMES.has(base) || COMMON_DOTFILE_BASENAMES.has(lowered)) {
|
|
352
|
-
return true;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
return hasKnownFileExtension(base);
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
function resolveRepoPath(ctx, fromFile, candidate, mode = 'relative-to-file') {
|
|
359
|
-
const normalized = toPosix(candidate.replace(/^\.\//, ''));
|
|
360
|
-
const baseDir = mode === 'repo-root'
|
|
361
|
-
? ctx.dir
|
|
362
|
-
: path.join(ctx.dir, path.posix.dirname(toPosix(fromFile)));
|
|
363
|
-
const absolute = path.resolve(baseDir, normalized);
|
|
364
|
-
const root = path.resolve(ctx.dir);
|
|
365
|
-
|
|
366
|
-
if (!(absolute === root || absolute.startsWith(`${root}${path.sep}`))) {
|
|
367
|
-
return null;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
return toPosix(path.relative(root, absolute));
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
function getScannableLines(content) {
|
|
374
|
-
const lines = String(content || '').split(/\r?\n/);
|
|
375
|
-
const output = [];
|
|
376
|
-
let fence = null;
|
|
377
|
-
let htmlComment = false;
|
|
378
|
-
let frontmatter = false;
|
|
379
|
-
let frontmatterConsumed = false;
|
|
380
|
-
|
|
381
|
-
for (let index = 0; index < lines.length; index++) {
|
|
382
|
-
const line = lines[index];
|
|
383
|
-
const trimmed = line.trim();
|
|
384
|
-
|
|
385
|
-
if (!frontmatterConsumed && index === 0 && /^(---|\+\+\+)$/.test(trimmed)) {
|
|
386
|
-
frontmatter = true;
|
|
387
|
-
frontmatterConsumed = true;
|
|
388
|
-
continue;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
if (frontmatter) {
|
|
392
|
-
if (/^(---|\+\+\+)$/.test(trimmed)) {
|
|
393
|
-
frontmatter = false;
|
|
394
|
-
}
|
|
395
|
-
continue;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
if (!fence && htmlComment) {
|
|
399
|
-
if (trimmed.includes('-->')) {
|
|
400
|
-
htmlComment = false;
|
|
401
|
-
}
|
|
402
|
-
continue;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
if (!fence && /^(```|~~~)/.test(trimmed)) {
|
|
406
|
-
fence = trimmed.slice(0, 3);
|
|
407
|
-
continue;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
if (fence) {
|
|
411
|
-
if (trimmed.startsWith(fence)) {
|
|
412
|
-
fence = null;
|
|
413
|
-
}
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if (/^<!--/.test(trimmed)) {
|
|
418
|
-
if (!trimmed.includes('-->')) {
|
|
419
|
-
htmlComment = true;
|
|
420
|
-
}
|
|
421
|
-
continue;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
output.push({ lineNumber: index + 1, text: line });
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
return output;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function buildFinding(pattern, ctx, finding) {
|
|
431
|
-
const evidence = resolveEvidence(pattern.key, ctx, {
|
|
432
|
-
file: finding.file,
|
|
433
|
-
line: finding.line,
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
return {
|
|
437
|
-
key: pattern.key,
|
|
438
|
-
id: null,
|
|
439
|
-
name: pattern.name,
|
|
440
|
-
category: 'shallow-risk',
|
|
441
|
-
layer: LAYERS.SHALLOW_RISK,
|
|
442
|
-
severity: finding.severity || pattern.severity,
|
|
443
|
-
impact: finding.severity || pattern.severity,
|
|
444
|
-
rating: null,
|
|
445
|
-
passed: false,
|
|
446
|
-
file: evidence ? evidence.file : (finding.file || null),
|
|
447
|
-
line: evidence ? evidence.line : (finding.line || null),
|
|
448
|
-
snippet: evidence ? evidence.snippet : (finding.snippet || null),
|
|
449
|
-
fix: finding.fix || null,
|
|
450
|
-
sourceUrl: finding.sourceUrl || pattern.sourceUrl || SHALLOW_RISK_DOC_URL,
|
|
451
|
-
};
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function isKnownConventionPath(relPath) {
|
|
455
|
-
const normalized = toPosix(relPath).replace(/^\.\//, '');
|
|
456
|
-
return KNOWN_CONVENTION_PATHS.has(normalized);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function findFirstRepoPath(ctx, matcher, options = {}) {
|
|
460
|
-
const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : 4;
|
|
461
|
-
const queue = [{ absDir: ctx.dir, relDir: '', depth: 0 }];
|
|
462
|
-
|
|
463
|
-
while (queue.length > 0) {
|
|
464
|
-
const current = queue.shift();
|
|
465
|
-
let entries = [];
|
|
466
|
-
try {
|
|
467
|
-
entries = fs.readdirSync(current.absDir, { withFileTypes: true });
|
|
468
|
-
} catch {
|
|
469
|
-
continue;
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
for (const entry of entries) {
|
|
473
|
-
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
|
474
|
-
const relPath = toPosix(path.join(current.relDir, entry.name));
|
|
475
|
-
const absPath = path.join(current.absDir, entry.name);
|
|
476
|
-
|
|
477
|
-
if (entry.isFile()) {
|
|
478
|
-
if (typeof matcher === 'function' ? matcher(relPath, entry.name) : matcher === relPath) {
|
|
479
|
-
return relPath;
|
|
480
|
-
}
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
if (entry.isDirectory() && current.depth < maxDepth) {
|
|
485
|
-
queue.push({ absDir: absPath, relDir: relPath, depth: current.depth + 1 });
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
return null;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
function findFirstStackEvidence(ctx, stackKey) {
|
|
494
|
-
const stack = STACKS[stackKey];
|
|
495
|
-
if (!stack) return null;
|
|
496
|
-
|
|
497
|
-
for (const probe of stack.files || []) {
|
|
498
|
-
if (fileExists(ctx, probe)) return probe;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
return findFirstRepoPath(ctx, (_relPath, baseName) => (stack.files || []).includes(baseName));
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function getDetectedStackEvidence(ctx) {
|
|
505
|
-
if (Array.isArray(ctx.__nerviqShallowRiskStackEvidence)) {
|
|
506
|
-
return ctx.__nerviqShallowRiskStackEvidence;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
const seen = new Set();
|
|
510
|
-
const evidence = [];
|
|
511
|
-
|
|
512
|
-
for (const stack of ctx.detectStacks(STACKS)) {
|
|
513
|
-
if (seen.has(stack.key)) continue;
|
|
514
|
-
seen.add(stack.key);
|
|
515
|
-
const file = findFirstStackEvidence(ctx, stack.key);
|
|
516
|
-
if (!file) continue;
|
|
517
|
-
evidence.push({
|
|
518
|
-
key: stack.key,
|
|
519
|
-
label: stack.label,
|
|
520
|
-
file,
|
|
521
|
-
});
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
ctx.__nerviqShallowRiskStackEvidence = evidence;
|
|
525
|
-
return evidence;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function detectClaimOnLine(line) {
|
|
529
|
-
for (const claim of STACK_CLAIMS) {
|
|
530
|
-
for (const pattern of claim.patterns) {
|
|
531
|
-
pattern.lastIndex = 0;
|
|
532
|
-
if (pattern.test(line)) {
|
|
533
|
-
return claim;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
return null;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
function collectStackClaims(ctx) {
|
|
541
|
-
if (Array.isArray(ctx.__nerviqShallowRiskClaims)) {
|
|
542
|
-
return ctx.__nerviqShallowRiskClaims;
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
const claims = [];
|
|
546
|
-
|
|
547
|
-
for (const entry of getAgentConfigEntries(ctx)) {
|
|
548
|
-
for (const { lineNumber, text } of getScannableLines(entry.content)) {
|
|
549
|
-
const claim = detectClaimOnLine(text);
|
|
550
|
-
if (!claim) continue;
|
|
551
|
-
claims.push({
|
|
552
|
-
key: claim.key,
|
|
553
|
-
label: claim.label,
|
|
554
|
-
stackKeys: claim.stackKeys,
|
|
555
|
-
file: entry.path,
|
|
556
|
-
line: lineNumber,
|
|
557
|
-
platform: entry.platform,
|
|
558
|
-
text,
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
ctx.__nerviqShallowRiskClaims = claims;
|
|
564
|
-
return claims;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function getClaimByKey(key) {
|
|
568
|
-
return STACK_CLAIM_BY_KEY.get(key) || null;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
function isClearlyLocalMcpBinary(command) {
|
|
572
|
-
if (!command) return false;
|
|
573
|
-
const base = path.posix.basename(toPosix(command)).toLowerCase();
|
|
574
|
-
if (LOCAL_MCP_BINARIES.has(base)) return true;
|
|
575
|
-
if (/^(node|npx|python|python3|bash|sh|pwsh|powershell)$/i.test(base)) return false;
|
|
576
|
-
return /(?:^|[-_.])mcp$/i.test(base) || /-mcp\b/i.test(base);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function getHookCommandPath(command) {
|
|
580
|
-
if (typeof command !== 'string' || !command.trim()) return null;
|
|
581
|
-
const tokens = command.match(/"[^"]+"|'[^']+'|\S+/g) || [];
|
|
582
|
-
const cleaned = tokens.map((token) => token.replace(/^['"]|['"]$/g, ''));
|
|
583
|
-
if (cleaned.length === 0) return null;
|
|
584
|
-
|
|
585
|
-
const first = cleaned[0];
|
|
586
|
-
if (looksLikeRelativeFileReference(first)) return first;
|
|
587
|
-
|
|
588
|
-
if (/^(node|python|python3|bash|sh|pwsh|powershell)$/i.test(first)) {
|
|
589
|
-
const second = cleaned[1];
|
|
590
|
-
if (!second || /^-(?:e|c|Command|EncodedCommand)$/.test(second)) return null;
|
|
591
|
-
if (looksLikeRelativeFileReference(second)) return second;
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
return null;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
function hasLegacyAiderPin(ctx) {
|
|
598
|
-
const files = [
|
|
599
|
-
'requirements.txt',
|
|
600
|
-
'requirements-dev.txt',
|
|
601
|
-
'requirements-dev.in',
|
|
602
|
-
'pyproject.toml',
|
|
603
|
-
];
|
|
604
|
-
|
|
605
|
-
const legacyVersion = /(?:aider|aider-chat)\s*(?:==|~=|<=|<)\s*0\.(\d+)/ig;
|
|
606
|
-
for (const file of files) {
|
|
607
|
-
const content = ctx.fileContent(file) || '';
|
|
608
|
-
legacyVersion.lastIndex = 0;
|
|
609
|
-
let match = legacyVersion.exec(content);
|
|
610
|
-
while (match) {
|
|
611
|
-
const minor = Number(match[1]);
|
|
612
|
-
if (Number.isFinite(minor) && minor < 60) {
|
|
613
|
-
return true;
|
|
614
|
-
}
|
|
615
|
-
match = legacyVersion.exec(content);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
return false;
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
module.exports = {
|
|
623
|
-
AIDER_P0_SOURCES,
|
|
624
|
-
SHALLOW_RISK_BANNER,
|
|
625
|
-
SHALLOW_RISK_BANNER_LINES,
|
|
626
|
-
SHALLOW_RISK_DOC_URL,
|
|
627
|
-
buildFinding,
|
|
628
|
-
collectStackClaims,
|
|
629
|
-
escapeRegExp,
|
|
630
|
-
fileExists,
|
|
631
|
-
findFirstRepoPath,
|
|
632
|
-
findFirstStackEvidence,
|
|
633
|
-
getAgentConfigEntries,
|
|
634
|
-
getAgentConfigFiles,
|
|
635
|
-
getClaimByKey,
|
|
636
|
-
getDetectedStackEvidence,
|
|
637
|
-
getHookCommandPath,
|
|
638
|
-
getScannableLines,
|
|
639
|
-
hasLegacyAiderPin,
|
|
640
|
-
isClearlyLocalMcpBinary,
|
|
641
|
-
isKnownConventionPath,
|
|
642
|
-
lineHasExampleContext,
|
|
643
|
-
looksLikeRelativeFileReference,
|
|
644
|
-
normalizeCandidatePath,
|
|
645
|
-
platformForFile,
|
|
646
|
-
resolveRepoPath,
|
|
647
|
-
toPosix,
|
|
648
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { resolveEvidence } = require('../audit/evidence');
|
|
6
|
+
const { LAYERS } = require('../audit/layers');
|
|
7
|
+
const { STACKS } = require('../techniques');
|
|
8
|
+
const { P0_SOURCES: AIDER_P0_SOURCES } = require('../aider/freshness');
|
|
9
|
+
|
|
10
|
+
const SHALLOW_RISK_DOC_URL = 'https://github.com/nerviq/nerviq/blob/main/docs/shallow-risk.md';
|
|
11
|
+
const SHALLOW_RISK_BANNER_LINES = [
|
|
12
|
+
'Shallow Risk mode (experimental, opt-in). NERVIQ checks 8 patterns',
|
|
13
|
+
'that sit at the intersection of your AI agent configuration and',
|
|
14
|
+
'your codebase - the kind of issues no generic scanner can find',
|
|
15
|
+
'because they require understanding CLAUDE.md, .claude/settings.json,',
|
|
16
|
+
'and similar files. For broader code-level security coverage, pair',
|
|
17
|
+
'this with Semgrep, CodeQL, or a dedicated secret scanner.',
|
|
18
|
+
];
|
|
19
|
+
const SHALLOW_RISK_BANNER = SHALLOW_RISK_BANNER_LINES.join('\n');
|
|
20
|
+
|
|
21
|
+
const ROOT_AGENT_FILES = [
|
|
22
|
+
'CLAUDE.md',
|
|
23
|
+
'AGENTS.md',
|
|
24
|
+
'GEMINI.md',
|
|
25
|
+
'.cursorrules',
|
|
26
|
+
'.windsurfrules',
|
|
27
|
+
'.aider.conf.yml',
|
|
28
|
+
'.aider.conf.yaml',
|
|
29
|
+
'.mcp.json',
|
|
30
|
+
'.claude/settings.json',
|
|
31
|
+
'.claude/CLAUDE.md',
|
|
32
|
+
'.gemini/settings.json',
|
|
33
|
+
'.github/copilot-instructions.md',
|
|
34
|
+
'.vscode/mcp.json',
|
|
35
|
+
'.vscode/settings.json',
|
|
36
|
+
'.codex/config.toml',
|
|
37
|
+
'opencode.json',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const ROOT_AGENT_DIRS = [
|
|
41
|
+
'.claude/agents',
|
|
42
|
+
'.claude/commands',
|
|
43
|
+
'.claude/hooks',
|
|
44
|
+
'.claude/rules',
|
|
45
|
+
'.claude/skills',
|
|
46
|
+
'.cursor/rules',
|
|
47
|
+
'.windsurf/rules',
|
|
48
|
+
'.codex/agents',
|
|
49
|
+
'.codex/hooks',
|
|
50
|
+
'.codex/skills',
|
|
51
|
+
'.github/instructions',
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const EXCLUDED_DIRS = new Set([
|
|
55
|
+
'.git',
|
|
56
|
+
'node_modules',
|
|
57
|
+
'coverage',
|
|
58
|
+
'dist',
|
|
59
|
+
'build',
|
|
60
|
+
'.next',
|
|
61
|
+
'.turbo',
|
|
62
|
+
'.cache',
|
|
63
|
+
'__pycache__',
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
const SPECIAL_FILE_BASENAMES = new Set([
|
|
67
|
+
'AGENTS.md',
|
|
68
|
+
'CLAUDE.md',
|
|
69
|
+
'GEMINI.md',
|
|
70
|
+
'SECURITY.md',
|
|
71
|
+
'README.md',
|
|
72
|
+
'CONTRIBUTING.md',
|
|
73
|
+
'CODEOWNERS',
|
|
74
|
+
'Dockerfile',
|
|
75
|
+
'Makefile',
|
|
76
|
+
'justfile',
|
|
77
|
+
'manifest.json',
|
|
78
|
+
'package.json',
|
|
79
|
+
'pyproject.toml',
|
|
80
|
+
'go.mod',
|
|
81
|
+
'Cargo.toml',
|
|
82
|
+
]);
|
|
83
|
+
|
|
84
|
+
const COMMON_DOTFILE_BASENAMES = new Set([
|
|
85
|
+
'.editorconfig',
|
|
86
|
+
'.env',
|
|
87
|
+
'.env.example',
|
|
88
|
+
'.env.sample',
|
|
89
|
+
'.env.template',
|
|
90
|
+
'.gitattributes',
|
|
91
|
+
'.gitignore',
|
|
92
|
+
'.npmrc',
|
|
93
|
+
'.nvmrc',
|
|
94
|
+
'.prettierrc',
|
|
95
|
+
'.python-version',
|
|
96
|
+
'.tool-versions',
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const KNOWN_CONVENTION_PATHS = new Set([
|
|
100
|
+
'CODEOWNERS',
|
|
101
|
+
'.github/CODEOWNERS',
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
const FILE_REFERENCE_EXTENSION_RE = /\.(?:md|mdc|txt|rst|json|jsonc|ya?ml|toml|conf|sh|ps1|js|cjs|mjs|ts|tsx|jsx|cts|mts|py|go|rs|java|kt|kts|gradle|cs|rb|php|swift|pbxproj|xcconfig|xcworkspace|xcodeproj|h|hpp|c|cc|cpp|m|mm|sql|ini|cfg|properties|xml|html|css|scss|sass|lock)$/i;
|
|
105
|
+
const KNOWN_DOMAIN_TLDS = new Set([
|
|
106
|
+
'ai',
|
|
107
|
+
'app',
|
|
108
|
+
'co',
|
|
109
|
+
'com',
|
|
110
|
+
'dev',
|
|
111
|
+
'io',
|
|
112
|
+
'net',
|
|
113
|
+
'org',
|
|
114
|
+
'sh',
|
|
115
|
+
]);
|
|
116
|
+
const KNOWN_HIDDEN_PATH_SEGMENTS = new Set([
|
|
117
|
+
'.claude',
|
|
118
|
+
'.codex',
|
|
119
|
+
'.cursor',
|
|
120
|
+
'.gemini',
|
|
121
|
+
'.github',
|
|
122
|
+
'.opencode',
|
|
123
|
+
'.vscode',
|
|
124
|
+
'.windsurf',
|
|
125
|
+
]);
|
|
126
|
+
const FRAMEWORK_LABEL_TOKENS = new Set([
|
|
127
|
+
'd3.js',
|
|
128
|
+
'go',
|
|
129
|
+
'golang',
|
|
130
|
+
'javascript',
|
|
131
|
+
'kotlin',
|
|
132
|
+
'next',
|
|
133
|
+
'next.js',
|
|
134
|
+
'node',
|
|
135
|
+
'node.js',
|
|
136
|
+
'python',
|
|
137
|
+
'rust',
|
|
138
|
+
'swift',
|
|
139
|
+
'typescript',
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
const LOCAL_MCP_BINARIES = new Set([
|
|
143
|
+
'context7-mcp',
|
|
144
|
+
'nerviq-mcp',
|
|
145
|
+
]);
|
|
146
|
+
|
|
147
|
+
const STACK_CLAIMS = [
|
|
148
|
+
{ key: 'go', label: 'Go', stackKeys: ['go'], patterns: [/\bprimary (?:language|stack)\s*:\s*(?:go|golang)\b/i, /\bthis (?:repo|project|service|codebase|app|microservice)\b[\s\S]{0,40}\b(?:go|golang)\b/i, /\bwritten in\s+(?:go|golang)\b/i] },
|
|
149
|
+
{ key: 'python', label: 'Python', stackKeys: ['python', 'django', 'fastapi'], patterns: [/\bprimary (?:language|stack)\s*:\s*python\b/i, /\bthis (?:repo|project|service|codebase|app|microservice)\b[\s\S]{0,40}\bpython\b/i, /\bwritten in\s+python\b/i] },
|
|
150
|
+
{ key: 'node', label: 'Node.js', stackKeys: ['node'], patterns: [/\bprimary (?:language|stack)\s*:\s*(?:node|node\.js)\b/i, /\bthis (?:repo|project|service|codebase|app|microservice)\b[\s\S]{0,40}\bnode(?:\.js)?\b/i] },
|
|
151
|
+
{ key: 'javascript', label: 'JavaScript', stackKeys: ['node'], patterns: [/\bprimary (?:language|stack)\s*:\s*javascript\b/i, /\bpure javascript project\b/i, /\bthis (?:repo|project|codebase|app)\b[\s\S]{0,40}\bjavascript\b/i] },
|
|
152
|
+
{ key: 'typescript', label: 'TypeScript', stackKeys: ['typescript', 'node'], patterns: [/\bprimary (?:language|stack)\s*:\s*typescript\b/i, /\buse\s+typescript\b/i, /\btypescript strict mode\b/i, /\bthis (?:repo|project|codebase|app)\b[\s\S]{0,40}\btypescript\b/i] },
|
|
153
|
+
{ key: 'rust', label: 'Rust', stackKeys: ['rust'], patterns: [/\bprimary (?:language|stack)\s*:\s*rust\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\brust\b/i] },
|
|
154
|
+
{ key: 'java', label: 'Java', stackKeys: ['java'], patterns: [/\bprimary (?:language|stack)\s*:\s*java\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bjava\b/i] },
|
|
155
|
+
{ key: 'kotlin', label: 'Kotlin', stackKeys: ['kotlin'], patterns: [/\bprimary (?:language|stack)\s*:\s*kotlin\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bkotlin\b/i] },
|
|
156
|
+
{ key: 'ruby', label: 'Ruby', stackKeys: ['ruby'], patterns: [/\bprimary (?:language|stack)\s*:\s*ruby\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bruby\b/i] },
|
|
157
|
+
{ key: 'php', label: 'PHP', stackKeys: ['php', 'laravel'], patterns: [/\bprimary (?:language|stack)\s*:\s*php\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bphp\b/i] },
|
|
158
|
+
{ key: 'dotnet', label: '.NET', stackKeys: ['dotnet'], patterns: [/\bprimary (?:language|stack)\s*:\s*(?:\.net|dotnet|c#)\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\b(?:\.net|dotnet|c#)\b/i] },
|
|
159
|
+
{ key: 'swift', label: 'Swift', stackKeys: ['swift'], patterns: [/\bprimary (?:language|stack)\s*:\s*swift\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bswift\b/i] },
|
|
160
|
+
{ key: 'flutter', label: 'Flutter', stackKeys: ['flutter'], patterns: [/\bprimary (?:language|stack)\s*:\s*flutter\b/i, /\bthis (?:repo|project|service|codebase|app)\b[\s\S]{0,40}\bflutter\b/i] },
|
|
161
|
+
];
|
|
162
|
+
|
|
163
|
+
const STACK_CLAIM_BY_KEY = new Map(STACK_CLAIMS.map((claim) => [claim.key, claim]));
|
|
164
|
+
|
|
165
|
+
function toPosix(filePath) {
|
|
166
|
+
return String(filePath || '').replace(/\\/g, '/');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function escapeRegExp(value) {
|
|
170
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function existsSyncSafe(targetPath) {
|
|
174
|
+
try {
|
|
175
|
+
return fs.existsSync(targetPath);
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isLikelyTextFile(relPath) {
|
|
182
|
+
const base = path.posix.basename(toPosix(relPath));
|
|
183
|
+
if (SPECIAL_FILE_BASENAMES.has(base)) return true;
|
|
184
|
+
if (COMMON_DOTFILE_BASENAMES.has(base)) return true;
|
|
185
|
+
if (base === '.cursorrules' || base === '.windsurfrules') return true;
|
|
186
|
+
return hasKnownFileExtension(base);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function fileExists(ctx, relPath) {
|
|
190
|
+
return existsSyncSafe(path.join(ctx.dir, relPath));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function listFilesRecursive(rootDir, relDir = '', output = []) {
|
|
194
|
+
const absDir = path.join(rootDir, relDir);
|
|
195
|
+
let entries = [];
|
|
196
|
+
try {
|
|
197
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
198
|
+
} catch {
|
|
199
|
+
return output;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const entry of entries) {
|
|
203
|
+
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
|
204
|
+
const nextRel = toPosix(path.join(relDir, entry.name));
|
|
205
|
+
if (entry.isDirectory()) {
|
|
206
|
+
listFilesRecursive(rootDir, nextRel, output);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (entry.isFile() && isLikelyTextFile(nextRel)) {
|
|
210
|
+
output.push(nextRel);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return output;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function getAgentConfigFiles(ctx) {
|
|
217
|
+
if (Array.isArray(ctx.__nerviqShallowRiskFiles)) {
|
|
218
|
+
return ctx.__nerviqShallowRiskFiles;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const files = new Set();
|
|
222
|
+
|
|
223
|
+
for (const relPath of ROOT_AGENT_FILES) {
|
|
224
|
+
if (fileExists(ctx, relPath)) {
|
|
225
|
+
files.add(toPosix(relPath));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const relDir of ROOT_AGENT_DIRS) {
|
|
230
|
+
if (!existsSyncSafe(path.join(ctx.dir, relDir))) continue;
|
|
231
|
+
for (const relPath of listFilesRecursive(ctx.dir, relDir)) {
|
|
232
|
+
files.add(toPosix(relPath));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
ctx.__nerviqShallowRiskFiles = [...files]
|
|
237
|
+
.filter((relPath) => {
|
|
238
|
+
try {
|
|
239
|
+
const size = fs.statSync(path.join(ctx.dir, relPath)).size;
|
|
240
|
+
return Number.isFinite(size) && size <= 512 * 1024;
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
.sort();
|
|
246
|
+
|
|
247
|
+
return ctx.__nerviqShallowRiskFiles;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function platformForFile(relPath) {
|
|
251
|
+
const normalized = toPosix(relPath);
|
|
252
|
+
if (normalized === 'CLAUDE.md' || normalized.startsWith('.claude/')) return 'claude';
|
|
253
|
+
if (normalized === 'AGENTS.md' || normalized.startsWith('.codex/')) return 'codex';
|
|
254
|
+
if (normalized === 'GEMINI.md' || normalized.startsWith('.gemini/')) return 'gemini';
|
|
255
|
+
if (normalized === '.cursorrules' || normalized.startsWith('.cursor/')) return 'cursor';
|
|
256
|
+
if (normalized === '.windsurfrules' || normalized.startsWith('.windsurf/')) return 'windsurf';
|
|
257
|
+
if (normalized.startsWith('.aider.')) return 'aider';
|
|
258
|
+
if (normalized.startsWith('.github/') || normalized.startsWith('.vscode/')) return 'copilot';
|
|
259
|
+
if (normalized === 'opencode.json' || normalized.startsWith('.opencode/')) return 'opencode';
|
|
260
|
+
if (normalized === '.mcp.json') return 'claude';
|
|
261
|
+
return 'agent';
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function getAgentConfigEntries(ctx) {
|
|
265
|
+
if (Array.isArray(ctx.__nerviqShallowRiskEntries)) {
|
|
266
|
+
return ctx.__nerviqShallowRiskEntries;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
ctx.__nerviqShallowRiskEntries = getAgentConfigFiles(ctx)
|
|
270
|
+
.map((file) => {
|
|
271
|
+
const content = ctx.fileContent(file);
|
|
272
|
+
if (!content || !content.trim()) return null;
|
|
273
|
+
return {
|
|
274
|
+
path: file,
|
|
275
|
+
platform: platformForFile(file),
|
|
276
|
+
content,
|
|
277
|
+
};
|
|
278
|
+
})
|
|
279
|
+
.filter(Boolean);
|
|
280
|
+
|
|
281
|
+
return ctx.__nerviqShallowRiskEntries;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function stripWrapperChars(value) {
|
|
285
|
+
return String(value || '')
|
|
286
|
+
.replace(/^[`"'(<\[]+/, '')
|
|
287
|
+
.replace(/[`"')>\].,:;!?]+$/, '');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function normalizeCandidatePath(rawValue) {
|
|
291
|
+
let value = stripWrapperChars(rawValue);
|
|
292
|
+
if (value.startsWith('@')) value = value.slice(1);
|
|
293
|
+
if (/^mdc:/i.test(value)) value = value.slice(4);
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function hasKnownFileExtension(baseName) {
|
|
298
|
+
return FILE_REFERENCE_EXTENSION_RE.test(baseName || '');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function isVersionLikeToken(candidate) {
|
|
302
|
+
return /^v?\d+(?:\.\d+)+(?:[a-z]+\d*|\.[xX*])?$/i.test(candidate || '');
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isFrameworkLabelToken(candidate) {
|
|
306
|
+
return FRAMEWORK_LABEL_TOKENS.has(String(candidate || '').toLowerCase());
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isDomainLikeToken(candidate) {
|
|
310
|
+
if (!candidate || candidate.includes('/')) return false;
|
|
311
|
+
const parts = String(candidate).split('.');
|
|
312
|
+
if (parts.length < 2) return false;
|
|
313
|
+
const tld = parts[parts.length - 1].toLowerCase();
|
|
314
|
+
if (!KNOWN_DOMAIN_TLDS.has(tld)) return false;
|
|
315
|
+
return parts.slice(0, -1).every((part) => /^[A-Za-z0-9-]+$/.test(part));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function lineHasExampleContext(line) {
|
|
319
|
+
const text = String(line || '');
|
|
320
|
+
if (/^\s*\|/.test(text)) return true;
|
|
321
|
+
if (/^\s*#{1,6}\s+/.test(text)) return true;
|
|
322
|
+
return /\b(?:e\.g\.?|for example|examples?|sample|placeholder|template|snippet|user request|problem|solution)\b/i.test(text);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function looksLikeRelativeFileReference(candidate) {
|
|
326
|
+
if (!candidate) return false;
|
|
327
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(candidate)) return false;
|
|
328
|
+
if (candidate.startsWith('#')) return false;
|
|
329
|
+
if (/[<>{}|]/.test(candidate)) return false;
|
|
330
|
+
|
|
331
|
+
const normalized = candidate.replace(/^\.\//, '');
|
|
332
|
+
const base = path.posix.basename(normalized);
|
|
333
|
+
const lowered = normalized.toLowerCase();
|
|
334
|
+
|
|
335
|
+
if (isDomainLikeToken(normalized)) return false;
|
|
336
|
+
if (isVersionLikeToken(normalized)) return false;
|
|
337
|
+
if (isFrameworkLabelToken(normalized)) return false;
|
|
338
|
+
if (base.startsWith('.') && !COMMON_DOTFILE_BASENAMES.has(base) && !COMMON_DOTFILE_BASENAMES.has(lowered)) {
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
if (normalized.split('/').some((segment) => /^\.[A-Za-z0-9_-]+$/.test(segment) && !COMMON_DOTFILE_BASENAMES.has(segment.toLowerCase()) && !KNOWN_HIDDEN_PATH_SEGMENTS.has(segment.toLowerCase()))) {
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
if (/^[A-Za-z][A-Za-z0-9_-]*(?:\.[A-Za-z][A-Za-z0-9_-]*){2,}$/i.test(normalized) && !hasKnownFileExtension(base)) {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
if (/^\.[A-Za-z0-9_-]+\.[A-Za-z0-9._-]+$/.test(base) && !COMMON_DOTFILE_BASENAMES.has(base) && !COMMON_DOTFILE_BASENAMES.has(lowered)) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (KNOWN_CONVENTION_PATHS.has(normalized) || SPECIAL_FILE_BASENAMES.has(base) || COMMON_DOTFILE_BASENAMES.has(base) || COMMON_DOTFILE_BASENAMES.has(lowered)) {
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return hasKnownFileExtension(base);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function resolveRepoPath(ctx, fromFile, candidate, mode = 'relative-to-file') {
|
|
359
|
+
const normalized = toPosix(candidate.replace(/^\.\//, ''));
|
|
360
|
+
const baseDir = mode === 'repo-root'
|
|
361
|
+
? ctx.dir
|
|
362
|
+
: path.join(ctx.dir, path.posix.dirname(toPosix(fromFile)));
|
|
363
|
+
const absolute = path.resolve(baseDir, normalized);
|
|
364
|
+
const root = path.resolve(ctx.dir);
|
|
365
|
+
|
|
366
|
+
if (!(absolute === root || absolute.startsWith(`${root}${path.sep}`))) {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return toPosix(path.relative(root, absolute));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function getScannableLines(content) {
|
|
374
|
+
const lines = String(content || '').split(/\r?\n/);
|
|
375
|
+
const output = [];
|
|
376
|
+
let fence = null;
|
|
377
|
+
let htmlComment = false;
|
|
378
|
+
let frontmatter = false;
|
|
379
|
+
let frontmatterConsumed = false;
|
|
380
|
+
|
|
381
|
+
for (let index = 0; index < lines.length; index++) {
|
|
382
|
+
const line = lines[index];
|
|
383
|
+
const trimmed = line.trim();
|
|
384
|
+
|
|
385
|
+
if (!frontmatterConsumed && index === 0 && /^(---|\+\+\+)$/.test(trimmed)) {
|
|
386
|
+
frontmatter = true;
|
|
387
|
+
frontmatterConsumed = true;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (frontmatter) {
|
|
392
|
+
if (/^(---|\+\+\+)$/.test(trimmed)) {
|
|
393
|
+
frontmatter = false;
|
|
394
|
+
}
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (!fence && htmlComment) {
|
|
399
|
+
if (trimmed.includes('-->')) {
|
|
400
|
+
htmlComment = false;
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
if (!fence && /^(```|~~~)/.test(trimmed)) {
|
|
406
|
+
fence = trimmed.slice(0, 3);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (fence) {
|
|
411
|
+
if (trimmed.startsWith(fence)) {
|
|
412
|
+
fence = null;
|
|
413
|
+
}
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (/^<!--/.test(trimmed)) {
|
|
418
|
+
if (!trimmed.includes('-->')) {
|
|
419
|
+
htmlComment = true;
|
|
420
|
+
}
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
output.push({ lineNumber: index + 1, text: line });
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return output;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function buildFinding(pattern, ctx, finding) {
|
|
431
|
+
const evidence = resolveEvidence(pattern.key, ctx, {
|
|
432
|
+
file: finding.file,
|
|
433
|
+
line: finding.line,
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
return {
|
|
437
|
+
key: pattern.key,
|
|
438
|
+
id: null,
|
|
439
|
+
name: pattern.name,
|
|
440
|
+
category: 'shallow-risk',
|
|
441
|
+
layer: LAYERS.SHALLOW_RISK,
|
|
442
|
+
severity: finding.severity || pattern.severity,
|
|
443
|
+
impact: finding.severity || pattern.severity,
|
|
444
|
+
rating: null,
|
|
445
|
+
passed: false,
|
|
446
|
+
file: evidence ? evidence.file : (finding.file || null),
|
|
447
|
+
line: evidence ? evidence.line : (finding.line || null),
|
|
448
|
+
snippet: evidence ? evidence.snippet : (finding.snippet || null),
|
|
449
|
+
fix: finding.fix || null,
|
|
450
|
+
sourceUrl: finding.sourceUrl || pattern.sourceUrl || SHALLOW_RISK_DOC_URL,
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function isKnownConventionPath(relPath) {
|
|
455
|
+
const normalized = toPosix(relPath).replace(/^\.\//, '');
|
|
456
|
+
return KNOWN_CONVENTION_PATHS.has(normalized);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function findFirstRepoPath(ctx, matcher, options = {}) {
|
|
460
|
+
const maxDepth = Number.isInteger(options.maxDepth) ? options.maxDepth : 4;
|
|
461
|
+
const queue = [{ absDir: ctx.dir, relDir: '', depth: 0 }];
|
|
462
|
+
|
|
463
|
+
while (queue.length > 0) {
|
|
464
|
+
const current = queue.shift();
|
|
465
|
+
let entries = [];
|
|
466
|
+
try {
|
|
467
|
+
entries = fs.readdirSync(current.absDir, { withFileTypes: true });
|
|
468
|
+
} catch {
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const entry of entries) {
|
|
473
|
+
if (EXCLUDED_DIRS.has(entry.name)) continue;
|
|
474
|
+
const relPath = toPosix(path.join(current.relDir, entry.name));
|
|
475
|
+
const absPath = path.join(current.absDir, entry.name);
|
|
476
|
+
|
|
477
|
+
if (entry.isFile()) {
|
|
478
|
+
if (typeof matcher === 'function' ? matcher(relPath, entry.name) : matcher === relPath) {
|
|
479
|
+
return relPath;
|
|
480
|
+
}
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (entry.isDirectory() && current.depth < maxDepth) {
|
|
485
|
+
queue.push({ absDir: absPath, relDir: relPath, depth: current.depth + 1 });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function findFirstStackEvidence(ctx, stackKey) {
|
|
494
|
+
const stack = STACKS[stackKey];
|
|
495
|
+
if (!stack) return null;
|
|
496
|
+
|
|
497
|
+
for (const probe of stack.files || []) {
|
|
498
|
+
if (fileExists(ctx, probe)) return probe;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return findFirstRepoPath(ctx, (_relPath, baseName) => (stack.files || []).includes(baseName));
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function getDetectedStackEvidence(ctx) {
|
|
505
|
+
if (Array.isArray(ctx.__nerviqShallowRiskStackEvidence)) {
|
|
506
|
+
return ctx.__nerviqShallowRiskStackEvidence;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const seen = new Set();
|
|
510
|
+
const evidence = [];
|
|
511
|
+
|
|
512
|
+
for (const stack of ctx.detectStacks(STACKS)) {
|
|
513
|
+
if (seen.has(stack.key)) continue;
|
|
514
|
+
seen.add(stack.key);
|
|
515
|
+
const file = findFirstStackEvidence(ctx, stack.key);
|
|
516
|
+
if (!file) continue;
|
|
517
|
+
evidence.push({
|
|
518
|
+
key: stack.key,
|
|
519
|
+
label: stack.label,
|
|
520
|
+
file,
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
ctx.__nerviqShallowRiskStackEvidence = evidence;
|
|
525
|
+
return evidence;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function detectClaimOnLine(line) {
|
|
529
|
+
for (const claim of STACK_CLAIMS) {
|
|
530
|
+
for (const pattern of claim.patterns) {
|
|
531
|
+
pattern.lastIndex = 0;
|
|
532
|
+
if (pattern.test(line)) {
|
|
533
|
+
return claim;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return null;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function collectStackClaims(ctx) {
|
|
541
|
+
if (Array.isArray(ctx.__nerviqShallowRiskClaims)) {
|
|
542
|
+
return ctx.__nerviqShallowRiskClaims;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const claims = [];
|
|
546
|
+
|
|
547
|
+
for (const entry of getAgentConfigEntries(ctx)) {
|
|
548
|
+
for (const { lineNumber, text } of getScannableLines(entry.content)) {
|
|
549
|
+
const claim = detectClaimOnLine(text);
|
|
550
|
+
if (!claim) continue;
|
|
551
|
+
claims.push({
|
|
552
|
+
key: claim.key,
|
|
553
|
+
label: claim.label,
|
|
554
|
+
stackKeys: claim.stackKeys,
|
|
555
|
+
file: entry.path,
|
|
556
|
+
line: lineNumber,
|
|
557
|
+
platform: entry.platform,
|
|
558
|
+
text,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
ctx.__nerviqShallowRiskClaims = claims;
|
|
564
|
+
return claims;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function getClaimByKey(key) {
|
|
568
|
+
return STACK_CLAIM_BY_KEY.get(key) || null;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function isClearlyLocalMcpBinary(command) {
|
|
572
|
+
if (!command) return false;
|
|
573
|
+
const base = path.posix.basename(toPosix(command)).toLowerCase();
|
|
574
|
+
if (LOCAL_MCP_BINARIES.has(base)) return true;
|
|
575
|
+
if (/^(node|npx|python|python3|bash|sh|pwsh|powershell)$/i.test(base)) return false;
|
|
576
|
+
return /(?:^|[-_.])mcp$/i.test(base) || /-mcp\b/i.test(base);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function getHookCommandPath(command) {
|
|
580
|
+
if (typeof command !== 'string' || !command.trim()) return null;
|
|
581
|
+
const tokens = command.match(/"[^"]+"|'[^']+'|\S+/g) || [];
|
|
582
|
+
const cleaned = tokens.map((token) => token.replace(/^['"]|['"]$/g, ''));
|
|
583
|
+
if (cleaned.length === 0) return null;
|
|
584
|
+
|
|
585
|
+
const first = cleaned[0];
|
|
586
|
+
if (looksLikeRelativeFileReference(first)) return first;
|
|
587
|
+
|
|
588
|
+
if (/^(node|python|python3|bash|sh|pwsh|powershell)$/i.test(first)) {
|
|
589
|
+
const second = cleaned[1];
|
|
590
|
+
if (!second || /^-(?:e|c|Command|EncodedCommand)$/.test(second)) return null;
|
|
591
|
+
if (looksLikeRelativeFileReference(second)) return second;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function hasLegacyAiderPin(ctx) {
|
|
598
|
+
const files = [
|
|
599
|
+
'requirements.txt',
|
|
600
|
+
'requirements-dev.txt',
|
|
601
|
+
'requirements-dev.in',
|
|
602
|
+
'pyproject.toml',
|
|
603
|
+
];
|
|
604
|
+
|
|
605
|
+
const legacyVersion = /(?:aider|aider-chat)\s*(?:==|~=|<=|<)\s*0\.(\d+)/ig;
|
|
606
|
+
for (const file of files) {
|
|
607
|
+
const content = ctx.fileContent(file) || '';
|
|
608
|
+
legacyVersion.lastIndex = 0;
|
|
609
|
+
let match = legacyVersion.exec(content);
|
|
610
|
+
while (match) {
|
|
611
|
+
const minor = Number(match[1]);
|
|
612
|
+
if (Number.isFinite(minor) && minor < 60) {
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
match = legacyVersion.exec(content);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return false;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
module.exports = {
|
|
623
|
+
AIDER_P0_SOURCES,
|
|
624
|
+
SHALLOW_RISK_BANNER,
|
|
625
|
+
SHALLOW_RISK_BANNER_LINES,
|
|
626
|
+
SHALLOW_RISK_DOC_URL,
|
|
627
|
+
buildFinding,
|
|
628
|
+
collectStackClaims,
|
|
629
|
+
escapeRegExp,
|
|
630
|
+
fileExists,
|
|
631
|
+
findFirstRepoPath,
|
|
632
|
+
findFirstStackEvidence,
|
|
633
|
+
getAgentConfigEntries,
|
|
634
|
+
getAgentConfigFiles,
|
|
635
|
+
getClaimByKey,
|
|
636
|
+
getDetectedStackEvidence,
|
|
637
|
+
getHookCommandPath,
|
|
638
|
+
getScannableLines,
|
|
639
|
+
hasLegacyAiderPin,
|
|
640
|
+
isClearlyLocalMcpBinary,
|
|
641
|
+
isKnownConventionPath,
|
|
642
|
+
lineHasExampleContext,
|
|
643
|
+
looksLikeRelativeFileReference,
|
|
644
|
+
normalizeCandidatePath,
|
|
645
|
+
platformForFile,
|
|
646
|
+
resolveRepoPath,
|
|
647
|
+
toPosix,
|
|
648
|
+
};
|