@nerviq/cli 1.26.0 → 1.27.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 +4 -4
- package/bin/cli.js +13 -1
- package/package.json +1 -1
- package/src/audit/layers.js +180 -179
- package/src/audit.js +118 -48
- package/src/formatters/csv.js +86 -85
- package/src/formatters/junit.js +123 -103
- package/src/formatters/markdown.js +164 -135
- package/src/shallow-risk/index.js +56 -0
- package/src/shallow-risk/patterns/agent-config-cross-platform-drift.js +50 -0
- package/src/shallow-risk/patterns/agent-config-dangerous-autoapprove.js +46 -0
- package/src/shallow-risk/patterns/agent-config-deprecated-keys.js +46 -0
- package/src/shallow-risk/patterns/agent-config-missing-file.js +72 -0
- package/src/shallow-risk/patterns/agent-config-secret-literal.js +49 -0
- package/src/shallow-risk/patterns/agent-config-stack-contradiction.js +34 -0
- package/src/shallow-risk/patterns/hook-script-missing.js +70 -0
- package/src/shallow-risk/patterns/mcp-server-no-allowlist.js +52 -0
- package/src/shallow-risk/shared.js +520 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { SHALLOW_RISK_DOC_URL, escapeRegExp } = require('../shared');
|
|
4
|
+
|
|
5
|
+
const DANGEROUS_ALLOW_PATTERNS = [
|
|
6
|
+
/\brm\b[\s\S]{0,40}-r/i,
|
|
7
|
+
/\bgit\s+push\s+--force\b/i,
|
|
8
|
+
/\bdrop\s+(?:database|table)\b/i,
|
|
9
|
+
/\btruncate\s+table\b/i,
|
|
10
|
+
/\bdelete\s+from\b/i,
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function isDangerousAllowRule(rule) {
|
|
14
|
+
if (typeof rule !== 'string') return false;
|
|
15
|
+
if (/\bdelete\s+from\b/i.test(rule)) {
|
|
16
|
+
return !/\bwhere\b/i.test(rule) || /\bwhere\s*1\s*=\s*1\b/i.test(rule);
|
|
17
|
+
}
|
|
18
|
+
return DANGEROUS_ALLOW_PATTERNS.some((pattern) => {
|
|
19
|
+
pattern.lastIndex = 0;
|
|
20
|
+
return pattern.test(rule);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
module.exports = {
|
|
25
|
+
key: 'agent-config-dangerous-autoapprove',
|
|
26
|
+
name: 'Agent config auto-approves destructive commands',
|
|
27
|
+
severity: 'critical',
|
|
28
|
+
layer: 'shallow-risk',
|
|
29
|
+
sourceUrl: SHALLOW_RISK_DOC_URL,
|
|
30
|
+
run(ctx) {
|
|
31
|
+
const file = '.claude/settings.json';
|
|
32
|
+
const config = ctx.jsonFile(file);
|
|
33
|
+
const allowRules = config && config.permissions && Array.isArray(config.permissions.allow)
|
|
34
|
+
? config.permissions.allow
|
|
35
|
+
: [];
|
|
36
|
+
if (allowRules.length === 0) return [];
|
|
37
|
+
|
|
38
|
+
return allowRules
|
|
39
|
+
.filter(isDangerousAllowRule)
|
|
40
|
+
.map((rule) => ({
|
|
41
|
+
file,
|
|
42
|
+
line: ctx.lineNumber(file, new RegExp(escapeRegExp(rule))) || 1,
|
|
43
|
+
fix: `${file} pre-approves the destructive rule \`${rule}\`. Remove it from the allow-list so destructive commands always require explicit review.`,
|
|
44
|
+
}));
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { AIDER_P0_SOURCES, SHALLOW_RISK_DOC_URL, hasLegacyAiderPin } = require('../shared');
|
|
4
|
+
|
|
5
|
+
const DEPRECATED_AIDER_KEYS = [
|
|
6
|
+
{
|
|
7
|
+
key: 'auto-commit',
|
|
8
|
+
replacement: 'auto-commits',
|
|
9
|
+
pattern: /^\s*auto-commit\s*:/i,
|
|
10
|
+
note: 'removed in Aider 0.60+',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
module.exports = {
|
|
15
|
+
key: 'agent-config-deprecated-keys',
|
|
16
|
+
name: 'Agent config uses deprecated keys',
|
|
17
|
+
severity: 'medium',
|
|
18
|
+
layer: 'shallow-risk',
|
|
19
|
+
sourceUrl: SHALLOW_RISK_DOC_URL,
|
|
20
|
+
run(ctx) {
|
|
21
|
+
if (!Array.isArray(AIDER_P0_SOURCES) || AIDER_P0_SOURCES.length < 2) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const file = ctx.fileContent('.aider.conf.yml') !== null ? '.aider.conf.yml'
|
|
26
|
+
: (ctx.fileContent('.aider.conf.yaml') !== null ? '.aider.conf.yaml' : null);
|
|
27
|
+
if (!file || hasLegacyAiderPin(ctx)) return [];
|
|
28
|
+
|
|
29
|
+
const findings = [];
|
|
30
|
+
const content = ctx.fileContent(file) || '';
|
|
31
|
+
const lines = content.split(/\r?\n/);
|
|
32
|
+
|
|
33
|
+
for (const keyDef of DEPRECATED_AIDER_KEYS) {
|
|
34
|
+
const lineIndex = lines.findIndex((line) => keyDef.pattern.test(line));
|
|
35
|
+
if (lineIndex === -1) continue;
|
|
36
|
+
findings.push({
|
|
37
|
+
file,
|
|
38
|
+
line: lineIndex + 1,
|
|
39
|
+
fix: `${file} uses deprecated Aider key \`${keyDef.key}\` (${keyDef.note}). Replace it with \`${keyDef.replacement}\` or remove it if the repo intentionally stays on an older Aider release.`,
|
|
40
|
+
sourceUrl: AIDER_P0_SOURCES.find((source) => source.key === 'aider-config-reference')?.url || SHALLOW_RISK_DOC_URL,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return findings;
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
SHALLOW_RISK_DOC_URL,
|
|
5
|
+
escapeRegExp,
|
|
6
|
+
getAgentConfigEntries,
|
|
7
|
+
getScannableLines,
|
|
8
|
+
isKnownConventionPath,
|
|
9
|
+
looksLikeRelativeFileReference,
|
|
10
|
+
normalizeCandidatePath,
|
|
11
|
+
resolveRepoPath,
|
|
12
|
+
toPosix,
|
|
13
|
+
} = require('../shared');
|
|
14
|
+
|
|
15
|
+
const POINTER_RE = /(?:^|[\s([`'"])(@?(?:\.{1,2}\/)?[A-Za-z0-9._/-]+)(?=$|[\s)\]`'",:;!?])/g;
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
key: 'agent-config-missing-file',
|
|
19
|
+
name: 'Agent config references missing file',
|
|
20
|
+
severity: 'high',
|
|
21
|
+
layer: 'shallow-risk',
|
|
22
|
+
sourceUrl: SHALLOW_RISK_DOC_URL,
|
|
23
|
+
run(ctx) {
|
|
24
|
+
const findings = [];
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
|
|
27
|
+
for (const entry of getAgentConfigEntries(ctx)) {
|
|
28
|
+
if (!/\.(?:md|mdc|txt|rst)$/i.test(entry.path) && !/\.cursorrules$|\.windsurfrules$/i.test(entry.path)) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
for (const { lineNumber, text } of getScannableLines(entry.content)) {
|
|
32
|
+
POINTER_RE.lastIndex = 0;
|
|
33
|
+
let match = POINTER_RE.exec(text);
|
|
34
|
+
while (match) {
|
|
35
|
+
const candidate = normalizeCandidatePath(match[1]);
|
|
36
|
+
if (!looksLikeRelativeFileReference(candidate)) {
|
|
37
|
+
match = POINTER_RE.exec(text);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const resolvedPath = resolveRepoPath(ctx, entry.path, candidate, 'relative-to-file');
|
|
42
|
+
if (!resolvedPath || isKnownConventionPath(resolvedPath)) {
|
|
43
|
+
match = POINTER_RE.exec(text);
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (ctx.fileContent(resolvedPath) !== null) {
|
|
48
|
+
match = POINTER_RE.exec(text);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const dedupeKey = `${entry.path}:${toPosix(resolvedPath)}`;
|
|
53
|
+
if (seen.has(dedupeKey)) {
|
|
54
|
+
match = POINTER_RE.exec(text);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
seen.add(dedupeKey);
|
|
58
|
+
|
|
59
|
+
findings.push({
|
|
60
|
+
file: entry.path,
|
|
61
|
+
line: lineNumber || ctx.lineNumber(entry.path, new RegExp(escapeRegExp(candidate))),
|
|
62
|
+
fix: `${entry.path} references \`${toPosix(resolvedPath)}\`, but the file is missing. Create the file or update the agent guidance to point at a real repo path.`,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
match = POINTER_RE.exec(text);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return findings;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { SHALLOW_RISK_DOC_URL, getAgentConfigEntries } = require('../shared');
|
|
4
|
+
|
|
5
|
+
const SECRET_PATTERNS = [
|
|
6
|
+
{ label: 'AWS access key', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
|
|
7
|
+
{ label: 'Stripe live key', pattern: /\bsk_live_[A-Za-z0-9]{24,}\b/g },
|
|
8
|
+
{ label: 'GitHub personal access token', pattern: /\bghp_[A-Za-z0-9]{36}\b/g },
|
|
9
|
+
{ label: 'SSH private key header', pattern: /-----BEGIN (?:OPENSSH|RSA|DSA|EC) PRIVATE KEY-----/g },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function looksLikePlaceholder(line, matchText) {
|
|
13
|
+
return /\b(example|sample|placeholder|replace[-_ ]?me|your[_-]?key|fake|dummy)\b/i.test(line) ||
|
|
14
|
+
/\bEXAMPLE\b/.test(matchText);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = {
|
|
18
|
+
key: 'agent-config-secret-literal',
|
|
19
|
+
name: 'Agent config contains secret literal',
|
|
20
|
+
severity: 'critical',
|
|
21
|
+
layer: 'shallow-risk',
|
|
22
|
+
sourceUrl: SHALLOW_RISK_DOC_URL,
|
|
23
|
+
run(ctx) {
|
|
24
|
+
const findings = [];
|
|
25
|
+
|
|
26
|
+
for (const entry of getAgentConfigEntries(ctx)) {
|
|
27
|
+
const lines = entry.content.split(/\r?\n/);
|
|
28
|
+
for (let index = 0; index < lines.length; index++) {
|
|
29
|
+
const line = lines[index];
|
|
30
|
+
for (const secret of SECRET_PATTERNS) {
|
|
31
|
+
secret.pattern.lastIndex = 0;
|
|
32
|
+
let match = secret.pattern.exec(line);
|
|
33
|
+
while (match) {
|
|
34
|
+
if (!looksLikePlaceholder(line, match[0])) {
|
|
35
|
+
findings.push({
|
|
36
|
+
file: entry.path,
|
|
37
|
+
line: index + 1,
|
|
38
|
+
fix: `${entry.path} contains a ${secret.label} shape. Rotate the secret, remove it from the agent config, and scrub it from git history if it was real.`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
match = secret.pattern.exec(line);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return findings;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
SHALLOW_RISK_DOC_URL,
|
|
5
|
+
collectStackClaims,
|
|
6
|
+
findFirstStackEvidence,
|
|
7
|
+
getDetectedStackEvidence,
|
|
8
|
+
} = require('../shared');
|
|
9
|
+
|
|
10
|
+
module.exports = {
|
|
11
|
+
key: 'agent-config-stack-contradiction',
|
|
12
|
+
name: 'Agent config contradicts actual stack',
|
|
13
|
+
severity: 'high',
|
|
14
|
+
layer: 'shallow-risk',
|
|
15
|
+
sourceUrl: SHALLOW_RISK_DOC_URL,
|
|
16
|
+
run(ctx) {
|
|
17
|
+
const claims = collectStackClaims(ctx);
|
|
18
|
+
const distinctClaims = [...new Set(claims.map((claim) => claim.key))];
|
|
19
|
+
if (distinctClaims.length !== 1) return [];
|
|
20
|
+
|
|
21
|
+
const declared = claims[0];
|
|
22
|
+
const hasDeclaredEvidence = declared.stackKeys.some((stackKey) => Boolean(findFirstStackEvidence(ctx, stackKey)));
|
|
23
|
+
if (hasDeclaredEvidence) return [];
|
|
24
|
+
|
|
25
|
+
const actual = getDetectedStackEvidence(ctx).find((item) => !declared.stackKeys.includes(item.key));
|
|
26
|
+
if (!actual) return [];
|
|
27
|
+
|
|
28
|
+
return [{
|
|
29
|
+
file: declared.file,
|
|
30
|
+
line: declared.line,
|
|
31
|
+
fix: `${declared.file} declares the primary stack as "${declared.label}", but the repo shows ${actual.label} signals (${actual.file}) and no ${declared.label} evidence. Align the agent guidance with the actual stack or document a real migration plan.`,
|
|
32
|
+
}];
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
SHALLOW_RISK_DOC_URL,
|
|
5
|
+
escapeRegExp,
|
|
6
|
+
getHookCommandPath,
|
|
7
|
+
resolveRepoPath,
|
|
8
|
+
} = require('../shared');
|
|
9
|
+
|
|
10
|
+
const HOOK_EVENTS = new Set([
|
|
11
|
+
'PreToolUse',
|
|
12
|
+
'PostToolUse',
|
|
13
|
+
'Stop',
|
|
14
|
+
'UserPromptSubmit',
|
|
15
|
+
'SessionStart',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function collectHookCommands(node, output = []) {
|
|
19
|
+
if (Array.isArray(node)) {
|
|
20
|
+
for (const item of node) collectHookCommands(item, output);
|
|
21
|
+
return output;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (!node || typeof node !== 'object') {
|
|
25
|
+
return output;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (node.type === 'command' && typeof node.command === 'string') {
|
|
29
|
+
output.push(node.command);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const value of Object.values(node)) {
|
|
33
|
+
collectHookCommands(value, output);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return output;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
key: 'hook-script-missing',
|
|
41
|
+
name: 'Configured hook script is missing',
|
|
42
|
+
severity: 'high',
|
|
43
|
+
layer: 'shallow-risk',
|
|
44
|
+
sourceUrl: SHALLOW_RISK_DOC_URL,
|
|
45
|
+
run(ctx) {
|
|
46
|
+
const file = '.claude/settings.json';
|
|
47
|
+
const config = ctx.jsonFile(file);
|
|
48
|
+
if (!config || !config.hooks || typeof config.hooks !== 'object') {
|
|
49
|
+
return [];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const findings = [];
|
|
53
|
+
for (const [eventName, entries] of Object.entries(config.hooks)) {
|
|
54
|
+
if (!HOOK_EVENTS.has(eventName)) continue;
|
|
55
|
+
for (const command of collectHookCommands(entries)) {
|
|
56
|
+
const scriptPath = getHookCommandPath(command);
|
|
57
|
+
if (!scriptPath) continue;
|
|
58
|
+
const resolvedPath = resolveRepoPath(ctx, file, scriptPath, 'repo-root');
|
|
59
|
+
if (!resolvedPath || ctx.fileContent(resolvedPath) !== null) continue;
|
|
60
|
+
findings.push({
|
|
61
|
+
file,
|
|
62
|
+
line: ctx.lineNumber(file, new RegExp(escapeRegExp(command))) || ctx.lineNumber(file, new RegExp(`"${escapeRegExp(eventName)}"`)) || 1,
|
|
63
|
+
fix: `${file} declares a ${eventName} hook at \`${resolvedPath}\`, but the script is missing. Create the hook file or remove the dead hook reference.`,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return findings;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
SHALLOW_RISK_DOC_URL,
|
|
5
|
+
escapeRegExp,
|
|
6
|
+
isClearlyLocalMcpBinary,
|
|
7
|
+
} = require('../shared');
|
|
8
|
+
|
|
9
|
+
function hasBroadOpenPermissions(server) {
|
|
10
|
+
if (!server || typeof server !== 'object') return false;
|
|
11
|
+
if (Array.isArray(server.permissions) && server.permissions.length === 0) return true;
|
|
12
|
+
if (server.allow === '*') return true;
|
|
13
|
+
if (Array.isArray(server.allow) && server.allow.includes('*')) return true;
|
|
14
|
+
if (server.permissions && typeof server.permissions === 'object') {
|
|
15
|
+
if (server.permissions.allow === '*') return true;
|
|
16
|
+
if (Array.isArray(server.permissions.allow) && server.permissions.allow.includes('*')) return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = {
|
|
22
|
+
key: 'mcp-server-no-allowlist',
|
|
23
|
+
name: 'MCP server has no allowlist',
|
|
24
|
+
severity: 'critical',
|
|
25
|
+
layer: 'shallow-risk',
|
|
26
|
+
sourceUrl: SHALLOW_RISK_DOC_URL,
|
|
27
|
+
run(ctx) {
|
|
28
|
+
const findings = [];
|
|
29
|
+
const candidates = ['.claude/settings.json', '.mcp.json'];
|
|
30
|
+
|
|
31
|
+
for (const file of candidates) {
|
|
32
|
+
const config = ctx.jsonFile(file);
|
|
33
|
+
const servers = config && config.mcpServers && typeof config.mcpServers === 'object'
|
|
34
|
+
? config.mcpServers
|
|
35
|
+
: null;
|
|
36
|
+
if (!servers) continue;
|
|
37
|
+
|
|
38
|
+
for (const [serverName, server] of Object.entries(servers)) {
|
|
39
|
+
if (!hasBroadOpenPermissions(server)) continue;
|
|
40
|
+
const command = typeof server.command === 'string' ? server.command : '';
|
|
41
|
+
findings.push({
|
|
42
|
+
severity: isClearlyLocalMcpBinary(command) ? 'high' : 'critical',
|
|
43
|
+
file,
|
|
44
|
+
line: ctx.lineNumber(file, new RegExp(`"${escapeRegExp(serverName)}"`)) || 1,
|
|
45
|
+
fix: `MCP server "${serverName}" in ${file} has broad access without an allowlist. Add a narrow allow/permissions list before relying on it in CI or production repos.`,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return findings;
|
|
51
|
+
},
|
|
52
|
+
};
|