fullcourtdefense-cli 1.14.3 → 1.14.5
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.
|
@@ -60,6 +60,8 @@ export interface DesktopDiscoveryHost {
|
|
|
60
60
|
probeMode: 'config' | 'deep';
|
|
61
61
|
/** Windows-only: PowerShell audit coverage (ScriptBlock Logging + Transcription). */
|
|
62
62
|
shellAudit?: ShellAuditReport;
|
|
63
|
+
/** True when the scan ran on an ephemeral CI runner (GitHub Actions, GitLab, etc.) — not a user machine. */
|
|
64
|
+
isCi?: boolean;
|
|
63
65
|
}
|
|
64
66
|
export declare function discoverCommand(args: DiscoverArgs, config: BotGuardConfig): Promise<void>;
|
|
65
67
|
/** Upload-only discover — used after install-mcp-gateway --upload. */
|
|
@@ -93,6 +93,16 @@ function machineFingerprint() {
|
|
|
93
93
|
const seed = [os.hostname(), os.userInfo().username, os.platform(), os.arch()].join('|');
|
|
94
94
|
return crypto.createHash('sha256').update(seed).digest('hex').slice(0, 16);
|
|
95
95
|
}
|
|
96
|
+
/** Detect ephemeral CI runners so they are never listed as user machines in the fleet. */
|
|
97
|
+
function isCiEnvironment() {
|
|
98
|
+
return process.env.GITHUB_ACTIONS === 'true'
|
|
99
|
+
|| process.env.GITLAB_CI === 'true'
|
|
100
|
+
|| process.env.TF_BUILD === 'True' // Azure Pipelines
|
|
101
|
+
|| !!process.env.JENKINS_URL
|
|
102
|
+
|| !!process.env.BUILDKITE
|
|
103
|
+
|| !!process.env.CIRCLECI
|
|
104
|
+
|| process.env.CI === 'true';
|
|
105
|
+
}
|
|
96
106
|
function buildHostMetadata(userEmail, probeMode = 'config') {
|
|
97
107
|
const info = os.userInfo();
|
|
98
108
|
return {
|
|
@@ -103,6 +113,7 @@ function buildHostMetadata(userEmail, probeMode = 'config') {
|
|
|
103
113
|
scannedAt: new Date().toISOString(),
|
|
104
114
|
probeMode,
|
|
105
115
|
shellAudit: (0, windowsAudit_1.getShellAuditReport)(),
|
|
116
|
+
isCi: isCiEnvironment() || undefined,
|
|
106
117
|
};
|
|
107
118
|
}
|
|
108
119
|
/** Where MCP client configs live — see discoverPaths.ts for the canonical list. */
|
|
@@ -45,8 +45,10 @@ const RISK_HEURISTICS = [
|
|
|
45
45
|
{ re: /\b(?:shell|bash|exec|subprocess|terminal|run_command)\b/i, tag: 'allows_shell' },
|
|
46
46
|
{ re: /\b(?:ignore (?:all )?(?:previous )?instructions|bypass|jailbreak|DAN mode)\b/i, tag: 'jailbreak_hint' },
|
|
47
47
|
{ re: /\b(?:always allow|never block|disable security|skip validation)\b/i, tag: 'weak_guardrails' },
|
|
48
|
-
{ re: /\b(?:read any file|full filesystem|\/etc\/passwd|sudo)\b/i, tag: 'broad_filesystem' },
|
|
49
|
-
{ re: /\b(?:
|
|
48
|
+
{ re: /\b(?:read any file|full filesystem|entire filesystem|workspace trust|trusted workspace|\/etc\/passwd|sudo)\b/i, tag: 'broad_filesystem' },
|
|
49
|
+
{ re: /\b(?:auto[- ]?approve|without asking|unattended|non[- ]?interactive|yolo mode)\b/i, tag: 'auto_approval' },
|
|
50
|
+
{ re: /\b(?:curl|wget|webhook|exfiltrate|post to slack|send email|http request)\b/i, tag: 'network_exfil' },
|
|
51
|
+
{ re: /\b(?:send email|post to slack|transfer funds|delete all|publish package|npm publish|git push)\b/i, tag: 'destructive_actions' },
|
|
50
52
|
{ re: /\b(?:api[_-]?key|secret|password|token)\s*[:=]/i, tag: 'embedded_secret_ref' },
|
|
51
53
|
];
|
|
52
54
|
function stableId(parts) {
|
|
@@ -25,6 +25,9 @@ function computeBlastRadius(input) {
|
|
|
25
25
|
const { mcpServers, clientCoverage, secrets, agentFiles } = input;
|
|
26
26
|
const directServers = mcpServers.filter(s => s.proxyStatus === 'direct');
|
|
27
27
|
const criticalSecrets = secrets.findings.filter(f => f.severity === 'critical' || f.severity === 'high');
|
|
28
|
+
const credentialStoreSecrets = criticalSecrets.filter(f => f.category === 'credential_store' || f.category === 'ssh_key');
|
|
29
|
+
const shellHistorySecrets = criticalSecrets.filter(f => f.category === 'shell_history');
|
|
30
|
+
const packagePublisherSecrets = criticalSecrets.filter(f => ['npm', 'pypi', 'github'].includes(f.provider || ''));
|
|
28
31
|
const riskyAgentFiles = agentFiles.filter(f => f.riskTags.length > 0);
|
|
29
32
|
if (hasTag(directServers, 'shell/exec', true)) {
|
|
30
33
|
combos.push({
|
|
@@ -58,6 +61,22 @@ function computeBlastRadius(input) {
|
|
|
58
61
|
detail: `${criticalSecrets.length} high/critical secret(s) on this machine while shell/exec MCP is enabled.`,
|
|
59
62
|
});
|
|
60
63
|
}
|
|
64
|
+
if (directServers.length > 0 && credentialStoreSecrets.length > 0) {
|
|
65
|
+
combos.push({
|
|
66
|
+
id: 'direct_mcp_plus_credential_store',
|
|
67
|
+
severity: 'critical',
|
|
68
|
+
title: 'Direct MCP + credential stores',
|
|
69
|
+
detail: `${credentialStoreSecrets.length} credential-store secret(s) are readable while direct MCP tools are configured.`,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (shellHistorySecrets.length > 0) {
|
|
73
|
+
combos.push({
|
|
74
|
+
id: 'shell_history_secrets',
|
|
75
|
+
severity: hasTag(directServers, 'shell/exec', true) ? 'critical' : 'high',
|
|
76
|
+
title: 'Secrets in shell history',
|
|
77
|
+
detail: `${shellHistorySecrets.length} token or credential value(s) were found in recent shell history.`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
61
80
|
if (hasTag(directServers, 'web/network', true) && criticalSecrets.length > 0) {
|
|
62
81
|
combos.push({
|
|
63
82
|
id: 'browser_plus_secrets',
|
|
@@ -101,6 +120,27 @@ function computeBlastRadius(input) {
|
|
|
101
120
|
detail: 'Cursor rules or skills mention shell, filesystem, or weak guardrails — review what the agent is allowed to do.',
|
|
102
121
|
});
|
|
103
122
|
}
|
|
123
|
+
if (riskyAgentFiles.some(f => f.riskTags.includes('allows_shell')
|
|
124
|
+
|| f.riskTags.includes('weak_guardrails')
|
|
125
|
+
|| f.riskTags.includes('auto_approval')
|
|
126
|
+
|| f.riskTags.includes('network_exfil')) && criticalSecrets.length > 0) {
|
|
127
|
+
combos.push({
|
|
128
|
+
id: 'permissive_rules_plus_secrets',
|
|
129
|
+
severity: 'high',
|
|
130
|
+
title: 'Permissive agent rules + local credentials',
|
|
131
|
+
detail: 'Agent instructions allow broad tool use while local credentials are present on disk.',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
if (packagePublisherSecrets.length > 0 && riskyAgentFiles.some(f => f.riskTags.includes('destructive_actions')
|
|
135
|
+
|| f.riskTags.includes('allows_shell')
|
|
136
|
+
|| f.riskTags.includes('auto_approval'))) {
|
|
137
|
+
combos.push({
|
|
138
|
+
id: 'supply_chain_token_plus_agent_actions',
|
|
139
|
+
severity: 'high',
|
|
140
|
+
title: 'Package publishing token + agent actions',
|
|
141
|
+
detail: `${packagePublisherSecrets.length} package/repository credential(s) found alongside rules or tools that can run commands.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
104
144
|
if (agentFiles.length >= 5 && !clientCoverage.some(c => c.cursorHooksInstalled)) {
|
|
105
145
|
combos.push({
|
|
106
146
|
id: 'rich_instructions_no_hooks',
|
|
@@ -70,6 +70,8 @@ const SECRET_PATTERNS = [
|
|
|
70
70
|
{ re: /\bhvs\.[a-zA-Z0-9_-]{20,}\b/, provider: 'vault', severity: 'critical', title: 'HashiCorp Vault token', impact: 'access to every secret the token policy allows' },
|
|
71
71
|
{ re: /\bag_org_[a-zA-Z0-9_-]{20,}\b/, provider: 'fullcourtdefense', severity: 'high', title: 'FullCourtDefense org API key', impact: 'org-scoped scan and shield API access' },
|
|
72
72
|
{ re: /\bshsk_[a-zA-Z0-9]{20,}\b/, provider: 'fullcourtdefense', severity: 'high', title: 'Shield API key', impact: 'submit scans as this shield' },
|
|
73
|
+
{ re: /\b(?:authorization:\s*)?bearer\s+[A-Za-z0-9._~+/=-]{20,}\b/i, provider: 'bearer', severity: 'high', title: 'Bearer token', impact: 'API access for the token scope' },
|
|
74
|
+
{ re: /https?:\/\/[^/\s:'"]+:[^@\s'"]+@[^\s'"]+/i, provider: 'url_basic_auth', severity: 'high', title: 'URL with embedded credentials', impact: 'authenticate to the referenced service' },
|
|
73
75
|
{ re: /\beyJ[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\.[a-zA-Z0-9_-]+\b/, provider: 'jwt', severity: 'medium', title: 'JWT token', impact: 'impersonate the session until the token expires' },
|
|
74
76
|
{ re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----/, provider: 'private_key', severity: 'critical', title: 'Private key material', impact: 'authenticate as the key owner' },
|
|
75
77
|
{ re: /postgres(?:ql)?:\/\/[^\s'"]+:[^\s'"]+@/i, provider: 'database', severity: 'critical', title: 'Database connection string', impact: 'direct database read/write access' },
|
|
@@ -89,7 +91,14 @@ const PROVIDER_REMEDIATION = {
|
|
|
89
91
|
sendgrid: 'Delete this key in SendGrid → Settings → API Keys and create a scoped replacement. SendGrid keys are shown once — the new one goes straight into a secret manager.',
|
|
90
92
|
slack: 'Regenerate the token/webhook in your Slack app settings (api.slack.com/apps) and update the consumer.',
|
|
91
93
|
npm: 'Revoke it with `npm token revoke` and create a granular automation token with the minimum package scope.',
|
|
94
|
+
pypi: 'Revoke the PyPI token, create a project-scoped token, and store it in your package publisher/secret manager.',
|
|
92
95
|
vault: 'Revoke the token (`vault token revoke`) and issue short-TTL tokens instead of long-lived ones.',
|
|
96
|
+
docker: 'Log out of unneeded registries (`docker logout <registry>`) and prefer short-lived registry tokens.',
|
|
97
|
+
kubernetes: 'Move kubeconfigs with cluster-admin or long-lived tokens out of agent-readable paths; prefer short-lived cloud auth plugins.',
|
|
98
|
+
gcp_adc: 'Revoke the local ADC refresh token (`gcloud auth application-default revoke`) if unneeded, then re-auth with least-privilege accounts.',
|
|
99
|
+
netrc: 'Remove plaintext passwords from .netrc or replace them with token helpers / OS keychain storage.',
|
|
100
|
+
bearer: 'Rotate the token and avoid pasting bearer tokens into shell history or config files.',
|
|
101
|
+
url_basic_auth: 'Rotate the password/token embedded in the URL and replace it with environment or secret-manager lookup.',
|
|
93
102
|
database: 'Change the database password and move the connection string to a secret manager; restrict the DB user\u2019s privileges.',
|
|
94
103
|
};
|
|
95
104
|
const SKIP_DIR_NAMES = new Set([
|
|
@@ -100,6 +109,8 @@ const SKIP_DIR_NAMES = new Set([
|
|
|
100
109
|
const MAX_FILE_BYTES = 256 * 1024;
|
|
101
110
|
const MAX_ENV_FILES = 400;
|
|
102
111
|
const MAX_FINDINGS = 200;
|
|
112
|
+
const INLINE_ALLOW_COMMENT = /\b(?:gitleaks:allow|trufflehog:ignore|pragma:\s*allowlist\s+secret|nosecret|secretlint-disable)\b/i;
|
|
113
|
+
const PLACEHOLDER_WORDS = /(?:example|sample|dummy|fake|placeholder|changeme|change_me|replace_me|your[_-]?key|your[_-]?token|test[_-]?only)/i;
|
|
103
114
|
function redact(value) {
|
|
104
115
|
const trimmed = value.trim();
|
|
105
116
|
if (trimmed.length <= 8)
|
|
@@ -109,6 +120,92 @@ function redact(value) {
|
|
|
109
120
|
function findingId(parts) {
|
|
110
121
|
return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
|
|
111
122
|
}
|
|
123
|
+
function pushStoreFinding(out, seen, input) {
|
|
124
|
+
if (out.length >= MAX_FINDINGS)
|
|
125
|
+
return;
|
|
126
|
+
const id = findingId([input.filePath, input.provider, input.title]);
|
|
127
|
+
if (seen.has(id))
|
|
128
|
+
return;
|
|
129
|
+
seen.add(id);
|
|
130
|
+
out.push({
|
|
131
|
+
id,
|
|
132
|
+
category: input.category,
|
|
133
|
+
severity: input.severity,
|
|
134
|
+
provider: input.provider,
|
|
135
|
+
filePath: input.filePath,
|
|
136
|
+
title: input.title,
|
|
137
|
+
detail: input.detail,
|
|
138
|
+
remediation: input.remediation || PROVIDER_REMEDIATION[input.provider] || 'Remove or rotate this credential and store it outside agent-readable plaintext files.',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function looksLikePlaceholder(raw, line) {
|
|
142
|
+
const joined = `${raw} ${line}`;
|
|
143
|
+
if (PLACEHOLDER_WORDS.test(joined))
|
|
144
|
+
return true;
|
|
145
|
+
if (/(?:sk-proj-|sk-|gsk_|xai-|hf_|npm_|ag_org_|shsk_)[x0a]{16,}/i.test(raw))
|
|
146
|
+
return true;
|
|
147
|
+
const compact = raw.replace(/[^A-Za-z0-9]/g, '');
|
|
148
|
+
if (!compact)
|
|
149
|
+
return false;
|
|
150
|
+
if (/^(?:x+|0+|1+|a+|z+)$/i.test(compact))
|
|
151
|
+
return true;
|
|
152
|
+
if (/^(?:1234567890|abcdef|abc123){2,}$/i.test(compact))
|
|
153
|
+
return true;
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
function scanCredentialStorePosture(filePath, content, category, out, seen) {
|
|
157
|
+
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
|
158
|
+
if (normalized.endsWith('/.docker/config.json') && /"auths"\s*:|"auth"\s*:|"identitytoken"\s*:/i.test(content)) {
|
|
159
|
+
pushStoreFinding(out, seen, {
|
|
160
|
+
filePath,
|
|
161
|
+
category,
|
|
162
|
+
provider: 'docker',
|
|
163
|
+
severity: 'high',
|
|
164
|
+
title: 'Docker registry credentials',
|
|
165
|
+
detail: 'Docker config contains registry auth material that agent tools or local malware can read.',
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
if (normalized.endsWith('/.kube/config') && /\b(?:token|client-key-data|client-certificate-data)\s*:/i.test(content)) {
|
|
169
|
+
pushStoreFinding(out, seen, {
|
|
170
|
+
filePath,
|
|
171
|
+
category,
|
|
172
|
+
provider: 'kubernetes',
|
|
173
|
+
severity: /cluster-admin|system:masters/i.test(content) ? 'critical' : 'high',
|
|
174
|
+
title: 'Kubernetes kubeconfig credential',
|
|
175
|
+
detail: 'Kubeconfig contains cluster credentials or client key material reachable from this machine.',
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (/gcloud\/application_default_credentials\.json$/i.test(normalized) && /"refresh_token"\s*:|"client_secret"\s*:/i.test(content)) {
|
|
179
|
+
pushStoreFinding(out, seen, {
|
|
180
|
+
filePath,
|
|
181
|
+
category,
|
|
182
|
+
provider: 'gcp_adc',
|
|
183
|
+
severity: 'high',
|
|
184
|
+
title: 'Google Cloud ADC refresh token',
|
|
185
|
+
detail: 'Application Default Credentials can mint access tokens for the signed-in Google account.',
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
if (normalized.endsWith('/.netrc') && /\bmachine\s+\S+[\s\S]{0,300}\b(?:password|login)\s+\S+/i.test(content)) {
|
|
189
|
+
pushStoreFinding(out, seen, {
|
|
190
|
+
filePath,
|
|
191
|
+
category,
|
|
192
|
+
provider: 'netrc',
|
|
193
|
+
severity: 'high',
|
|
194
|
+
title: 'Netrc plaintext credential',
|
|
195
|
+
detail: '.netrc contains machine login/password material used by CLI tools.',
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (normalized.endsWith('/.pypirc') && /\b(?:password|token)\s*=\s*\S+/i.test(content)) {
|
|
199
|
+
pushStoreFinding(out, seen, {
|
|
200
|
+
filePath,
|
|
201
|
+
category,
|
|
202
|
+
provider: 'pypi',
|
|
203
|
+
severity: 'high',
|
|
204
|
+
title: 'Python package registry credential',
|
|
205
|
+
detail: '.pypirc contains package publishing credentials (supply-chain risk).',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
112
209
|
function scoreFromFindings(findings) {
|
|
113
210
|
let score = 100;
|
|
114
211
|
for (const f of findings) {
|
|
@@ -135,6 +232,8 @@ function scanTextLines(filePath, content, category, out, seen) {
|
|
|
135
232
|
const line = lines[i];
|
|
136
233
|
if (!line.trim() || line.trim().startsWith('#'))
|
|
137
234
|
continue;
|
|
235
|
+
if (INLINE_ALLOW_COMMENT.test(line))
|
|
236
|
+
continue;
|
|
138
237
|
// First (most specific) pattern wins per matched string — prevents e.g. an
|
|
139
238
|
// sk-ant- Anthropic key from also being reported as a generic OpenAI sk- key.
|
|
140
239
|
const matchedRaws = [];
|
|
@@ -168,6 +267,12 @@ function scanTextLines(filePath, content, category, out, seen) {
|
|
|
168
267
|
detail = `Matched ${pattern.provider} pattern in ${path.basename(filePath)} — looks like an intentional placeholder.`;
|
|
169
268
|
remediation = 'Confirm this is a dummy value. If a real credential was pasted into the example file, rotate it and replace with a placeholder.';
|
|
170
269
|
}
|
|
270
|
+
else if (looksLikePlaceholder(raw, line)) {
|
|
271
|
+
severity = 'info';
|
|
272
|
+
title = `${pattern.title} (placeholder-like value)`;
|
|
273
|
+
detail = `Matched ${pattern.provider} pattern in ${path.basename(filePath)}, but the value/line contains common placeholder markers.`;
|
|
274
|
+
remediation = 'Replace placeholders before production. If this is a real credential despite the placeholder wording, rotate it and move it to a secret manager.';
|
|
275
|
+
}
|
|
171
276
|
else if (isClientExposedWebKey) {
|
|
172
277
|
severity = 'medium';
|
|
173
278
|
title = 'Google/Firebase web API key (client-exposed)';
|
|
@@ -312,9 +417,11 @@ function scanSecrets(options = {}) {
|
|
|
312
417
|
if (store.category === 'shell_history') {
|
|
313
418
|
const tail = content.split(/\r?\n/).slice(-500).join('\n');
|
|
314
419
|
scanTextLines(store.path, tail, store.category, findings, seen);
|
|
420
|
+
scanCredentialStorePosture(store.path, tail, store.category, findings, seen);
|
|
315
421
|
}
|
|
316
422
|
else {
|
|
317
423
|
scanTextLines(store.path, content, store.category, findings, seen);
|
|
424
|
+
scanCredentialStorePosture(store.path, content, store.category, findings, seen);
|
|
318
425
|
}
|
|
319
426
|
if (findings.length >= MAX_FINDINGS)
|
|
320
427
|
break;
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.14.
|
|
3
|
+
"version": "1.14.5",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
"test:shell-guard": "npm run build && node scripts/test-shell-guard.js",
|
|
21
21
|
"test:cmd-guard": "npm run build && node scripts/test-cmd-guard.js",
|
|
22
22
|
"test:posix-guard": "npm run build && node scripts/test-posix-guard.js",
|
|
23
|
+
"test:secret-posture": "npm run build && node scripts/test-secret-posture-fixtures.js",
|
|
24
|
+
"test:posture-blast": "npm run build && node scripts/test-posture-blast-radius.js",
|
|
23
25
|
"test:ping-e2e": "npm run build && node scripts/test-ping-e2e.js",
|
|
24
26
|
"prepublishOnly": "npm run build"
|
|
25
27
|
},
|