fullcourtdefense-cli 1.14.4 → 1.14.6

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.
@@ -41,14 +41,10 @@ const path = __importStar(require("path"));
41
41
  const MAX_FILES = 150;
42
42
  const MAX_FILE_BYTES = 512 * 1024;
43
43
  const EXCERPT_LEN = 180;
44
- const RISK_HEURISTICS = [
45
- { re: /\b(?:shell|bash|exec|subprocess|terminal|run_command)\b/i, tag: 'allows_shell' },
46
- { re: /\b(?:ignore (?:all )?(?:previous )?instructions|bypass|jailbreak|DAN mode)\b/i, tag: 'jailbreak_hint' },
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(?:send email|post to slack|transfer funds|delete all)\b/i, tag: 'destructive_actions' },
50
- { re: /\b(?:api[_-]?key|secret|password|token)\s*[:=]/i, tag: 'embedded_secret_ref' },
51
- ];
44
+ const NEGATED_RISK_LINE = /\b(?:do not|don't|never|avoid|forbid|block|wrong|bad|dangerous|must not)\b/i;
45
+ function stripCodeBlocks(content) {
46
+ return content.replace(/```[\s\S]*?```/g, '');
47
+ }
52
48
  function stableId(parts) {
53
49
  return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
54
50
  }
@@ -64,11 +60,46 @@ function readAgentFile(filePath) {
64
60
  return null;
65
61
  }
66
62
  }
63
+ function lineHasRiskIntent(line, action, target) {
64
+ if (NEGATED_RISK_LINE.test(line))
65
+ return false;
66
+ if (!action.test(line))
67
+ return false;
68
+ return target ? target.test(line) : true;
69
+ }
70
+ function lineHasOrderedRiskIntent(line, pattern) {
71
+ return !NEGATED_RISK_LINE.test(line) && pattern.test(line);
72
+ }
67
73
  function analyzeContent(content) {
68
74
  const tags = new Set();
69
- for (const rule of RISK_HEURISTICS) {
70
- if (rule.re.test(content))
71
- tags.add(rule.tag);
75
+ const text = stripCodeBlocks(content);
76
+ const lines = text.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
77
+ const whole = lines.join('\n');
78
+ if (/\b(?:ignore (?:all )?(?:previous )?instructions|bypass|jailbreak|DAN mode)\b/i.test(whole)) {
79
+ tags.add('jailbreak_hint');
80
+ }
81
+ if (/\b(?:always allow|never block|disable security|skip validation)\b/i.test(whole)) {
82
+ tags.add('weak_guardrails');
83
+ }
84
+ if (/\b(?:api[_-]?key|secret|password|token)\s*[:=]/i.test(whole)) {
85
+ tags.add('embedded_secret_ref');
86
+ }
87
+ for (const line of lines) {
88
+ if (lineHasOrderedRiskIntent(line, /\b(?:allow|can|may|must|should|use|run|execute|invoke|call|enable|grant)\b.{0,100}\b(?:shell|bash|exec|subprocess|run_command|command runner)\b/i)) {
89
+ tags.add('allows_shell');
90
+ }
91
+ if (lineHasOrderedRiskIntent(line, /\b(?:allow|can|may|must|should|read|write|access|trust|grant)\b.{0,100}\b(?:read any file|full filesystem|entire filesystem|trusted workspace|workspace trust|\/etc\/passwd|sudo)\b/i)) {
92
+ tags.add('broad_filesystem');
93
+ }
94
+ if (lineHasRiskIntent(line, /\b(?:auto[- ]?approve|without asking|unattended|non[- ]?interactive|yolo mode)\b/i)) {
95
+ tags.add('auto_approval');
96
+ }
97
+ if (lineHasOrderedRiskIntent(line, /\b(?:exfiltrate|send|post|upload|curl|wget)\b.{0,120}\b(?:secret|token|credential|api[_-]?key|env|customer|database|private|confidential)\b/i)) {
98
+ tags.add('network_exfil');
99
+ }
100
+ if (lineHasOrderedRiskIntent(line, /\b(?:allow|can|may|must|should|run|execute|auto[- ]?approve)\b.{0,120}\b(?:transfer funds|delete all|publish package|npm publish|git push|drop table|remove directory)\b/i)) {
101
+ tags.add('destructive_actions');
102
+ }
72
103
  }
73
104
  return [...tags];
74
105
  }
@@ -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: 'Agent can reach local credentials',
131
+ detail: `${criticalSecrets.length} high-risk credential(s) are on this machine, and agent instructions allow broad actions such as shell, filesystem, auto-approval, or network sends. If the agent is hijacked, those credentials could be read or used.`,
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: 'Publishing token exposed to agent actions',
141
+ detail: `${packagePublisherSecrets.length} package/repository token(s) are on this machine while agent rules allow command execution, auto-approval, or publishing actions. A hijacked agent could publish packages or push code with those credentials.`,
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([
@@ -111,6 +120,24 @@ function redact(value) {
111
120
  function findingId(parts) {
112
121
  return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
113
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
+ }
114
141
  function looksLikePlaceholder(raw, line) {
115
142
  const joined = `${raw} ${line}`;
116
143
  if (PLACEHOLDER_WORDS.test(joined))
@@ -126,6 +153,59 @@ function looksLikePlaceholder(raw, line) {
126
153
  return true;
127
154
  return false;
128
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
+ }
129
209
  function scoreFromFindings(findings) {
130
210
  let score = 100;
131
211
  for (const f of findings) {
@@ -337,9 +417,11 @@ function scanSecrets(options = {}) {
337
417
  if (store.category === 'shell_history') {
338
418
  const tail = content.split(/\r?\n/).slice(-500).join('\n');
339
419
  scanTextLines(store.path, tail, store.category, findings, seen);
420
+ scanCredentialStorePosture(store.path, tail, store.category, findings, seen);
340
421
  }
341
422
  else {
342
423
  scanTextLines(store.path, content, store.category, findings, seen);
424
+ scanCredentialStorePosture(store.path, content, store.category, findings, seen);
343
425
  }
344
426
  if (findings.length >= MAX_FINDINGS)
345
427
  break;
package/dist/version.json CHANGED
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "1.14.4"
2
+ "version": "1.14.6"
3
3
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fullcourtdefense-cli",
3
- "version": "1.14.4",
3
+ "version": "1.14.6",
4
4
  "description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -21,6 +21,9 @@
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
23
  "test:secret-posture": "npm run build && node scripts/test-secret-posture-fixtures.js",
24
+ "test:agent-file-posture": "npm run build && node scripts/test-agent-file-posture.js",
25
+ "test:realworld-posture": "npm run build && node scripts/test-realworld-posture-fixtures.js",
26
+ "test:posture-blast": "npm run build && node scripts/test-posture-blast-radius.js",
24
27
  "test:ping-e2e": "npm run build && node scripts/test-ping-e2e.js",
25
28
  "prepublishOnly": "npm run build"
26
29
  },