fullcourtdefense-cli 1.7.16 → 1.7.18
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/dist/commands/agentCi.d.ts +12 -0
- package/dist/commands/agentCi.js +303 -0
- package/dist/commands/local-scan.js +119 -6
- package/dist/index.js +22 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { BotGuardConfig } from '../config';
|
|
2
|
+
export interface AgentCiArgs {
|
|
3
|
+
failOn?: string;
|
|
4
|
+
format?: string;
|
|
5
|
+
upload?: string;
|
|
6
|
+
requireGateway?: string;
|
|
7
|
+
requireApprovalFor?: string;
|
|
8
|
+
extraPath?: string;
|
|
9
|
+
apiKey?: string;
|
|
10
|
+
apiUrl?: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function agentCiCommand(args: AgentCiArgs, config: BotGuardConfig): Promise<void>;
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.agentCiCommand = agentCiCommand;
|
|
37
|
+
const crypto = __importStar(require("crypto"));
|
|
38
|
+
const fs = __importStar(require("fs"));
|
|
39
|
+
const path = __importStar(require("path"));
|
|
40
|
+
const config_1 = require("../config");
|
|
41
|
+
const discoverPaths_1 = require("./discoverPaths");
|
|
42
|
+
const discoverAgentFiles_1 = require("./discoverAgentFiles");
|
|
43
|
+
const discoverSecrets_1 = require("./discoverSecrets");
|
|
44
|
+
const discover_1 = require("./discover");
|
|
45
|
+
const SEVERITY_RANK = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
46
|
+
function stableId(parts) {
|
|
47
|
+
return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
|
|
48
|
+
}
|
|
49
|
+
function parseJsonFile(filePath) {
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function parseTomlMcpServers(content) {
|
|
58
|
+
const servers = {};
|
|
59
|
+
const lines = content.split(/\r?\n/);
|
|
60
|
+
let current = null;
|
|
61
|
+
for (const raw of lines) {
|
|
62
|
+
const line = raw.trim();
|
|
63
|
+
const section = line.match(/^\[mcp_servers\.([^\]]+)\]$/);
|
|
64
|
+
if (section) {
|
|
65
|
+
current = section[1].replace(/^["']|["']$/g, '');
|
|
66
|
+
servers[current] = {};
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (!current || !line || line.startsWith('#'))
|
|
70
|
+
continue;
|
|
71
|
+
const match = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+)$/);
|
|
72
|
+
if (!match)
|
|
73
|
+
continue;
|
|
74
|
+
const key = match[1];
|
|
75
|
+
const rawValue = match[2].trim().replace(/,$/, '');
|
|
76
|
+
if (rawValue.startsWith('[')) {
|
|
77
|
+
servers[current][key] = rawValue
|
|
78
|
+
.replace(/^\[|\]$/g, '')
|
|
79
|
+
.split(',')
|
|
80
|
+
.map(part => part.trim().replace(/^["']|["']$/g, ''))
|
|
81
|
+
.filter(Boolean);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
servers[current][key] = rawValue.replace(/^["']|["']$/g, '');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return servers;
|
|
88
|
+
}
|
|
89
|
+
function extractServerMap(parsed) {
|
|
90
|
+
if (!parsed || typeof parsed !== 'object')
|
|
91
|
+
return {};
|
|
92
|
+
if (parsed.mcpServers && typeof parsed.mcpServers === 'object')
|
|
93
|
+
return parsed.mcpServers;
|
|
94
|
+
if (parsed.servers && typeof parsed.servers === 'object')
|
|
95
|
+
return parsed.servers;
|
|
96
|
+
if (parsed.mcp?.servers && typeof parsed.mcp.servers === 'object')
|
|
97
|
+
return parsed.mcp.servers;
|
|
98
|
+
if (parsed['mcp.servers'] && typeof parsed['mcp.servers'] === 'object')
|
|
99
|
+
return parsed['mcp.servers'];
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
function riskSignalsForServer(name, server) {
|
|
103
|
+
const text = [
|
|
104
|
+
name,
|
|
105
|
+
server?.command,
|
|
106
|
+
...(Array.isArray(server?.args) ? server.args : []),
|
|
107
|
+
server?.url,
|
|
108
|
+
].filter(Boolean).join(' ').toLowerCase();
|
|
109
|
+
const signals = [
|
|
110
|
+
[/\b(shell|exec|command|terminal|powershell|bash|cmd\.exe)\b/, 'shell'],
|
|
111
|
+
[/\b(file|filesystem|readfile|writefile|directory|path)\b/, 'filesystem'],
|
|
112
|
+
[/\b(database|postgres|mysql|mongo|sql|db)\b/, 'database'],
|
|
113
|
+
[/\b(secret|token|key|password|credential|env)\b/, 'secrets'],
|
|
114
|
+
[/\b(payment|wire|transfer|bank|iban|invoice|billing)\b/, 'payment'],
|
|
115
|
+
[/\b(customer|user|pii|ssn|email|phone|record)\b/, 'pii'],
|
|
116
|
+
[/\b(browser|web|http|fetch|request|webhook)\b/, 'network'],
|
|
117
|
+
[/\b(delete|remove|destroy|drop|wipe|purge|write|update|create)\b/, 'write'],
|
|
118
|
+
];
|
|
119
|
+
return signals.filter(([re]) => re.test(text)).map(([, signal]) => signal);
|
|
120
|
+
}
|
|
121
|
+
function discoverMcpServers(cwd, extraPath) {
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const candidate of (0, discoverPaths_1.discoverScanTargets)(cwd, extraPath)) {
|
|
124
|
+
if (!fs.existsSync(candidate.path))
|
|
125
|
+
continue;
|
|
126
|
+
const isToml = candidate.path.endsWith('.toml');
|
|
127
|
+
const parsed = isToml
|
|
128
|
+
? parseTomlMcpServers(fs.readFileSync(candidate.path, 'utf8'))
|
|
129
|
+
: parseJsonFile(candidate.path);
|
|
130
|
+
const map = extractServerMap(parsed);
|
|
131
|
+
for (const [name, server] of Object.entries(map)) {
|
|
132
|
+
const cfg = server;
|
|
133
|
+
const command = typeof cfg.command === 'string' ? cfg.command : undefined;
|
|
134
|
+
const args = Array.isArray(cfg.args) ? cfg.args.map(String) : [];
|
|
135
|
+
const url = typeof cfg.url === 'string' ? cfg.url : typeof cfg.endpoint === 'string' ? cfg.endpoint : undefined;
|
|
136
|
+
const targetText = [command, ...args, url].filter(Boolean).join(' ');
|
|
137
|
+
const protectedGateway = /fullcourtdefense|agentguard|mcp-gateway|5066|fcd/i.test(targetText);
|
|
138
|
+
out.push({
|
|
139
|
+
name,
|
|
140
|
+
source: candidate.source,
|
|
141
|
+
filePath: candidate.path,
|
|
142
|
+
command,
|
|
143
|
+
args,
|
|
144
|
+
url,
|
|
145
|
+
direct: !protectedGateway,
|
|
146
|
+
protectedGateway,
|
|
147
|
+
riskSignals: riskSignalsForServer(name, cfg),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
function parseRequiredControls(value) {
|
|
154
|
+
return (value || 'payment,delete,shell,secrets')
|
|
155
|
+
.split(',')
|
|
156
|
+
.map(part => part.trim().toLowerCase())
|
|
157
|
+
.filter(Boolean);
|
|
158
|
+
}
|
|
159
|
+
function finding(input) {
|
|
160
|
+
return { ...input, id: stableId([input.category, input.title, input.filePath || '', input.detail]) };
|
|
161
|
+
}
|
|
162
|
+
function evaluateAgentGate(args) {
|
|
163
|
+
const cwd = process.cwd();
|
|
164
|
+
const mcpServers = discoverMcpServers(cwd, args.extraPath);
|
|
165
|
+
const agentFiles = (0, discoverAgentFiles_1.scanAgentFiles)({ cwd });
|
|
166
|
+
const secrets = (0, discoverSecrets_1.scanSecrets)({ cwd, maxEnvDepth: 3 });
|
|
167
|
+
const requireGateway = args.requireGateway !== 'false';
|
|
168
|
+
const requiredControls = parseRequiredControls(args.requireApprovalFor);
|
|
169
|
+
const findings = [];
|
|
170
|
+
for (const server of mcpServers) {
|
|
171
|
+
if (requireGateway && server.direct && server.riskSignals.length > 0) {
|
|
172
|
+
findings.push(finding({
|
|
173
|
+
severity: server.riskSignals.includes('payment') || server.riskSignals.includes('shell') || server.riskSignals.includes('secrets') ? 'critical' : 'high',
|
|
174
|
+
category: 'mcp',
|
|
175
|
+
title: `Unprotected MCP server: ${server.name}`,
|
|
176
|
+
detail: `${server.source} exposes ${server.riskSignals.join(', ')} without Full Court Defense gateway wrapping.`,
|
|
177
|
+
filePath: server.filePath,
|
|
178
|
+
remediation: 'Wrap this MCP server with fullcourtdefense protect-all or install-mcp-gateway before merging.',
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
const missingControls = server.riskSignals.filter(signal => requiredControls.includes(signal));
|
|
182
|
+
if (missingControls.length > 0 && server.direct) {
|
|
183
|
+
findings.push(finding({
|
|
184
|
+
severity: 'high',
|
|
185
|
+
category: 'policy',
|
|
186
|
+
title: `Missing approval policy evidence for ${server.name}`,
|
|
187
|
+
detail: `Risky tool signals need approval/block policy before merge: ${missingControls.join(', ')}.`,
|
|
188
|
+
filePath: server.filePath,
|
|
189
|
+
remediation: 'Add an AgentGuard action policy requiring approval/block for this tool class, then route through the gateway.',
|
|
190
|
+
}));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
for (const file of agentFiles.filter(item => item.riskTags.length > 0)) {
|
|
194
|
+
findings.push(finding({
|
|
195
|
+
severity: file.riskTags.includes('allows_shell') || file.riskTags.includes('broad_filesystem') ? 'high' : 'medium',
|
|
196
|
+
category: 'agent-file',
|
|
197
|
+
title: `Risky agent instruction: ${file.title}`,
|
|
198
|
+
detail: `${file.filePath} contains agent risk tags: ${file.riskTags.join(', ')}.`,
|
|
199
|
+
filePath: file.filePath,
|
|
200
|
+
remediation: 'Review the instruction/rule and add explicit least-privilege boundaries.',
|
|
201
|
+
}));
|
|
202
|
+
}
|
|
203
|
+
for (const secret of secrets.findings.filter(item => item.severity === 'critical' || item.severity === 'high')) {
|
|
204
|
+
findings.push(finding({
|
|
205
|
+
severity: secret.severity === 'critical' ? 'critical' : 'high',
|
|
206
|
+
category: 'secret',
|
|
207
|
+
title: secret.title,
|
|
208
|
+
detail: secret.detail,
|
|
209
|
+
filePath: secret.filePath,
|
|
210
|
+
remediation: secret.remediation,
|
|
211
|
+
}));
|
|
212
|
+
}
|
|
213
|
+
let score = 100;
|
|
214
|
+
for (const item of findings) {
|
|
215
|
+
score -= item.severity === 'critical' ? 25 : item.severity === 'high' ? 12 : item.severity === 'medium' ? 5 : 2;
|
|
216
|
+
}
|
|
217
|
+
return { findings: findings.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]), score: Math.max(0, score), mcpServers };
|
|
218
|
+
}
|
|
219
|
+
function shouldFail(findings, failOn) {
|
|
220
|
+
return findings.some(item => SEVERITY_RANK[item.severity] >= SEVERITY_RANK[failOn]);
|
|
221
|
+
}
|
|
222
|
+
function toSarif(findings) {
|
|
223
|
+
return {
|
|
224
|
+
version: '2.1.0',
|
|
225
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
226
|
+
runs: [{
|
|
227
|
+
tool: {
|
|
228
|
+
driver: {
|
|
229
|
+
name: 'Full Court Defense Agent CI Gate',
|
|
230
|
+
informationUri: 'https://fullcourtdefense.ai',
|
|
231
|
+
rules: findings.map(item => ({
|
|
232
|
+
id: item.id,
|
|
233
|
+
name: item.title,
|
|
234
|
+
shortDescription: { text: item.title },
|
|
235
|
+
fullDescription: { text: item.detail },
|
|
236
|
+
help: { text: item.remediation },
|
|
237
|
+
defaultConfiguration: {
|
|
238
|
+
level: item.severity === 'critical' || item.severity === 'high' ? 'error' : item.severity === 'medium' ? 'warning' : 'note',
|
|
239
|
+
},
|
|
240
|
+
})),
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
results: findings.map(item => ({
|
|
244
|
+
ruleId: item.id,
|
|
245
|
+
level: item.severity === 'critical' || item.severity === 'high' ? 'error' : item.severity === 'medium' ? 'warning' : 'note',
|
|
246
|
+
message: { text: `${item.title}: ${item.detail}` },
|
|
247
|
+
locations: item.filePath ? [{
|
|
248
|
+
physicalLocation: {
|
|
249
|
+
artifactLocation: { uri: path.relative(process.cwd(), item.filePath).replace(/\\/g, '/') },
|
|
250
|
+
region: { startLine: 1 },
|
|
251
|
+
},
|
|
252
|
+
}] : [],
|
|
253
|
+
})),
|
|
254
|
+
}],
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function printSummary(report, failed, failOn) {
|
|
258
|
+
console.log(`Agent CI Gate: ${failed ? 'FAIL' : 'PASS'}`);
|
|
259
|
+
console.log(`Score: ${report.score}/100`);
|
|
260
|
+
console.log(`MCP servers: ${report.mcpServers.length}`);
|
|
261
|
+
console.log(`Findings: ${report.findings.length} (fail-on: ${failOn})`);
|
|
262
|
+
for (const item of report.findings.slice(0, 20)) {
|
|
263
|
+
console.log(`- [${item.severity.toUpperCase()}] ${item.title}`);
|
|
264
|
+
console.log(` ${item.detail}`);
|
|
265
|
+
if (item.filePath)
|
|
266
|
+
console.log(` ${path.relative(process.cwd(), item.filePath)}`);
|
|
267
|
+
}
|
|
268
|
+
if (report.findings.length > 20)
|
|
269
|
+
console.log(`... and ${report.findings.length - 20} more findings`);
|
|
270
|
+
}
|
|
271
|
+
async function agentCiCommand(args, config) {
|
|
272
|
+
const failOn = (['critical', 'high', 'medium', 'low'].includes(String(args.failOn || '').toLowerCase())
|
|
273
|
+
? String(args.failOn).toLowerCase()
|
|
274
|
+
: 'high');
|
|
275
|
+
const format = (['summary', 'json', 'sarif'].includes(String(args.format || '').toLowerCase())
|
|
276
|
+
? String(args.format).toLowerCase()
|
|
277
|
+
: 'summary');
|
|
278
|
+
const report = evaluateAgentGate(args);
|
|
279
|
+
const failed = shouldFail(report.findings, failOn);
|
|
280
|
+
if (format === 'json') {
|
|
281
|
+
console.log(JSON.stringify({ ...report, failed, failOn }, null, 2));
|
|
282
|
+
}
|
|
283
|
+
else if (format === 'sarif') {
|
|
284
|
+
console.log(JSON.stringify(toSarif(report.findings), null, 2));
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
printSummary(report, failed, failOn);
|
|
288
|
+
}
|
|
289
|
+
if (args.upload === 'true') {
|
|
290
|
+
const creds = (0, config_1.resolveCliCredentials)(config, { apiKey: args.apiKey, apiUrl: args.apiUrl });
|
|
291
|
+
if (creds.apiKey || (creds.shieldId && creds.shieldKey)) {
|
|
292
|
+
await (0, discover_1.runDiscoverUpload)({ apiKey: args.apiKey, apiUrl: args.apiUrl, silent: true }, config).catch(error => {
|
|
293
|
+
if (format === 'summary')
|
|
294
|
+
console.error(`Upload skipped: ${error instanceof Error ? error.message : String(error)}`);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
else if (format === 'summary') {
|
|
298
|
+
console.error('Upload skipped: run fullcourtdefense login first or pass --api-key.');
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (failed)
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
@@ -1111,7 +1111,7 @@ function compactScanHtml(args) {
|
|
|
1111
1111
|
.workflowCard { min-height: 118px; }
|
|
1112
1112
|
.workflowCard b { font-size: 18px; }
|
|
1113
1113
|
.workflowCard span { font-size: 13px; }
|
|
1114
|
-
.landingScanGrid { display: grid; grid-template-columns: repeat(
|
|
1114
|
+
.landingScanGrid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 10px; }
|
|
1115
1115
|
.scanTypeGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 12px 0; }
|
|
1116
1116
|
.advancedScanGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; margin: 8px 0 12px; }
|
|
1117
1117
|
.card:hover, .card.active { border-color: #2563eb; background: #eff6ff; transform: translateY(-1px); }
|
|
@@ -1203,6 +1203,7 @@ function compactScanHtml(args) {
|
|
|
1203
1203
|
<button class="card workflowCard" data-workflow="bot"><b>Bot Scan</b><span>Red-team an internal bot, agent, or chat API.</span></button>
|
|
1204
1204
|
<button class="card workflowCard" data-workflow="posture"><b>Security Posture Scan</b><span>Run web-style endpoint security checkup.</span></button>
|
|
1205
1205
|
<button class="card workflowCard" data-workflow="mcp"><b>MCP Scan</b><span>Scan an MCP server or protected gateway for risky tool paths.</span></button>
|
|
1206
|
+
<button class="card workflowCard" data-workflow="mcp-posture"><b>MCP Posture Scan</b><span>Inventory exposed MCP tools without invoking them.</span></button>
|
|
1206
1207
|
</div>
|
|
1207
1208
|
</div>
|
|
1208
1209
|
<div class="menuGroup">
|
|
@@ -1281,6 +1282,7 @@ function compactScanHtml(args) {
|
|
|
1281
1282
|
<div class="advancedScanGrid">
|
|
1282
1283
|
<button class="card scanTypeCard" data-target="rag"><b>RAG Scan</b><span>Test retrieval URL or local corpus path.</span></button>
|
|
1283
1284
|
<button class="card scanTypeCard" data-target="mcp"><b>MCP Scan</b><span>Scan tool responses and tool-abuse paths.</span></button>
|
|
1285
|
+
<button class="card scanTypeCard" data-target="mcp-posture"><b>MCP Posture Scan</b><span>List tools and flag risky exposed capabilities.</span></button>
|
|
1284
1286
|
</div>
|
|
1285
1287
|
</div>
|
|
1286
1288
|
|
|
@@ -1329,6 +1331,7 @@ function compactScanHtml(args) {
|
|
|
1329
1331
|
</div>
|
|
1330
1332
|
|
|
1331
1333
|
<div id="mcp-fields" class="hidden">
|
|
1334
|
+
<div class="note" id="mcpModeNote">MCP Scan runs red-team templates against MCP tool behavior. MCP Posture Scan lists exposed tools and flags risky capabilities without invoking them.</div>
|
|
1332
1335
|
<div class="two">
|
|
1333
1336
|
<div><label>MCP HTTP/SSE URL</label><input id="mcpUrl" placeholder="http://127.0.0.1:5066/mcp" /></div>
|
|
1334
1337
|
<div><label>MCP tool</label><input id="mcpTool" value="all" /></div>
|
|
@@ -1414,19 +1417,25 @@ Local targets stay on this machine; saved scan reports go to Full Court Defense.
|
|
|
1414
1417
|
</section>
|
|
1415
1418
|
</main>
|
|
1416
1419
|
<script>
|
|
1417
|
-
const scanTargets = ['endpoint','posture','rag','mcp'];
|
|
1420
|
+
const scanTargets = ['endpoint','posture','rag','mcp','mcp-posture'];
|
|
1418
1421
|
let target = 'home';
|
|
1419
1422
|
let step = 1;
|
|
1420
1423
|
const qs = new URLSearchParams(location.search);
|
|
1421
|
-
if (['runner','endpoint','posture','rag','mcp','help'].includes(qs.get('target'))) target = qs.get('target');
|
|
1424
|
+
if (['runner','endpoint','posture','rag','mcp','mcp-posture','help'].includes(qs.get('target'))) target = qs.get('target');
|
|
1422
1425
|
const byId = id => document.getElementById(id);
|
|
1423
1426
|
const workflow = () => target === 'endpoint' ? 'bot' : target === 'posture' ? 'posture' : target === 'home' ? '' : target;
|
|
1424
1427
|
const sync = () => {
|
|
1425
1428
|
document.querySelectorAll('.workflowCard').forEach(c => c.classList.toggle('active', c.dataset.workflow === workflow()));
|
|
1426
1429
|
document.querySelectorAll('.scanTypeCard').forEach(c => c.classList.toggle('active', c.dataset.target === target));
|
|
1427
1430
|
byId('workflowPanel').classList.toggle('hidden', target === 'home');
|
|
1428
|
-
['runner','endpoint','posture','rag','mcp','help'].forEach(id =>
|
|
1431
|
+
['runner','endpoint','posture','rag','mcp','help'].forEach(id => {
|
|
1432
|
+
const show = id === 'mcp' ? ['mcp','mcp-posture'].includes(target) : id === target;
|
|
1433
|
+
byId(id + '-fields').classList.toggle('hidden', !show);
|
|
1434
|
+
});
|
|
1429
1435
|
const scanTarget = scanTargets.includes(target);
|
|
1436
|
+
byId('mcpModeNote').textContent = target === 'mcp-posture'
|
|
1437
|
+
? 'MCP Posture Scan lists exposed tools, checks whether the server appears protected, and flags risky capabilities without invoking any tool.'
|
|
1438
|
+
: 'MCP Scan runs red-team templates against MCP tool behavior. Use MCP Posture Scan for inventory-only posture.';
|
|
1430
1439
|
byId('scan-picker').classList.toggle('hidden', !scanTarget);
|
|
1431
1440
|
byId('scanSteps').classList.toggle('hidden', !scanTarget);
|
|
1432
1441
|
byId('scanActions').classList.toggle('hidden', !scanTarget);
|
|
@@ -1434,12 +1443,16 @@ Local targets stay on this machine; saved scan reports go to Full Court Defense.
|
|
|
1434
1443
|
? 'Run MCP Tool'
|
|
1435
1444
|
: target === 'help'
|
|
1436
1445
|
? 'Local workflow options'
|
|
1437
|
-
:
|
|
1446
|
+
: target === 'mcp-posture'
|
|
1447
|
+
? 'MCP posture scan'
|
|
1448
|
+
: 'Choose scan target';
|
|
1438
1449
|
byId('connectSubtitle').textContent = target === 'runner'
|
|
1439
1450
|
? 'Call one MCP tool from this machine. This is not a scan.'
|
|
1440
1451
|
: target === 'help'
|
|
1441
1452
|
? 'Choose one-off tool run or local red-team scan.'
|
|
1442
|
-
:
|
|
1453
|
+
: target === 'mcp-posture'
|
|
1454
|
+
? 'Connect an MCP server or protected gateway. No tools are invoked.'
|
|
1455
|
+
: 'Select Endpoint, RAG, or MCP, then connect the internal target.';
|
|
1443
1456
|
if (!scanTarget || target === 'home') setStep(1);
|
|
1444
1457
|
};
|
|
1445
1458
|
const setStep = next => {
|
|
@@ -1512,6 +1525,7 @@ Local targets stay on this machine; saved scan reports go to Full Court Defense.
|
|
|
1512
1525
|
if (p.target === 'endpoint') parts.push('--endpoint', JSON.stringify(p.endpoint), '--method POST --request-format custom --input-field', p.inputField || 'message', '--output-field', p.outputField || 'response');
|
|
1513
1526
|
if (p.target === 'posture') return 'Security Posture Scan runs through this local web UI and calls the Full Court Defense posture checkup API for ' + (p.postureUrl || '<endpoint-url>');
|
|
1514
1527
|
if (p.target === 'rag') { if (p.ragUrl) parts.push('--rag-url', JSON.stringify(p.ragUrl), '--method POST --request-format custom --input-field', p.ragInputField || 'query', '--output-field', p.ragOutputField || 'chunks'); else parts.push('--rag-path', JSON.stringify(p.ragPath || '.\\\\docs')); }
|
|
1528
|
+
if (p.target === 'mcp-posture') return 'MCP Posture Scan runs through this local web UI and inventories exposed tools for ' + (p.mcpUrl || ((p.mcpCommand || 'node') + ' ' + (p.mcpArgs || '.\\\\server.js')));
|
|
1515
1529
|
if (p.target === 'mcp') { if (p.mcpUrl) parts.push('--mcp-url', JSON.stringify(p.mcpUrl)); else parts.push('--mcp-command', p.mcpCommand || 'node', '--mcp-args', JSON.stringify(p.mcpArgs || '.\\\\server.js')); parts.push('--mcp-tool', p.mcpTool || 'all'); }
|
|
1516
1530
|
return parts.join(' ');
|
|
1517
1531
|
};
|
|
@@ -1767,6 +1781,20 @@ Local targets stay on this machine; saved scan reports go to Full Court Defense.
|
|
|
1767
1781
|
byId('run').disabled = true;
|
|
1768
1782
|
byId('run').textContent = 'Running...';
|
|
1769
1783
|
clearScanSummary();
|
|
1784
|
+
if (target === 'mcp-posture') {
|
|
1785
|
+
try {
|
|
1786
|
+
setConsole('Starting MCP Posture Scan...\\nNo MCP tools will be invoked; this only lists exposed tools.\\n', 'Running MCP posture');
|
|
1787
|
+
const postureRes = await fetch('/mcp/posture', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(payload()) });
|
|
1788
|
+
const postureData = await postureRes.json();
|
|
1789
|
+
setConsole(JSON.stringify(postureData, null, 2), postureData.ok ? 'MCP posture complete' : 'MCP posture failed');
|
|
1790
|
+
} catch (error) {
|
|
1791
|
+
setConsole('MCP Posture Scan failed: ' + (error && error.message ? error.message : String(error)), 'MCP posture failed');
|
|
1792
|
+
} finally {
|
|
1793
|
+
byId('run').disabled = false;
|
|
1794
|
+
byId('run').textContent = 'Run local scan';
|
|
1795
|
+
}
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1770
1798
|
const backendOk = await checkBackend();
|
|
1771
1799
|
if (!backendOk) {
|
|
1772
1800
|
setConsole('Outbound backend check failed. Fix backend connectivity before scanning so verdicts and reports can be generated.', 'Blocked');
|
|
@@ -2039,6 +2067,84 @@ async function runMcpToolFromBrowser(body) {
|
|
|
2039
2067
|
client.close();
|
|
2040
2068
|
}
|
|
2041
2069
|
}
|
|
2070
|
+
function mcpPostureSignals(tool) {
|
|
2071
|
+
const text = `${tool.name || ''} ${tool.description || ''} ${JSON.stringify(tool.inputSchema || {})}`.toLowerCase();
|
|
2072
|
+
const checks = [
|
|
2073
|
+
[/\b(delete|remove|destroy|drop|wipe|purge|erase)\b/, 'destructive operation'],
|
|
2074
|
+
[/\b(write|update|create|insert|modify|patch|replace)\b/, 'write-capable operation'],
|
|
2075
|
+
[/\b(exec|shell|command|process|spawn|powershell|bash|terminal)\b/, 'shell/command execution'],
|
|
2076
|
+
[/\b(file|filesystem|path|readfile|writefile|directory|upload|download)\b/, 'file-system access'],
|
|
2077
|
+
[/\b(secret|token|key|password|credential|env|private)\b/, 'secret/credential access'],
|
|
2078
|
+
[/\b(payment|wire|transfer|invoice|billing|bank|account|iban)\b/, 'financial operation'],
|
|
2079
|
+
[/\b(http|request|fetch|webhook|url|network|browser)\b/, 'network/browser access'],
|
|
2080
|
+
[/\b(customer|user|email|phone|address|ssn|pii|record)\b/, 'customer/PII data access'],
|
|
2081
|
+
];
|
|
2082
|
+
return checks.filter(([pattern]) => pattern.test(text)).map(([, label]) => label);
|
|
2083
|
+
}
|
|
2084
|
+
async function runMcpPostureScanFromBrowser(body) {
|
|
2085
|
+
const rawUrl = typeof body.mcpUrl === 'string' ? body.mcpUrl.trim() : '';
|
|
2086
|
+
const rawCommand = typeof body.mcpCommand === 'string' ? body.mcpCommand.trim() : '';
|
|
2087
|
+
const rawArgs = typeof body.mcpArgs === 'string' ? body.mcpArgs.trim() : '';
|
|
2088
|
+
if (!rawUrl && !rawCommand)
|
|
2089
|
+
throw new Error('Provide either an MCP HTTP/SSE URL or stdio command.');
|
|
2090
|
+
const transport = rawUrl
|
|
2091
|
+
? (/\/sse\/?$/.test(new URL(rawUrl).pathname) ? 'sse' : 'http')
|
|
2092
|
+
: 'stdio';
|
|
2093
|
+
const options = {
|
|
2094
|
+
transport,
|
|
2095
|
+
url: rawUrl || undefined,
|
|
2096
|
+
command: rawCommand || undefined,
|
|
2097
|
+
args: parseArgsList(rawArgs),
|
|
2098
|
+
authType: 'none',
|
|
2099
|
+
toolName: 'all',
|
|
2100
|
+
toolArgs: {},
|
|
2101
|
+
};
|
|
2102
|
+
const destination = rawUrl || `${rawCommand} ${options.args?.join(' ') || ''}`.trim();
|
|
2103
|
+
const protectedGatewayDetected = /fullcourtdefense|agentguard|fcd|5066|gateway|proxy/i.test(destination);
|
|
2104
|
+
const client = createMcpClient(options);
|
|
2105
|
+
const startedAt = Date.now();
|
|
2106
|
+
try {
|
|
2107
|
+
await client.initialize();
|
|
2108
|
+
const tools = await client.listTools();
|
|
2109
|
+
const toolFindings = tools.map(tool => {
|
|
2110
|
+
const signals = mcpPostureSignals(tool);
|
|
2111
|
+
return {
|
|
2112
|
+
name: tool.name,
|
|
2113
|
+
description: tool.description || '',
|
|
2114
|
+
risk: signals.length >= 3 ? 'high' : signals.length >= 1 ? 'medium' : 'low',
|
|
2115
|
+
signals,
|
|
2116
|
+
};
|
|
2117
|
+
});
|
|
2118
|
+
const highRiskTools = toolFindings.filter(tool => tool.risk === 'high').length;
|
|
2119
|
+
const mediumRiskTools = toolFindings.filter(tool => tool.risk === 'medium').length;
|
|
2120
|
+
const findings = [
|
|
2121
|
+
...(protectedGatewayDetected ? [] : ['MCP target does not look like a protected Full Court Defense gateway/proxy.']),
|
|
2122
|
+
...(highRiskTools ? [`${highRiskTools} high-risk MCP tool(s) exposed.`] : []),
|
|
2123
|
+
...(mediumRiskTools ? [`${mediumRiskTools} medium-risk MCP tool(s) exposed.`] : []),
|
|
2124
|
+
...(tools.length > 20 ? [`Large MCP surface: ${tools.length} tools exposed.`] : []),
|
|
2125
|
+
];
|
|
2126
|
+
return {
|
|
2127
|
+
ok: true,
|
|
2128
|
+
scanType: 'mcp_posture',
|
|
2129
|
+
posture: findings.length === 0 ? 'pass' : highRiskTools || !protectedGatewayDetected ? 'fail' : 'warn',
|
|
2130
|
+
transport,
|
|
2131
|
+
destination,
|
|
2132
|
+
protectedGatewayDetected,
|
|
2133
|
+
elapsedMs: Date.now() - startedAt,
|
|
2134
|
+
summary: {
|
|
2135
|
+
totalTools: tools.length,
|
|
2136
|
+
highRiskTools,
|
|
2137
|
+
mediumRiskTools,
|
|
2138
|
+
lowRiskTools: toolFindings.filter(tool => tool.risk === 'low').length,
|
|
2139
|
+
},
|
|
2140
|
+
findings,
|
|
2141
|
+
tools: toolFindings,
|
|
2142
|
+
};
|
|
2143
|
+
}
|
|
2144
|
+
finally {
|
|
2145
|
+
client.close();
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2042
2148
|
function startCompactWebServer(args) {
|
|
2043
2149
|
const server = http.createServer(async (req, res) => {
|
|
2044
2150
|
try {
|
|
@@ -2100,6 +2206,13 @@ function startCompactWebServer(args) {
|
|
|
2100
2206
|
res.end(JSON.stringify(result));
|
|
2101
2207
|
return;
|
|
2102
2208
|
}
|
|
2209
|
+
if (req.method === 'POST' && url.pathname === '/mcp/posture') {
|
|
2210
|
+
const body = await readRequestBody(req);
|
|
2211
|
+
const result = await runMcpPostureScanFromBrowser(body);
|
|
2212
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
|
|
2213
|
+
res.end(JSON.stringify(result));
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2103
2216
|
if (req.method === 'POST' && url.pathname === '/posture/run') {
|
|
2104
2217
|
const body = await readRequestBody(req);
|
|
2105
2218
|
const result = await runPostureScanFromBrowser(args, body);
|
package/dist/index.js
CHANGED
|
@@ -43,6 +43,7 @@ const configure_1 = require("./commands/configure");
|
|
|
43
43
|
const login_1 = require("./commands/login");
|
|
44
44
|
const flushSpool_1 = require("./commands/flushSpool");
|
|
45
45
|
const discover_1 = require("./commands/discover");
|
|
46
|
+
const agentCi_1 = require("./commands/agentCi");
|
|
46
47
|
const hook_1 = require("./commands/hook");
|
|
47
48
|
const installCursorHook_1 = require("./commands/installCursorHook");
|
|
48
49
|
const mcpGateway_1 = require("./commands/mcpGateway");
|
|
@@ -125,6 +126,8 @@ function printHelp() {
|
|
|
125
126
|
discover Finds MCP servers, local secrets, agent rules/skills, and machine
|
|
126
127
|
blast radius on this laptop. Use --surface all --upload for AI Fleet.
|
|
127
128
|
Use --schedule logon to upload inventory/posture at machine login.
|
|
129
|
+
agent-ci CI/CD gate for agents and tools only. Fails builds on risky MCP,
|
|
130
|
+
agent instruction, gateway, policy, or secret drift. Not a bot scan.
|
|
128
131
|
install-cursor-hook
|
|
129
132
|
Installs a Cursor hook so EVERY agent action on this machine is
|
|
130
133
|
checked against your org's Action Policies — in any repo/folder.
|
|
@@ -218,6 +221,8 @@ function printHelp() {
|
|
|
218
221
|
\x1b[1mRecommended Commands:\x1b[0m
|
|
219
222
|
fullcourtdefense doctor
|
|
220
223
|
fullcourtdefense configure
|
|
224
|
+
fullcourtdefense agent-ci --fail-on high --require-gateway --format summary
|
|
225
|
+
fullcourtdefense agent-ci --fail-on high --format sarif --upload
|
|
221
226
|
fullcourtdefense scan --local
|
|
222
227
|
fullcourtdefense scan --local --type mcp --mcp-command node --mcp-args ./server.js --mcp-tool all --mode full --format report
|
|
223
228
|
fullcourtdefense scan --local --type mcp --mcp-url https://internal.company.com/mcp --mcp-auth-type bearer --mcp-token TOKEN --mcp-tool all --mode full --format report
|
|
@@ -256,6 +261,9 @@ function printHelp() {
|
|
|
256
261
|
--rag-url <url> Live RAG HTTP endpoint to scan
|
|
257
262
|
--shield-id <id> Shield ID for local outbound Shield verdicts
|
|
258
263
|
--shield-key <key> Optional Shield key for locked Shields
|
|
264
|
+
--fail-on <severity> agent-ci fail threshold: critical, high, medium, low
|
|
265
|
+
--require-gateway agent-ci requires risky MCP servers to use FCD gateway
|
|
266
|
+
--require-approval-for agent-ci risky controls list, e.g. payment,delete,shell,secrets
|
|
259
267
|
--method <GET|POST> Local endpoint method
|
|
260
268
|
--request-format <fmt> Local endpoint request format: custom or openai
|
|
261
269
|
--auth-type <type> none, bearer, basic, api-key
|
|
@@ -489,6 +497,20 @@ async function main() {
|
|
|
489
497
|
await (0, discover_1.discoverCommand)(args, config);
|
|
490
498
|
break;
|
|
491
499
|
}
|
|
500
|
+
case 'agent-ci': {
|
|
501
|
+
const args = {
|
|
502
|
+
failOn: flags['fail-on'],
|
|
503
|
+
format: flags.format,
|
|
504
|
+
upload: flags.upload,
|
|
505
|
+
requireGateway: flags['require-gateway'],
|
|
506
|
+
requireApprovalFor: flags['require-approval-for'],
|
|
507
|
+
extraPath: flags.path,
|
|
508
|
+
apiKey: flags['api-key'],
|
|
509
|
+
apiUrl: flags['api-url'],
|
|
510
|
+
};
|
|
511
|
+
await (0, agentCi_1.agentCiCommand)(args, config);
|
|
512
|
+
break;
|
|
513
|
+
}
|
|
492
514
|
case 'install-cursor-hook': {
|
|
493
515
|
const args = {
|
|
494
516
|
project: flags.project,
|
package/dist/version.json
CHANGED