fullcourtdefense-cli 1.21.25 → 1.21.26
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/cmdGuard.d.ts +9 -0
- package/dist/commands/cmdGuard.js +33 -0
- package/dist/commands/daemon.js +25 -0
- package/dist/commands/deterministicGuard.d.ts +3 -1
- package/dist/commands/deterministicGuard.js +48 -1
- package/dist/commands/onboard.js +7 -0
- package/dist/honeypot.d.ts +45 -0
- package/dist/honeypot.js +190 -0
- package/dist/localSafetySnapshot.js +5 -0
- package/dist/runtimeConfig.d.ts +4 -0
- package/dist/runtimeConfig.js +6 -3
- package/dist/version.json +1 -1
- package/package.json +2 -1
|
@@ -18,3 +18,12 @@ export declare function installCmdGuardCommand(_args?: InstallCmdGuardArgs): Pro
|
|
|
18
18
|
export declare function uninstallCmdGuardCommand(): Promise<void>;
|
|
19
19
|
/** Refresh shared rules JSON when cmd guard is installed. */
|
|
20
20
|
export declare function refreshCmdGuardRules(): void;
|
|
21
|
+
/**
|
|
22
|
+
* Self-heal a stale cmd-guard AutoRun: if the registry still references our
|
|
23
|
+
* autorun script but the file was deleted (manual cleanup, interrupted
|
|
24
|
+
* uninstall), EVERY cmd.exe on the machine errors on startup and — worse —
|
|
25
|
+
* poisons `cmd /c` exit codes to 1, making healthy installs/commands look
|
|
26
|
+
* failed. Strips only OUR fragment; a customer's own AutoRun entries survive.
|
|
27
|
+
* Returns true when a repair was applied.
|
|
28
|
+
*/
|
|
29
|
+
export declare function repairStaleCmdAutorun(): boolean;
|
|
@@ -38,6 +38,7 @@ exports.getCmdGuardStatus = getCmdGuardStatus;
|
|
|
38
38
|
exports.installCmdGuardCommand = installCmdGuardCommand;
|
|
39
39
|
exports.uninstallCmdGuardCommand = uninstallCmdGuardCommand;
|
|
40
40
|
exports.refreshCmdGuardRules = refreshCmdGuardRules;
|
|
41
|
+
exports.repairStaleCmdAutorun = repairStaleCmdAutorun;
|
|
41
42
|
const child_process_1 = require("child_process");
|
|
42
43
|
const fs = __importStar(require("fs"));
|
|
43
44
|
const os = __importStar(require("os"));
|
|
@@ -264,3 +265,35 @@ function refreshCmdGuardRules() {
|
|
|
264
265
|
}
|
|
265
266
|
catch { /* best-effort */ }
|
|
266
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Self-heal a stale cmd-guard AutoRun: if the registry still references our
|
|
270
|
+
* autorun script but the file was deleted (manual cleanup, interrupted
|
|
271
|
+
* uninstall), EVERY cmd.exe on the machine errors on startup and — worse —
|
|
272
|
+
* poisons `cmd /c` exit codes to 1, making healthy installs/commands look
|
|
273
|
+
* failed. Strips only OUR fragment; a customer's own AutoRun entries survive.
|
|
274
|
+
* Returns true when a repair was applied.
|
|
275
|
+
*/
|
|
276
|
+
function repairStaleCmdAutorun() {
|
|
277
|
+
try {
|
|
278
|
+
if (process.platform !== 'win32')
|
|
279
|
+
return false;
|
|
280
|
+
const current = readCurrentAutorun();
|
|
281
|
+
if (!current.includes(AUTORUN_MARKER))
|
|
282
|
+
return false;
|
|
283
|
+
if (fs.existsSync(AUTORUN_BAT_PATH))
|
|
284
|
+
return false;
|
|
285
|
+
const next = stripAutorunValue(current);
|
|
286
|
+
if (next) {
|
|
287
|
+
setRegString(CMD_AUTORUN_KEY, AUTORUN_VALUE, next);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
(0, child_process_1.execFileSync)('reg', ['delete', CMD_AUTORUN_KEY, '/v', AUTORUN_VALUE, '/f'], {
|
|
291
|
+
windowsHide: true, timeout: 10000, stdio: ['ignore', 'pipe', 'ignore'],
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
catch {
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
}
|
package/dist/commands/daemon.js
CHANGED
|
@@ -55,6 +55,7 @@ const discoveryMarker_1 = require("../discoveryMarker");
|
|
|
55
55
|
const selfUpdate_1 = require("../selfUpdate");
|
|
56
56
|
const machineActionVerify_1 = require("../machineActionVerify");
|
|
57
57
|
const desktopChatGuard_1 = require("./desktopChatGuard");
|
|
58
|
+
const honeypot_1 = require("../honeypot");
|
|
58
59
|
const COLOR = {
|
|
59
60
|
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
60
61
|
red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
@@ -732,6 +733,30 @@ async function runDaemon(args, config) {
|
|
|
732
733
|
log,
|
|
733
734
|
});
|
|
734
735
|
}
|
|
736
|
+
// Honeypot decoys: org-controlled via fleet settings. Planting is
|
|
737
|
+
// idempotent (a decoy deleted by an attacker is re-planted on the next
|
|
738
|
+
// poll); disabling removes only files we created.
|
|
739
|
+
if (bundle.honeypot?.enabled) {
|
|
740
|
+
try {
|
|
741
|
+
const result = (0, honeypot_1.plantHoneypots)();
|
|
742
|
+
if (result.planted.length > 0) {
|
|
743
|
+
log(`Honeypot: planted ${result.planted.length} decoy credential file(s).`);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
log(`Honeypot: planting failed: ${error.message}`);
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
else if (bundle.honeypot && !bundle.honeypot.enabled && (0, honeypot_1.getHoneypotPaths)().length > 0) {
|
|
751
|
+
try {
|
|
752
|
+
const removed = (0, honeypot_1.removeHoneypots)();
|
|
753
|
+
if (removed.length > 0)
|
|
754
|
+
log(`Honeypot: removed ${removed.length} decoy file(s) (disabled by org policy).`);
|
|
755
|
+
}
|
|
756
|
+
catch (error) {
|
|
757
|
+
log(`Honeypot: removal failed: ${error.message}`);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
735
760
|
}
|
|
736
761
|
catch { /* offline — cached stance applies */ }
|
|
737
762
|
};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export type DeterministicDirection = 'request' | 'response';
|
|
2
|
-
export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration';
|
|
2
|
+
export type DeterministicCategory = 'sensitive_file' | 'metadata_ssrf' | 'destructive_command' | 'destructive_sql' | 'infra_destroy' | 'reverse_shell' | 'secret_exfiltration' | 'honeypot';
|
|
3
3
|
export interface DeterministicFinding {
|
|
4
4
|
blocked: true;
|
|
5
5
|
ruleId: string;
|
|
@@ -24,6 +24,8 @@ export interface LocalSafetyScanOptions {
|
|
|
24
24
|
policyHash?: string;
|
|
25
25
|
cwd?: string;
|
|
26
26
|
inspectScripts?: boolean;
|
|
27
|
+
/** Absolute paths of planted honeypot decoy files — ANY reference blocks. */
|
|
28
|
+
honeypotPaths?: string[];
|
|
27
29
|
}
|
|
28
30
|
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
29
31
|
export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
@@ -37,6 +37,7 @@ exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
|
37
37
|
exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
|
|
38
38
|
exports.scanDeterministicPrompt = scanDeterministicPrompt;
|
|
39
39
|
const fs = __importStar(require("fs"));
|
|
40
|
+
const os = __importStar(require("os"));
|
|
40
41
|
const path = __importStar(require("path"));
|
|
41
42
|
const SECRET_PATTERNS = [
|
|
42
43
|
{ itemId: 'private_key_material', label: 'private key material', re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/i },
|
|
@@ -262,6 +263,43 @@ function builtIn(itemId, categoryId, category, ruleId, reason, findingEvidence,
|
|
|
262
263
|
policyHash: options?.policyHash,
|
|
263
264
|
};
|
|
264
265
|
}
|
|
266
|
+
/**
|
|
267
|
+
* Honeypot decoy touch — the highest-precision signal the guard has. The decoy
|
|
268
|
+
* paths are machine-local absolute paths planted by the daemon; matching is on
|
|
269
|
+
* the normalized absolute path or its home-anchored `~/relative` form, so a
|
|
270
|
+
* repo file that merely shares a basename (e.g. a project-local `.env.bak`)
|
|
271
|
+
* never matches. Fires regardless of tool context: no legitimate workflow
|
|
272
|
+
* references these files at all.
|
|
273
|
+
*/
|
|
274
|
+
function honeypotTouch(value, options) {
|
|
275
|
+
const paths = options?.honeypotPaths;
|
|
276
|
+
if (!paths || paths.length === 0)
|
|
277
|
+
return undefined;
|
|
278
|
+
const text = normalizeForPath(value);
|
|
279
|
+
const home = normalizeForPath(os.homedir());
|
|
280
|
+
for (const decoyPath of paths) {
|
|
281
|
+
const decoy = normalizeForPath(decoyPath);
|
|
282
|
+
if (!decoy)
|
|
283
|
+
continue;
|
|
284
|
+
const candidates = [decoy];
|
|
285
|
+
if (home && decoy.startsWith(home + '/'))
|
|
286
|
+
candidates.push('~' + decoy.slice(home.length));
|
|
287
|
+
if (candidates.some(candidate => text.includes(candidate))) {
|
|
288
|
+
return {
|
|
289
|
+
blocked: true,
|
|
290
|
+
ruleId: 'local-honeypot',
|
|
291
|
+
category: 'honeypot',
|
|
292
|
+
categoryId: 'honeypot',
|
|
293
|
+
itemId: 'honeypot_decoy_access',
|
|
294
|
+
source: 'builtin',
|
|
295
|
+
reason: 'Blocked access to a decoy credential file (honeypot). No legitimate tool uses this file — this indicates credential-hunting behavior.',
|
|
296
|
+
evidence: evidence(value),
|
|
297
|
+
policyHash: options?.policyHash,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
265
303
|
function customBlock(value, options) {
|
|
266
304
|
const lower = value.toLowerCase();
|
|
267
305
|
for (const block of options?.customBlocks || []) {
|
|
@@ -441,6 +479,9 @@ function isSensitivePathContext(toolName, candidate) {
|
|
|
441
479
|
return /(?:path|file|filename|dir|directory|target|source|src|dest|location|glob|pattern|uri|url)/i.test(lastKey);
|
|
442
480
|
}
|
|
443
481
|
function scanTextValue(toolName, value, options) {
|
|
482
|
+
const honeypot = honeypotTouch(value, options);
|
|
483
|
+
if (honeypot)
|
|
484
|
+
return honeypot;
|
|
444
485
|
const custom = customBlock(value, options);
|
|
445
486
|
if (custom)
|
|
446
487
|
return custom;
|
|
@@ -505,7 +546,13 @@ function scanDeterministicToolCall(toolName, toolArgs, options) {
|
|
|
505
546
|
continue;
|
|
506
547
|
if (finding.category === 'sensitive_file' && !isSensitivePathContext(toolName, candidate))
|
|
507
548
|
continue;
|
|
508
|
-
|
|
549
|
+
// Honeypot decoy paths block in ANY context where the string can act —
|
|
550
|
+
// path access, command execution, or outbound egress (a decoy path inside
|
|
551
|
+
// a request body is exfiltration staging). Inert file-content mentions
|
|
552
|
+
// still pass, consistent with the other path rules.
|
|
553
|
+
if (finding.category === 'honeypot' && !(isActionableContext(toolName, candidate) || outbound))
|
|
554
|
+
continue;
|
|
555
|
+
if (isCommandContext(toolName, candidate) || finding.category === 'sensitive_file' || finding.category === 'metadata_ssrf' || finding.category === 'honeypot')
|
|
509
556
|
return finding;
|
|
510
557
|
}
|
|
511
558
|
for (const candidate of candidates) {
|
package/dist/commands/onboard.js
CHANGED
|
@@ -248,6 +248,13 @@ async function onboardCommand(args, config) {
|
|
|
248
248
|
// 1. Connectivity.
|
|
249
249
|
console.log(`${BOLD}[1/6] Checking compatibility and connectivity…${RESET}`);
|
|
250
250
|
mark('preflight', 'running');
|
|
251
|
+
// A stale cmd-guard AutoRun (registry points at a deleted autorun script)
|
|
252
|
+
// poisons every cmd.exe exit code on the machine — child spawns and the MSI's
|
|
253
|
+
// .cmd shim would report failure for successful work. Heal it before anything
|
|
254
|
+
// else runs.
|
|
255
|
+
if ((0, cmdGuard_1.repairStaleCmdAutorun)()) {
|
|
256
|
+
console.log(` ${DIM}Repaired a stale cmd.exe AutoRun entry left by an earlier uninstall.${RESET}`);
|
|
257
|
+
}
|
|
251
258
|
const reachable = await checkBackendReachable(apiUrl);
|
|
252
259
|
const compatible = checkCompatibility();
|
|
253
260
|
printCheck(compatible);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Honeypot decoy credentials.
|
|
3
|
+
*
|
|
4
|
+
* When the org enables honeypots (fleet settings → delivered via the runtime
|
|
5
|
+
* bundle), the daemon plants a small set of decoy credential files in
|
|
6
|
+
* locations a credential-hunting agent would search. No legitimate tool ever
|
|
7
|
+
* reads these files, so ANY tool call touching one is a near-zero-false-
|
|
8
|
+
* positive compromise signal: the deterministic guard blocks it on-device and
|
|
9
|
+
* the resulting event (categoryId "honeypot") lets the backend auto-contain
|
|
10
|
+
* the machine.
|
|
11
|
+
*
|
|
12
|
+
* Safety rules:
|
|
13
|
+
* - A decoy is only created where NO file exists. If a real file is already
|
|
14
|
+
* at a decoy path we skip it and never track it — we must never overwrite
|
|
15
|
+
* or later delete a user's real file.
|
|
16
|
+
* - Only files recorded in the state file (i.e. files WE created) are ever
|
|
17
|
+
* removed when the feature is disabled.
|
|
18
|
+
*/
|
|
19
|
+
export interface HoneypotDecoyState {
|
|
20
|
+
path: string;
|
|
21
|
+
itemId: string;
|
|
22
|
+
}
|
|
23
|
+
export interface HoneypotState {
|
|
24
|
+
version: 1;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
plantedAt: string;
|
|
27
|
+
decoys: HoneypotDecoyState[];
|
|
28
|
+
}
|
|
29
|
+
export interface PlantResult {
|
|
30
|
+
planted: string[];
|
|
31
|
+
existing: string[];
|
|
32
|
+
skipped: string[];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Plant (or re-plant) decoys. Idempotent — safe to call on every bundle poll.
|
|
36
|
+
* Never overwrites a file we did not create.
|
|
37
|
+
*/
|
|
38
|
+
export declare function plantHoneypots(): PlantResult;
|
|
39
|
+
/** Remove decoys WE planted (state-tracked only) and clear the state file. */
|
|
40
|
+
export declare function removeHoneypots(): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Absolute paths of active decoys, for the deterministic guard. Fast (one
|
|
43
|
+
* small JSON read), never throws — enforcement surfaces call this per scan.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getHoneypotPaths(): string[];
|
package/dist/honeypot.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
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.plantHoneypots = plantHoneypots;
|
|
37
|
+
exports.removeHoneypots = removeHoneypots;
|
|
38
|
+
exports.getHoneypotPaths = getHoneypotPaths;
|
|
39
|
+
const crypto = __importStar(require("crypto"));
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const os = __importStar(require("os"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
const STATE_DIR = path.join(os.homedir(), '.fullcourtdefense');
|
|
44
|
+
const STATE_FILE = path.join(STATE_DIR, 'honeypots.json');
|
|
45
|
+
function randomToken(length, alphabet) {
|
|
46
|
+
const bytes = crypto.randomBytes(length);
|
|
47
|
+
let out = '';
|
|
48
|
+
for (let i = 0; i < length; i++)
|
|
49
|
+
out += alphabet[bytes[i] % alphabet.length];
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
const UPPER_NUM = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
53
|
+
const BASE64ISH = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
54
|
+
// PEM armor markers assembled at runtime so this source file (and the built
|
|
55
|
+
// bundle) never contains contiguous key-material signatures — our own secret
|
|
56
|
+
// scanners and third-party ones must not flag the decoy GENERATOR.
|
|
57
|
+
function pemMarker(kind) {
|
|
58
|
+
return ['-'.repeat(5) + kind, 'OPENSSH', 'PRIVATE', 'KEY' + '-'.repeat(5)].join(' ');
|
|
59
|
+
}
|
|
60
|
+
/** Decoy definitions. Content is random per machine so a leaked copy is traceable to this plant. */
|
|
61
|
+
function decoyDefinitions() {
|
|
62
|
+
const home = os.homedir();
|
|
63
|
+
return [
|
|
64
|
+
{
|
|
65
|
+
absPath: path.join(home, '.aws', 'credentials.bak'),
|
|
66
|
+
itemId: 'honeypot_aws_credentials',
|
|
67
|
+
content: () => [
|
|
68
|
+
'[default]',
|
|
69
|
+
`aws_access_key_id = ${'AKIA'}${randomToken(16, UPPER_NUM)}`,
|
|
70
|
+
`aws_secret_access_key = ${randomToken(40, BASE64ISH)}`,
|
|
71
|
+
'',
|
|
72
|
+
'[prod]',
|
|
73
|
+
`aws_access_key_id = ${'AKIA'}${randomToken(16, UPPER_NUM)}`,
|
|
74
|
+
`aws_secret_access_key = ${randomToken(40, BASE64ISH)}`,
|
|
75
|
+
'',
|
|
76
|
+
].join('\n'),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
absPath: path.join(home, '.ssh', 'id_rsa.bak'),
|
|
80
|
+
itemId: 'honeypot_ssh_key',
|
|
81
|
+
content: () => {
|
|
82
|
+
const lines = [pemMarker('BEGIN')];
|
|
83
|
+
for (let i = 0; i < 24; i++)
|
|
84
|
+
lines.push(randomToken(70, BASE64ISH));
|
|
85
|
+
lines.push(`${randomToken(28, BASE64ISH)}==`);
|
|
86
|
+
lines.push(pemMarker('END'));
|
|
87
|
+
lines.push('');
|
|
88
|
+
return lines.join('\n');
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
absPath: path.join(home, '.env.bak'),
|
|
93
|
+
itemId: 'honeypot_env_backup',
|
|
94
|
+
content: () => [
|
|
95
|
+
'# production backup',
|
|
96
|
+
`DATABASE_URL=postgres://app:${randomToken(24, BASE64ISH)}@db-prod.internal:5432/main`,
|
|
97
|
+
`STRIPE_SECRET_KEY=${'sk_live_'}${randomToken(24, BASE64ISH)}`,
|
|
98
|
+
`JWT_SECRET=${randomToken(48, BASE64ISH)}`,
|
|
99
|
+
`ADMIN_API_TOKEN=${randomToken(32, BASE64ISH)}`,
|
|
100
|
+
'',
|
|
101
|
+
].join('\n'),
|
|
102
|
+
},
|
|
103
|
+
];
|
|
104
|
+
}
|
|
105
|
+
function readState() {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
108
|
+
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.decoys))
|
|
109
|
+
return undefined;
|
|
110
|
+
return parsed;
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function writeState(state) {
|
|
117
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
118
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Plant (or re-plant) decoys. Idempotent — safe to call on every bundle poll.
|
|
122
|
+
* Never overwrites a file we did not create.
|
|
123
|
+
*/
|
|
124
|
+
function plantHoneypots() {
|
|
125
|
+
const previous = readState();
|
|
126
|
+
const tracked = new Set((previous?.decoys || []).map(decoy => decoy.path));
|
|
127
|
+
const result = { planted: [], existing: [], skipped: [] };
|
|
128
|
+
const decoys = [];
|
|
129
|
+
for (const def of decoyDefinitions()) {
|
|
130
|
+
const exists = fs.existsSync(def.absPath);
|
|
131
|
+
if (exists && !tracked.has(def.absPath)) {
|
|
132
|
+
// A real user file lives here — leave it alone and never track it.
|
|
133
|
+
result.skipped.push(def.absPath);
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (exists) {
|
|
137
|
+
decoys.push({ path: def.absPath, itemId: def.itemId });
|
|
138
|
+
result.existing.push(def.absPath);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
fs.mkdirSync(path.dirname(def.absPath), { recursive: true });
|
|
143
|
+
fs.writeFileSync(def.absPath, def.content(), { encoding: 'utf8', mode: 0o600 });
|
|
144
|
+
decoys.push({ path: def.absPath, itemId: def.itemId });
|
|
145
|
+
result.planted.push(def.absPath);
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
// Unwritable location (permissions) — skip; other decoys still count.
|
|
149
|
+
result.skipped.push(def.absPath);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
writeState({
|
|
153
|
+
version: 1,
|
|
154
|
+
enabled: true,
|
|
155
|
+
plantedAt: previous?.plantedAt || new Date().toISOString(),
|
|
156
|
+
decoys,
|
|
157
|
+
});
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
/** Remove decoys WE planted (state-tracked only) and clear the state file. */
|
|
161
|
+
function removeHoneypots() {
|
|
162
|
+
const state = readState();
|
|
163
|
+
if (!state)
|
|
164
|
+
return [];
|
|
165
|
+
const removed = [];
|
|
166
|
+
for (const decoy of state.decoys) {
|
|
167
|
+
try {
|
|
168
|
+
if (fs.existsSync(decoy.path)) {
|
|
169
|
+
fs.unlinkSync(decoy.path);
|
|
170
|
+
removed.push(decoy.path);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch { /* best-effort — a locked file stays but detection also stays */ }
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
fs.unlinkSync(STATE_FILE);
|
|
177
|
+
}
|
|
178
|
+
catch { /* already gone */ }
|
|
179
|
+
return removed;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Absolute paths of active decoys, for the deterministic guard. Fast (one
|
|
183
|
+
* small JSON read), never throws — enforcement surfaces call this per scan.
|
|
184
|
+
*/
|
|
185
|
+
function getHoneypotPaths() {
|
|
186
|
+
const state = readState();
|
|
187
|
+
if (!state || state.enabled !== true)
|
|
188
|
+
return [];
|
|
189
|
+
return state.decoys.map(decoy => decoy.path).filter(Boolean);
|
|
190
|
+
}
|
|
@@ -40,6 +40,7 @@ const crypto = __importStar(require("crypto"));
|
|
|
40
40
|
const fs = __importStar(require("fs"));
|
|
41
41
|
const os = __importStar(require("os"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
|
+
const honeypot_1 = require("./honeypot");
|
|
43
44
|
const CACHE_TTL_MS = 60_000;
|
|
44
45
|
const CACHE_DIR = path.join(os.homedir(), '.fullcourtdefense');
|
|
45
46
|
function cachePath(input) {
|
|
@@ -132,6 +133,10 @@ function snapshotToScanOptions(snapshot, extra = {}) {
|
|
|
132
133
|
disabledBuiltInItemIds: snapshot?.disabledBuiltInItemIds,
|
|
133
134
|
customBlocks: snapshot?.customBlocks,
|
|
134
135
|
policyHash: snapshot?.policyHash,
|
|
136
|
+
// Machine-local decoy paths planted by the daemon — every enforcement
|
|
137
|
+
// surface (hooks, MCP gateway, desktop chat guard) gets honeypot
|
|
138
|
+
// detection through this single seam.
|
|
139
|
+
honeypotPaths: (0, honeypot_1.getHoneypotPaths)(),
|
|
135
140
|
...extra,
|
|
136
141
|
};
|
|
137
142
|
}
|
package/dist/runtimeConfig.d.ts
CHANGED
|
@@ -33,6 +33,10 @@ export interface RuntimeBundle {
|
|
|
33
33
|
enabled: boolean;
|
|
34
34
|
targetVersion?: string;
|
|
35
35
|
};
|
|
36
|
+
/** Org honeypot policy: the daemon plants/removes decoy credential files. */
|
|
37
|
+
honeypot?: {
|
|
38
|
+
enabled: boolean;
|
|
39
|
+
};
|
|
36
40
|
/** One constrained, auditable action queued for the resident daemon. */
|
|
37
41
|
machineAction?: {
|
|
38
42
|
id: string;
|
package/dist/runtimeConfig.js
CHANGED
|
@@ -72,7 +72,7 @@ async function getRuntimeBundle(input) {
|
|
|
72
72
|
const cached = cache[input.shieldId];
|
|
73
73
|
const fresh = cached && Date.now() - cached.fetchedAt < ttl;
|
|
74
74
|
if (cached && fresh && !input.force) {
|
|
75
|
-
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, machineAction: cached.machineAction, source: 'cache' };
|
|
75
|
+
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, honeypot: cached.honeypot, machineAction: cached.machineAction, source: 'cache' };
|
|
76
76
|
}
|
|
77
77
|
try {
|
|
78
78
|
const headers = { 'Content-Type': 'application/json' };
|
|
@@ -92,7 +92,7 @@ async function getRuntimeBundle(input) {
|
|
|
92
92
|
if (resp.status === 304 && cached) {
|
|
93
93
|
cache[input.shieldId] = { ...cached, fetchedAt: Date.now() };
|
|
94
94
|
writeCacheFile(cache);
|
|
95
|
-
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, machineAction: cached.machineAction, source: 'cache' };
|
|
95
|
+
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, honeypot: cached.honeypot, machineAction: cached.machineAction, source: 'cache' };
|
|
96
96
|
}
|
|
97
97
|
if (resp.ok) {
|
|
98
98
|
const body = await resp.json().catch(() => ({}));
|
|
@@ -117,6 +117,9 @@ async function getRuntimeBundle(input) {
|
|
|
117
117
|
targetVersion: typeof body.data.autoUpdate.targetVersion === 'string' ? body.data.autoUpdate.targetVersion : undefined,
|
|
118
118
|
}
|
|
119
119
|
: undefined,
|
|
120
|
+
honeypot: body.data.honeypot && typeof body.data.honeypot === 'object'
|
|
121
|
+
? { enabled: body.data.honeypot.enabled === true }
|
|
122
|
+
: undefined,
|
|
120
123
|
machineAction: body.data.machineAction,
|
|
121
124
|
fetchedAt: Date.now(),
|
|
122
125
|
};
|
|
@@ -128,7 +131,7 @@ async function getRuntimeBundle(input) {
|
|
|
128
131
|
}
|
|
129
132
|
catch { /* fall through to cache / default */ }
|
|
130
133
|
if (cached) {
|
|
131
|
-
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, machineAction: cached.machineAction, source: 'cache' };
|
|
134
|
+
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, honeypot: cached.honeypot, machineAction: cached.machineAction, source: 'cache' };
|
|
132
135
|
}
|
|
133
136
|
return { mode: 'block', version: '', source: 'default' };
|
|
134
137
|
}
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.26",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"build": "tsc && node scripts/copy-attack-corpus.js",
|
|
17
17
|
"test:deterministic-guard": "npm run build && node scripts/test-deterministic-guard.js",
|
|
18
18
|
"test:guard-content-context": "npm run build && node scripts/test-guard-content-context.js",
|
|
19
|
+
"test:honeypot": "npm run build && node scripts/test-honeypot.js",
|
|
19
20
|
"test:browser-credentials-rule": "npm run build && node scripts/test-browser-credentials-rule.js",
|
|
20
21
|
"test:taint-ledger": "npm run build && node scripts/test-taint-ledger.js",
|
|
21
22
|
"test:shell-audit": "npm run build && node scripts/test-shell-audit.js",
|