fullcourtdefense-cli 1.36.0 → 1.36.2
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/actionPolicyEngine.d.ts +23 -0
- package/dist/actionPolicyEngine.js +58 -19
- package/dist/approvalPrompt.d.ts +15 -0
- package/dist/approvalPrompt.js +157 -20
- package/dist/askDialog.d.ts +2 -0
- package/dist/askDialog.js +15 -1
- package/dist/askHeader.d.ts +81 -0
- package/dist/askHeader.js +573 -0
- package/dist/blockExplanation.d.ts +9 -0
- package/dist/blockExplanation.js +14 -0
- package/dist/callerEnv.d.ts +1 -1
- package/dist/callerEnv.js +14 -0
- package/dist/cloudGrant.d.ts +46 -0
- package/dist/cloudGrant.js +420 -0
- package/dist/cloudGrantTools.d.ts +97 -0
- package/dist/cloudGrantTools.js +1112 -0
- package/dist/commandNarrative.d.ts +8 -0
- package/dist/commandNarrative.js +222 -49
- package/dist/commands/cmdGuard.js +1 -1
- package/dist/commands/daemon.js +14 -0
- package/dist/commands/deterministicGuard.d.ts +8 -2
- package/dist/commands/deterministicGuard.js +219 -55
- package/dist/commands/discover.js +37 -14
- package/dist/commands/discoverAgentFiles.d.ts +2 -0
- package/dist/commands/discoverAgentFiles.js +1 -0
- package/dist/commands/discoverPaths.d.ts +4 -0
- package/dist/commands/discoverPaths.js +19 -1
- package/dist/commands/discoverProjectRoots.d.ts +72 -0
- package/dist/commands/discoverProjectRoots.js +327 -0
- package/dist/commands/discoverProxy.d.ts +1 -1
- package/dist/commands/discoverProxy.js +1 -1
- package/dist/commands/discoverSecrets.d.ts +44 -0
- package/dist/commands/discoverSecrets.js +158 -14
- package/dist/commands/hook.js +10 -7
- package/dist/commands/mcpGateway.js +9 -7
- package/dist/commands/posixShellGuard.js +1 -1
- package/dist/commands/shellGuard.js +70 -25
- package/dist/commands/uninstallAll.d.ts +1 -7
- package/dist/commands/uninstallAll.js +6 -45
- package/dist/config.d.ts +1 -1
- package/dist/config.js +3 -3
- package/dist/credentialGrant.d.ts +48 -0
- package/dist/credentialGrant.js +29 -18
- package/dist/credentialStore.js +1 -1
- package/dist/devConfirm.d.ts +44 -3
- package/dist/devConfirm.js +166 -30
- package/dist/distress.d.ts +2 -0
- package/dist/distress.js +2 -0
- package/dist/grantTargets.d.ts +21 -0
- package/dist/grantTargets.js +235 -0
- package/dist/localSafetySnapshot.js +2 -0
- package/dist/msiProductCode.d.ts +10 -0
- package/dist/msiProductCode.js +124 -0
- package/dist/notify.js +1 -0
- package/dist/selfTest.d.ts +1 -1
- package/dist/selfTest.js +1 -1
- package/dist/selfUpdate.d.ts +51 -2
- package/dist/selfUpdate.js +217 -19
- package/dist/shellAst.d.ts +2 -0
- package/dist/shellAst.js +7 -1
- package/dist/shellAstLoader.js +12 -1
- package/dist/version.json +1 -1
- package/package.json +6 -1
|
@@ -33,7 +33,13 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.probeWindowsFileAttributes = void 0;
|
|
37
|
+
exports.dockerConfigCredentialFacts = dockerConfigCredentialFacts;
|
|
38
|
+
exports.isCloudPlaceholderAttributes = isCloudPlaceholderAttributes;
|
|
39
|
+
exports.isCloudSyncedPath = isCloudSyncedPath;
|
|
40
|
+
exports.dropCloudPlaceholders = dropCloudPlaceholders;
|
|
36
41
|
exports.scanSecrets = scanSecrets;
|
|
42
|
+
const child_process_1 = require("child_process");
|
|
37
43
|
const crypto = __importStar(require("crypto"));
|
|
38
44
|
const fs = __importStar(require("fs"));
|
|
39
45
|
const os = __importStar(require("os"));
|
|
@@ -158,17 +164,58 @@ function looksLikePlaceholder(raw, line) {
|
|
|
158
164
|
return true;
|
|
159
165
|
return false;
|
|
160
166
|
}
|
|
167
|
+
/** Fields of a Docker `auths` entry that carry a usable credential (docker/cli configfile AuthConfig). */
|
|
168
|
+
const DOCKER_INLINE_CREDENTIAL_KEYS = new Set(['auth', 'password', 'identitytoken', 'registrytoken']);
|
|
169
|
+
/**
|
|
170
|
+
* Whether ~/.docker/config.json holds credentials itself. `auths` entries that
|
|
171
|
+
* only name a registry are metadata when `credsStore` / `credHelpers` keep the
|
|
172
|
+
* secret in the OS store (Docker Desktop on Windows: `credsStore: desktop` =
|
|
173
|
+
* Windows Credential Manager). Counts only; never returns a value.
|
|
174
|
+
*/
|
|
175
|
+
function dockerConfigCredentialFacts(content) {
|
|
176
|
+
let config;
|
|
177
|
+
try {
|
|
178
|
+
config = JSON.parse(content);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Unparseable: fall back to "a credential field with a non-empty string value".
|
|
182
|
+
const inline = /"(?:auth|password|identitytoken|registrytoken)"\s*:\s*"[^"\s]+"/i.test(content);
|
|
183
|
+
return { inlineRegistries: inline ? 1 : 0, usesCredentialStore: /"(?:credsStore|credHelpers)"\s*:/.test(content) };
|
|
184
|
+
}
|
|
185
|
+
if (!config || typeof config !== 'object')
|
|
186
|
+
return { inlineRegistries: 0, usesCredentialStore: false };
|
|
187
|
+
const record = config;
|
|
188
|
+
const credsStore = typeof record.credsStore === 'string' && record.credsStore.trim() !== '';
|
|
189
|
+
const credHelpers = !!record.credHelpers && typeof record.credHelpers === 'object' && Object.keys(record.credHelpers).length > 0;
|
|
190
|
+
let inlineRegistries = 0;
|
|
191
|
+
const auths = record.auths;
|
|
192
|
+
if (auths && typeof auths === 'object' && !Array.isArray(auths)) {
|
|
193
|
+
for (const entry of Object.values(auths)) {
|
|
194
|
+
if (!entry || typeof entry !== 'object')
|
|
195
|
+
continue;
|
|
196
|
+
const hasInline = Object.entries(entry).some(([key, value]) => DOCKER_INLINE_CREDENTIAL_KEYS.has(key.toLowerCase()) && typeof value === 'string' && value.trim() !== '');
|
|
197
|
+
if (hasInline)
|
|
198
|
+
inlineRegistries += 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return { inlineRegistries, usesCredentialStore: credsStore || credHelpers };
|
|
202
|
+
}
|
|
161
203
|
function scanCredentialStorePosture(filePath, content, category, out, seen) {
|
|
162
204
|
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
|
163
|
-
if (normalized.endsWith('/.docker/config.json')
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
205
|
+
if (normalized.endsWith('/.docker/config.json')) {
|
|
206
|
+
const docker = dockerConfigCredentialFacts(content);
|
|
207
|
+
if (docker.inlineRegistries > 0) {
|
|
208
|
+
pushStoreFinding(out, seen, {
|
|
209
|
+
filePath,
|
|
210
|
+
category,
|
|
211
|
+
provider: 'docker',
|
|
212
|
+
severity: 'high',
|
|
213
|
+
title: 'Docker registry credentials',
|
|
214
|
+
detail: docker.usesCredentialStore
|
|
215
|
+
? `Docker config stores credentials inline for ${docker.inlineRegistries} registr${docker.inlineRegistries === 1 ? 'y' : 'ies'} next to a credential store; the inline entries bypass it and agent tools or local malware can read them.`
|
|
216
|
+
: `Docker config stores credentials inline for ${docker.inlineRegistries} registr${docker.inlineRegistries === 1 ? 'y' : 'ies'} that agent tools or local malware can read.`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
172
219
|
}
|
|
173
220
|
if (normalized.endsWith('/.kube/config') && /\b(?:token|client-key-data|client-certificate-data)\s*:/i.test(content)) {
|
|
174
221
|
pushStoreFinding(out, seen, {
|
|
@@ -402,10 +449,15 @@ function scanSshKeys(home, out, seen, excludedRoots = []) {
|
|
|
402
449
|
}
|
|
403
450
|
return scanned;
|
|
404
451
|
}
|
|
452
|
+
// Every root starts at depth 0 in one breadth-first queue, and a folder is
|
|
453
|
+
// walked once: a project root that also sits under the home folder is walked
|
|
454
|
+
// from its own top level (depth 0) before the home walk could reach it at
|
|
455
|
+
// depth 4+, and its files are never read twice.
|
|
405
456
|
function collectEnvFiles(roots, maxDepth, excludedRoots = []) {
|
|
406
457
|
const found = [];
|
|
407
458
|
let excluded = 0;
|
|
408
459
|
let depthLimited = false;
|
|
460
|
+
const visited = new Set();
|
|
409
461
|
const queue = roots.map(dir => ({ dir, depth: 0 }));
|
|
410
462
|
while (queue.length > 0 && found.length < MAX_ENV_FILES) {
|
|
411
463
|
const { dir, depth } = queue.shift();
|
|
@@ -413,6 +465,10 @@ function collectEnvFiles(roots, maxDepth, excludedRoots = []) {
|
|
|
413
465
|
excluded += 1;
|
|
414
466
|
continue;
|
|
415
467
|
}
|
|
468
|
+
const key = (0, discoverPaths_1.normalizedPath)(dir);
|
|
469
|
+
if (visited.has(key))
|
|
470
|
+
continue;
|
|
471
|
+
visited.add(key);
|
|
416
472
|
let entries;
|
|
417
473
|
try {
|
|
418
474
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -447,12 +503,14 @@ function collectEnvFiles(roots, maxDepth, excludedRoots = []) {
|
|
|
447
503
|
}
|
|
448
504
|
}
|
|
449
505
|
}
|
|
450
|
-
|
|
506
|
+
const countLimited = found.length >= MAX_ENV_FILES;
|
|
507
|
+
return { files: found, truncated: depthLimited || countLimited || queue.length > 0, depthLimited, countLimited: countLimited || queue.length > 0, excluded };
|
|
451
508
|
}
|
|
452
509
|
function collectProjectCredentialFiles(roots, maxDepth, excludedRoots = []) {
|
|
453
510
|
const found = [];
|
|
454
511
|
let excluded = 0;
|
|
455
512
|
let depthLimited = false;
|
|
513
|
+
const visited = new Set();
|
|
456
514
|
const queue = roots.map(dir => ({ dir, depth: 0 }));
|
|
457
515
|
const isCredentialFile = (filePath) => {
|
|
458
516
|
const normalized = filePath.replace(/\\/g, '/').toLowerCase();
|
|
@@ -470,6 +528,10 @@ function collectProjectCredentialFiles(roots, maxDepth, excludedRoots = []) {
|
|
|
470
528
|
excluded += 1;
|
|
471
529
|
continue;
|
|
472
530
|
}
|
|
531
|
+
const key = (0, discoverPaths_1.normalizedPath)(dir);
|
|
532
|
+
if (visited.has(key))
|
|
533
|
+
continue;
|
|
534
|
+
visited.add(key);
|
|
473
535
|
let entries;
|
|
474
536
|
try {
|
|
475
537
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
@@ -501,7 +563,81 @@ function collectProjectCredentialFiles(roots, maxDepth, excludedRoots = []) {
|
|
|
501
563
|
found.push(full);
|
|
502
564
|
}
|
|
503
565
|
}
|
|
504
|
-
|
|
566
|
+
const countLimited = found.length >= MAX_ENV_FILES;
|
|
567
|
+
return { files: found, truncated: depthLimited || countLimited || queue.length > 0, depthLimited, countLimited: countLimited || queue.length > 0, excluded };
|
|
568
|
+
}
|
|
569
|
+
// Windows file attributes that mark a cloud-sync placeholder (OneDrive Files
|
|
570
|
+
// On-Demand): reading such a file makes the sync client download it.
|
|
571
|
+
const FILE_ATTRIBUTE_OFFLINE = 0x1000;
|
|
572
|
+
const FILE_ATTRIBUTE_RECALL_ON_OPEN = 0x40000;
|
|
573
|
+
const FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 0x400000;
|
|
574
|
+
function isCloudPlaceholderAttributes(attributes) {
|
|
575
|
+
return Number.isInteger(attributes) && attributes >= 0
|
|
576
|
+
&& (attributes & (FILE_ATTRIBUTE_OFFLINE | FILE_ATTRIBUTE_RECALL_ON_OPEN | FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS)) !== 0;
|
|
577
|
+
}
|
|
578
|
+
/** True for paths inside a OneDrive folder (by env root or a "OneDrive…" path segment). */
|
|
579
|
+
function isCloudSyncedPath(filePath, env = process.env) {
|
|
580
|
+
const roots = [env.OneDrive, env.OneDriveCommercial, env.OneDriveConsumer].filter((root) => !!root);
|
|
581
|
+
if (roots.some(root => (0, discoverPaths_1.pathIsWithin)(filePath, root)))
|
|
582
|
+
return true;
|
|
583
|
+
return filePath.split(/[\\/]/).some(segment => /^onedrive\b/i.test(segment));
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* One PowerShell call reads the attributes of every candidate (no file is
|
|
587
|
+
* opened, so nothing is downloaded). Paths go in base64 on stdin so non-ASCII
|
|
588
|
+
* folder names survive the console code page; one integer per line comes back
|
|
589
|
+
* in input order. Returns null when PowerShell is unavailable or times out.
|
|
590
|
+
*/
|
|
591
|
+
const probeWindowsFileAttributes = files => {
|
|
592
|
+
if (files.length === 0)
|
|
593
|
+
return [];
|
|
594
|
+
const script = [
|
|
595
|
+
"$ErrorActionPreference = 'SilentlyContinue'",
|
|
596
|
+
'foreach ($line in [Console]::In.ReadToEnd() -split "`n") {',
|
|
597
|
+
' $line = $line.Trim(); if (-not $line) { continue }',
|
|
598
|
+
' try { $p = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($line)); [Console]::Out.WriteLine([int][IO.File]::GetAttributes($p)) }',
|
|
599
|
+
' catch { [Console]::Out.WriteLine(-1) }',
|
|
600
|
+
'}',
|
|
601
|
+
].join('\n');
|
|
602
|
+
try {
|
|
603
|
+
const result = (0, child_process_1.spawnSync)('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
604
|
+
input: files.map(file => Buffer.from(file, 'utf8').toString('base64')).join('\n'),
|
|
605
|
+
encoding: 'utf8',
|
|
606
|
+
timeout: 15000,
|
|
607
|
+
windowsHide: true,
|
|
608
|
+
});
|
|
609
|
+
if (result.status !== 0 || typeof result.stdout !== 'string')
|
|
610
|
+
return null;
|
|
611
|
+
const values = result.stdout.split(/\r?\n/).map(line => line.trim()).filter(Boolean).map(Number);
|
|
612
|
+
if (values.length !== files.length)
|
|
613
|
+
return null;
|
|
614
|
+
return values.map(value => (Number.isInteger(value) && value >= 0 ? value : undefined));
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
exports.probeWindowsFileAttributes = probeWindowsFileAttributes;
|
|
621
|
+
/**
|
|
622
|
+
* Drop OneDrive online-only placeholders before any read. Only files under a
|
|
623
|
+
* OneDrive folder are probed; a failed probe keeps every file (the per-file
|
|
624
|
+
* size cap still bounds what is read).
|
|
625
|
+
*/
|
|
626
|
+
function dropCloudPlaceholders(files, options = {}) {
|
|
627
|
+
if ((options.platform ?? process.platform) !== 'win32')
|
|
628
|
+
return { files, skipped: 0 };
|
|
629
|
+
const env = options.env ?? process.env;
|
|
630
|
+
const synced = files.filter(file => isCloudSyncedPath(file, env));
|
|
631
|
+
if (synced.length === 0)
|
|
632
|
+
return { files, skipped: 0 };
|
|
633
|
+
const attributes = (options.probe ?? exports.probeWindowsFileAttributes)(synced);
|
|
634
|
+
if (!attributes)
|
|
635
|
+
return { files, skipped: 0 };
|
|
636
|
+
const placeholders = new Set(synced.filter((_, index) => {
|
|
637
|
+
const value = attributes[index];
|
|
638
|
+
return value !== undefined && isCloudPlaceholderAttributes(value);
|
|
639
|
+
}).map(file => (0, discoverPaths_1.normalizedPath)(file)));
|
|
640
|
+
return { files: files.filter(file => !placeholders.has((0, discoverPaths_1.normalizedPath)(file))), skipped: placeholders.size };
|
|
505
641
|
}
|
|
506
642
|
function scanSecrets(options = {}) {
|
|
507
643
|
const home = os.homedir();
|
|
@@ -550,10 +686,13 @@ function scanSecrets(options = {}) {
|
|
|
550
686
|
path.join(home, 'Documents'),
|
|
551
687
|
path.join(home, 'code'),
|
|
552
688
|
].filter(root => fs.existsSync(root) && !(0, discoverPaths_1.isExcludedPath)(root, disabledRoots)));
|
|
689
|
+
const placeholderOptions = { platform: options.platform, env: options.env, probe: options.probeFileAttributes };
|
|
553
690
|
const envCollection = collectEnvFiles(envRoots, maxDepth, disabledRoots);
|
|
554
691
|
excludedFiles += envCollection.excluded;
|
|
555
692
|
candidatesTruncated = candidatesTruncated || envCollection.truncated;
|
|
556
|
-
|
|
693
|
+
const envFiles = dropCloudPlaceholders(envCollection.files, placeholderOptions);
|
|
694
|
+
let cloudPlaceholdersSkipped = envFiles.skipped;
|
|
695
|
+
for (const envFile of envFiles.files) {
|
|
557
696
|
if (findings.length >= MAX_FINDINGS)
|
|
558
697
|
break;
|
|
559
698
|
const content = readTextFile(envFile);
|
|
@@ -565,7 +704,9 @@ function scanSecrets(options = {}) {
|
|
|
565
704
|
const credentialCollection = collectProjectCredentialFiles([cwd, ...extraRoots], maxDepth, disabledRoots);
|
|
566
705
|
excludedFiles += credentialCollection.excluded;
|
|
567
706
|
candidatesTruncated = candidatesTruncated || credentialCollection.truncated;
|
|
568
|
-
|
|
707
|
+
const credentialFiles = dropCloudPlaceholders(credentialCollection.files, placeholderOptions);
|
|
708
|
+
cloudPlaceholdersSkipped += credentialFiles.skipped;
|
|
709
|
+
for (const credentialFile of credentialFiles.files) {
|
|
569
710
|
if (findings.length >= MAX_FINDINGS)
|
|
570
711
|
break;
|
|
571
712
|
const content = readTextFile(credentialFile);
|
|
@@ -602,7 +743,10 @@ function scanSecrets(options = {}) {
|
|
|
602
743
|
scannedStores,
|
|
603
744
|
healthScore: scoreFromFindings(findings),
|
|
604
745
|
limits: { maxFileBytes: MAX_FILE_BYTES, maxEnvFiles: MAX_ENV_FILES, maxFindings: MAX_FINDINGS, maxDepth },
|
|
605
|
-
stats: { candidateFiles: scannedFiles, excludedFiles },
|
|
746
|
+
stats: { candidateFiles: scannedFiles, excludedFiles, cloudPlaceholdersSkipped },
|
|
606
747
|
truncated: { candidateFiles: candidatesTruncated, findings: findings.length >= MAX_FINDINGS },
|
|
748
|
+
truncatedReasons: {
|
|
749
|
+
candidateFiles: (0, discoverPaths_1.truncationReason)(envCollection.depthLimited || credentialCollection.depthLimited, envCollection.countLimited || credentialCollection.countLimited),
|
|
750
|
+
},
|
|
607
751
|
};
|
|
608
752
|
}
|
package/dist/commands/hook.js
CHANGED
|
@@ -1248,11 +1248,13 @@ function explainGuardStop(kind, event, call, reason, ruleId) {
|
|
|
1248
1248
|
});
|
|
1249
1249
|
}
|
|
1250
1250
|
function spoolLocalFinding(input) {
|
|
1251
|
+
// An action that ran is never recorded as "Blocked …" (monitor showed "Blocked recursive force delete" on a warn).
|
|
1252
|
+
const recorded = input.decision === 'warn' ? input.recorded || 'warned' : undefined;
|
|
1251
1253
|
(0, telemetry_1.spoolEvent)({
|
|
1252
1254
|
decision: input.decision || 'block',
|
|
1253
1255
|
toolName: input.toolName,
|
|
1254
1256
|
operation: input.operation,
|
|
1255
|
-
reason: input.finding.reason,
|
|
1257
|
+
reason: recorded ? (0, blockExplanation_1.recordedReason)(input.finding.reason, recorded) : input.finding.reason,
|
|
1256
1258
|
ruleId: input.finding.ruleId,
|
|
1257
1259
|
category: input.finding.category,
|
|
1258
1260
|
categoryId: input.finding.categoryId,
|
|
@@ -1811,8 +1813,9 @@ async function hookCommandInner(args, config, io) {
|
|
|
1811
1813
|
}
|
|
1812
1814
|
// An ask carries the dialog envelope for the per-event hook process in the
|
|
1813
1815
|
// developer's session (askDialog.ts); it is removed before the IDE reads the verdict.
|
|
1816
|
+
// The same client the runtime event records as `agentClient`: the dialog names the agent the console shows.
|
|
1814
1817
|
if (ask && askEnvelope)
|
|
1815
|
-
decision = (0, askDialog_1.attachAskEnvelope)(decision, askEnvelope);
|
|
1818
|
+
decision = (0, askDialog_1.attachAskEnvelope)(decision, { ...askEnvelope, agentClient: client });
|
|
1816
1819
|
(0, hookIo_1.dbg)({ phase: 'verdict', event, format: hookFormat, blocked, decision: ask && askEnvelope ? { ...decision, [askDialog_1.ASK_ENVELOPE_FIELD]: '(envelope)' } : decision });
|
|
1817
1820
|
if (hookFormat === 'claude' && event === 'prompt' && denied) {
|
|
1818
1821
|
// Claude Desktop/Code variants do not all honor the structured
|
|
@@ -1914,8 +1917,8 @@ async function hookCommandInner(args, config, io) {
|
|
|
1914
1917
|
(0, hookIo_1.dbg)({ phase: 'local_deterministic_prompt_block', event, ruleId: localBlock.ruleId, category: localBlock.category });
|
|
1915
1918
|
if (shadow) {
|
|
1916
1919
|
// Truth-in-reporting: this prompt RAN (shadow/monitor) — 'warn', never 'block'.
|
|
1917
|
-
spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt', decision: 'warn' });
|
|
1918
|
-
respond(false, undefined, `[FullCourtDefense shadow]
|
|
1920
|
+
spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt', decision: 'warn', recorded: 'would_block' });
|
|
1921
|
+
respond(false, undefined, `[FullCourtDefense shadow] ${(0, blockExplanation_1.recordedReason)(localBlock.reason, 'would_block')}`);
|
|
1919
1922
|
return;
|
|
1920
1923
|
}
|
|
1921
1924
|
spoolLocalFinding({ finding: localBlock, toolName: 'prompt', operation: 'prompt' });
|
|
@@ -2059,8 +2062,8 @@ async function enforceActionPolicy(ctx) {
|
|
|
2059
2062
|
(0, hookIo_1.dbg)({ phase: 'local_deterministic_block', event, tool: call.toolName, ruleId: localBlock.ruleId, category: localBlock.category });
|
|
2060
2063
|
if (shadow) {
|
|
2061
2064
|
// Truth-in-reporting: this action RAN (shadow/monitor) — 'warn', never 'block'.
|
|
2062
|
-
spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event, decision: 'warn' });
|
|
2063
|
-
respond(false, undefined, `[FullCourtDefense shadow]
|
|
2065
|
+
spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event, decision: 'warn', recorded: 'would_block' });
|
|
2066
|
+
respond(false, undefined, `[FullCourtDefense shadow] ${call.toolName}: ${(0, blockExplanation_1.recordedReason)(localBlock.reason, 'would_block')}`);
|
|
2064
2067
|
return;
|
|
2065
2068
|
}
|
|
2066
2069
|
spoolLocalFinding({ finding: localBlock, toolName: call.toolName, operation: event });
|
|
@@ -2077,7 +2080,7 @@ async function enforceActionPolicy(ctx) {
|
|
|
2077
2080
|
if (localAsk) {
|
|
2078
2081
|
(0, hookIo_1.dbg)({ phase: 'local_deterministic_ask', event, tool: call.toolName, ruleId: localAsk.ruleId, humanPresent: (0, devConfirm_1.isHumanPresent)(), shadow });
|
|
2079
2082
|
if (shadow) {
|
|
2080
|
-
spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event, decision: 'warn' });
|
|
2083
|
+
spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event, decision: 'warn', recorded: 'would_ask' });
|
|
2081
2084
|
}
|
|
2082
2085
|
else if (!(0, devConfirm_1.isHumanPresent)()) {
|
|
2083
2086
|
spoolLocalFinding({ finding: localAsk, toolName: call.toolName, operation: event });
|
|
@@ -1546,7 +1546,7 @@ class McpGatewayServer {
|
|
|
1546
1546
|
process.stderr.write(`FullCourtDefense asking the developer to confirm ${toolName}: ${ask.reason} (${origin} "${ask.itemId}", ${ask.ruleId})\n`);
|
|
1547
1547
|
const alwaysScopeLabel = (0, devConfirm_1.describeGrantScope)(grantScope);
|
|
1548
1548
|
const alwaysLimitsLabel = grantLimits ? (0, devConfirm_1.describeGrantLimits)(grantLimits) : undefined;
|
|
1549
|
-
const answer = await this.confirmWithDeveloper({ toolName, reason: ask.reason, ruleLabel: `${origin} "${ask.itemId}" (${ask.ruleId})`, evidence: ask.evidence, argsDigest: digest, allowAlways: Boolean(grantLimits), alwaysScopeLabel, alwaysLimitsLabel });
|
|
1549
|
+
const answer = await this.confirmWithDeveloper({ toolName, reason: ask.reason, ruleLabel: `${origin} "${ask.itemId}" (${ask.ruleId})`, evidence: ask.evidence, argsDigest: digest, allowAlways: Boolean(grantLimits), alwaysScopeLabel, alwaysLimitsLabel, alwaysCovers: (0, devConfirm_1.grantCoverage)(grantScope) });
|
|
1550
1550
|
if (answer.answer === 'accept') {
|
|
1551
1551
|
// Settle through the shared ledger so "remember for this session" is
|
|
1552
1552
|
// recorded exactly like an IDE approval would be.
|
|
@@ -1594,8 +1594,9 @@ class McpGatewayServer {
|
|
|
1594
1594
|
// where the scan can still yield block-action findings. Record the
|
|
1595
1595
|
// would-block and let the call through.
|
|
1596
1596
|
const would = requestOutcome.blockingFinding ? 'would block' : 'would ask you to confirm';
|
|
1597
|
-
|
|
1598
|
-
|
|
1597
|
+
const recorded = (0, blockExplanation_1.recordedReason)(localBlock.reason, requestOutcome.blockingFinding ? 'would_block' : 'would_ask');
|
|
1598
|
+
this.spoolLocalFinding({ ...localBlock, reason: `[monitor] ${recorded}` }, toolName, operation, 'warn');
|
|
1599
|
+
process.stderr.write(`FullCourtDefense (monitor) ${would} ${toolName}: ${recorded.replace(/^Would (?:block|ask): /, '')} (${origin} "${localBlock.itemId}", ${localBlock.ruleId})\n`);
|
|
1599
1600
|
}
|
|
1600
1601
|
else {
|
|
1601
1602
|
this.spoolLocalFinding(localBlock, toolName, operation);
|
|
@@ -1881,7 +1882,7 @@ class McpGatewayServer {
|
|
|
1881
1882
|
const responseOrigin = localResponseBlock.source === 'custom' ? 'org custom rule' : 'built-in Local Safety rule';
|
|
1882
1883
|
if (reportOnlyMode) {
|
|
1883
1884
|
// MONITOR CONTRACT (see request-side block above): record, never block.
|
|
1884
|
-
this.spoolLocalFinding({ ...localResponseBlock, reason: `[monitor] ${localResponseBlock.reason}` }, toolName, operation, 'warn');
|
|
1885
|
+
this.spoolLocalFinding({ ...localResponseBlock, reason: `[monitor] ${(0, blockExplanation_1.recordedReason)(localResponseBlock.reason, 'would_block')}` }, toolName, operation, 'warn');
|
|
1885
1886
|
process.stderr.write(`FullCourtDefense (monitor) would block the ${toolName} response: ${localResponseBlock.reason} (${responseOrigin} "${localResponseBlock.itemId}", ${localResponseBlock.ruleId})\n`);
|
|
1886
1887
|
}
|
|
1887
1888
|
else {
|
|
@@ -1901,7 +1902,7 @@ class McpGatewayServer {
|
|
|
1901
1902
|
if (!fullyMasked) {
|
|
1902
1903
|
const finding = responseOutcome.maskFindings[0];
|
|
1903
1904
|
if (reportOnlyMode) {
|
|
1904
|
-
this.spoolLocalFinding({ ...finding, reason: `[monitor] ${finding.reason}` }, toolName, operation, 'warn');
|
|
1905
|
+
this.spoolLocalFinding({ ...finding, reason: `[monitor] ${(0, blockExplanation_1.recordedReason)(finding.reason, 'would_block')}` }, toolName, operation, 'warn');
|
|
1905
1906
|
process.stderr.write(`FullCourtDefense (monitor) would block the ${toolName} response: mask rule "${finding.itemId}" could not rewrite this payload.\n`);
|
|
1906
1907
|
}
|
|
1907
1908
|
else {
|
|
@@ -2243,6 +2244,7 @@ class McpGatewayServer {
|
|
|
2243
2244
|
allowAlways,
|
|
2244
2245
|
alwaysScopeLabel,
|
|
2245
2246
|
alwaysLimitsLabel,
|
|
2247
|
+
alwaysCovers: (0, devConfirm_1.grantCoverage)(grantScope),
|
|
2246
2248
|
});
|
|
2247
2249
|
if (answer.answer === 'accept') {
|
|
2248
2250
|
// Policy memory limits are authoritative; a client cannot upgrade 'none'.
|
|
@@ -2320,7 +2322,7 @@ class McpGatewayServer {
|
|
|
2320
2322
|
}
|
|
2321
2323
|
async askInOsDialog(input, clientAutoAnswered) {
|
|
2322
2324
|
const evidence = input.evidence ? `\nEvidence: ${input.evidence.slice(0, 240)}` : '';
|
|
2323
|
-
const dialog = await (0, devConfirm_1.confirmNativeActionDetailed)(`Agent: ${this.gatewayConfig.agentName}\nIDE: ${this.gatewayConfig.agentClient}\nDeveloper: ${this.gatewayConfig.developerName || 'Unknown'}\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nYour answer is reported to your security dashboard as your decision.`, OS_DIALOG_TIMEOUT_MS, { allowAlways: input.allowAlways === true, alwaysScopeLabel: input.alwaysScopeLabel, alwaysLimitsLabel: input.alwaysLimitsLabel });
|
|
2325
|
+
const dialog = await (0, devConfirm_1.confirmNativeActionDetailed)(`Agent: ${this.gatewayConfig.agentName}\nIDE: ${this.gatewayConfig.agentClient}\nDeveloper: ${this.gatewayConfig.developerName || 'Unknown'}\nTool: ${input.toolName}\nRule: ${input.ruleLabel}\nWhy: ${input.reason}${evidence}\n\nYour answer is reported to your security dashboard as your decision.`, OS_DIALOG_TIMEOUT_MS, { allowAlways: input.allowAlways === true, alwaysScopeLabel: input.alwaysScopeLabel, alwaysLimitsLabel: input.alwaysLimitsLabel, alwaysCovers: input.alwaysCovers });
|
|
2324
2326
|
process.stderr.write(`FullCourtDefense: dialog for ${input.toolName} ended with "${dialog.outcome}" after ${dialog.latencyMs}ms.\n`);
|
|
2325
2327
|
const answer = dialog.outcome === 'allow' || dialog.outcome === 'allow_always' ? 'accept' : dialog.outcome === 'deny' ? 'decline' : dialog.outcome;
|
|
2326
2328
|
return { answer, remember: false, always: dialog.outcome === 'allow_always', latencyMs: dialog.latencyMs, channel: 'os_dialog', clientAutoAnswered };
|
|
@@ -3079,7 +3081,7 @@ function healCodexTomlAgentIdentities(file, dryRun) {
|
|
|
3079
3081
|
* runtime-agnostic agent identity that protect-all/auto-protect produce (`<developer>-<server>`).
|
|
3080
3082
|
* Otherwise the gateway reports a generic `<developer>-<client>` name and a dashboard policy
|
|
3081
3083
|
* targeting the MCP server (agentName contains <server>) silently never matches — the exact
|
|
3082
|
-
* bug where a block policy on `
|
|
3084
|
+
* bug where a block policy on `contoso-bank-finance` did not fire because the Claude Desktop
|
|
3083
3085
|
* entry reported `boazl-claude-desktop`. An explicit --agent-name / FCD_AGENT_NAME still wins.
|
|
3084
3086
|
*/
|
|
3085
3087
|
function applyPerServerAgentName(gatewayConfig, args, serverName) {
|
|
@@ -171,7 +171,7 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
171
171
|
`// Execution view (aligned with the CLI's stripInertDataSegments): quoted spans containing whitespace are`,
|
|
172
172
|
`// prose (inert) unless in a code position — interpreter -c/-Command flags, remote executors (ssh/wsl/docker`,
|
|
173
173
|
`// exec), $( ) substitution, or real backtick command substitution (>=2 chars; a 1-char pair is a PS escape).`,
|
|
174
|
-
`function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s
|
|
174
|
+
`function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|(?<!\\|)\\|(?!\\|)\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b|\\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\s+(?:-c|\\/c|-e|-command|-scriptblock)\\b|\\b(?:ssh|wsl(?:\\.exe)?|chroot)\\b|\\b(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]{2,}\`/;const C=/(?:(?:^|[;&|(]|\\s)(?:-c|\\/c|-e|-command|-scriptblock|-filter)[= ]\\s*|(?:^|[;&|(]\\s*)(?:\\S*[\\\\\\/])?(?:sudo\\s+|doas\\s+)?(?:ssh|wsl(?:\\.exe)?|chroot|su|screen|tmux)(?:\\s[^;&|"']*)?\\s|(?:\\S*[\\\\\\/])?(?:docker|kubectl|podman|nerdctl)(?:\\.exe)?\\s+(?:exec|run)\\b[^;&|"']*\\s)$/i;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');const s=o;o=s.replace(/"((?:[^"\\\\]|\\\\.)*)"|'((?:[^'\\\\]|\\\\.)*)'/g,(w,dq,sq,i)=>{const b=dq!==undefined?dq:(sq||'');if(!/\\s/.test(b))return w;if(C.test(s.slice(Math.max(0,i-80),i)))return w;if(dq!==undefined&&K.test(b))return w;return ' fcd_inert_data ';});return o;}`,
|
|
175
175
|
`// Precedence on one line: block outranks ask, ask outranks warn.`,
|
|
176
176
|
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;const asks=[];for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action==='block')return r;if(r.action==='ask'){asks.push(r);}else if(!warnHit)warnHit=r;}}catch{}}if(asks.length===1)return asks[0];if(asks.length>1){asks.sort((a,b)=>a.id<b.id?-1:a.id>b.id?1:0);return{...asks[0],id:'asks:'+asks.map(r=>encodeURIComponent(r.id)).join('+'),reason:asks.map(r=>r.reason).join('; ')}}return warnHit;}`,
|
|
177
177
|
`function triggerFlush(){try{if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore'});c.unref();}}catch{}}`,
|
|
@@ -183,10 +183,32 @@ function compileSpec(spec) {
|
|
|
183
183
|
pattern += `(?=${SEG}\\s(?:${spec.target.join('|')}))`;
|
|
184
184
|
return { id: spec.id, category: spec.category, severity: spec.severity, reason: spec.reason, source: 'builtin', pattern };
|
|
185
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* Every body must occur on one line, in any order. Tried only at line starts: an
|
|
188
|
+
* unanchored `(?=.*a)(?=.*b)` re-scans the rest of the line from every
|
|
189
|
+
* character, which is quadratic on a long line that does not match (a 16 KB
|
|
190
|
+
* commit message took seconds per rule). Same matches in .NET and JS: a hit from
|
|
191
|
+
* mid-line implies one from the start of that line.
|
|
192
|
+
*/
|
|
193
|
+
function sameLine(...bodies) {
|
|
194
|
+
return String.raw `(?:^|[\n\r\u2028\u2029])` + bodies.map(body => `(?=.*${body})`).join('');
|
|
195
|
+
}
|
|
186
196
|
// Reusable target fragments.
|
|
187
|
-
|
|
197
|
+
/** The root itself; a bare `*` (the current folder's contents) is its own finding. */
|
|
198
|
+
const T_UNIX_ROOT_ONLY = String.raw `["']?/\*?["']?(?:\s|$|[;&|])`;
|
|
199
|
+
const T_BARE_STAR = String.raw `\*(?:\s|$|[;&|])`;
|
|
188
200
|
const T_WIN_DRIVE = String.raw `["']?[a-z]:(?:[\\/](?:\*(?:\.\*)?)?)?["']?\s*(?:$|[;&|])`;
|
|
189
201
|
const T_WIN_SYSDIR = String.raw `["']?[a-z]:[\\/](?:windows|program files(?: \(x86\))?|programdata|users)\b`;
|
|
202
|
+
/**
|
|
203
|
+
* A bare `*` right after `cd /` (or `cd ~`) is the root's (home's) contents, not "the current
|
|
204
|
+
* folder". Whole-line literal rules, listed before the bare-star specs so the line reads first.
|
|
205
|
+
*/
|
|
206
|
+
const CD_ROOT = String.raw `(?:^|[;&|\n]\s*)cd\s+["']?/["']?\s*(?:&&|;|\n)\s*(?:sudo\s+)?`;
|
|
207
|
+
const CD_HOME = String.raw `(?:^|[;&|\n]\s*)cd\s+["']?(?:~|\$HOME|\$\{HOME\})[\\/]?["']?\s*(?:&&|;|\n)\s*(?:sudo\s+)?`;
|
|
208
|
+
function starAfterCd(cd, command, flags) {
|
|
209
|
+
return `${cd}${command}\\b${flags.map(f => `(?=[^;&|\\n]*\\s(?:${f})(?:\\s|$))`).join('')}[^;&|\\n]*\\s\\*(?:\\s|$|[;&|])`;
|
|
210
|
+
}
|
|
211
|
+
const F_RECURSIVE = String.raw `-[a-z]*r[a-z]*|--recursive`;
|
|
190
212
|
/**
|
|
191
213
|
* Built-in dangerous-command rules. Flag/target-bearing rules are compiled from
|
|
192
214
|
* specs (order-independent by construction); the rest are literal regexes. Item
|
|
@@ -196,7 +218,14 @@ const T_WIN_SYSDIR = String.raw `["']?[a-z]:[\\/](?:windows|program files(?: \(x
|
|
|
196
218
|
const BUILTIN_RULES = [
|
|
197
219
|
// Destructive filesystem — spec-compiled (order-independent, optional flags not required).
|
|
198
220
|
compileSpec({ id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of filesystem root',
|
|
199
|
-
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [
|
|
221
|
+
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [T_UNIX_ROOT_ONLY] }),
|
|
222
|
+
{ id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of filesystem root', source: 'builtin',
|
|
223
|
+
pattern: starAfterCd(CD_ROOT, 'rm', [F_RECURSIVE]) },
|
|
224
|
+
{ id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of the home directory', source: 'builtin',
|
|
225
|
+
pattern: starAfterCd(CD_HOME, 'rm', [F_RECURSIVE]) },
|
|
226
|
+
// Same item (same verdict and console row); the fact is the current folder, not the root.
|
|
227
|
+
compileSpec({ id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of everything in the current folder (rm -r *)',
|
|
228
|
+
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [T_BARE_STAR] }),
|
|
200
229
|
compileSpec({ id: 'rm_rf_home', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of the home directory',
|
|
201
230
|
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [String.raw `["']?(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%|\$env:USERPROFILE)(?:[\\/]\*?)?["']?(?:\s|$|[;&|])`] }),
|
|
202
231
|
compileSpec({ id: 'windows_drive_delete', category: 'destructive_command', severity: 'critical', reason: 'recursive Windows drive delete',
|
|
@@ -210,10 +239,19 @@ const BUILTIN_RULES = [
|
|
|
210
239
|
// switch and an action.
|
|
211
240
|
compileSpec({ id: 'windows_drive_delete', category: 'destructive_command', severity: 'critical', reason: 'recursive Remove-Item on a drive root',
|
|
212
241
|
command: ['remove-item', 'ri'], flags: [String.raw `-recurse\w*`], target: [String.raw `["']?[a-z]:[\\/]?["']?(?:\s|$|[;&|])`] }),
|
|
242
|
+
// Root vs a bare `*` (the current folder, or the root after `cd /`): same item, the right fact.
|
|
213
243
|
compileSpec({ id: 'chmod_root_777', category: 'destructive_command', severity: 'high', reason: 'recursive world-writable permission change on root',
|
|
214
|
-
command: ['chmod'], flags: [
|
|
244
|
+
command: ['chmod'], flags: [F_RECURSIVE, String.raw `[0-7]?777`], target: [T_UNIX_ROOT_ONLY] }),
|
|
245
|
+
{ id: 'chmod_root_777', category: 'destructive_command', severity: 'high', reason: 'recursive world-writable permission change on root', source: 'builtin',
|
|
246
|
+
pattern: starAfterCd(CD_ROOT, 'chmod', [F_RECURSIVE, String.raw `[0-7]?777`]) },
|
|
247
|
+
compileSpec({ id: 'chmod_root_777', category: 'destructive_command', severity: 'high', reason: 'recursive world-writable permission change on everything in the current folder (chmod -R 777 *)',
|
|
248
|
+
command: ['chmod'], flags: [F_RECURSIVE, String.raw `[0-7]?777`], target: [T_BARE_STAR] }),
|
|
215
249
|
compileSpec({ id: 'chown_root_recursive', category: 'destructive_command', severity: 'high', reason: 'recursive ownership change on the filesystem root',
|
|
216
|
-
command: ['chown'], flags: [
|
|
250
|
+
command: ['chown'], flags: [F_RECURSIVE], target: [T_UNIX_ROOT_ONLY] }),
|
|
251
|
+
{ id: 'chown_root_recursive', category: 'destructive_command', severity: 'high', reason: 'recursive ownership change on the filesystem root', source: 'builtin',
|
|
252
|
+
pattern: starAfterCd(CD_ROOT, 'chown', [F_RECURSIVE]) },
|
|
253
|
+
compileSpec({ id: 'chown_root_recursive', category: 'destructive_command', severity: 'high', reason: 'recursive ownership change on everything in the current folder (chown -R *)',
|
|
254
|
+
command: ['chown'], flags: [F_RECURSIVE], target: [T_BARE_STAR] }),
|
|
217
255
|
compileSpec({ id: 'dd_disk_overwrite', category: 'destructive_command', severity: 'critical', reason: 'raw disk overwrite command',
|
|
218
256
|
command: ['dd'], target: [String.raw `of=/dev/(?:sd|xvd|hd|nvme|disk)\S*`] }),
|
|
219
257
|
{ id: 'disk_device_overwrite', category: 'destructive_command', severity: 'critical', reason: 'redirecting output over a raw disk device', source: 'builtin',
|
|
@@ -248,7 +286,7 @@ const BUILTIN_RULES = [
|
|
|
248
286
|
{ id: 'remote_pipe_interpreter', category: 'reverse_shell', severity: 'high', reason: 'remote script piped to an interpreter', source: 'builtin',
|
|
249
287
|
pattern: String.raw `\b(?:curl|wget)\b[^|;&]{0,300}\|\s*(?:sudo\s+)?(?:python[0-9.]*|perl|ruby|node|php)\b` },
|
|
250
288
|
{ id: 'powershell_download_exec', category: 'reverse_shell', severity: 'high', reason: 'download-and-execute (IEX + web download)', source: 'builtin',
|
|
251
|
-
pattern: String.raw
|
|
289
|
+
pattern: sameLine(String.raw `\b(?:iex\b|invoke-expression\b)`, String.raw `(?:downloadstring|net\.webclient|invoke-webrequest\b|\biwr\b|invoke-restmethod\b|\birm\b)`) },
|
|
252
290
|
{ id: 'python_socket_subprocess', category: 'reverse_shell', severity: 'high', reason: 'python reverse shell', source: 'builtin',
|
|
253
291
|
pattern: String.raw `\bpython(?:3)?\s+-c\b(?=.*socket)(?=.*subprocess)` },
|
|
254
292
|
{ id: 'perl_reverse_shell', category: 'reverse_shell', severity: 'high', reason: 'perl reverse shell', source: 'builtin',
|
|
@@ -259,15 +297,15 @@ const BUILTIN_RULES = [
|
|
|
259
297
|
// One terminal rule per CONSOLE toggle (catalog ids) — a single combined rule
|
|
260
298
|
// meant switching off / asking on one endpoint changed nothing in a shell.
|
|
261
299
|
{ id: 'aws_gcp_metadata_ip', category: 'metadata_ssrf', severity: 'high', reason: 'request to the AWS/GCP metadata endpoint (169.254.169.254)', source: 'builtin',
|
|
262
|
-
pattern: String.raw
|
|
300
|
+
pattern: sameLine(String.raw `\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b`, String.raw `169\.254\.169\.254`) },
|
|
263
301
|
{ id: 'gcp_metadata_host', category: 'metadata_ssrf', severity: 'high', reason: 'request to the GCP metadata host (metadata.google.internal)', source: 'builtin',
|
|
264
|
-
pattern: String.raw
|
|
302
|
+
pattern: sameLine(String.raw `\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b`, String.raw `metadata\.google\.internal`) },
|
|
265
303
|
{ id: 'azure_metadata_host', category: 'metadata_ssrf', severity: 'high', reason: 'request to the Azure metadata host (metadata.azure.com)', source: 'builtin',
|
|
266
|
-
pattern: String.raw
|
|
304
|
+
pattern: sameLine(String.raw `\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b`, String.raw `metadata\.azure\.com`) },
|
|
267
305
|
{ id: 'alibaba_metadata_ip', category: 'metadata_ssrf', severity: 'high', reason: 'request to the Alibaba metadata endpoint (100.100.100.200)', source: 'builtin',
|
|
268
|
-
pattern: String.raw
|
|
306
|
+
pattern: sameLine(String.raw `\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|irm)\b`, String.raw `100\.100\.100\.200`) },
|
|
269
307
|
{ id: 'credential_exfiltration', category: 'secret_exfiltration', severity: 'critical', reason: 'credential file sent to the network', source: 'builtin',
|
|
270
|
-
pattern: String.raw
|
|
308
|
+
pattern: sameLine(String.raw `\b(?:curl|wget|iwr|invoke-webrequest|invoke-restmethod|scp|nc|ncat)\b`, String.raw `(?:\.ssh[\\/]id_(?:rsa|ed25519|ecdsa|dsa)|\.aws[\\/]credentials|application_default_credentials\.json|\.npmrc|\.netrc|\.kube[\\/]config)`) },
|
|
271
309
|
// Infra destroy — spec-compiled where flags matter (order-independent).
|
|
272
310
|
compileSpec({ id: 'terraform_destroy_auto', category: 'infra_destroy', severity: 'high', reason: 'terraform destroy with auto-approve',
|
|
273
311
|
command: ['terraform'], extra: [String.raw `\bdestroy\b`], flags: [String.raw `--auto-approve`] }),
|
|
@@ -616,25 +654,32 @@ function evaluateShellCommand(line, rules = activeShellGuardRules()) {
|
|
|
616
654
|
}).filter((x) => !!x);
|
|
617
655
|
// Whole line first (some rules intentionally span a pipe, e.g. curl | sh),
|
|
618
656
|
// then each &&/||/;/newline segment (catches "cleanup && del /s c:\").
|
|
619
|
-
|
|
657
|
+
// Built-in rules judge the execution view — quoted prose (a grep pattern, a
|
|
658
|
+
// commit message) is inert — exactly as the live PowerShell / cmd / posix
|
|
659
|
+
// guards do; org custom rules see the raw line, as they do there.
|
|
660
|
+
const segmentsOf = (text) => [text, ...text.split(/&&|\|\||[;&\n]|\|/).map(s => s.trim()).filter(Boolean)];
|
|
661
|
+
const views = [[false, segmentsOf((0, deterministicGuard_1.stripInertDataSegments)(line))], [true, segmentsOf(line)]];
|
|
620
662
|
const seen = new Set();
|
|
621
663
|
const matches = [];
|
|
622
|
-
for (const
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
664
|
+
for (const [custom, candidates] of views)
|
|
665
|
+
for (const candidate of candidates) {
|
|
666
|
+
const norm = candidate.replace(/\s+/g, ' ').trim();
|
|
667
|
+
for (const { rule, re } of compiled) {
|
|
668
|
+
if ((rule.source === 'custom') !== custom)
|
|
669
|
+
continue;
|
|
670
|
+
try {
|
|
671
|
+
if (re.test(norm) || re.test(candidate)) {
|
|
672
|
+
const key = `${rule.id}|${candidate}`;
|
|
673
|
+
if (seen.has(key))
|
|
674
|
+
continue;
|
|
675
|
+
seen.add(key);
|
|
676
|
+
matches.push({ rule, segment: candidate });
|
|
677
|
+
break; // first rule per segment is enough
|
|
678
|
+
}
|
|
633
679
|
}
|
|
680
|
+
catch { /* skip bad regex */ }
|
|
634
681
|
}
|
|
635
|
-
catch { /* skip bad regex */ }
|
|
636
682
|
}
|
|
637
|
-
}
|
|
638
683
|
return matches;
|
|
639
684
|
}
|
|
640
685
|
/**
|
|
@@ -733,7 +778,7 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
733
778
|
`# false-negative-safe. Single-token quoted args stay: argv (rm -rf "/").`,
|
|
734
779
|
`function global:Get-FcdExecView {`,
|
|
735
780
|
` param([string]$CommandLine)`,
|
|
736
|
-
String.raw ` if ($CommandLine -match '(?i)\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s
|
|
781
|
+
String.raw ` if ($CommandLine -match '(?i)\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|(?<!\|)\|(?!\|)\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b|\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\s+(?:-c|/c|-e|-command|-scriptblock)\b|\b(?:ssh|wsl(?:\.exe)?|chroot)\b|\b(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b') { return $CommandLine }`,
|
|
737
782
|
` $out = $CommandLine`,
|
|
738
783
|
" $interp = '\\$\\(|" + '`[^`]{2,}`' + "'",
|
|
739
784
|
" $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match $interp) { $m.Value } else { ' fcd_inert_data ' } }",
|
|
@@ -28,13 +28,7 @@ export declare function removeFcdConfigBackups(): {
|
|
|
28
28
|
export declare function purgeLocalState(config: BotGuardConfig): {
|
|
29
29
|
failures: string[];
|
|
30
30
|
};
|
|
31
|
-
|
|
32
|
-
* Product code of the installed FullCourtDefense MSI, from the registry
|
|
33
|
-
* uninstall hive (both 64-bit and WOW6432Node views). Undefined on npm
|
|
34
|
-
* installs or when the MSI entry is missing.
|
|
35
|
-
*/
|
|
36
|
-
export declare function parseMsiProductCodeFromRegOutput(output: string): string | undefined;
|
|
37
|
-
export declare function findMsiProductCode(): string | undefined;
|
|
31
|
+
export { findMsiProductCode, parseMsiProductCodeFromRegOutput } from '../msiProductCode';
|
|
38
32
|
/**
|
|
39
33
|
* `fullcourtdefense detach` — reverse `onboard`/`install-all` protection but
|
|
40
34
|
* KEEP machine credentials and the installed application, so the machine can
|