fullcourtdefense-cli 1.22.0 → 1.22.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/commands/autoProtect.js +29 -26
- package/dist/commands/daemon.d.ts +42 -0
- package/dist/commands/daemon.js +302 -60
- package/dist/commands/discoverSchedule.js +34 -20
- 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.buildTaskXmlVariants)('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() {
|
|
@@ -32,6 +32,47 @@ export declare function discoverSweepCredentialEnv(credentials?: {
|
|
|
32
32
|
shieldKey?: string;
|
|
33
33
|
apiUrl?: string;
|
|
34
34
|
}): Record<string, string>;
|
|
35
|
+
export type TaskLogonType = 'S4U' | 'InteractiveToken';
|
|
36
|
+
/** Fully-qualified current user (DOMAIN\\user) for the task principal. */
|
|
37
|
+
export declare function taskUserId(): string;
|
|
38
|
+
/** Local wall-clock timestamp (no ms, no tz) as Task Scheduler expects. */
|
|
39
|
+
export declare function taskLocalTimestamp(date: Date): string;
|
|
40
|
+
/**
|
|
41
|
+
* Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
|
|
42
|
+
*
|
|
43
|
+
* @param command CLI subcommand + args ('daemon' | 'watchdog' | 'discover …').
|
|
44
|
+
* @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
|
|
45
|
+
* time trigger for the 5-minute watchdog.
|
|
46
|
+
* @param logonType S4U (windowless, needs elevation to register) or
|
|
47
|
+
* InteractiveToken (registers unelevated, may flash).
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildTaskXml(command: string, triggers: string, logonType?: TaskLogonType): string;
|
|
50
|
+
/**
|
|
51
|
+
* Windowless-first XML variants for one task: S4U (no window, needs elevation
|
|
52
|
+
* to register) first, InteractiveToken (unelevated fallback) second.
|
|
53
|
+
* registerHiddenTask() tries them in order.
|
|
54
|
+
*/
|
|
55
|
+
export declare function buildTaskXmlVariants(command: string, triggers: string): string[];
|
|
56
|
+
/**
|
|
57
|
+
* Register a scheduled task from XML (node run directly). Tries each XML
|
|
58
|
+
* variant in order — S4U (windowless) first, then InteractiveToken — and
|
|
59
|
+
* finally falls back to a plain `/TR node …` task. The fallbacks may briefly
|
|
60
|
+
* flash a console window but still contain NO script host, so the malware
|
|
61
|
+
* fingerprint never returns. Returns true if any path succeeds.
|
|
62
|
+
*
|
|
63
|
+
* @param taskName Scheduled-task name.
|
|
64
|
+
* @param xml Task Scheduler XML variant(s), tried in order.
|
|
65
|
+
* @param command CLI subcommand for the /TR fallback ('daemon'|'watchdog').
|
|
66
|
+
* @param scheduleArgs schtasks schedule flags for the fallback (e.g. ['/SC','MINUTE','/MO','5']).
|
|
67
|
+
*/
|
|
68
|
+
export declare function registerHiddenTask(taskName: string, xml: string | string[], command: string, scheduleArgs: string[]): boolean;
|
|
69
|
+
/** True when an existing scheduled task's action still runs wscript/cscript
|
|
70
|
+
* (the pre-1.22.1 VBS launcher) — the migration trigger. */
|
|
71
|
+
export declare function windowsTaskReferencesScriptHost(taskName: string): boolean;
|
|
72
|
+
/** True when an existing task still runs with an InteractiveToken principal —
|
|
73
|
+
* the 1.22.1 window-flash bug (console window on every trigger). Migration
|
|
74
|
+
* trigger for the windowless S4U principal. */
|
|
75
|
+
export declare function windowsTaskRunsInteractive(taskName: string): boolean;
|
|
35
76
|
/** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
|
|
36
77
|
export interface DaemonRuntimeState {
|
|
37
78
|
alive: boolean;
|
|
@@ -45,6 +86,7 @@ export declare function spawnDetachedDaemon(): boolean;
|
|
|
45
86
|
/** Pid-alive check shared with the watchdog (EPERM still means alive). */
|
|
46
87
|
export declare function pidIsAlive(pid: number): boolean;
|
|
47
88
|
export declare function macosLaunchdPath(currentPath?: string, execPath?: string): string;
|
|
89
|
+
export declare function xmlEscape(value: string): string;
|
|
48
90
|
/** Whether the daemon has been registered to start automatically. */
|
|
49
91
|
/** Is the 5-minute watchdog scheduled task registered? (Windows-only feature.) */
|
|
50
92
|
export declare function isWatchdogTaskInstalled(): boolean;
|
package/dist/commands/daemon.js
CHANGED
|
@@ -36,10 +36,18 @@ 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.buildTaskXmlVariants = buildTaskXmlVariants;
|
|
43
|
+
exports.registerHiddenTask = registerHiddenTask;
|
|
44
|
+
exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
|
|
45
|
+
exports.windowsTaskRunsInteractive = windowsTaskRunsInteractive;
|
|
39
46
|
exports.daemonRuntimeState = daemonRuntimeState;
|
|
40
47
|
exports.spawnDetachedDaemon = spawnDetachedDaemon;
|
|
41
48
|
exports.pidIsAlive = pidIsAlive;
|
|
42
49
|
exports.macosLaunchdPath = macosLaunchdPath;
|
|
50
|
+
exports.xmlEscape = xmlEscape;
|
|
43
51
|
exports.isWatchdogTaskInstalled = isWatchdogTaskInstalled;
|
|
44
52
|
exports.isDaemonAutostartInstalled = isDaemonAutostartInstalled;
|
|
45
53
|
exports.daemonCommand = daemonCommand;
|
|
@@ -1181,32 +1189,242 @@ async function runDaemon(args, config) {
|
|
|
1181
1189
|
// Keep the process alive forever (timers alone would do it, but be explicit).
|
|
1182
1190
|
await new Promise(() => { });
|
|
1183
1191
|
}
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1192
|
+
/** Fully-qualified current user (DOMAIN\\user) for the task principal. */
|
|
1193
|
+
function taskUserId() {
|
|
1194
|
+
const domain = process.env.USERDOMAIN;
|
|
1195
|
+
let user = process.env.USERNAME;
|
|
1196
|
+
if (!user) {
|
|
1197
|
+
try {
|
|
1198
|
+
user = os.userInfo().username;
|
|
1199
|
+
}
|
|
1200
|
+
catch {
|
|
1201
|
+
user = undefined;
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (user && domain)
|
|
1205
|
+
return `${domain}\\${user}`;
|
|
1206
|
+
if (user)
|
|
1207
|
+
return user;
|
|
1208
|
+
return os.hostname();
|
|
1209
|
+
}
|
|
1210
|
+
/** Local wall-clock timestamp (no ms, no tz) as Task Scheduler expects. */
|
|
1211
|
+
function taskLocalTimestamp(date) {
|
|
1212
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
1213
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
1214
|
+
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
|
|
1218
|
+
*
|
|
1219
|
+
* @param command CLI subcommand + args ('daemon' | 'watchdog' | 'discover …').
|
|
1220
|
+
* @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
|
|
1221
|
+
* time trigger for the 5-minute watchdog.
|
|
1222
|
+
* @param logonType S4U (windowless, needs elevation to register) or
|
|
1223
|
+
* InteractiveToken (registers unelevated, may flash).
|
|
1224
|
+
*/
|
|
1225
|
+
function buildTaskXml(command, triggers, logonType = 'S4U') {
|
|
1226
|
+
const userId = xmlEscape(taskUserId());
|
|
1227
|
+
const nodeExe = xmlEscape(process.execPath);
|
|
1228
|
+
const args = xmlEscape(`"${cliEntry()}" ${command}`);
|
|
1229
|
+
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
1230
|
+
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
1231
|
+
<RegistrationInfo>
|
|
1232
|
+
<Description>FullCourtDefense ${xmlEscape(command)} — runs node directly, windowless, no script host.</Description>
|
|
1233
|
+
</RegistrationInfo>
|
|
1234
|
+
<Triggers>
|
|
1235
|
+
${triggers}
|
|
1236
|
+
</Triggers>
|
|
1237
|
+
<Principals>
|
|
1238
|
+
<Principal id="Author">
|
|
1239
|
+
<UserId>${userId}</UserId>
|
|
1240
|
+
<LogonType>${logonType}</LogonType>
|
|
1241
|
+
<RunLevel>LeastPrivilege</RunLevel>
|
|
1242
|
+
</Principal>
|
|
1243
|
+
</Principals>
|
|
1244
|
+
<Settings>
|
|
1245
|
+
<Hidden>true</Hidden>
|
|
1246
|
+
<MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
|
|
1247
|
+
<DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
|
|
1248
|
+
<StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
|
|
1249
|
+
<StartWhenAvailable>true</StartWhenAvailable>
|
|
1250
|
+
<RunOnlyIfNetworkAvailable>false</RunOnlyIfNetworkAvailable>
|
|
1251
|
+
<AllowHardTerminate>true</AllowHardTerminate>
|
|
1252
|
+
<AllowStartOnDemand>true</AllowStartOnDemand>
|
|
1253
|
+
<Enabled>true</Enabled>
|
|
1254
|
+
<ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
|
|
1255
|
+
<IdleSettings>
|
|
1256
|
+
<StopOnIdleEnd>false</StopOnIdleEnd>
|
|
1257
|
+
<RestartOnIdle>false</RestartOnIdle>
|
|
1258
|
+
</IdleSettings>
|
|
1259
|
+
</Settings>
|
|
1260
|
+
<Actions Context="Author">
|
|
1261
|
+
<Exec>
|
|
1262
|
+
<Command>${nodeExe}</Command>
|
|
1263
|
+
<Arguments>${args}</Arguments>
|
|
1264
|
+
</Exec>
|
|
1265
|
+
</Actions>
|
|
1266
|
+
</Task>
|
|
1267
|
+
`;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Windowless-first XML variants for one task: S4U (no window, needs elevation
|
|
1271
|
+
* to register) first, InteractiveToken (unelevated fallback) second.
|
|
1272
|
+
* registerHiddenTask() tries them in order.
|
|
1273
|
+
*/
|
|
1274
|
+
function buildTaskXmlVariants(command, triggers) {
|
|
1275
|
+
return [buildTaskXml(command, triggers, 'S4U'), buildTaskXml(command, triggers, 'InteractiveToken')];
|
|
1276
|
+
}
|
|
1277
|
+
function daemonTaskTrigger() {
|
|
1278
|
+
return ` <LogonTrigger>
|
|
1279
|
+
<Enabled>true</Enabled>
|
|
1280
|
+
<UserId>${xmlEscape(taskUserId())}</UserId>
|
|
1281
|
+
</LogonTrigger>`;
|
|
1282
|
+
}
|
|
1283
|
+
/** Daemon task variants: start at logon (node run windowless, no repetition). */
|
|
1284
|
+
function buildDaemonTaskXml() {
|
|
1285
|
+
return buildTaskXmlVariants('daemon', daemonTaskTrigger());
|
|
1286
|
+
}
|
|
1287
|
+
/**
|
|
1288
|
+
* Watchdog task: run the `watchdog` command every 5 minutes (liveness beacon +
|
|
1289
|
+
* daemon revival + crash post-mortem) — the beacon is what lets the console
|
|
1290
|
+
* tell "daemon dead" apart from "machine off". A repeating time trigger with a
|
|
1291
|
+
* past start boundary fires promptly and keeps repeating across reboots.
|
|
1292
|
+
*/
|
|
1293
|
+
function buildWatchdogTaskXml() {
|
|
1294
|
+
const start = taskLocalTimestamp(new Date(Date.now() - 60_000));
|
|
1295
|
+
const trigger = ` <TimeTrigger>
|
|
1296
|
+
<Enabled>true</Enabled>
|
|
1297
|
+
<StartBoundary>${start}</StartBoundary>
|
|
1298
|
+
<Repetition>
|
|
1299
|
+
<Interval>PT5M</Interval>
|
|
1300
|
+
<StopAtDurationEnd>false</StopAtDurationEnd>
|
|
1301
|
+
</Repetition>
|
|
1302
|
+
</TimeTrigger>`;
|
|
1303
|
+
return buildTaskXmlVariants('watchdog', trigger);
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Register a scheduled task from XML (node run directly). Tries each XML
|
|
1307
|
+
* variant in order — S4U (windowless) first, then InteractiveToken — and
|
|
1308
|
+
* finally falls back to a plain `/TR node …` task. The fallbacks may briefly
|
|
1309
|
+
* flash a console window but still contain NO script host, so the malware
|
|
1310
|
+
* fingerprint never returns. Returns true if any path succeeds.
|
|
1311
|
+
*
|
|
1312
|
+
* @param taskName Scheduled-task name.
|
|
1313
|
+
* @param xml Task Scheduler XML variant(s), tried in order.
|
|
1314
|
+
* @param command CLI subcommand for the /TR fallback ('daemon'|'watchdog').
|
|
1315
|
+
* @param scheduleArgs schtasks schedule flags for the fallback (e.g. ['/SC','MINUTE','/MO','5']).
|
|
1316
|
+
*/
|
|
1317
|
+
function registerHiddenTask(taskName, xml, command, scheduleArgs) {
|
|
1189
1318
|
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
1190
1319
|
const dir = path.join(base, 'FullCourtDefense');
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
const
|
|
1196
|
-
const
|
|
1197
|
-
|
|
1198
|
-
|
|
1320
|
+
try {
|
|
1321
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1322
|
+
}
|
|
1323
|
+
catch { /* ignore */ }
|
|
1324
|
+
const xmlPath = path.join(dir, `${taskName.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`);
|
|
1325
|
+
for (const variant of Array.isArray(xml) ? xml : [xml]) {
|
|
1326
|
+
try {
|
|
1327
|
+
// schtasks /XML insists the file encoding matches the XML declaration and
|
|
1328
|
+
// rejects UTF-8 ("unable to switch the encoding") — write UTF-16LE + BOM.
|
|
1329
|
+
fs.writeFileSync(xmlPath, `\ufeff${variant}`, 'utf16le');
|
|
1330
|
+
const created = (0, child_process_1.spawnSync)('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], {
|
|
1331
|
+
stdio: 'ignore', windowsHide: true, timeout: 20_000,
|
|
1332
|
+
});
|
|
1333
|
+
if (created.status === 0)
|
|
1334
|
+
return true;
|
|
1335
|
+
}
|
|
1336
|
+
catch { /* try next variant, then /TR */ }
|
|
1337
|
+
}
|
|
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;
|
|
1199
1346
|
}
|
|
1200
|
-
|
|
1201
|
-
|
|
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 an existing task still runs with an InteractiveToken principal —
|
|
1373
|
+
* the 1.22.1 window-flash bug (console window on every trigger). Migration
|
|
1374
|
+
* trigger for the windowless S4U principal. */
|
|
1375
|
+
function windowsTaskRunsInteractive(taskName) {
|
|
1376
|
+
try {
|
|
1377
|
+
const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/XML'], {
|
|
1378
|
+
encoding: 'utf8', windowsHide: true, timeout: 10_000,
|
|
1379
|
+
});
|
|
1380
|
+
if (query.status !== 0 || typeof query.stdout !== 'string')
|
|
1381
|
+
return false;
|
|
1382
|
+
return /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(query.stdout);
|
|
1383
|
+
}
|
|
1384
|
+
catch {
|
|
1385
|
+
return false;
|
|
1386
|
+
}
|
|
1202
1387
|
}
|
|
1203
1388
|
/**
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1389
|
+
* Upgrade an EXISTING (working) task to the windowless S4U principal. Tries
|
|
1390
|
+
* ONLY the S4U XML — on failure (unelevated daemon) the current task is left
|
|
1391
|
+
* untouched so autostart never regresses from "flashes" to "broken".
|
|
1207
1392
|
*/
|
|
1208
|
-
function
|
|
1209
|
-
|
|
1393
|
+
function upgradeTaskWindowless(taskName, s4uXml, logFn) {
|
|
1394
|
+
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
1395
|
+
const dir = path.join(base, 'FullCourtDefense');
|
|
1396
|
+
try {
|
|
1397
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1398
|
+
}
|
|
1399
|
+
catch { /* ignore */ }
|
|
1400
|
+
const xmlPath = path.join(dir, `${taskName.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`);
|
|
1401
|
+
try {
|
|
1402
|
+
fs.writeFileSync(xmlPath, `\ufeff${s4uXml}`, 'utf16le');
|
|
1403
|
+
const created = (0, child_process_1.spawnSync)('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], {
|
|
1404
|
+
stdio: 'ignore', windowsHide: true, timeout: 20_000,
|
|
1405
|
+
});
|
|
1406
|
+
if (created.status === 0) {
|
|
1407
|
+
logFn(`Self-heal: "${taskName}" migrated to the windowless S4U principal (no more console-window flash).`);
|
|
1408
|
+
}
|
|
1409
|
+
else {
|
|
1410
|
+
logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs elevation) — existing task kept as-is.`);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
catch { /* keep the existing working task */ }
|
|
1414
|
+
}
|
|
1415
|
+
/** True when the per-user Run-key fallback still names wscript/a .vbs. */
|
|
1416
|
+
function windowsRunKeyReferencesScriptHost() {
|
|
1417
|
+
try {
|
|
1418
|
+
const query = (0, child_process_1.spawnSync)('reg', ['query', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE], {
|
|
1419
|
+
encoding: 'utf8', windowsHide: true, timeout: 5_000,
|
|
1420
|
+
});
|
|
1421
|
+
if (query.status !== 0 || typeof query.stdout !== 'string')
|
|
1422
|
+
return false;
|
|
1423
|
+
return /wscript|cscript|\.vbs/i.test(query.stdout);
|
|
1424
|
+
}
|
|
1425
|
+
catch {
|
|
1426
|
+
return false;
|
|
1427
|
+
}
|
|
1210
1428
|
}
|
|
1211
1429
|
const WINDOWS_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
1212
1430
|
const WINDOWS_RUN_VALUE = 'FullCourtDefenseDaemon';
|
|
@@ -1219,7 +1437,7 @@ function isWindowsRunKeyInstalled() {
|
|
|
1219
1437
|
/** Launch the daemon right now, outside our own process tree. WMI process
|
|
1220
1438
|
* creation escapes the Windows Installer job object, which would otherwise
|
|
1221
1439
|
* kill the daemon the moment an MSI custom action finishes. */
|
|
1222
|
-
function startDaemonNowWindows(
|
|
1440
|
+
function startDaemonNowWindows(viaTask = false) {
|
|
1223
1441
|
try {
|
|
1224
1442
|
const existing = Number(fs.readFileSync(pidFile(), 'utf8').trim());
|
|
1225
1443
|
if (Number.isFinite(existing) && existing > 0 && isPidAlive(existing)) {
|
|
@@ -1242,42 +1460,41 @@ function startDaemonNowWindows(vbs, viaTask = false) {
|
|
|
1242
1460
|
if (run.status === 0)
|
|
1243
1461
|
return;
|
|
1244
1462
|
}
|
|
1245
|
-
|
|
1463
|
+
// WMI process creation escapes the MSI job object. Runs node.exe DIRECTLY —
|
|
1464
|
+
// no wscript/VBS (Defender's Commando.A!ml heuristic) — with a hidden window.
|
|
1465
|
+
const nodeEsc = process.execPath.replace(/'/g, "''");
|
|
1466
|
+
const entryEsc = cliEntry().replace(/'/g, "''");
|
|
1246
1467
|
(0, child_process_1.spawnSync)('powershell', [
|
|
1247
1468
|
'-NoProfile', '-NonInteractive', '-Command',
|
|
1248
|
-
`Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('
|
|
1469
|
+
`Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('\"' + '${nodeEsc}' + '\" \"' + '${entryEsc}' + '\" daemon') } | Out-Null`,
|
|
1249
1470
|
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
1250
1471
|
}
|
|
1251
1472
|
function installWindows() {
|
|
1252
|
-
|
|
1253
|
-
// Preferred: Scheduled Task at logon
|
|
1254
|
-
//
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
'/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
|
|
1258
|
-
], { stdio: 'ignore', windowsHide: true });
|
|
1259
|
-
const taskOk = task.status === 0;
|
|
1473
|
+
removeLegacyWindowsLaunchers();
|
|
1474
|
+
// Preferred: Scheduled Task at logon, running node.exe directly and hidden
|
|
1475
|
+
// via task XML. Creating a logon-trigger task can require rights, so this may
|
|
1476
|
+
// fail for some standard-user/unelevated installs.
|
|
1477
|
+
const taskOk = registerHiddenTask(TASK_NAME, buildDaemonTaskXml(), 'daemon', ['/SC', 'ONLOGON', '/RL', 'LIMITED']);
|
|
1260
1478
|
let ok = taskOk;
|
|
1261
1479
|
if (!ok) {
|
|
1262
1480
|
// Fallback: per-user Run key — no elevation needed, runs at every logon.
|
|
1481
|
+
// node.exe directly (no wscript). May flash briefly at logon in this rare
|
|
1482
|
+
// fallback, but carries no script-host persistence fingerprint.
|
|
1263
1483
|
const reg = (0, child_process_1.spawnSync)('reg', [
|
|
1264
1484
|
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1265
|
-
'/d', `
|
|
1485
|
+
'/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1266
1486
|
], { stdio: 'ignore', windowsHide: true });
|
|
1267
1487
|
ok = reg.status === 0;
|
|
1268
1488
|
}
|
|
1269
1489
|
// Watchdog: logon triggers only START the daemon — nothing restarts it if it
|
|
1270
|
-
// is killed or crashes mid-session. A time-based
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
(
|
|
1276
|
-
'/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${ensureWindowsWatchdogLauncher()}"`,
|
|
1277
|
-
'/SC', 'MINUTE', '/MO', '5', '/F',
|
|
1278
|
-
], { stdio: 'ignore', windowsHide: true });
|
|
1490
|
+
// is killed or crashes mid-session. A time-based task runs the `watchdog`
|
|
1491
|
+
// command every 5 minutes: it beacons daemon liveness to the console (so
|
|
1492
|
+
// "daemon dead" is visible even when the daemon can't say so itself) and
|
|
1493
|
+
// revives a dead daemon within one tick. Best-effort: a missing watchdog
|
|
1494
|
+
// never fails the install.
|
|
1495
|
+
registerHiddenTask(WATCHDOG_TASK_NAME, buildWatchdogTaskXml(), 'watchdog', ['/SC', 'MINUTE', '/MO', '5']);
|
|
1279
1496
|
if (ok)
|
|
1280
|
-
startDaemonNowWindows(
|
|
1497
|
+
startDaemonNowWindows(taskOk);
|
|
1281
1498
|
return ok;
|
|
1282
1499
|
}
|
|
1283
1500
|
/**
|
|
@@ -1296,29 +1513,52 @@ function ensureWindowsAutostartHealthy(logFn) {
|
|
|
1296
1513
|
if (process.env.FCD_DAEMON_NO_TASK_SELF_HEAL === '1')
|
|
1297
1514
|
return;
|
|
1298
1515
|
try {
|
|
1299
|
-
|
|
1300
|
-
|
|
1516
|
+
// Migration: pre-1.22.1 installs registered wscript/VBS-based tasks that
|
|
1517
|
+
// Defender flags as Trojan:Win32/Commando.A!ml. If the existing task still
|
|
1518
|
+
// references a script host, force-replace it with the node-direct XML task.
|
|
1519
|
+
const legacyDaemon = windowsTaskReferencesScriptHost(TASK_NAME);
|
|
1520
|
+
const legacyWatchdog = windowsTaskReferencesScriptHost(WATCHDOG_TASK_NAME);
|
|
1521
|
+
if (legacyDaemon || legacyWatchdog) {
|
|
1522
|
+
logFn('Self-heal: replacing legacy wscript-based autostart task(s) with node-direct hidden tasks (Defender false-positive fix).');
|
|
1523
|
+
}
|
|
1301
1524
|
const daemonTask = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1302
|
-
if (daemonTask.status !== 0 && !isWindowsRunKeyInstalled()) {
|
|
1303
|
-
const created = (
|
|
1304
|
-
|
|
1305
|
-
'/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
|
|
1306
|
-
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
1307
|
-
if (created.status !== 0) {
|
|
1525
|
+
if (legacyDaemon || (daemonTask.status !== 0 && !isWindowsRunKeyInstalled())) {
|
|
1526
|
+
const created = registerHiddenTask(TASK_NAME, buildDaemonTaskXml(), 'daemon', ['/SC', 'ONLOGON', '/RL', 'LIMITED']);
|
|
1527
|
+
if (!created) {
|
|
1308
1528
|
(0, child_process_1.spawnSync)('reg', [
|
|
1309
1529
|
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1310
|
-
'/d', `
|
|
1530
|
+
'/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1311
1531
|
], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1312
1532
|
}
|
|
1313
|
-
|
|
1533
|
+
if (daemonTask.status !== 0)
|
|
1534
|
+
logFn('Self-heal: daemon autostart entry was missing — recreated.');
|
|
1535
|
+
}
|
|
1536
|
+
// Rewrite the watchdog whenever it's missing OR still script-host based.
|
|
1537
|
+
const watchdogQuery = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', WATCHDOG_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1538
|
+
if (legacyWatchdog || watchdogQuery.status !== 0) {
|
|
1539
|
+
const ok = registerHiddenTask(WATCHDOG_TASK_NAME, buildWatchdogTaskXml(), 'watchdog', ['/SC', 'MINUTE', '/MO', '5']);
|
|
1540
|
+
if (!ok) {
|
|
1541
|
+
logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
|
|
1542
|
+
}
|
|
1314
1543
|
}
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
|
|
1544
|
+
// Migration (1.22.1 -> 1.22.2): InteractiveToken tasks flash a console
|
|
1545
|
+
// window on every trigger. Upgrade healthy existing tasks to S4U; S4U-only
|
|
1546
|
+
// attempt so a denied re-registration never breaks a working task.
|
|
1547
|
+
if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsInteractive(TASK_NAME)) {
|
|
1548
|
+
upgradeTaskWindowless(TASK_NAME, buildDaemonTaskXml()[0], logFn);
|
|
1321
1549
|
}
|
|
1550
|
+
if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsInteractive(WATCHDOG_TASK_NAME)) {
|
|
1551
|
+
upgradeTaskWindowless(WATCHDOG_TASK_NAME, buildWatchdogTaskXml()[0], logFn);
|
|
1552
|
+
}
|
|
1553
|
+
// A stale Run-key fallback from an older install may still name wscript.
|
|
1554
|
+
if (windowsRunKeyReferencesScriptHost()) {
|
|
1555
|
+
(0, child_process_1.spawnSync)('reg', [
|
|
1556
|
+
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1557
|
+
'/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1558
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1559
|
+
logFn('Self-heal: rewrote legacy wscript Run-key entry to run node directly.');
|
|
1560
|
+
}
|
|
1561
|
+
removeLegacyWindowsLaunchers();
|
|
1322
1562
|
}
|
|
1323
1563
|
catch { /* self-heal is best-effort — never blocks daemon boot */ }
|
|
1324
1564
|
}
|
|
@@ -1357,10 +1597,12 @@ function uninstallWindows() {
|
|
|
1357
1597
|
const task = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
|
|
1358
1598
|
(0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', WATCHDOG_TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
|
|
1359
1599
|
const reg = (0, child_process_1.spawnSync)('reg', ['delete', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true });
|
|
1600
|
+
removeLegacyWindowsLaunchers();
|
|
1601
|
+
// Clean up the node-direct task XML we now drop next to the launchers.
|
|
1360
1602
|
const launcherDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense');
|
|
1361
|
-
for (const
|
|
1603
|
+
for (const name of [TASK_NAME, WATCHDOG_TASK_NAME]) {
|
|
1362
1604
|
try {
|
|
1363
|
-
fs.unlinkSync(path.join(launcherDir,
|
|
1605
|
+
fs.unlinkSync(path.join(launcherDir, `${name.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`));
|
|
1364
1606
|
}
|
|
1365
1607
|
catch { /* ignore */ }
|
|
1366
1608
|
}
|
|
@@ -40,17 +40,17 @@ const child_process_1 = require("child_process");
|
|
|
40
40
|
const fs = __importStar(require("fs"));
|
|
41
41
|
const os = __importStar(require("os"));
|
|
42
42
|
const path = __importStar(require("path"));
|
|
43
|
+
const daemon_1 = require("./daemon");
|
|
43
44
|
const TASK_NAME = 'FullCourtDefenseDesktopDiscover';
|
|
44
45
|
const STARTUP_LAUNCHER = 'FullCourtDefenseDesktopDiscover.cmd';
|
|
45
46
|
function windowsStartupLauncherPath() {
|
|
46
47
|
const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
|
47
48
|
return path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup', STARTUP_LAUNCHER);
|
|
48
49
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const script = path.resolve(process.argv[1] || path.join(__dirname, '..', 'index.js'));
|
|
50
|
+
/** The `discover …` argument tail (no node/script prefix) for task actions. */
|
|
51
|
+
function discoverCommandTail(opts = {}) {
|
|
52
52
|
const q = (value) => (/\s/.test(value) ? `"${value}"` : value);
|
|
53
|
-
const parts = [
|
|
53
|
+
const parts = ['discover', '--upload', '--deep', '--silent'];
|
|
54
54
|
if (opts.surface)
|
|
55
55
|
parts.push('--surface', q(opts.surface));
|
|
56
56
|
if (opts.scanRoot)
|
|
@@ -61,26 +61,40 @@ function discoverCommandLine(opts = {}) {
|
|
|
61
61
|
parts.push('--user-email', q(opts.userEmail));
|
|
62
62
|
return parts.join(' ');
|
|
63
63
|
}
|
|
64
|
+
function discoverCommandLine(opts = {}) {
|
|
65
|
+
const node = process.execPath;
|
|
66
|
+
const script = path.resolve(process.argv[1] || path.join(__dirname, '..', 'index.js'));
|
|
67
|
+
const q = (value) => (/\s/.test(value) ? `"${value}"` : value);
|
|
68
|
+
return [q(node), q(script), discoverCommandTail(opts)].join(' ');
|
|
69
|
+
}
|
|
70
|
+
// Registered through the shared windowless task helpers (daemon.ts): S4U
|
|
71
|
+
// principal first (no console-window flash on the daily run), InteractiveToken
|
|
72
|
+
// then plain /TR as fallbacks — never a script host.
|
|
64
73
|
function installWindowsSchedule(hour, opts, trigger = 'daily') {
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
74
|
+
const tail = discoverCommandTail(opts);
|
|
75
|
+
const startBoundary = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}-${String(new Date().getDate()).padStart(2, '0')}T${String(hour).padStart(2, '0')}:00:00`;
|
|
76
|
+
const triggerXml = trigger === 'logon'
|
|
77
|
+
? ` <LogonTrigger>
|
|
78
|
+
<Enabled>true</Enabled>
|
|
79
|
+
<UserId>${(0, daemon_1.xmlEscape)((0, daemon_1.taskUserId)())}</UserId>
|
|
80
|
+
</LogonTrigger>`
|
|
81
|
+
: ` <CalendarTrigger>
|
|
82
|
+
<Enabled>true</Enabled>
|
|
83
|
+
<StartBoundary>${startBoundary}</StartBoundary>
|
|
84
|
+
<ScheduleByDay>
|
|
85
|
+
<DaysInterval>1</DaysInterval>
|
|
86
|
+
</ScheduleByDay>
|
|
87
|
+
</CalendarTrigger>`;
|
|
88
|
+
const schedArgs = trigger === 'logon'
|
|
89
|
+
? ['/SC', 'ONLOGON', '/RL', 'LIMITED']
|
|
90
|
+
: ['/SC', 'DAILY', '/ST', `${String(hour).padStart(2, '0')}:00`, '/RL', 'LIMITED'];
|
|
91
|
+
const ok = (0, daemon_1.registerHiddenTask)(TASK_NAME, (0, daemon_1.buildTaskXmlVariants)(tail, triggerXml), tail, schedArgs);
|
|
92
|
+
if (!ok) {
|
|
79
93
|
if (trigger !== 'logon')
|
|
80
|
-
throw
|
|
94
|
+
throw new Error(`Could not register scheduled task "${TASK_NAME}" (Task Scheduler denied all variants).`);
|
|
81
95
|
const launcher = windowsStartupLauncherPath();
|
|
82
96
|
fs.mkdirSync(path.dirname(launcher), { recursive: true });
|
|
83
|
-
fs.writeFileSync(launcher, `@echo off\r\n${
|
|
97
|
+
fs.writeFileSync(launcher, `@echo off\r\n${discoverCommandLine(opts)} > "%USERPROFILE%\\.fullcourtdefense-discover.log" 2>&1\r\n`, 'utf8');
|
|
84
98
|
console.log(`Task Scheduler denied access; installed user logon launcher instead: ${launcher}`);
|
|
85
99
|
}
|
|
86
100
|
}
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.2",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"test:real-scenario": "npm run build && node scripts/test-real-scenario-drill.js",
|
|
61
61
|
"test:blocking-approval": "npm run build && node scripts/test-blocking-approval-drill.js",
|
|
62
62
|
"test:clipboard-scan": "npm run build && node scripts/test-clipboard-scan.js",
|
|
63
|
+
"test:no-script-host": "npm run build && node scripts/test-no-script-host.js",
|
|
63
64
|
"build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
|
|
64
65
|
"prepublishOnly": "npm run build"
|
|
65
66
|
},
|