fullcourtdefense-cli 1.21.40 → 1.22.1
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/autoProtect.js +29 -26
- package/dist/commands/cmdGuard.js +3 -2
- package/dist/commands/daemon.d.ts +28 -0
- package/dist/commands/daemon.js +258 -69
- package/dist/commands/deterministicGuard.d.ts +55 -1
- package/dist/commands/deterministicGuard.js +182 -5
- package/dist/commands/hook.js +17 -3
- package/dist/commands/mcpGateway.js +69 -6
- package/dist/commands/posixShellGuard.js +3 -2
- package/dist/commands/shellGuard.d.ts +7 -0
- package/dist/commands/shellGuard.js +66 -6
- package/dist/localSafetySnapshot.d.ts +3 -1
- package/dist/localSafetySnapshot.js +17 -0
- package/dist/machineActionVerify.d.ts +15 -2
- package/dist/machineActionVerify.js +30 -4
- package/dist/telemetry.d.ts +1 -1
- package/dist/version.json +1 -1
- package/package.json +2 -1
|
@@ -39,6 +39,7 @@ const os = __importStar(require("os"));
|
|
|
39
39
|
const path = __importStar(require("path"));
|
|
40
40
|
const child_process_1 = require("child_process");
|
|
41
41
|
const mcpGateway_1 = require("./mcpGateway");
|
|
42
|
+
const daemon_1 = require("./daemon");
|
|
42
43
|
const COLOR = {
|
|
43
44
|
reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m',
|
|
44
45
|
red: '\x1b[31m', yellow: '\x1b[33m', green: '\x1b[32m', cyan: '\x1b[36m', gray: '\x1b[90m',
|
|
@@ -54,38 +55,40 @@ function launcherDir() {
|
|
|
54
55
|
: path.join(os.homedir(), '.fullcourtdefense');
|
|
55
56
|
return path.join(base, 'FullCourtDefense');
|
|
56
57
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
const vbs = path.join(dir, 'auto-protect.vbs');
|
|
62
|
-
const node = process.execPath;
|
|
63
|
-
const cli = cliEntry();
|
|
64
|
-
// WScript.Shell.Run with window style 0 = hidden, wait = false. Run node
|
|
65
|
-
// directly — `cmd /c` breaks quote parsing under paths with spaces.
|
|
66
|
-
const inner = `"${node}" "${cli}" protect-all`;
|
|
67
|
-
const content = `CreateObject("WScript.Shell").Run "${inner.replace(/"/g, '""')}", 0, False\n`;
|
|
68
|
-
fs.writeFileSync(vbs, content, 'utf8');
|
|
69
|
-
return vbs;
|
|
70
|
-
}
|
|
58
|
+
// No VBS/wscript launcher here — Windows Defender's Commando.A!ml heuristic
|
|
59
|
+
// flags "scheduled task -> wscript runs a user-writable script on an interval"
|
|
60
|
+
// as script persistence. The task runs node.exe directly, hidden via task XML
|
|
61
|
+
// (shared helpers in daemon.ts).
|
|
71
62
|
function installWindows(intervalMinutes, onLogon) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
63
|
+
const trigger = onLogon
|
|
64
|
+
? ` <LogonTrigger>
|
|
65
|
+
<Enabled>true</Enabled>
|
|
66
|
+
<UserId>${(0, daemon_1.xmlEscape)((0, daemon_1.taskUserId)())}</UserId>
|
|
67
|
+
</LogonTrigger>`
|
|
68
|
+
: ` <TimeTrigger>
|
|
69
|
+
<Enabled>true</Enabled>
|
|
70
|
+
<StartBoundary>${(0, daemon_1.taskLocalTimestamp)(new Date(Date.now() - 60_000))}</StartBoundary>
|
|
71
|
+
<Repetition>
|
|
72
|
+
<Interval>PT${Math.max(1, intervalMinutes)}M</Interval>
|
|
73
|
+
<StopAtDurationEnd>false</StopAtDurationEnd>
|
|
74
|
+
</Repetition>
|
|
75
|
+
</TimeTrigger>`;
|
|
75
76
|
const schedArgs = onLogon
|
|
76
|
-
? ['/SC', 'ONLOGON']
|
|
77
|
-
: ['/SC', 'MINUTE', '/MO', String(intervalMinutes)];
|
|
78
|
-
|
|
79
|
-
return result.status === 0;
|
|
77
|
+
? ['/SC', 'ONLOGON', '/RL', 'LIMITED']
|
|
78
|
+
: ['/SC', 'MINUTE', '/MO', String(Math.max(1, intervalMinutes)), '/RL', 'LIMITED'];
|
|
79
|
+
return (0, daemon_1.registerHiddenTask)(TASK_NAME, (0, daemon_1.buildTaskXml)('protect-all', trigger), 'protect-all', schedArgs);
|
|
80
80
|
}
|
|
81
81
|
function uninstallWindows() {
|
|
82
82
|
const result = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'inherit' });
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
83
|
+
// Legacy VBS launcher from pre-1.22.1 installs + the current task XML.
|
|
84
|
+
for (const name of ['auto-protect.vbs', `${TASK_NAME.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`]) {
|
|
85
|
+
const file = path.join(launcherDir(), name);
|
|
86
|
+
try {
|
|
87
|
+
if (fs.existsSync(file))
|
|
88
|
+
fs.unlinkSync(file);
|
|
89
|
+
}
|
|
90
|
+
catch { /* ignore */ }
|
|
87
91
|
}
|
|
88
|
-
catch { /* ignore */ }
|
|
89
92
|
return result.status === 0;
|
|
90
93
|
}
|
|
91
94
|
function statusWindows() {
|
|
@@ -117,14 +117,15 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
117
117
|
`const SPOOL_PATH=${JSON.stringify(path.join(os.homedir(), '.fullcourtdefense-spool.jsonl'))};`,
|
|
118
118
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
119
119
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
120
|
-
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
|
|
121
|
-
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();for(const r of rules){try{if(r.re.test(n)||r.re.test(line))return r;}catch{}}return
|
|
120
|
+
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='monitor'?'monitor':'block',rules};}catch{return{mode:'block',rules:[]};}}`,
|
|
121
|
+
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{if(r.re.test(n)||r.re.test(line)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
122
122
|
`function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){spawnSync(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{stdio:'ignore',windowsHide:true});}}catch{}}`,
|
|
123
123
|
`function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
|
|
124
124
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
125
125
|
`const line=args.join(' ');const{mode,rules}=loadRules();const hit=matchLine(line,rules);`,
|
|
126
126
|
`if(!hit)delegate(args);`,
|
|
127
127
|
`if(mode==='monitor'){console.error('[FullCourtDefense] monitor: would block ['+(hit.severity||'')+'] '+hit.reason+' (rule '+hit.id+')');spoolEvent(hit,line,'allow');delegate(args);}`,
|
|
128
|
+
`if(hit.action==='warn'){console.error('[FullCourtDefense] warning ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+') — allowed by org policy, reported to your security dashboard.');spoolEvent(hit,line,'warn');delegate(args);}`,
|
|
128
129
|
`console.error('[FullCourtDefense] BLOCKED ['+(hit.severity||'')+']: '+hit.reason+' (rule '+hit.id+')');`,
|
|
129
130
|
`console.error('This command was not executed. Reported to your security dashboard.');`,
|
|
130
131
|
`spoolEvent(hit,line,'block');process.exit(1);`,
|
|
@@ -32,6 +32,33 @@ export declare function discoverSweepCredentialEnv(credentials?: {
|
|
|
32
32
|
shieldKey?: string;
|
|
33
33
|
apiUrl?: string;
|
|
34
34
|
}): Record<string, string>;
|
|
35
|
+
/** Fully-qualified current user (DOMAIN\\user) for the task principal. */
|
|
36
|
+
export declare function taskUserId(): string;
|
|
37
|
+
/** Local wall-clock timestamp (no ms, no tz) as Task Scheduler expects. */
|
|
38
|
+
export declare function taskLocalTimestamp(date: Date): string;
|
|
39
|
+
/**
|
|
40
|
+
* Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
|
|
41
|
+
*
|
|
42
|
+
* @param command CLI subcommand ('daemon' | 'watchdog').
|
|
43
|
+
* @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
|
|
44
|
+
* time trigger for the 5-minute watchdog.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildTaskXml(command: string, triggers: string): string;
|
|
47
|
+
/**
|
|
48
|
+
* Register a scheduled task from XML (node run directly, window hidden). Falls
|
|
49
|
+
* back to a plain `/TR node …` task if XML registration fails — that variant
|
|
50
|
+
* may briefly flash a console window but still contains NO script host, so the
|
|
51
|
+
* malware fingerprint never returns. Returns true if either path succeeds.
|
|
52
|
+
*
|
|
53
|
+
* @param taskName Scheduled-task name.
|
|
54
|
+
* @param xml Full Task Scheduler XML (from buildDaemon/Watchdog).
|
|
55
|
+
* @param command CLI subcommand for the /TR fallback ('daemon'|'watchdog').
|
|
56
|
+
* @param scheduleArgs schtasks schedule flags for the fallback (e.g. ['/SC','MINUTE','/MO','5']).
|
|
57
|
+
*/
|
|
58
|
+
export declare function registerHiddenTask(taskName: string, xml: string, command: string, scheduleArgs: string[]): boolean;
|
|
59
|
+
/** True when an existing scheduled task's action still runs wscript/cscript
|
|
60
|
+
* (the pre-1.22.1 VBS launcher) — the migration trigger. */
|
|
61
|
+
export declare function windowsTaskReferencesScriptHost(taskName: string): boolean;
|
|
35
62
|
/** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
|
|
36
63
|
export interface DaemonRuntimeState {
|
|
37
64
|
alive: boolean;
|
|
@@ -45,6 +72,7 @@ export declare function spawnDetachedDaemon(): boolean;
|
|
|
45
72
|
/** Pid-alive check shared with the watchdog (EPERM still means alive). */
|
|
46
73
|
export declare function pidIsAlive(pid: number): boolean;
|
|
47
74
|
export declare function macosLaunchdPath(currentPath?: string, execPath?: string): string;
|
|
75
|
+
export declare function xmlEscape(value: string): string;
|
|
48
76
|
/** Whether the daemon has been registered to start automatically. */
|
|
49
77
|
/** Is the 5-minute watchdog scheduled task registered? (Windows-only feature.) */
|
|
50
78
|
export declare function isWatchdogTaskInstalled(): boolean;
|
package/dist/commands/daemon.js
CHANGED
|
@@ -36,10 +36,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.daemonDiscoverSweepArgs = daemonDiscoverSweepArgs;
|
|
37
37
|
exports.summarizeDiscoverStderr = summarizeDiscoverStderr;
|
|
38
38
|
exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
|
|
39
|
+
exports.taskUserId = taskUserId;
|
|
40
|
+
exports.taskLocalTimestamp = taskLocalTimestamp;
|
|
41
|
+
exports.buildTaskXml = buildTaskXml;
|
|
42
|
+
exports.registerHiddenTask = registerHiddenTask;
|
|
43
|
+
exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
|
|
39
44
|
exports.daemonRuntimeState = daemonRuntimeState;
|
|
40
45
|
exports.spawnDetachedDaemon = spawnDetachedDaemon;
|
|
41
46
|
exports.pidIsAlive = pidIsAlive;
|
|
42
47
|
exports.macosLaunchdPath = macosLaunchdPath;
|
|
48
|
+
exports.xmlEscape = xmlEscape;
|
|
43
49
|
exports.isWatchdogTaskInstalled = isWatchdogTaskInstalled;
|
|
44
50
|
exports.isDaemonAutostartInstalled = isDaemonAutostartInstalled;
|
|
45
51
|
exports.daemonCommand = daemonCommand;
|
|
@@ -718,23 +724,22 @@ async function runDaemon(args, config) {
|
|
|
718
724
|
executingActionIds.add(action.id);
|
|
719
725
|
// Cryptographic gate: never execute an action the control plane didn't
|
|
720
726
|
// sign — a Firestore/backend compromise must not become fleet-wide RCE.
|
|
721
|
-
|
|
727
|
+
// Machine binding is enforced too: a correctly signed action targeting a
|
|
728
|
+
// DIFFERENT machine is rejected (bundle fetches send this machine's id, so
|
|
729
|
+
// a mismatch means misrouting/replay, not normal operation). Legacy fleets
|
|
730
|
+
// whose enrolled fingerprint drifted can set FCD_MACHINE_ACTION_ANY_MACHINE_OK=1
|
|
731
|
+
// locally while they re-enroll.
|
|
732
|
+
const localIdentity = (0, machineIdentity_1.getMachineIdentity)();
|
|
733
|
+
const verdict = (0, machineActionVerify_1.verifyMachineAction)(action, { localMachineId: localIdentity.machineId });
|
|
722
734
|
if (!verdict.ok) {
|
|
723
735
|
log(`Remote action REJECTED: ${action.type} (${action.id}) — ${verdict.reason}`);
|
|
724
|
-
await reportMachineAction(action.id, 'failed', { error: `
|
|
736
|
+
await reportMachineAction(action.id, 'failed', { error: `Action verification failed: ${verdict.reason}` });
|
|
725
737
|
executingActionIds.delete(action.id);
|
|
726
738
|
await uploadLogTail();
|
|
727
739
|
return;
|
|
728
740
|
}
|
|
729
741
|
if (verdict.reason)
|
|
730
|
-
log(`Remote action
|
|
731
|
-
const localIdentity = (0, machineIdentity_1.getMachineIdentity)();
|
|
732
|
-
if (action.machineId && action.machineId !== localIdentity.machineId) {
|
|
733
|
-
// Warn-only for now: enrolled machines can carry legacy fingerprints that
|
|
734
|
-
// differ from the freshly computed identity. Tighten to reject once the
|
|
735
|
-
// fleet is fully on stable machine ids.
|
|
736
|
-
log(`Remote action machine-binding notice: action targets ${action.machineId}, local id is ${localIdentity.machineId}.`);
|
|
737
|
-
}
|
|
742
|
+
log(`Remote action verification notice: ${verdict.reason}`);
|
|
738
743
|
await reportMachineAction(action.id, 'running');
|
|
739
744
|
log(`Remote action started: ${action.type} (${action.id}).`);
|
|
740
745
|
await uploadLogTail();
|
|
@@ -1185,29 +1190,198 @@ async function runDaemon(args, config) {
|
|
|
1185
1190
|
// ---------------------------------------------------------------------------
|
|
1186
1191
|
// Autostart install / uninstall / status
|
|
1187
1192
|
// ---------------------------------------------------------------------------
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1193
|
+
// ---------------------------------------------------------------------------
|
|
1194
|
+
// Windows autostart launchers
|
|
1195
|
+
//
|
|
1196
|
+
// HISTORY: we used to run `wscript.exe <script>.vbs` from the scheduled tasks
|
|
1197
|
+
// to launch node with no console-window flash. Windows Defender's ML heuristic
|
|
1198
|
+
// (`Trojan:Win32/Commando.A!ml`) flags "schtasks task -> wscript runs a script
|
|
1199
|
+
// from a user-writable dir on a short recurring interval" as script-based
|
|
1200
|
+
// persistence — a textbook malware pattern — and quarantined our own watchdog.
|
|
1201
|
+
//
|
|
1202
|
+
// FIX: no VBS, no wscript, no PowerShell anywhere in the autostart chain. The
|
|
1203
|
+
// scheduled tasks run the bundled `node.exe` DIRECTLY, and the console window
|
|
1204
|
+
// is suppressed the supported way — Task Scheduler XML `<Hidden>true</Hidden>`
|
|
1205
|
+
// with an InteractiveToken principal (needs no password, no elevation).
|
|
1206
|
+
// ---------------------------------------------------------------------------
|
|
1207
|
+
/** Fully-qualified current user (DOMAIN\\user) for the task principal. */
|
|
1208
|
+
function taskUserId() {
|
|
1209
|
+
const domain = process.env.USERDOMAIN;
|
|
1210
|
+
let user = process.env.USERNAME;
|
|
1211
|
+
if (!user) {
|
|
1212
|
+
try {
|
|
1213
|
+
user = os.userInfo().username;
|
|
1214
|
+
}
|
|
1215
|
+
catch {
|
|
1216
|
+
user = undefined;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
if (user && domain)
|
|
1220
|
+
return `${domain}\\${user}`;
|
|
1221
|
+
if (user)
|
|
1222
|
+
return user;
|
|
1223
|
+
return os.hostname();
|
|
1200
1224
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1225
|
+
/** Local wall-clock timestamp (no ms, no tz) as Task Scheduler expects. */
|
|
1226
|
+
function taskLocalTimestamp(date) {
|
|
1227
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1228
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
1229
|
+
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
1203
1230
|
}
|
|
1204
1231
|
/**
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1232
|
+
* Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
|
|
1233
|
+
*
|
|
1234
|
+
* @param command CLI subcommand ('daemon' | 'watchdog').
|
|
1235
|
+
* @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
|
|
1236
|
+
* time trigger for the 5-minute watchdog.
|
|
1208
1237
|
*/
|
|
1209
|
-
function
|
|
1210
|
-
|
|
1238
|
+
function buildTaskXml(command, triggers) {
|
|
1239
|
+
const userId = xmlEscape(taskUserId());
|
|
1240
|
+
const nodeExe = xmlEscape(process.execPath);
|
|
1241
|
+
const args = xmlEscape(`"${cliEntry()}" ${command}`);
|
|
1242
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
1243
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
1244
|
+
<RegistrationInfo>
|
|
1245
|
+
<Description>FullCourtDefense ${command} — runs node directly, hidden, no script host.</Description>
|
|
1246
|
+
</RegistrationInfo>
|
|
1247
|
+
<Triggers>
|
|
1248
|
+
${triggers}
|
|
1249
|
+
</Triggers>
|
|
1250
|
+
<Principals>
|
|
1251
|
+
<Principal id="Author">
|
|
1252
|
+
<UserId>${userId}</UserId>
|
|
1253
|
+
<LogonType>InteractiveToken</LogonType>
|
|
1254
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
1255
|
+
</Principal>
|
|
1256
|
+
</Principals>
|
|
1257
|
+
<Settings>
|
|
1258
|
+
<Hidden>true</Hidden>
|
|
1259
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
1260
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
1261
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
1262
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
1263
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
1264
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
1265
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
1266
|
+
<Enabled>true</Enabled>
|
|
1267
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
1268
|
+
<IdleSettings>
|
|
1269
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
1270
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
1271
|
+
</IdleSettings>
|
|
1272
|
+
</Settings>
|
|
1273
|
+
<Actions Context="Author">
|
|
1274
|
+
<Exec>
|
|
1275
|
+
<Command>${nodeExe}</Command>
|
|
1276
|
+
<Arguments>${args}</Arguments>
|
|
1277
|
+
</Exec>
|
|
1278
|
+
</Actions>
|
|
1279
|
+
</Task>
|
|
1280
|
+
`;
|
|
1281
|
+
}
|
|
1282
|
+
/** Daemon task: start at logon (node run hidden, no repetition). */
|
|
1283
|
+
function buildDaemonTaskXml() {
|
|
1284
|
+
const trigger = ` <LogonTrigger>
|
|
1285
|
+
<Enabled>true</Enabled>
|
|
1286
|
+
<UserId>${xmlEscape(taskUserId())}</UserId>
|
|
1287
|
+
</LogonTrigger>`;
|
|
1288
|
+
return buildTaskXml('daemon', trigger);
|
|
1289
|
+
}
|
|
1290
|
+
/**
|
|
1291
|
+
* Watchdog task: run the `watchdog` command every 5 minutes (liveness beacon +
|
|
1292
|
+
* daemon revival + crash post-mortem) — the beacon is what lets the console
|
|
1293
|
+
* tell "daemon dead" apart from "machine off". A repeating time trigger with a
|
|
1294
|
+
* past start boundary fires promptly and keeps repeating across reboots.
|
|
1295
|
+
*/
|
|
1296
|
+
function buildWatchdogTaskXml() {
|
|
1297
|
+
const start = taskLocalTimestamp(new Date(Date.now() - 60_000));
|
|
1298
|
+
const trigger = ` <TimeTrigger>
|
|
1299
|
+
<Enabled>true</Enabled>
|
|
1300
|
+
<StartBoundary>${start}</StartBoundary>
|
|
1301
|
+
<Repetition>
|
|
1302
|
+
<Interval>PT5M</Interval>
|
|
1303
|
+
<StopAtDurationEnd>false</StopAtDurationEnd>
|
|
1304
|
+
</Repetition>
|
|
1305
|
+
</TimeTrigger>`;
|
|
1306
|
+
return buildTaskXml('watchdog', trigger);
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Register a scheduled task from XML (node run directly, window hidden). Falls
|
|
1310
|
+
* back to a plain `/TR node …` task if XML registration fails — that variant
|
|
1311
|
+
* may briefly flash a console window but still contains NO script host, so the
|
|
1312
|
+
* malware fingerprint never returns. Returns true if either path succeeds.
|
|
1313
|
+
*
|
|
1314
|
+
* @param taskName Scheduled-task name.
|
|
1315
|
+
* @param xml Full Task Scheduler XML (from buildDaemon/Watchdog).
|
|
1316
|
+
* @param command CLI subcommand for the /TR fallback ('daemon'|'watchdog').
|
|
1317
|
+
* @param scheduleArgs schtasks schedule flags for the fallback (e.g. ['/SC','MINUTE','/MO','5']).
|
|
1318
|
+
*/
|
|
1319
|
+
function registerHiddenTask(taskName, xml, command, scheduleArgs) {
|
|
1320
|
+
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
1321
|
+
const dir = path.join(base, 'FullCourtDefense');
|
|
1322
|
+
try {
|
|
1323
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1324
|
+
}
|
|
1325
|
+
catch { /* ignore */ }
|
|
1326
|
+
const xmlPath = path.join(dir, `${taskName.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`);
|
|
1327
|
+
try {
|
|
1328
|
+
// schtasks /XML insists the file encoding matches the XML declaration and
|
|
1329
|
+
// rejects UTF-8 ("unable to switch the encoding") — write UTF-16LE + BOM.
|
|
1330
|
+
fs.writeFileSync(xmlPath, `\ufeff${xml}`, 'utf16le');
|
|
1331
|
+
const created = (0, child_process_1.spawnSync)('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], {
|
|
1332
|
+
stdio: 'ignore', windowsHide: true, timeout: 20_000,
|
|
1333
|
+
});
|
|
1334
|
+
if (created.status === 0)
|
|
1335
|
+
return true;
|
|
1336
|
+
}
|
|
1337
|
+
catch { /* fall through to /TR */ }
|
|
1338
|
+
// Fallback: node directly via /TR (no wscript, no VBS). node/CLI may live
|
|
1339
|
+
// under a path with spaces (Program Files), so quote each and let schtasks
|
|
1340
|
+
// pass the whole /TR string through.
|
|
1341
|
+
const tr = `"${process.execPath}" "${cliEntry()}" ${command}`;
|
|
1342
|
+
const fallback = (0, child_process_1.spawnSync)('schtasks', [
|
|
1343
|
+
'/Create', '/TN', taskName, '/TR', tr, ...scheduleArgs, '/F',
|
|
1344
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
1345
|
+
return fallback.status === 0;
|
|
1346
|
+
}
|
|
1347
|
+
/** Remove legacy VBS launchers left by pre-1.22.1 installs. */
|
|
1348
|
+
function removeLegacyWindowsLaunchers() {
|
|
1349
|
+
const launcherDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense');
|
|
1350
|
+
for (const launcher of ['daemon.vbs', 'watchdog.vbs']) {
|
|
1351
|
+
try {
|
|
1352
|
+
fs.unlinkSync(path.join(launcherDir, launcher));
|
|
1353
|
+
}
|
|
1354
|
+
catch { /* already gone */ }
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
/** True when an existing scheduled task's action still runs wscript/cscript
|
|
1358
|
+
* (the pre-1.22.1 VBS launcher) — the migration trigger. */
|
|
1359
|
+
function windowsTaskReferencesScriptHost(taskName) {
|
|
1360
|
+
try {
|
|
1361
|
+
const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/XML'], {
|
|
1362
|
+
encoding: 'utf8', windowsHide: true, timeout: 10_000,
|
|
1363
|
+
});
|
|
1364
|
+
if (query.status !== 0 || typeof query.stdout !== 'string')
|
|
1365
|
+
return false;
|
|
1366
|
+
return /wscript|cscript|\.vbs/i.test(query.stdout);
|
|
1367
|
+
}
|
|
1368
|
+
catch {
|
|
1369
|
+
return false;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
/** True when the per-user Run-key fallback still names wscript/a .vbs. */
|
|
1373
|
+
function windowsRunKeyReferencesScriptHost() {
|
|
1374
|
+
try {
|
|
1375
|
+
const query = (0, child_process_1.spawnSync)('reg', ['query', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE], {
|
|
1376
|
+
encoding: 'utf8', windowsHide: true, timeout: 5_000,
|
|
1377
|
+
});
|
|
1378
|
+
if (query.status !== 0 || typeof query.stdout !== 'string')
|
|
1379
|
+
return false;
|
|
1380
|
+
return /wscript|cscript|\.vbs/i.test(query.stdout);
|
|
1381
|
+
}
|
|
1382
|
+
catch {
|
|
1383
|
+
return false;
|
|
1384
|
+
}
|
|
1211
1385
|
}
|
|
1212
1386
|
const WINDOWS_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
1213
1387
|
const WINDOWS_RUN_VALUE = 'FullCourtDefenseDaemon';
|
|
@@ -1220,7 +1394,7 @@ function isWindowsRunKeyInstalled() {
|
|
|
1220
1394
|
/** Launch the daemon right now, outside our own process tree. WMI process
|
|
1221
1395
|
* creation escapes the Windows Installer job object, which would otherwise
|
|
1222
1396
|
* kill the daemon the moment an MSI custom action finishes. */
|
|
1223
|
-
function startDaemonNowWindows(
|
|
1397
|
+
function startDaemonNowWindows(viaTask = false) {
|
|
1224
1398
|
try {
|
|
1225
1399
|
const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
|
|
1226
1400
|
if (Number.isFinite(existing) && existing > 0 && isPidAlive(existing)) {
|
|
@@ -1243,42 +1417,41 @@ function startDaemonNowWindows(vbs, viaTask = false) {
|
|
|
1243
1417
|
if (run.status === 0)
|
|
1244
1418
|
return;
|
|
1245
1419
|
}
|
|
1246
|
-
|
|
1420
|
+
// WMI process creation escapes the MSI job object. Runs node.exe DIRECTLY —
|
|
1421
|
+
// no wscript/VBS (Defender's Commando.A!ml heuristic) — with a hidden window.
|
|
1422
|
+
const nodeEsc = process.execPath.replace(/'/g, "''");
|
|
1423
|
+
const entryEsc = cliEntry().replace(/'/g, "''");
|
|
1247
1424
|
(0, child_process_1.spawnSync)('powershell', [
|
|
1248
1425
|
'-NoProfile', '-NonInteractive', '-Command',
|
|
1249
|
-
`Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('
|
|
1426
|
+
`Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('\"' + '${nodeEsc}' + '\" \"' + '${entryEsc}' + '\" daemon') } | Out-Null`,
|
|
1250
1427
|
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
1251
1428
|
}
|
|
1252
1429
|
function installWindows() {
|
|
1253
|
-
|
|
1254
|
-
// Preferred: Scheduled Task at logon
|
|
1255
|
-
//
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
'/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
|
|
1259
|
-
], { stdio: 'ignore', windowsHide: true });
|
|
1260
|
-
const taskOk = task.status === 0;
|
|
1430
|
+
removeLegacyWindowsLaunchers();
|
|
1431
|
+
// Preferred: Scheduled Task at logon, running node.exe directly and hidden
|
|
1432
|
+
// via task XML. Creating a logon-trigger task can require rights, so this may
|
|
1433
|
+
// fail for some standard-user/unelevated installs.
|
|
1434
|
+
const taskOk = registerHiddenTask(TASK_NAME, buildDaemonTaskXml(), 'daemon', ['/SC', 'ONLOGON', '/RL', 'LIMITED']);
|
|
1261
1435
|
let ok = taskOk;
|
|
1262
1436
|
if (!ok) {
|
|
1263
1437
|
// Fallback: per-user Run key — no elevation needed, runs at every logon.
|
|
1438
|
+
// node.exe directly (no wscript). May flash briefly at logon in this rare
|
|
1439
|
+
// fallback, but carries no script-host persistence fingerprint.
|
|
1264
1440
|
const reg = (0, child_process_1.spawnSync)('reg', [
|
|
1265
1441
|
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1266
|
-
'/d', `
|
|
1442
|
+
'/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1267
1443
|
], { stdio: 'ignore', windowsHide: true });
|
|
1268
1444
|
ok = reg.status === 0;
|
|
1269
1445
|
}
|
|
1270
1446
|
// Watchdog: logon triggers only START the daemon — nothing restarts it if it
|
|
1271
|
-
// is killed or crashes mid-session. A time-based
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
(
|
|
1277
|
-
'/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${ensureWindowsWatchdogLauncher()}"`,
|
|
1278
|
-
'/SC', 'MINUTE', '/MO', '5', '/F',
|
|
1279
|
-
], { stdio: 'ignore', windowsHide: true });
|
|
1447
|
+
// is killed or crashes mid-session. A time-based task runs the `watchdog`
|
|
1448
|
+
// command every 5 minutes: it beacons daemon liveness to the console (so
|
|
1449
|
+
// "daemon dead" is visible even when the daemon can't say so itself) and
|
|
1450
|
+
// revives a dead daemon within one tick. Best-effort: a missing watchdog
|
|
1451
|
+
// never fails the install.
|
|
1452
|
+
registerHiddenTask(WATCHDOG_TASK_NAME, buildWatchdogTaskXml(), 'watchdog', ['/SC', 'MINUTE', '/MO', '5']);
|
|
1280
1453
|
if (ok)
|
|
1281
|
-
startDaemonNowWindows(
|
|
1454
|
+
startDaemonNowWindows(taskOk);
|
|
1282
1455
|
return ok;
|
|
1283
1456
|
}
|
|
1284
1457
|
/**
|
|
@@ -1297,29 +1470,43 @@ function ensureWindowsAutostartHealthy(logFn) {
|
|
|
1297
1470
|
if (process.env.FCD_DAEMON_NO_TASK_SELF_HEAL === '1')
|
|
1298
1471
|
return;
|
|
1299
1472
|
try {
|
|
1300
|
-
|
|
1301
|
-
|
|
1473
|
+
// Migration: pre-1.22.1 installs registered wscript/VBS-based tasks that
|
|
1474
|
+
// Defender flags as Trojan:Win32/Commando.A!ml. If the existing task still
|
|
1475
|
+
// references a script host, force-replace it with the node-direct XML task.
|
|
1476
|
+
const legacyDaemon = windowsTaskReferencesScriptHost(TASK_NAME);
|
|
1477
|
+
const legacyWatchdog = windowsTaskReferencesScriptHost(WATCHDOG_TASK_NAME);
|
|
1478
|
+
if (legacyDaemon || legacyWatchdog) {
|
|
1479
|
+
logFn('Self-heal: replacing legacy wscript-based autostart task(s) with node-direct hidden tasks (Defender false-positive fix).');
|
|
1480
|
+
}
|
|
1302
1481
|
const daemonTask = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1303
|
-
if (daemonTask.status !== 0 && !isWindowsRunKeyInstalled()) {
|
|
1304
|
-
const created = (
|
|
1305
|
-
|
|
1306
|
-
'/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
|
|
1307
|
-
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
1308
|
-
if (created.status !== 0) {
|
|
1482
|
+
if (legacyDaemon || (daemonTask.status !== 0 && !isWindowsRunKeyInstalled())) {
|
|
1483
|
+
const created = registerHiddenTask(TASK_NAME, buildDaemonTaskXml(), 'daemon', ['/SC', 'ONLOGON', '/RL', 'LIMITED']);
|
|
1484
|
+
if (!created) {
|
|
1309
1485
|
(0, child_process_1.spawnSync)('reg', [
|
|
1310
1486
|
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1311
|
-
'/d', `
|
|
1487
|
+
'/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1312
1488
|
], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1313
1489
|
}
|
|
1314
|
-
|
|
1490
|
+
if (daemonTask.status !== 0)
|
|
1491
|
+
logFn('Self-heal: daemon autostart entry was missing — recreated.');
|
|
1315
1492
|
}
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1493
|
+
// Rewrite the watchdog whenever it's missing OR still script-host based.
|
|
1494
|
+
const watchdogQuery = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', WATCHDOG_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1495
|
+
if (legacyWatchdog || watchdogQuery.status !== 0) {
|
|
1496
|
+
const ok = registerHiddenTask(WATCHDOG_TASK_NAME, buildWatchdogTaskXml(), 'watchdog', ['/SC', 'MINUTE', '/MO', '5']);
|
|
1497
|
+
if (!ok) {
|
|
1498
|
+
logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
|
|
1499
|
+
}
|
|
1322
1500
|
}
|
|
1501
|
+
// A stale Run-key fallback from an older install may still name wscript.
|
|
1502
|
+
if (windowsRunKeyReferencesScriptHost()) {
|
|
1503
|
+
(0, child_process_1.spawnSync)('reg', [
|
|
1504
|
+
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1505
|
+
'/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1506
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1507
|
+
logFn('Self-heal: rewrote legacy wscript Run-key entry to run node directly.');
|
|
1508
|
+
}
|
|
1509
|
+
removeLegacyWindowsLaunchers();
|
|
1323
1510
|
}
|
|
1324
1511
|
catch { /* self-heal is best-effort — never blocks daemon boot */ }
|
|
1325
1512
|
}
|
|
@@ -1358,10 +1545,12 @@ function uninstallWindows() {
|
|
|
1358
1545
|
const task = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
|
|
1359
1546
|
(0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', WATCHDOG_TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
|
|
1360
1547
|
const reg = (0, child_process_1.spawnSync)('reg', ['delete', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true });
|
|
1548
|
+
removeLegacyWindowsLaunchers();
|
|
1549
|
+
// Clean up the node-direct task XML we now drop next to the launchers.
|
|
1361
1550
|
const launcherDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense');
|
|
1362
|
-
for (const
|
|
1551
|
+
for (const name of [TASK_NAME, WATCHDOG_TASK_NAME]) {
|
|
1363
1552
|
try {
|
|
1364
|
-
fs.unlinkSync(path.join(launcherDir,
|
|
1553
|
+
fs.unlinkSync(path.join(launcherDir, `${name.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`));
|
|
1365
1554
|
}
|
|
1366
1555
|
catch { /* ignore */ }
|
|
1367
1556
|
}
|