fullcourtdefense-cli 1.21.28 → 1.21.30
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/daemon.d.ts +14 -0
- package/dist/commands/daemon.js +181 -13
- package/dist/commands/discover.d.ts +2 -0
- package/dist/commands/discover.js +2 -0
- package/dist/commands/onboard.js +96 -7
- package/dist/commands/onboardingJournal.d.ts +1 -1
- package/dist/commands/onboardingJournal.js +4 -0
- package/dist/commands/watchdog.d.ts +25 -0
- package/dist/commands/watchdog.js +114 -0
- package/dist/config.d.ts +25 -1
- package/dist/config.js +36 -7
- package/dist/daemonForensics.d.ts +77 -0
- package/dist/daemonForensics.js +188 -0
- package/dist/envDiagnostics.d.ts +46 -0
- package/dist/envDiagnostics.js +98 -0
- package/dist/index.js +12 -0
- package/dist/securityAgents.d.ts +26 -0
- package/dist/securityAgents.js +65 -0
- package/dist/telemetry.d.ts +14 -0
- package/dist/telemetry.js +7 -0
- package/dist/version.json +1 -1
- package/package.json +2 -1
|
@@ -32,7 +32,21 @@ export declare function discoverSweepCredentialEnv(credentials?: {
|
|
|
32
32
|
shieldKey?: string;
|
|
33
33
|
apiUrl?: string;
|
|
34
34
|
}): Record<string, string>;
|
|
35
|
+
/** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
|
|
36
|
+
export interface DaemonRuntimeState {
|
|
37
|
+
alive: boolean;
|
|
38
|
+
pid?: number;
|
|
39
|
+
version?: string;
|
|
40
|
+
startedAt?: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function daemonRuntimeState(): DaemonRuntimeState;
|
|
43
|
+
/** Relaunch the daemon outside our process tree (plain node — no shells). */
|
|
44
|
+
export declare function spawnDetachedDaemon(): boolean;
|
|
45
|
+
/** Pid-alive check shared with the watchdog (EPERM still means alive). */
|
|
46
|
+
export declare function pidIsAlive(pid: number): boolean;
|
|
35
47
|
export declare function macosLaunchdPath(currentPath?: string, execPath?: string): string;
|
|
36
48
|
/** Whether the daemon has been registered to start automatically. */
|
|
49
|
+
/** Is the 5-minute watchdog scheduled task registered? (Windows-only feature.) */
|
|
50
|
+
export declare function isWatchdogTaskInstalled(): boolean;
|
|
37
51
|
export declare function isDaemonAutostartInstalled(): boolean;
|
|
38
52
|
export declare function daemonCommand(args: DaemonArgs, config: BotGuardConfig): Promise<void>;
|
package/dist/commands/daemon.js
CHANGED
|
@@ -36,7 +36,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.daemonDiscoverSweepArgs = daemonDiscoverSweepArgs;
|
|
37
37
|
exports.summarizeDiscoverStderr = summarizeDiscoverStderr;
|
|
38
38
|
exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
|
|
39
|
+
exports.daemonRuntimeState = daemonRuntimeState;
|
|
40
|
+
exports.spawnDetachedDaemon = spawnDetachedDaemon;
|
|
41
|
+
exports.pidIsAlive = pidIsAlive;
|
|
39
42
|
exports.macosLaunchdPath = macosLaunchdPath;
|
|
43
|
+
exports.isWatchdogTaskInstalled = isWatchdogTaskInstalled;
|
|
40
44
|
exports.isDaemonAutostartInstalled = isDaemonAutostartInstalled;
|
|
41
45
|
exports.daemonCommand = daemonCommand;
|
|
42
46
|
const fs = __importStar(require("fs"));
|
|
@@ -44,6 +48,8 @@ const os = __importStar(require("os"));
|
|
|
44
48
|
const path = __importStar(require("path"));
|
|
45
49
|
const child_process_1 = require("child_process");
|
|
46
50
|
const config_1 = require("../config");
|
|
51
|
+
const daemonForensics_1 = require("../daemonForensics");
|
|
52
|
+
const securityAgents_1 = require("../securityAgents");
|
|
47
53
|
const mcpGateway_1 = require("./mcpGateway");
|
|
48
54
|
const protectionRepair_1 = require("./protectionRepair");
|
|
49
55
|
const runtimeConfig_1 = require("../runtimeConfig");
|
|
@@ -351,6 +357,18 @@ async function runDaemon(args, config) {
|
|
|
351
357
|
const quiet = args.quiet === 'true';
|
|
352
358
|
log(`Daemon started (pid ${process.pid}, platform ${process.platform}).`);
|
|
353
359
|
log(`Shield: ${creds.shieldId || '(none — protect-only mode, telemetry disabled)'} API: ${creds.apiUrl}`);
|
|
360
|
+
// A machine enrolled with a DPAPI key whose decrypt fails (blocked
|
|
361
|
+
// PowerShell) lands here: shieldId readable, key not. Say so LOUDLY — this
|
|
362
|
+
// state looks identical to "machine off" on the dashboard otherwise.
|
|
363
|
+
if (creds.shieldId && !creds.shieldKey) {
|
|
364
|
+
const ph = (0, config_1.getPowershellHealth)();
|
|
365
|
+
log(`Shield key UNAVAILABLE${ph && !ph.spawnOk ? ' — powershell.exe could not start (EDR/policy block?), so the DPAPI-protected key cannot be decrypted' : ''}. Telemetry/control-plane sync disabled; retrying periodically.`);
|
|
366
|
+
}
|
|
367
|
+
// Crash forensics: was the previous daemon killed without a clean shutdown?
|
|
368
|
+
const postmortem = (0, daemonForensics_1.recordDaemonStart)({ version: cliVersion(), isPidAlive });
|
|
369
|
+
if (postmortem) {
|
|
370
|
+
log(`Previous daemon (v${postmortem.version || '?'}) died UNCLEANLY — last alive ${postmortem.lastAliveAt || 'unknown'}, no shutdown recorded. Likely killed externally (EDR/AV, OOM, or forced termination). Post-mortem recorded; reporting upstream.`);
|
|
371
|
+
}
|
|
354
372
|
// --- state ---------------------------------------------------------------
|
|
355
373
|
const watchers = new Map(); // directory -> watcher
|
|
356
374
|
const rootWatchers = new Map(); // admin protection roots -> recursive watcher
|
|
@@ -363,6 +381,34 @@ async function runDaemon(args, config) {
|
|
|
363
381
|
const executingActionIds = new Set();
|
|
364
382
|
/** Latest org auto-update policy seen on a bundle poll. */
|
|
365
383
|
let autoUpdatePolicy;
|
|
384
|
+
/** Heartbeat ticks spent without full credentials (drives recovery cadence). */
|
|
385
|
+
let credRecoveryTicks = 0;
|
|
386
|
+
/**
|
|
387
|
+
* A daemon that starts without a usable shield key (DPAPI decrypt blocked,
|
|
388
|
+
* config written after start by `login`) must NOT stay credential-less
|
|
389
|
+
* forever — that silences telemetry AND auto-update, stranding the machine.
|
|
390
|
+
* Retry quickly right after start, then hourly (each retry may spawn
|
|
391
|
+
* PowerShell for DPAPI, which we keep rare on machines that block it).
|
|
392
|
+
*/
|
|
393
|
+
const recoverCredentialsIfMissing = () => {
|
|
394
|
+
if (creds.shieldId && creds.shieldKey)
|
|
395
|
+
return;
|
|
396
|
+
credRecoveryTicks += 1;
|
|
397
|
+
if (credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
|
|
398
|
+
return;
|
|
399
|
+
try {
|
|
400
|
+
const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
|
|
401
|
+
shieldId: args.shieldId,
|
|
402
|
+
shieldKey: args.shieldKey,
|
|
403
|
+
apiUrl: args.apiUrl,
|
|
404
|
+
});
|
|
405
|
+
if (fresh.shieldId && fresh.shieldKey) {
|
|
406
|
+
Object.assign(creds, fresh);
|
|
407
|
+
log('Credentials recovered — telemetry and control-plane sync restored.');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
catch { /* next tick */ }
|
|
411
|
+
};
|
|
366
412
|
const reprotect = async (reasonPaths) => {
|
|
367
413
|
if (reprotecting || stopped)
|
|
368
414
|
return;
|
|
@@ -812,10 +858,16 @@ async function runDaemon(args, config) {
|
|
|
812
858
|
catch { /* offline — cached stance applies */ }
|
|
813
859
|
};
|
|
814
860
|
const heartbeat = async () => {
|
|
861
|
+
(0, daemonForensics_1.touchDaemonAlive)();
|
|
862
|
+
recoverCredentialsIfMissing();
|
|
815
863
|
if (!creds.shieldId)
|
|
816
864
|
return;
|
|
817
865
|
try {
|
|
818
866
|
const integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
867
|
+
// Report a previous daemon's unclean death exactly once.
|
|
868
|
+
const crash = (0, daemonForensics_1.readPostmortem)();
|
|
869
|
+
const unreportedCrash = crash && !crash.reportedAt ? crash : undefined;
|
|
870
|
+
const powershell = (0, config_1.getPowershellHealth)();
|
|
819
871
|
const result = await (0, telemetry_1.flushSpool)({
|
|
820
872
|
apiUrl: creds.apiUrl,
|
|
821
873
|
shieldId: creds.shieldId,
|
|
@@ -827,7 +879,21 @@ async function runDaemon(args, config) {
|
|
|
827
879
|
integrityReasons: integrity.reasons,
|
|
828
880
|
integrityCheckedAt: integrity.checkedAt,
|
|
829
881
|
desktopChatGuard: (0, desktopChatGuard_1.desktopChatGuardSupported)() && (0, desktopChatGuard_1.desktopChatGuardHealthy)(),
|
|
882
|
+
securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
|
|
883
|
+
powershellSpawnOk: powershell?.spawnOk,
|
|
884
|
+
powershellDecryptOk: powershell?.decryptOk,
|
|
885
|
+
lastCrash: unreportedCrash
|
|
886
|
+
? {
|
|
887
|
+
version: unreportedCrash.version,
|
|
888
|
+
startedAt: unreportedCrash.startedAt,
|
|
889
|
+
lastAliveAt: unreportedCrash.lastAliveAt,
|
|
890
|
+
detectedAt: unreportedCrash.detectedAt,
|
|
891
|
+
detectedBy: unreportedCrash.detectedBy,
|
|
892
|
+
}
|
|
893
|
+
: undefined,
|
|
830
894
|
});
|
|
895
|
+
if (result && unreportedCrash)
|
|
896
|
+
(0, daemonForensics_1.markPostmortemReported)();
|
|
831
897
|
if (result && result.accepted > 0)
|
|
832
898
|
log(`Heartbeat: flushed ${result.accepted} spooled event(s).`);
|
|
833
899
|
if (!integrity.ok)
|
|
@@ -840,6 +906,9 @@ async function runDaemon(args, config) {
|
|
|
840
906
|
await verifyPendingUpgrade();
|
|
841
907
|
};
|
|
842
908
|
// --- boot ---------------------------------------------------------------
|
|
909
|
+
// Self-heal autostart + watchdog tasks: machines installed by older versions
|
|
910
|
+
// (or where task creation failed once) must converge without a reinstall.
|
|
911
|
+
ensureWindowsAutostartHealthy(log);
|
|
843
912
|
const watched = refreshWatchTargets();
|
|
844
913
|
log(`Watching ${watched} config file(s) across ${watchers.size} director${watchers.size === 1 ? 'y' : 'ies'}.`);
|
|
845
914
|
// First: if this boot IS the post-upgrade relaunch, confirm the pending
|
|
@@ -962,6 +1031,9 @@ async function runDaemon(args, config) {
|
|
|
962
1031
|
watcher.close();
|
|
963
1032
|
for (const watcher of rootWatchers.values())
|
|
964
1033
|
watcher.close();
|
|
1034
|
+
// Forensics: this is an INTENTIONAL exit — without this stamp the next
|
|
1035
|
+
// boot would report it as an external kill.
|
|
1036
|
+
(0, daemonForensics_1.recordCleanExit)(signal);
|
|
965
1037
|
releasePidLock();
|
|
966
1038
|
process.exit(0);
|
|
967
1039
|
};
|
|
@@ -975,18 +1047,29 @@ async function runDaemon(args, config) {
|
|
|
975
1047
|
// Autostart install / uninstall / status
|
|
976
1048
|
// ---------------------------------------------------------------------------
|
|
977
1049
|
/** Hidden VBS launcher so the logon task doesn't flash a console window. */
|
|
978
|
-
function
|
|
1050
|
+
function writeHiddenLauncher(fileName, cliCommand) {
|
|
979
1051
|
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
980
1052
|
const dir = path.join(base, 'FullCourtDefense');
|
|
981
1053
|
fs.mkdirSync(dir, { recursive: true });
|
|
982
|
-
const vbs = path.join(dir,
|
|
1054
|
+
const vbs = path.join(dir, fileName);
|
|
983
1055
|
// Run node directly — routing through `cmd /c` breaks cmd's quote parsing
|
|
984
1056
|
// when node/CLI live under a path with spaces (e.g. Program Files).
|
|
985
|
-
const inner = `"${process.execPath}" "${cliEntry()}"
|
|
1057
|
+
const inner = `"${process.execPath}" "${cliEntry()}" ${cliCommand}`;
|
|
986
1058
|
const content = `CreateObject("WScript.Shell").Run "${inner.replace(/"/g, '""')}", 0, False\n`;
|
|
987
1059
|
fs.writeFileSync(vbs, content, 'utf8');
|
|
988
1060
|
return vbs;
|
|
989
1061
|
}
|
|
1062
|
+
function ensureWindowsLauncher() {
|
|
1063
|
+
return writeHiddenLauncher('daemon.vbs', 'daemon');
|
|
1064
|
+
}
|
|
1065
|
+
/**
|
|
1066
|
+
* Watchdog launcher: runs the `watchdog` command (liveness beacon + daemon
|
|
1067
|
+
* revival + crash post-mortem) instead of blindly starting the daemon — the
|
|
1068
|
+
* beacon is what lets the console tell "daemon dead" apart from "machine off".
|
|
1069
|
+
*/
|
|
1070
|
+
function ensureWindowsWatchdogLauncher() {
|
|
1071
|
+
return writeHiddenLauncher('watchdog.vbs', 'watchdog');
|
|
1072
|
+
}
|
|
990
1073
|
const WINDOWS_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
991
1074
|
const WINDOWS_RUN_VALUE = 'FullCourtDefenseDaemon';
|
|
992
1075
|
function isWindowsRunKeyInstalled() {
|
|
@@ -1047,28 +1130,102 @@ function installWindows() {
|
|
|
1047
1130
|
}
|
|
1048
1131
|
// Watchdog: logon triggers only START the daemon — nothing restarts it if it
|
|
1049
1132
|
// is killed or crashes mid-session. A time-based per-user task (no elevation
|
|
1050
|
-
// needed, unlike ONLOGON)
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
1053
|
-
// watchdog never fails the install.
|
|
1133
|
+
// needed, unlike ONLOGON) runs the `watchdog` command every 5 minutes: it
|
|
1134
|
+
// beacons daemon liveness to the console (so "daemon dead" is visible even
|
|
1135
|
+
// when the daemon can't say so itself) and revives a dead daemon within one
|
|
1136
|
+
// tick. Best-effort: a missing watchdog never fails the install.
|
|
1054
1137
|
(0, child_process_1.spawnSync)('schtasks', [
|
|
1055
|
-
'/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${
|
|
1138
|
+
'/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${ensureWindowsWatchdogLauncher()}"`,
|
|
1056
1139
|
'/SC', 'MINUTE', '/MO', '5', '/F',
|
|
1057
1140
|
], { stdio: 'ignore', windowsHide: true });
|
|
1058
1141
|
if (ok)
|
|
1059
1142
|
startDaemonNowWindows(vbs, taskOk);
|
|
1060
1143
|
return ok;
|
|
1061
1144
|
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Boot-time self-heal for the Windows autostart chain. Recreates a missing
|
|
1147
|
+
* daemon logon entry, refreshes the launcher scripts (paths move on upgrade),
|
|
1148
|
+
* and re-points the watchdog task at the `watchdog` launcher (older versions
|
|
1149
|
+
* pointed it at daemon.vbs, which restarts but never beacons). Creating
|
|
1150
|
+
* per-user MINUTE tasks needs no elevation; ONLOGON does — fall back to the
|
|
1151
|
+
* per-user Run key exactly like installWindows().
|
|
1152
|
+
*/
|
|
1153
|
+
function ensureWindowsAutostartHealthy(logFn) {
|
|
1154
|
+
if (process.platform !== 'win32')
|
|
1155
|
+
return;
|
|
1156
|
+
// Kill-switch for sandboxed test daemons — a test-spawned daemon must never
|
|
1157
|
+
// (re)write the developer machine's real scheduled tasks.
|
|
1158
|
+
if (process.env.FCD_DAEMON_NO_TASK_SELF_HEAL === '1')
|
|
1159
|
+
return;
|
|
1160
|
+
try {
|
|
1161
|
+
const vbs = ensureWindowsLauncher();
|
|
1162
|
+
const watchdogVbs = ensureWindowsWatchdogLauncher();
|
|
1163
|
+
const daemonTask = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1164
|
+
if (daemonTask.status !== 0 && !isWindowsRunKeyInstalled()) {
|
|
1165
|
+
const created = (0, child_process_1.spawnSync)('schtasks', [
|
|
1166
|
+
'/Create', '/TN', TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
|
|
1167
|
+
'/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
|
|
1168
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
1169
|
+
if (created.status !== 0) {
|
|
1170
|
+
(0, child_process_1.spawnSync)('reg', [
|
|
1171
|
+
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1172
|
+
'/d', `wscript.exe "${vbs}"`, '/f',
|
|
1173
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1174
|
+
}
|
|
1175
|
+
logFn('Self-heal: daemon autostart entry was missing — recreated.');
|
|
1176
|
+
}
|
|
1177
|
+
const watchdog = (0, child_process_1.spawnSync)('schtasks', [
|
|
1178
|
+
'/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${watchdogVbs}"`,
|
|
1179
|
+
'/SC', 'MINUTE', '/MO', '5', '/F',
|
|
1180
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
1181
|
+
if (watchdog.status !== 0) {
|
|
1182
|
+
logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
catch { /* self-heal is best-effort — never blocks daemon boot */ }
|
|
1186
|
+
}
|
|
1187
|
+
function daemonRuntimeState() {
|
|
1188
|
+
try {
|
|
1189
|
+
const pid = Number(fs.readFileSync(pidFile(), 'utf8').trim());
|
|
1190
|
+
if (Number.isFinite(pid) && pid > 0 && isPidAlive(pid)) {
|
|
1191
|
+
const meta = readDaemonMeta();
|
|
1192
|
+
const owned = meta?.pid === pid;
|
|
1193
|
+
return { alive: true, pid, version: owned ? meta?.version : undefined, startedAt: owned ? meta?.startedAt : undefined };
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
catch { /* no pid file — not running */ }
|
|
1197
|
+
return { alive: false };
|
|
1198
|
+
}
|
|
1199
|
+
/** Relaunch the daemon outside our process tree (plain node — no shells). */
|
|
1200
|
+
function spawnDetachedDaemon() {
|
|
1201
|
+
try {
|
|
1202
|
+
const child = (0, child_process_1.spawn)(process.execPath, [cliEntry(), 'daemon'], {
|
|
1203
|
+
detached: true,
|
|
1204
|
+
stdio: 'ignore',
|
|
1205
|
+
windowsHide: true,
|
|
1206
|
+
});
|
|
1207
|
+
child.unref();
|
|
1208
|
+
return true;
|
|
1209
|
+
}
|
|
1210
|
+
catch {
|
|
1211
|
+
return false;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
/** Pid-alive check shared with the watchdog (EPERM still means alive). */
|
|
1215
|
+
function pidIsAlive(pid) {
|
|
1216
|
+
return isPidAlive(pid);
|
|
1217
|
+
}
|
|
1062
1218
|
function uninstallWindows() {
|
|
1063
1219
|
const task = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
|
|
1064
1220
|
(0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', WATCHDOG_TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
|
|
1065
1221
|
const reg = (0, child_process_1.spawnSync)('reg', ['delete', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/f'], { stdio: 'ignore', windowsHide: true });
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
fs.unlinkSync(
|
|
1222
|
+
const launcherDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense');
|
|
1223
|
+
for (const launcher of ['daemon.vbs', 'watchdog.vbs']) {
|
|
1224
|
+
try {
|
|
1225
|
+
fs.unlinkSync(path.join(launcherDir, launcher));
|
|
1226
|
+
}
|
|
1227
|
+
catch { /* ignore */ }
|
|
1070
1228
|
}
|
|
1071
|
-
catch { /* ignore */ }
|
|
1072
1229
|
return task.status === 0 || reg.status === 0;
|
|
1073
1230
|
}
|
|
1074
1231
|
function launchdPlistPath() {
|
|
@@ -1134,6 +1291,17 @@ function systemdUnitPath() {
|
|
|
1134
1291
|
return path.join(base, 'systemd', 'user', SYSTEMD_UNIT);
|
|
1135
1292
|
}
|
|
1136
1293
|
/** Whether the daemon has been registered to start automatically. */
|
|
1294
|
+
/** Is the 5-minute watchdog scheduled task registered? (Windows-only feature.) */
|
|
1295
|
+
function isWatchdogTaskInstalled() {
|
|
1296
|
+
if (process.platform !== 'win32')
|
|
1297
|
+
return false;
|
|
1298
|
+
const result = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', WATCHDOG_TASK_NAME], {
|
|
1299
|
+
stdio: 'ignore',
|
|
1300
|
+
timeout: 5_000,
|
|
1301
|
+
windowsHide: true,
|
|
1302
|
+
});
|
|
1303
|
+
return result.status === 0;
|
|
1304
|
+
}
|
|
1137
1305
|
function isDaemonAutostartInstalled() {
|
|
1138
1306
|
if (process.platform === 'win32') {
|
|
1139
1307
|
const result = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], {
|
|
@@ -64,6 +64,8 @@ export interface DesktopDiscoveryHost {
|
|
|
64
64
|
probeMode: 'config' | 'deep';
|
|
65
65
|
/** Windows-only: PowerShell audit coverage (ScriptBlock Logging + Transcription). */
|
|
66
66
|
shellAudit?: ShellAuditReport;
|
|
67
|
+
/** Windows-only: EDR/AV products detected on this machine (read-only service probe). */
|
|
68
|
+
securityAgents?: string[];
|
|
67
69
|
/** True when the scan ran on an ephemeral CI runner (GitHub Actions, GitLab, etc.) — not a user machine. */
|
|
68
70
|
isCi?: boolean;
|
|
69
71
|
}
|
|
@@ -51,6 +51,7 @@ const discoverAgentFiles_1 = require("./discoverAgentFiles");
|
|
|
51
51
|
const discoverBlastRadius_1 = require("./discoverBlastRadius");
|
|
52
52
|
const discoverSecrets_1 = require("./discoverSecrets");
|
|
53
53
|
const windowsAudit_1 = require("./windowsAudit");
|
|
54
|
+
const securityAgents_1 = require("../securityAgents");
|
|
54
55
|
const discoveryMarker_1 = require("../discoveryMarker");
|
|
55
56
|
const machineIdentity_1 = require("../machineIdentity");
|
|
56
57
|
const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
|
|
@@ -126,6 +127,7 @@ function buildHostMetadata(userEmail, probeMode = 'config') {
|
|
|
126
127
|
scannedAt: new Date().toISOString(),
|
|
127
128
|
probeMode,
|
|
128
129
|
shellAudit: (0, windowsAudit_1.getShellAuditReport)(),
|
|
130
|
+
securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
|
|
129
131
|
isCi: isCiEnvironment() || undefined,
|
|
130
132
|
};
|
|
131
133
|
}
|
package/dist/commands/onboard.js
CHANGED
|
@@ -53,6 +53,8 @@ const machineIdentity_1 = require("../machineIdentity");
|
|
|
53
53
|
const appDetection_1 = require("../appDetection");
|
|
54
54
|
const integrity_1 = require("../integrity");
|
|
55
55
|
const telemetry_1 = require("../telemetry");
|
|
56
|
+
const envDiagnostics_1 = require("../envDiagnostics");
|
|
57
|
+
const daemonForensics_1 = require("../daemonForensics");
|
|
56
58
|
const onboardingJournal_1 = require("./onboardingJournal");
|
|
57
59
|
const GREEN = '\x1b[32m';
|
|
58
60
|
const RED = '\x1b[31m';
|
|
@@ -246,7 +248,7 @@ async function onboardCommand(args, config) {
|
|
|
246
248
|
const apiUrl = creds.apiUrl;
|
|
247
249
|
const token = (args.token || process.env.FCD_ENROLL_TOKEN || '').trim();
|
|
248
250
|
// 1. Connectivity.
|
|
249
|
-
console.log(`${BOLD}[1/
|
|
251
|
+
console.log(`${BOLD}[1/7] Checking compatibility and connectivity…${RESET}`);
|
|
250
252
|
mark('preflight', 'running');
|
|
251
253
|
// A stale cmd-guard AutoRun (registry points at a deleted autorun script)
|
|
252
254
|
// poisons every cmd.exe exit code on the machine — child spawns and the MSI's
|
|
@@ -280,7 +282,7 @@ async function onboardCommand(args, config) {
|
|
|
280
282
|
}
|
|
281
283
|
mark('preflight', 'completed');
|
|
282
284
|
// 2. Enrollment.
|
|
283
|
-
console.log(`\n${BOLD}[2/
|
|
285
|
+
console.log(`\n${BOLD}[2/7] Enrolling this machine…${RESET}`);
|
|
284
286
|
mark('enrollment', 'running');
|
|
285
287
|
const alreadyEnrolled = Boolean(creds.shieldId && creds.shieldKey);
|
|
286
288
|
if (dryRun) {
|
|
@@ -305,7 +307,7 @@ async function onboardCommand(args, config) {
|
|
|
305
307
|
// Re-read config: login just wrote fresh shield credentials to ~/.fullcourtdefense.yml.
|
|
306
308
|
const freshConfig = (0, config_1.loadConfig)();
|
|
307
309
|
// 3. Protection.
|
|
308
|
-
console.log(`\n${BOLD}[3/
|
|
310
|
+
console.log(`\n${BOLD}[3/7] Installing protection (install-all)…${RESET}`);
|
|
309
311
|
if (dryRun) {
|
|
310
312
|
mark('protection', 'skipped', 'would install MCP gateways, IDE hooks, and terminal guards');
|
|
311
313
|
}
|
|
@@ -334,7 +336,7 @@ async function onboardCommand(args, config) {
|
|
|
334
336
|
return;
|
|
335
337
|
}
|
|
336
338
|
}
|
|
337
|
-
console.log(`\n${BOLD}[4/
|
|
339
|
+
console.log(`\n${BOLD}[4/7] Installing background protection…${RESET}`);
|
|
338
340
|
if (dryRun || args.noDaemon === 'true') {
|
|
339
341
|
mark('daemon', 'skipped', dryRun ? 'would register the resident daemon' : 'disabled by --no-daemon true');
|
|
340
342
|
}
|
|
@@ -355,7 +357,7 @@ async function onboardCommand(args, config) {
|
|
|
355
357
|
}
|
|
356
358
|
// Discovery and scheduling are useful fleet telemetry, but must never mark a
|
|
357
359
|
// device as unprotected or prevent a repair of the enforcement surfaces.
|
|
358
|
-
console.log(`\n${BOLD}[5/
|
|
360
|
+
console.log(`\n${BOLD}[5/7] Recording optional discovery and schedule…${RESET}`);
|
|
359
361
|
if (dryRun || args.discover === 'false') {
|
|
360
362
|
mark('discovery', 'skipped', dryRun ? 'would upload desktop discovery' : 'disabled by --discover false');
|
|
361
363
|
}
|
|
@@ -383,8 +385,38 @@ async function onboardCommand(args, config) {
|
|
|
383
385
|
mark('schedule', 'failed', undefined, error instanceof Error ? error.message : String(error));
|
|
384
386
|
}
|
|
385
387
|
}
|
|
386
|
-
// 4.
|
|
387
|
-
|
|
388
|
+
// 4. Environment diagnostics — the "will this machine stay alive?" snapshot.
|
|
389
|
+
// Runs while we still have an interactive session on the (possibly EDR-
|
|
390
|
+
// hardened) machine: EDR/AV inventory, PowerShell/DPAPI health, daemon
|
|
391
|
+
// kill-detection (alive now AND still alive seconds later), watchdog task,
|
|
392
|
+
// and any crash post-mortem from a previous install. Never critical — the
|
|
393
|
+
// findings ship to the fleet console in the first heartbeat below.
|
|
394
|
+
console.log(`\n${BOLD}[6/7] Collecting environment diagnostics…${RESET}`);
|
|
395
|
+
let envDiag;
|
|
396
|
+
if (dryRun) {
|
|
397
|
+
mark('diagnostics', 'skipped', 'would probe EDR/AV, PowerShell health, and daemon liveness');
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
mark('diagnostics', 'running');
|
|
401
|
+
try {
|
|
402
|
+
envDiag = await (0, envDiagnostics_1.collectEnvironmentDiagnostics)({ recheckDelayMs: args.noDaemon === 'true' ? 0 : 5_000 });
|
|
403
|
+
const summary = (0, envDiagnostics_1.summarizeEnvironmentDiagnostics)(envDiag);
|
|
404
|
+
console.log(` ${DIM}${summary}${RESET}`);
|
|
405
|
+
if ((0, envDiagnostics_1.environmentLooksHealthy)(envDiag)) {
|
|
406
|
+
mark('diagnostics', 'completed', summary);
|
|
407
|
+
}
|
|
408
|
+
else {
|
|
409
|
+
// Non-critical: a hostile environment is REPORTED, not treated as an
|
|
410
|
+
// install failure — the protection surfaces below are what gate.
|
|
411
|
+
mark('diagnostics', 'failed', summary, summary);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
mark('diagnostics', 'failed', undefined, error instanceof Error ? error.message : String(error));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// 5. Verification.
|
|
419
|
+
console.log(`\n${BOLD}[7/7] Verifying protection surfaces…${RESET}`);
|
|
388
420
|
mark('verification', 'running');
|
|
389
421
|
const postCreds = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(), { apiUrl });
|
|
390
422
|
// Immediate heartbeat: the machine must appear ONLINE in the fleet console
|
|
@@ -409,9 +441,31 @@ async function onboardCommand(args, config) {
|
|
|
409
441
|
integrityOk: integrity.ok,
|
|
410
442
|
integrityReasons: integrity.reasons,
|
|
411
443
|
integrityCheckedAt: integrity.checkedAt,
|
|
444
|
+
// Environment diagnostics ride the FIRST heartbeat: the fleet console
|
|
445
|
+
// knows this machine's EDR/AV products, PowerShell health, and any
|
|
446
|
+
// previous crash the moment onboarding finishes — before the machine
|
|
447
|
+
// has any chance to go silent.
|
|
448
|
+
securityAgents: envDiag?.securityAgents,
|
|
449
|
+
powershellSpawnOk: envDiag?.powershellSpawnOk,
|
|
450
|
+
powershellDecryptOk: envDiag?.powershellDecryptOk,
|
|
451
|
+
lastCrash: envDiag?.lastCrash
|
|
452
|
+
? {
|
|
453
|
+
version: envDiag.lastCrash.version,
|
|
454
|
+
startedAt: envDiag.lastCrash.startedAt,
|
|
455
|
+
lastAliveAt: envDiag.lastCrash.lastAliveAt,
|
|
456
|
+
detectedAt: envDiag.lastCrash.detectedAt,
|
|
457
|
+
detectedBy: envDiag.lastCrash.detectedBy,
|
|
458
|
+
}
|
|
459
|
+
: undefined,
|
|
412
460
|
timeoutMs: 8_000,
|
|
413
461
|
});
|
|
414
462
|
firstHeartbeatOk = flush !== null;
|
|
463
|
+
if (firstHeartbeatOk && envDiag?.lastCrash) {
|
|
464
|
+
try {
|
|
465
|
+
(0, daemonForensics_1.markPostmortemReported)();
|
|
466
|
+
}
|
|
467
|
+
catch { /* best-effort */ }
|
|
468
|
+
}
|
|
415
469
|
}
|
|
416
470
|
catch { /* the daemon heartbeat remains the backstop */ }
|
|
417
471
|
}
|
|
@@ -455,6 +509,41 @@ async function onboardCommand(args, config) {
|
|
|
455
509
|
? 'this machine now shows ONLINE in the fleet console'
|
|
456
510
|
: dryRun ? 'skipped (dry run)' : 'not delivered yet — the daemon retries within 5 minutes',
|
|
457
511
|
},
|
|
512
|
+
// --- environment diagnostics (informational: report, never block) -------
|
|
513
|
+
{
|
|
514
|
+
label: 'Daemon survived post-install liveness check',
|
|
515
|
+
ok: envDiag ? envDiag.daemonSurvivedRecheck !== false && envDiag.daemonAlive : true,
|
|
516
|
+
optional: true,
|
|
517
|
+
detail: !envDiag
|
|
518
|
+
? 'skipped (dry run)'
|
|
519
|
+
: envDiag.daemonSurvivedRecheck === false
|
|
520
|
+
? 'daemon was KILLED seconds after starting — security software on this machine is terminating it'
|
|
521
|
+
: envDiag.daemonAlive ? `pid ${envDiag.daemonPid}` : 'daemon not running — the watchdog will start it within 5 minutes',
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
label: 'Watchdog liveness beacon (5-minute health reports)',
|
|
525
|
+
ok: envDiag ? envDiag.watchdogTaskInstalled : true,
|
|
526
|
+
optional: true,
|
|
527
|
+
detail: envDiag && !envDiag.watchdogTaskInstalled && process.platform === 'win32'
|
|
528
|
+
? 'task not registered — the daemon re-creates it at every start'
|
|
529
|
+
: undefined,
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
label: 'PowerShell available for secure key storage',
|
|
533
|
+
ok: envDiag ? envDiag.powershellSpawnOk !== false : true,
|
|
534
|
+
optional: true,
|
|
535
|
+
detail: envDiag?.powershellSpawnOk === false
|
|
536
|
+
? 'PowerShell is blocked on this machine (security policy) — credentials use a PowerShell-free path'
|
|
537
|
+
: envDiag?.powershellDecryptOk === false ? 'PowerShell runs but the stored key did not decrypt' : undefined,
|
|
538
|
+
},
|
|
539
|
+
{
|
|
540
|
+
label: 'Security software inventory',
|
|
541
|
+
ok: true,
|
|
542
|
+
optional: true,
|
|
543
|
+
detail: envDiag?.securityAgents
|
|
544
|
+
? envDiag.securityAgents.length ? envDiag.securityAgents.join(', ') : 'none detected'
|
|
545
|
+
: 'not probed (non-Windows or dry run)',
|
|
546
|
+
},
|
|
458
547
|
];
|
|
459
548
|
console.log('');
|
|
460
549
|
for (const check of checks)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type OnboardingStepName = 'preflight' | 'enrollment' | 'protection' | 'daemon' | 'discovery' | 'schedule' | 'verification';
|
|
1
|
+
export type OnboardingStepName = 'preflight' | 'enrollment' | 'protection' | 'daemon' | 'discovery' | 'schedule' | 'diagnostics' | 'verification';
|
|
2
2
|
export type OnboardingStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped';
|
|
3
3
|
export interface OnboardingStep {
|
|
4
4
|
status: OnboardingStepStatus;
|
|
@@ -48,6 +48,10 @@ const STEP_DEFINITIONS = [
|
|
|
48
48
|
['daemon', true],
|
|
49
49
|
['discovery', false],
|
|
50
50
|
['schedule', false],
|
|
51
|
+
// Post-install environment diagnostics (EDR/AV inventory, PowerShell
|
|
52
|
+
// health, watchdog + daemon liveness). Never critical: diagnostics report
|
|
53
|
+
// problems, they must not BE one.
|
|
54
|
+
['diagnostics', false],
|
|
51
55
|
['verification', true],
|
|
52
56
|
];
|
|
53
57
|
function onboardingJournalPath(home = os.homedir()) {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { BotGuardConfig } from '../config';
|
|
2
|
+
/**
|
|
3
|
+
* Watchdog tick — runs OUTSIDE the daemon (Windows scheduled task, every
|
|
4
|
+
* 5 minutes) so machine health stays observable even when the daemon itself
|
|
5
|
+
* is dead. Three jobs, all best-effort, never throws, exits 0:
|
|
6
|
+
*
|
|
7
|
+
* 1. Liveness: is the daemon running? If not, detect + persist the unclean
|
|
8
|
+
* death post-mortem (the daemon can no longer say how it died).
|
|
9
|
+
* 2. Revival: relaunch a dead daemon (plain detached node — no shells).
|
|
10
|
+
* 3. Beacon: report daemon state to the console so the dashboard can tell
|
|
11
|
+
* "daemon dead since 11:02" apart from "machine off".
|
|
12
|
+
*
|
|
13
|
+
* Shell-quiet by design: credential resolution NEVER attempts the DPAPI/
|
|
14
|
+
* PowerShell decrypt here (this runs every 5 minutes — exactly the hourly-
|
|
15
|
+
* PowerShell pattern that made EDRs kill us). On DPAPI-enrolled machines the
|
|
16
|
+
* beacon goes out WITHOUT a shield key; the backend accepts it on a
|
|
17
|
+
* constrained, rate-limited path that can only update watchdog liveness
|
|
18
|
+
* fields of an already-enrolled machine.
|
|
19
|
+
*/
|
|
20
|
+
export interface WatchdogArgs {
|
|
21
|
+
apiUrl?: string;
|
|
22
|
+
shieldId?: string;
|
|
23
|
+
shieldKey?: string;
|
|
24
|
+
}
|
|
25
|
+
export declare function watchdogCommand(args: WatchdogArgs, config: BotGuardConfig): Promise<void>;
|