fullcourtdefense-cli 1.21.28 → 1.21.29
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 +12 -0
- package/dist/commands/daemon.js +169 -13
- package/dist/commands/discover.d.ts +2 -0
- package/dist/commands/discover.js +2 -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/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,6 +32,18 @@ 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. */
|
|
37
49
|
export declare function isDaemonAutostartInstalled(): boolean;
|
package/dist/commands/daemon.js
CHANGED
|
@@ -36,6 +36,9 @@ 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;
|
|
40
43
|
exports.isDaemonAutostartInstalled = isDaemonAutostartInstalled;
|
|
41
44
|
exports.daemonCommand = daemonCommand;
|
|
@@ -44,6 +47,8 @@ const os = __importStar(require("os"));
|
|
|
44
47
|
const path = __importStar(require("path"));
|
|
45
48
|
const child_process_1 = require("child_process");
|
|
46
49
|
const config_1 = require("../config");
|
|
50
|
+
const daemonForensics_1 = require("../daemonForensics");
|
|
51
|
+
const securityAgents_1 = require("../securityAgents");
|
|
47
52
|
const mcpGateway_1 = require("./mcpGateway");
|
|
48
53
|
const protectionRepair_1 = require("./protectionRepair");
|
|
49
54
|
const runtimeConfig_1 = require("../runtimeConfig");
|
|
@@ -351,6 +356,18 @@ async function runDaemon(args, config) {
|
|
|
351
356
|
const quiet = args.quiet === 'true';
|
|
352
357
|
log(`Daemon started (pid ${process.pid}, platform ${process.platform}).`);
|
|
353
358
|
log(`Shield: ${creds.shieldId || '(none — protect-only mode, telemetry disabled)'} API: ${creds.apiUrl}`);
|
|
359
|
+
// A machine enrolled with a DPAPI key whose decrypt fails (blocked
|
|
360
|
+
// PowerShell) lands here: shieldId readable, key not. Say so LOUDLY — this
|
|
361
|
+
// state looks identical to "machine off" on the dashboard otherwise.
|
|
362
|
+
if (creds.shieldId && !creds.shieldKey) {
|
|
363
|
+
const ph = (0, config_1.getPowershellHealth)();
|
|
364
|
+
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.`);
|
|
365
|
+
}
|
|
366
|
+
// Crash forensics: was the previous daemon killed without a clean shutdown?
|
|
367
|
+
const postmortem = (0, daemonForensics_1.recordDaemonStart)({ version: cliVersion(), isPidAlive });
|
|
368
|
+
if (postmortem) {
|
|
369
|
+
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.`);
|
|
370
|
+
}
|
|
354
371
|
// --- state ---------------------------------------------------------------
|
|
355
372
|
const watchers = new Map(); // directory -> watcher
|
|
356
373
|
const rootWatchers = new Map(); // admin protection roots -> recursive watcher
|
|
@@ -363,6 +380,34 @@ async function runDaemon(args, config) {
|
|
|
363
380
|
const executingActionIds = new Set();
|
|
364
381
|
/** Latest org auto-update policy seen on a bundle poll. */
|
|
365
382
|
let autoUpdatePolicy;
|
|
383
|
+
/** Heartbeat ticks spent without full credentials (drives recovery cadence). */
|
|
384
|
+
let credRecoveryTicks = 0;
|
|
385
|
+
/**
|
|
386
|
+
* A daemon that starts without a usable shield key (DPAPI decrypt blocked,
|
|
387
|
+
* config written after start by `login`) must NOT stay credential-less
|
|
388
|
+
* forever — that silences telemetry AND auto-update, stranding the machine.
|
|
389
|
+
* Retry quickly right after start, then hourly (each retry may spawn
|
|
390
|
+
* PowerShell for DPAPI, which we keep rare on machines that block it).
|
|
391
|
+
*/
|
|
392
|
+
const recoverCredentialsIfMissing = () => {
|
|
393
|
+
if (creds.shieldId && creds.shieldKey)
|
|
394
|
+
return;
|
|
395
|
+
credRecoveryTicks += 1;
|
|
396
|
+
if (credRecoveryTicks > 3 && credRecoveryTicks % 12 !== 0)
|
|
397
|
+
return;
|
|
398
|
+
try {
|
|
399
|
+
const fresh = (0, config_1.resolveCliCredentials)((0, config_1.loadConfig)(args.config), {
|
|
400
|
+
shieldId: args.shieldId,
|
|
401
|
+
shieldKey: args.shieldKey,
|
|
402
|
+
apiUrl: args.apiUrl,
|
|
403
|
+
});
|
|
404
|
+
if (fresh.shieldId && fresh.shieldKey) {
|
|
405
|
+
Object.assign(creds, fresh);
|
|
406
|
+
log('Credentials recovered — telemetry and control-plane sync restored.');
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch { /* next tick */ }
|
|
410
|
+
};
|
|
366
411
|
const reprotect = async (reasonPaths) => {
|
|
367
412
|
if (reprotecting || stopped)
|
|
368
413
|
return;
|
|
@@ -812,10 +857,16 @@ async function runDaemon(args, config) {
|
|
|
812
857
|
catch { /* offline — cached stance applies */ }
|
|
813
858
|
};
|
|
814
859
|
const heartbeat = async () => {
|
|
860
|
+
(0, daemonForensics_1.touchDaemonAlive)();
|
|
861
|
+
recoverCredentialsIfMissing();
|
|
815
862
|
if (!creds.shieldId)
|
|
816
863
|
return;
|
|
817
864
|
try {
|
|
818
865
|
const integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
866
|
+
// Report a previous daemon's unclean death exactly once.
|
|
867
|
+
const crash = (0, daemonForensics_1.readPostmortem)();
|
|
868
|
+
const unreportedCrash = crash && !crash.reportedAt ? crash : undefined;
|
|
869
|
+
const powershell = (0, config_1.getPowershellHealth)();
|
|
819
870
|
const result = await (0, telemetry_1.flushSpool)({
|
|
820
871
|
apiUrl: creds.apiUrl,
|
|
821
872
|
shieldId: creds.shieldId,
|
|
@@ -827,7 +878,21 @@ async function runDaemon(args, config) {
|
|
|
827
878
|
integrityReasons: integrity.reasons,
|
|
828
879
|
integrityCheckedAt: integrity.checkedAt,
|
|
829
880
|
desktopChatGuard: (0, desktopChatGuard_1.desktopChatGuardSupported)() && (0, desktopChatGuard_1.desktopChatGuardHealthy)(),
|
|
881
|
+
securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
|
|
882
|
+
powershellSpawnOk: powershell?.spawnOk,
|
|
883
|
+
powershellDecryptOk: powershell?.decryptOk,
|
|
884
|
+
lastCrash: unreportedCrash
|
|
885
|
+
? {
|
|
886
|
+
version: unreportedCrash.version,
|
|
887
|
+
startedAt: unreportedCrash.startedAt,
|
|
888
|
+
lastAliveAt: unreportedCrash.lastAliveAt,
|
|
889
|
+
detectedAt: unreportedCrash.detectedAt,
|
|
890
|
+
detectedBy: unreportedCrash.detectedBy,
|
|
891
|
+
}
|
|
892
|
+
: undefined,
|
|
830
893
|
});
|
|
894
|
+
if (result && unreportedCrash)
|
|
895
|
+
(0, daemonForensics_1.markPostmortemReported)();
|
|
831
896
|
if (result && result.accepted > 0)
|
|
832
897
|
log(`Heartbeat: flushed ${result.accepted} spooled event(s).`);
|
|
833
898
|
if (!integrity.ok)
|
|
@@ -840,6 +905,9 @@ async function runDaemon(args, config) {
|
|
|
840
905
|
await verifyPendingUpgrade();
|
|
841
906
|
};
|
|
842
907
|
// --- boot ---------------------------------------------------------------
|
|
908
|
+
// Self-heal autostart + watchdog tasks: machines installed by older versions
|
|
909
|
+
// (or where task creation failed once) must converge without a reinstall.
|
|
910
|
+
ensureWindowsAutostartHealthy(log);
|
|
843
911
|
const watched = refreshWatchTargets();
|
|
844
912
|
log(`Watching ${watched} config file(s) across ${watchers.size} director${watchers.size === 1 ? 'y' : 'ies'}.`);
|
|
845
913
|
// First: if this boot IS the post-upgrade relaunch, confirm the pending
|
|
@@ -962,6 +1030,9 @@ async function runDaemon(args, config) {
|
|
|
962
1030
|
watcher.close();
|
|
963
1031
|
for (const watcher of rootWatchers.values())
|
|
964
1032
|
watcher.close();
|
|
1033
|
+
// Forensics: this is an INTENTIONAL exit — without this stamp the next
|
|
1034
|
+
// boot would report it as an external kill.
|
|
1035
|
+
(0, daemonForensics_1.recordCleanExit)(signal);
|
|
965
1036
|
releasePidLock();
|
|
966
1037
|
process.exit(0);
|
|
967
1038
|
};
|
|
@@ -975,18 +1046,29 @@ async function runDaemon(args, config) {
|
|
|
975
1046
|
// Autostart install / uninstall / status
|
|
976
1047
|
// ---------------------------------------------------------------------------
|
|
977
1048
|
/** Hidden VBS launcher so the logon task doesn't flash a console window. */
|
|
978
|
-
function
|
|
1049
|
+
function writeHiddenLauncher(fileName, cliCommand) {
|
|
979
1050
|
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
980
1051
|
const dir = path.join(base, 'FullCourtDefense');
|
|
981
1052
|
fs.mkdirSync(dir, { recursive: true });
|
|
982
|
-
const vbs = path.join(dir,
|
|
1053
|
+
const vbs = path.join(dir, fileName);
|
|
983
1054
|
// Run node directly — routing through `cmd /c` breaks cmd's quote parsing
|
|
984
1055
|
// when node/CLI live under a path with spaces (e.g. Program Files).
|
|
985
|
-
const inner = `"${process.execPath}" "${cliEntry()}"
|
|
1056
|
+
const inner = `"${process.execPath}" "${cliEntry()}" ${cliCommand}`;
|
|
986
1057
|
const content = `CreateObject("WScript.Shell").Run "${inner.replace(/"/g, '""')}", 0, False\n`;
|
|
987
1058
|
fs.writeFileSync(vbs, content, 'utf8');
|
|
988
1059
|
return vbs;
|
|
989
1060
|
}
|
|
1061
|
+
function ensureWindowsLauncher() {
|
|
1062
|
+
return writeHiddenLauncher('daemon.vbs', 'daemon');
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* Watchdog launcher: runs the `watchdog` command (liveness beacon + daemon
|
|
1066
|
+
* revival + crash post-mortem) instead of blindly starting the daemon — the
|
|
1067
|
+
* beacon is what lets the console tell "daemon dead" apart from "machine off".
|
|
1068
|
+
*/
|
|
1069
|
+
function ensureWindowsWatchdogLauncher() {
|
|
1070
|
+
return writeHiddenLauncher('watchdog.vbs', 'watchdog');
|
|
1071
|
+
}
|
|
990
1072
|
const WINDOWS_RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
991
1073
|
const WINDOWS_RUN_VALUE = 'FullCourtDefenseDaemon';
|
|
992
1074
|
function isWindowsRunKeyInstalled() {
|
|
@@ -1047,28 +1129,102 @@ function installWindows() {
|
|
|
1047
1129
|
}
|
|
1048
1130
|
// Watchdog: logon triggers only START the daemon — nothing restarts it if it
|
|
1049
1131
|
// 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.
|
|
1132
|
+
// needed, unlike ONLOGON) runs the `watchdog` command every 5 minutes: it
|
|
1133
|
+
// beacons daemon liveness to the console (so "daemon dead" is visible even
|
|
1134
|
+
// when the daemon can't say so itself) and revives a dead daemon within one
|
|
1135
|
+
// tick. Best-effort: a missing watchdog never fails the install.
|
|
1054
1136
|
(0, child_process_1.spawnSync)('schtasks', [
|
|
1055
|
-
'/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${
|
|
1137
|
+
'/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${ensureWindowsWatchdogLauncher()}"`,
|
|
1056
1138
|
'/SC', 'MINUTE', '/MO', '5', '/F',
|
|
1057
1139
|
], { stdio: 'ignore', windowsHide: true });
|
|
1058
1140
|
if (ok)
|
|
1059
1141
|
startDaemonNowWindows(vbs, taskOk);
|
|
1060
1142
|
return ok;
|
|
1061
1143
|
}
|
|
1144
|
+
/**
|
|
1145
|
+
* Boot-time self-heal for the Windows autostart chain. Recreates a missing
|
|
1146
|
+
* daemon logon entry, refreshes the launcher scripts (paths move on upgrade),
|
|
1147
|
+
* and re-points the watchdog task at the `watchdog` launcher (older versions
|
|
1148
|
+
* pointed it at daemon.vbs, which restarts but never beacons). Creating
|
|
1149
|
+
* per-user MINUTE tasks needs no elevation; ONLOGON does — fall back to the
|
|
1150
|
+
* per-user Run key exactly like installWindows().
|
|
1151
|
+
*/
|
|
1152
|
+
function ensureWindowsAutostartHealthy(logFn) {
|
|
1153
|
+
if (process.platform !== 'win32')
|
|
1154
|
+
return;
|
|
1155
|
+
// Kill-switch for sandboxed test daemons — a test-spawned daemon must never
|
|
1156
|
+
// (re)write the developer machine's real scheduled tasks.
|
|
1157
|
+
if (process.env.FCD_DAEMON_NO_TASK_SELF_HEAL === '1')
|
|
1158
|
+
return;
|
|
1159
|
+
try {
|
|
1160
|
+
const vbs = ensureWindowsLauncher();
|
|
1161
|
+
const watchdogVbs = ensureWindowsWatchdogLauncher();
|
|
1162
|
+
const daemonTask = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1163
|
+
if (daemonTask.status !== 0 && !isWindowsRunKeyInstalled()) {
|
|
1164
|
+
const created = (0, child_process_1.spawnSync)('schtasks', [
|
|
1165
|
+
'/Create', '/TN', TASK_NAME, '/TR', `wscript.exe "${vbs}"`,
|
|
1166
|
+
'/SC', 'ONLOGON', '/F', '/RL', 'LIMITED',
|
|
1167
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
1168
|
+
if (created.status !== 0) {
|
|
1169
|
+
(0, child_process_1.spawnSync)('reg', [
|
|
1170
|
+
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1171
|
+
'/d', `wscript.exe "${vbs}"`, '/f',
|
|
1172
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1173
|
+
}
|
|
1174
|
+
logFn('Self-heal: daemon autostart entry was missing — recreated.');
|
|
1175
|
+
}
|
|
1176
|
+
const watchdog = (0, child_process_1.spawnSync)('schtasks', [
|
|
1177
|
+
'/Create', '/TN', WATCHDOG_TASK_NAME, '/TR', `wscript.exe "${watchdogVbs}"`,
|
|
1178
|
+
'/SC', 'MINUTE', '/MO', '5', '/F',
|
|
1179
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
1180
|
+
if (watchdog.status !== 0) {
|
|
1181
|
+
logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
catch { /* self-heal is best-effort — never blocks daemon boot */ }
|
|
1185
|
+
}
|
|
1186
|
+
function daemonRuntimeState() {
|
|
1187
|
+
try {
|
|
1188
|
+
const pid = Number(fs.readFileSync(pidFile(), 'utf8').trim());
|
|
1189
|
+
if (Number.isFinite(pid) && pid > 0 && isPidAlive(pid)) {
|
|
1190
|
+
const meta = readDaemonMeta();
|
|
1191
|
+
const owned = meta?.pid === pid;
|
|
1192
|
+
return { alive: true, pid, version: owned ? meta?.version : undefined, startedAt: owned ? meta?.startedAt : undefined };
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
catch { /* no pid file — not running */ }
|
|
1196
|
+
return { alive: false };
|
|
1197
|
+
}
|
|
1198
|
+
/** Relaunch the daemon outside our process tree (plain node — no shells). */
|
|
1199
|
+
function spawnDetachedDaemon() {
|
|
1200
|
+
try {
|
|
1201
|
+
const child = (0, child_process_1.spawn)(process.execPath, [cliEntry(), 'daemon'], {
|
|
1202
|
+
detached: true,
|
|
1203
|
+
stdio: 'ignore',
|
|
1204
|
+
windowsHide: true,
|
|
1205
|
+
});
|
|
1206
|
+
child.unref();
|
|
1207
|
+
return true;
|
|
1208
|
+
}
|
|
1209
|
+
catch {
|
|
1210
|
+
return false;
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
/** Pid-alive check shared with the watchdog (EPERM still means alive). */
|
|
1214
|
+
function pidIsAlive(pid) {
|
|
1215
|
+
return isPidAlive(pid);
|
|
1216
|
+
}
|
|
1062
1217
|
function uninstallWindows() {
|
|
1063
1218
|
const task = (0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
|
|
1064
1219
|
(0, child_process_1.spawnSync)('schtasks', ['/Delete', '/TN', WATCHDOG_TASK_NAME, '/F'], { stdio: 'ignore', windowsHide: true });
|
|
1065
1220
|
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(
|
|
1221
|
+
const launcherDir = path.join(process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'FullCourtDefense');
|
|
1222
|
+
for (const launcher of ['daemon.vbs', 'watchdog.vbs']) {
|
|
1223
|
+
try {
|
|
1224
|
+
fs.unlinkSync(path.join(launcherDir, launcher));
|
|
1225
|
+
}
|
|
1226
|
+
catch { /* ignore */ }
|
|
1070
1227
|
}
|
|
1071
|
-
catch { /* ignore */ }
|
|
1072
1228
|
return task.status === 0 || reg.status === 0;
|
|
1073
1229
|
}
|
|
1074
1230
|
function launchdPlistPath() {
|
|
@@ -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
|
}
|
|
@@ -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>;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.watchdogCommand = watchdogCommand;
|
|
37
|
+
const config_1 = require("../config");
|
|
38
|
+
const daemon_1 = require("./daemon");
|
|
39
|
+
const daemonForensics_1 = require("../daemonForensics");
|
|
40
|
+
const machineIdentity_1 = require("../machineIdentity");
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
42
|
+
const path = __importStar(require("path"));
|
|
43
|
+
function cliVersion() {
|
|
44
|
+
try {
|
|
45
|
+
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', 'package.json'), 'utf8'));
|
|
46
|
+
return typeof pkg.version === 'string' ? pkg.version : undefined;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function crashSummary(pm) {
|
|
53
|
+
if (!pm || pm.reportedAt)
|
|
54
|
+
return undefined;
|
|
55
|
+
return {
|
|
56
|
+
version: pm.version,
|
|
57
|
+
startedAt: pm.startedAt,
|
|
58
|
+
lastAliveAt: pm.lastAliveAt,
|
|
59
|
+
detectedAt: pm.detectedAt,
|
|
60
|
+
detectedBy: pm.detectedBy,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async function watchdogCommand(args, config) {
|
|
64
|
+
const state = (0, daemon_1.daemonRuntimeState)();
|
|
65
|
+
let relaunched = false;
|
|
66
|
+
let postmortem = (0, daemonForensics_1.readPostmortem)();
|
|
67
|
+
if (!state.alive) {
|
|
68
|
+
// The daemon is down — capture how it died BEFORE reviving it (the fresh
|
|
69
|
+
// daemon rewrites the alive marker on boot).
|
|
70
|
+
postmortem = (0, daemonForensics_1.recordUncleanDeathFromWatchdog)(daemon_1.pidIsAlive) || postmortem;
|
|
71
|
+
// Revival can be disabled for tests/diagnostics; default is on.
|
|
72
|
+
if (process.env.FCD_WATCHDOG_NO_RELAUNCH !== '1') {
|
|
73
|
+
relaunched = (0, daemon_1.spawnDetachedDaemon)();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Beacon — fail-soft on every axis: missing credentials, endpoint not yet
|
|
77
|
+
// deployed (404), network down. The next tick retries in 5 minutes.
|
|
78
|
+
const creds = (0, config_1.resolveCliCredentialsShellFree)(config, {
|
|
79
|
+
shieldId: args.shieldId,
|
|
80
|
+
shieldKey: args.shieldKey,
|
|
81
|
+
apiUrl: args.apiUrl,
|
|
82
|
+
});
|
|
83
|
+
if (!creds.shieldId)
|
|
84
|
+
return;
|
|
85
|
+
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
86
|
+
const beacon = {
|
|
87
|
+
shieldId: creds.shieldId,
|
|
88
|
+
machineId: identity.machineId,
|
|
89
|
+
agentVersion: cliVersion(),
|
|
90
|
+
daemonAlive: state.alive,
|
|
91
|
+
daemonPid: state.pid,
|
|
92
|
+
daemonVersion: state.version,
|
|
93
|
+
daemonStartedAt: state.startedAt,
|
|
94
|
+
relaunched: relaunched || undefined,
|
|
95
|
+
lastCrash: crashSummary(postmortem),
|
|
96
|
+
};
|
|
97
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
98
|
+
if (creds.shieldKey)
|
|
99
|
+
headers['x-shield-key'] = creds.shieldKey;
|
|
100
|
+
try {
|
|
101
|
+
const resp = await fetch(`${creds.apiUrl}/api/cli/machines/watchdog`, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers,
|
|
104
|
+
body: JSON.stringify(beacon),
|
|
105
|
+
signal: AbortSignal.timeout(8_000),
|
|
106
|
+
});
|
|
107
|
+
// Only a trusted (shield-key) delivery consumes the crash report — the
|
|
108
|
+
// constrained path stores a hint but the daemon should still report the
|
|
109
|
+
// full post-mortem on its next authenticated heartbeat.
|
|
110
|
+
if (resp.ok && beacon.lastCrash && creds.shieldKey)
|
|
111
|
+
(0, daemonForensics_1.markPostmortemReported)();
|
|
112
|
+
}
|
|
113
|
+
catch { /* offline / endpoint not deployed yet — next tick retries */ }
|
|
114
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -18,6 +18,20 @@ export interface BotGuardConfig {
|
|
|
18
18
|
webhookFormat?: string;
|
|
19
19
|
};
|
|
20
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* Outcome of the most recent DPAPI PowerShell attempt in THIS process —
|
|
23
|
+
* the fleet's "is PowerShell blocked on this machine?" health bit, derived
|
|
24
|
+
* from work we had to do anyway (never a dedicated PowerShell spawn).
|
|
25
|
+
* spawnOk=false means powershell.exe/pwsh.exe could not even start (EDR/
|
|
26
|
+
* AppLocker block, missing shells); spawnOk=true + decryptOk=false means the
|
|
27
|
+
* shell ran but the blob would not decrypt (wrong user profile / corrupt).
|
|
28
|
+
*/
|
|
29
|
+
export interface PowershellHealth {
|
|
30
|
+
spawnOk: boolean;
|
|
31
|
+
decryptOk: boolean;
|
|
32
|
+
checkedAt: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function getPowershellHealth(): PowershellHealth | undefined;
|
|
21
35
|
export declare function getHomeConfigPath(): string;
|
|
22
36
|
export declare function loadConfig(configPath?: string): BotGuardConfig;
|
|
23
37
|
export declare function getDefaultConfigPath(): string;
|
|
@@ -53,7 +67,17 @@ export interface ResolvedCliCredentials {
|
|
|
53
67
|
*/
|
|
54
68
|
export declare function daemonHandoffShieldKey(env?: NodeJS.ProcessEnv): string | undefined;
|
|
55
69
|
/** Merge saved ~/.fullcourtdefense.yml + env + optional CLI flag overrides. */
|
|
56
|
-
export declare function resolveCliCredentials(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials
|
|
70
|
+
export declare function resolveCliCredentials(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>, options?: {
|
|
71
|
+
skipDpapi?: boolean;
|
|
72
|
+
}): ResolvedCliCredentials;
|
|
73
|
+
/**
|
|
74
|
+
* Credential resolution that is guaranteed to spawn NO child process — for
|
|
75
|
+
* frequent low-privilege callers (watchdog beacon every 5 min). Skips the
|
|
76
|
+
* DPAPI/PowerShell decrypt entirely; on a DPAPI-enrolled machine this may
|
|
77
|
+
* yield no shieldKey, in which case the caller sends a constrained
|
|
78
|
+
* (unauthenticated) beacon instead.
|
|
79
|
+
*/
|
|
80
|
+
export declare function resolveCliCredentialsShellFree(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>): ResolvedCliCredentials;
|
|
57
81
|
export declare function isCliSetupComplete(config: BotGuardConfig): boolean;
|
|
58
82
|
/** Fail fast with a clear message when setup was not run. */
|
|
59
83
|
export declare function requireCliSetup(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>, options?: {
|
package/dist/config.js
CHANGED
|
@@ -33,12 +33,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.getPowershellHealth = getPowershellHealth;
|
|
36
37
|
exports.getHomeConfigPath = getHomeConfigPath;
|
|
37
38
|
exports.loadConfig = loadConfig;
|
|
38
39
|
exports.getDefaultConfigPath = getDefaultConfigPath;
|
|
39
40
|
exports.saveShieldConfig = saveShieldConfig;
|
|
40
41
|
exports.daemonHandoffShieldKey = daemonHandoffShieldKey;
|
|
41
42
|
exports.resolveCliCredentials = resolveCliCredentials;
|
|
43
|
+
exports.resolveCliCredentialsShellFree = resolveCliCredentialsShellFree;
|
|
42
44
|
exports.isCliSetupComplete = isCliSetupComplete;
|
|
43
45
|
exports.requireCliSetup = requireCliSetup;
|
|
44
46
|
exports.saveSetupConfig = saveSetupConfig;
|
|
@@ -155,15 +157,21 @@ function readConfigFile(filePath) {
|
|
|
155
157
|
: undefined,
|
|
156
158
|
};
|
|
157
159
|
}
|
|
160
|
+
let powershellHealth;
|
|
161
|
+
function getPowershellHealth() {
|
|
162
|
+
return powershellHealth;
|
|
163
|
+
}
|
|
158
164
|
function powershellDpapi(script, value) {
|
|
159
165
|
if (process.platform !== 'win32' || !value)
|
|
160
166
|
return undefined;
|
|
161
167
|
// Windows PowerShell 5.1 first, then PowerShell 7 — both use DPAPI for
|
|
162
168
|
// SecureString on Windows. Some locked-down/service contexts (e.g. CI
|
|
163
169
|
// runners) break one shell but not the other.
|
|
170
|
+
let anyShellSpawned = false;
|
|
171
|
+
let output;
|
|
164
172
|
for (const shell of ['powershell.exe', 'pwsh.exe']) {
|
|
165
173
|
try {
|
|
166
|
-
const
|
|
174
|
+
const raw = (0, child_process_1.execFileSync)(shell, [
|
|
167
175
|
'-NoProfile',
|
|
168
176
|
'-NonInteractive',
|
|
169
177
|
'-ExecutionPolicy', 'Bypass',
|
|
@@ -175,12 +183,23 @@ function powershellDpapi(script, value) {
|
|
|
175
183
|
timeout: 15_000,
|
|
176
184
|
windowsHide: true,
|
|
177
185
|
}).trim();
|
|
178
|
-
|
|
179
|
-
|
|
186
|
+
anyShellSpawned = true;
|
|
187
|
+
if (raw) {
|
|
188
|
+
output = raw;
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
// A non-zero exit still proves the shell STARTED (status set); a spawn
|
|
194
|
+
// failure (ENOENT/EACCES/EPERM, or timeout kill) proves it did not.
|
|
195
|
+
const err = error;
|
|
196
|
+
if (typeof err.status === 'number')
|
|
197
|
+
anyShellSpawned = true;
|
|
198
|
+
/* try the next shell */
|
|
180
199
|
}
|
|
181
|
-
catch { /* try the next shell */ }
|
|
182
200
|
}
|
|
183
|
-
|
|
201
|
+
powershellHealth = { spawnOk: anyShellSpawned, decryptOk: !!output, checkedAt: new Date().toISOString() };
|
|
202
|
+
return output;
|
|
184
203
|
}
|
|
185
204
|
function protectShieldKeyForCurrentWindowsUser(value) {
|
|
186
205
|
return powershellDpapi('$secure=ConvertTo-SecureString -String $env:FCD_DPAPI_VALUE -AsPlainText -Force; ConvertFrom-SecureString -SecureString $secure', value);
|
|
@@ -245,7 +264,7 @@ function daemonHandoffShieldKey(env = process.env) {
|
|
|
245
264
|
return env.FCD_SHIELD_ID && env.FCD_SHIELD_KEY ? env.FCD_SHIELD_KEY : undefined;
|
|
246
265
|
}
|
|
247
266
|
/** Merge saved ~/.fullcourtdefense.yml + env + optional CLI flag overrides. */
|
|
248
|
-
function resolveCliCredentials(config, overrides = {}) {
|
|
267
|
+
function resolveCliCredentials(config, overrides = {}, options = {}) {
|
|
249
268
|
return {
|
|
250
269
|
apiKey: overrides.apiKey
|
|
251
270
|
|| config.apiKey
|
|
@@ -266,13 +285,23 @@ function resolveCliCredentials(config, overrides = {}) {
|
|
|
266
285
|
// Daemon handoff BEFORE the DPAPI decrypt: no powershell.exe spawn at
|
|
267
286
|
// all during daemon-driven sweeps (see daemonHandoffShieldKey docs).
|
|
268
287
|
|| daemonHandoffShieldKey()
|
|
269
|
-
|| unprotectShieldKeyForCurrentWindowsUser(config.shieldKeyDpapi || '')
|
|
288
|
+
|| (options.skipDpapi ? undefined : unprotectShieldKeyForCurrentWindowsUser(config.shieldKeyDpapi || ''))
|
|
270
289
|
|| config.shieldKey
|
|
271
290
|
|| process.env.FCD_SHIELD_KEY
|
|
272
291
|
|| process.env.FULLCOURTDEFENSE_SHIELD_KEY
|
|
273
292
|
|| process.env.AGENTGUARD_SHIELD_KEY,
|
|
274
293
|
};
|
|
275
294
|
}
|
|
295
|
+
/**
|
|
296
|
+
* Credential resolution that is guaranteed to spawn NO child process — for
|
|
297
|
+
* frequent low-privilege callers (watchdog beacon every 5 min). Skips the
|
|
298
|
+
* DPAPI/PowerShell decrypt entirely; on a DPAPI-enrolled machine this may
|
|
299
|
+
* yield no shieldKey, in which case the caller sends a constrained
|
|
300
|
+
* (unauthenticated) beacon instead.
|
|
301
|
+
*/
|
|
302
|
+
function resolveCliCredentialsShellFree(config, overrides = {}) {
|
|
303
|
+
return resolveCliCredentials(config, overrides, { skipDpapi: true });
|
|
304
|
+
}
|
|
276
305
|
function isCliSetupComplete(config) {
|
|
277
306
|
const creds = resolveCliCredentials(config);
|
|
278
307
|
return Boolean(creds.shieldId && creds.apiKey && creds.organizationId);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Daemon crash forensics — answers "HOW did the daemon die?" after the fact.
|
|
3
|
+
*
|
|
4
|
+
* The daemon keeps an alive-marker on disk (pid + lastAliveAt, refreshed on
|
|
5
|
+
* every heartbeat tick) and records a clean-exit stamp when it shuts down
|
|
6
|
+
* normally. Whoever runs next — a restarted daemon or the watchdog task —
|
|
7
|
+
* compares the marker against reality: a marker without a clean exit whose
|
|
8
|
+
* pid is gone means the previous daemon was killed externally (EDR/AV kill,
|
|
9
|
+
* OOM, forced reboot). That produces a post-mortem record (with the last
|
|
10
|
+
* daemon.log lines) which is reported upstream via the heartbeat/beacon, so
|
|
11
|
+
* a remote fleet machine's death is diagnosable without hands-on access.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is best-effort file I/O: forensics must never break the
|
|
14
|
+
* daemon or the watchdog. Pure logic (`detectUncleanDeath`) is injectable
|
|
15
|
+
* for offline tests.
|
|
16
|
+
*/
|
|
17
|
+
export interface DaemonAliveMarker {
|
|
18
|
+
pid: number;
|
|
19
|
+
version?: string;
|
|
20
|
+
startedAt: string;
|
|
21
|
+
lastAliveAt: string;
|
|
22
|
+
/** Present only when the daemon shut down on purpose (SIGINT/SIGTERM/supersede). */
|
|
23
|
+
cleanExit?: {
|
|
24
|
+
at: string;
|
|
25
|
+
reason: string;
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export interface DaemonPostmortem {
|
|
29
|
+
/** Version of the daemon that died. */
|
|
30
|
+
version?: string;
|
|
31
|
+
startedAt?: string;
|
|
32
|
+
/** Last proof of life — death happened within one heartbeat tick after this. */
|
|
33
|
+
lastAliveAt?: string;
|
|
34
|
+
detectedAt: string;
|
|
35
|
+
detectedBy: 'daemon' | 'watchdog';
|
|
36
|
+
uncleanExit: true;
|
|
37
|
+
/** Tail of daemon.log at detection time — the daemon's last words. */
|
|
38
|
+
lastLogLines: string[];
|
|
39
|
+
/** Set once the post-mortem reached the backend (stop re-sending). */
|
|
40
|
+
reportedAt?: string;
|
|
41
|
+
}
|
|
42
|
+
export declare function aliveMarkerFile(): string;
|
|
43
|
+
export declare function postmortemFile(): string;
|
|
44
|
+
export declare function readAliveMarker(): DaemonAliveMarker | undefined;
|
|
45
|
+
export declare function readPostmortem(): DaemonPostmortem | undefined;
|
|
46
|
+
/** Last non-empty lines of daemon.log (the dead daemon's final output). */
|
|
47
|
+
export declare function tailDaemonLog(maxLines?: number): string[];
|
|
48
|
+
/**
|
|
49
|
+
* Pure detection: did the previously recorded daemon die WITHOUT a clean exit?
|
|
50
|
+
* Returns the post-mortem (not yet persisted) or undefined when the previous
|
|
51
|
+
* daemon exited cleanly, is still alive, or nothing was recorded.
|
|
52
|
+
*/
|
|
53
|
+
export declare function detectUncleanDeath(input: {
|
|
54
|
+
marker: DaemonAliveMarker | undefined;
|
|
55
|
+
isPidAlive: (pid: number) => boolean;
|
|
56
|
+
detectedBy: 'daemon' | 'watchdog';
|
|
57
|
+
lastLogLines?: string[];
|
|
58
|
+
ownPid?: number;
|
|
59
|
+
now?: string;
|
|
60
|
+
}): DaemonPostmortem | undefined;
|
|
61
|
+
/**
|
|
62
|
+
* Called once at daemon startup: detect an unclean previous death (persisting
|
|
63
|
+
* the post-mortem for reporting), then claim the alive marker for this pid.
|
|
64
|
+
* Returns the detected post-mortem so the daemon can log it.
|
|
65
|
+
*/
|
|
66
|
+
export declare function recordDaemonStart(input: {
|
|
67
|
+
version?: string;
|
|
68
|
+
isPidAlive: (pid: number) => boolean;
|
|
69
|
+
}): DaemonPostmortem | undefined;
|
|
70
|
+
/** Watchdog-side detection (daemon not running): persist + return the post-mortem. */
|
|
71
|
+
export declare function recordUncleanDeathFromWatchdog(isPidAlive: (pid: number) => boolean): DaemonPostmortem | undefined;
|
|
72
|
+
/** Refresh proof-of-life; called from the daemon's heartbeat tick. */
|
|
73
|
+
export declare function touchDaemonAlive(): void;
|
|
74
|
+
/** Record an intentional shutdown so the next boot knows this was NOT a kill. */
|
|
75
|
+
export declare function recordCleanExit(reason: string): void;
|
|
76
|
+
/** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
|
|
77
|
+
export declare function markPostmortemReported(): void;
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.aliveMarkerFile = aliveMarkerFile;
|
|
37
|
+
exports.postmortemFile = postmortemFile;
|
|
38
|
+
exports.readAliveMarker = readAliveMarker;
|
|
39
|
+
exports.readPostmortem = readPostmortem;
|
|
40
|
+
exports.tailDaemonLog = tailDaemonLog;
|
|
41
|
+
exports.detectUncleanDeath = detectUncleanDeath;
|
|
42
|
+
exports.recordDaemonStart = recordDaemonStart;
|
|
43
|
+
exports.recordUncleanDeathFromWatchdog = recordUncleanDeathFromWatchdog;
|
|
44
|
+
exports.touchDaemonAlive = touchDaemonAlive;
|
|
45
|
+
exports.recordCleanExit = recordCleanExit;
|
|
46
|
+
exports.markPostmortemReported = markPostmortemReported;
|
|
47
|
+
const fs = __importStar(require("fs"));
|
|
48
|
+
const os = __importStar(require("os"));
|
|
49
|
+
const path = __importStar(require("path"));
|
|
50
|
+
function stateDir() {
|
|
51
|
+
return path.join(os.homedir(), '.fullcourtdefense');
|
|
52
|
+
}
|
|
53
|
+
function aliveMarkerFile() {
|
|
54
|
+
return path.join(stateDir(), 'daemon.alive.json');
|
|
55
|
+
}
|
|
56
|
+
function postmortemFile() {
|
|
57
|
+
return path.join(stateDir(), 'daemon.postmortem.json');
|
|
58
|
+
}
|
|
59
|
+
function readAliveMarker() {
|
|
60
|
+
try {
|
|
61
|
+
const marker = JSON.parse(fs.readFileSync(aliveMarkerFile(), 'utf8'));
|
|
62
|
+
return marker && typeof marker.pid === 'number' ? marker : undefined;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function readPostmortem() {
|
|
69
|
+
try {
|
|
70
|
+
const pm = JSON.parse(fs.readFileSync(postmortemFile(), 'utf8'));
|
|
71
|
+
return pm && pm.uncleanExit === true ? pm : undefined;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function writeJson(file, value) {
|
|
78
|
+
try {
|
|
79
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
80
|
+
fs.writeFileSync(file, JSON.stringify(value, null, 2), 'utf8');
|
|
81
|
+
}
|
|
82
|
+
catch { /* best-effort */ }
|
|
83
|
+
}
|
|
84
|
+
/** Last non-empty lines of daemon.log (the dead daemon's final output). */
|
|
85
|
+
function tailDaemonLog(maxLines = 40) {
|
|
86
|
+
try {
|
|
87
|
+
const file = path.join(stateDir(), 'daemon.log');
|
|
88
|
+
const raw = fs.readFileSync(file, 'utf8');
|
|
89
|
+
return raw
|
|
90
|
+
.split(/\r?\n/)
|
|
91
|
+
.map(line => line.trim())
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.slice(-maxLines)
|
|
94
|
+
.map(line => line.slice(0, 400));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Pure detection: did the previously recorded daemon die WITHOUT a clean exit?
|
|
102
|
+
* Returns the post-mortem (not yet persisted) or undefined when the previous
|
|
103
|
+
* daemon exited cleanly, is still alive, or nothing was recorded.
|
|
104
|
+
*/
|
|
105
|
+
function detectUncleanDeath(input) {
|
|
106
|
+
const { marker } = input;
|
|
107
|
+
if (!marker || marker.cleanExit)
|
|
108
|
+
return undefined;
|
|
109
|
+
if (marker.pid === (input.ownPid ?? process.pid))
|
|
110
|
+
return undefined;
|
|
111
|
+
if (input.isPidAlive(marker.pid))
|
|
112
|
+
return undefined;
|
|
113
|
+
return {
|
|
114
|
+
version: marker.version,
|
|
115
|
+
startedAt: marker.startedAt,
|
|
116
|
+
lastAliveAt: marker.lastAliveAt,
|
|
117
|
+
detectedAt: input.now || new Date().toISOString(),
|
|
118
|
+
detectedBy: input.detectedBy,
|
|
119
|
+
uncleanExit: true,
|
|
120
|
+
lastLogLines: input.lastLogLines ?? [],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Called once at daemon startup: detect an unclean previous death (persisting
|
|
125
|
+
* the post-mortem for reporting), then claim the alive marker for this pid.
|
|
126
|
+
* Returns the detected post-mortem so the daemon can log it.
|
|
127
|
+
*/
|
|
128
|
+
function recordDaemonStart(input) {
|
|
129
|
+
const postmortem = detectUncleanDeath({
|
|
130
|
+
marker: readAliveMarker(),
|
|
131
|
+
isPidAlive: input.isPidAlive,
|
|
132
|
+
detectedBy: 'daemon',
|
|
133
|
+
lastLogLines: tailDaemonLog(),
|
|
134
|
+
});
|
|
135
|
+
if (postmortem)
|
|
136
|
+
writeJson(postmortemFile(), postmortem);
|
|
137
|
+
const now = new Date().toISOString();
|
|
138
|
+
const marker = {
|
|
139
|
+
pid: process.pid,
|
|
140
|
+
version: input.version,
|
|
141
|
+
startedAt: now,
|
|
142
|
+
lastAliveAt: now,
|
|
143
|
+
};
|
|
144
|
+
writeJson(aliveMarkerFile(), marker);
|
|
145
|
+
return postmortem;
|
|
146
|
+
}
|
|
147
|
+
/** Watchdog-side detection (daemon not running): persist + return the post-mortem. */
|
|
148
|
+
function recordUncleanDeathFromWatchdog(isPidAlive) {
|
|
149
|
+
const existing = readPostmortem();
|
|
150
|
+
const postmortem = detectUncleanDeath({
|
|
151
|
+
marker: readAliveMarker(),
|
|
152
|
+
isPidAlive,
|
|
153
|
+
detectedBy: 'watchdog',
|
|
154
|
+
lastLogLines: tailDaemonLog(),
|
|
155
|
+
});
|
|
156
|
+
if (!postmortem)
|
|
157
|
+
return existing;
|
|
158
|
+
// Don't clobber an identical, possibly already-reported record.
|
|
159
|
+
if (existing && existing.lastAliveAt === postmortem.lastAliveAt && existing.startedAt === postmortem.startedAt) {
|
|
160
|
+
return existing;
|
|
161
|
+
}
|
|
162
|
+
writeJson(postmortemFile(), postmortem);
|
|
163
|
+
return postmortem;
|
|
164
|
+
}
|
|
165
|
+
/** Refresh proof-of-life; called from the daemon's heartbeat tick. */
|
|
166
|
+
function touchDaemonAlive() {
|
|
167
|
+
const marker = readAliveMarker();
|
|
168
|
+
if (!marker || marker.pid !== process.pid)
|
|
169
|
+
return;
|
|
170
|
+
marker.lastAliveAt = new Date().toISOString();
|
|
171
|
+
writeJson(aliveMarkerFile(), marker);
|
|
172
|
+
}
|
|
173
|
+
/** Record an intentional shutdown so the next boot knows this was NOT a kill. */
|
|
174
|
+
function recordCleanExit(reason) {
|
|
175
|
+
const marker = readAliveMarker();
|
|
176
|
+
if (!marker || marker.pid !== process.pid)
|
|
177
|
+
return;
|
|
178
|
+
marker.cleanExit = { at: new Date().toISOString(), reason: reason.slice(0, 120) };
|
|
179
|
+
writeJson(aliveMarkerFile(), marker);
|
|
180
|
+
}
|
|
181
|
+
/** Stamp the post-mortem as delivered so heartbeats stop re-sending it. */
|
|
182
|
+
function markPostmortemReported() {
|
|
183
|
+
const pm = readPostmortem();
|
|
184
|
+
if (!pm || pm.reportedAt)
|
|
185
|
+
return;
|
|
186
|
+
pm.reportedAt = new Date().toISOString();
|
|
187
|
+
writeJson(postmortemFile(), pm);
|
|
188
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -53,6 +53,7 @@ const onboard_1 = require("./commands/onboard");
|
|
|
53
53
|
const uninstallAll_1 = require("./commands/uninstallAll");
|
|
54
54
|
const autoProtect_1 = require("./commands/autoProtect");
|
|
55
55
|
const daemon_1 = require("./commands/daemon");
|
|
56
|
+
const watchdog_1 = require("./commands/watchdog");
|
|
56
57
|
const desktopChatGuard_1 = require("./commands/desktopChatGuard");
|
|
57
58
|
const windowsAudit_1 = require("./commands/windowsAudit");
|
|
58
59
|
const shellGuard_1 = require("./commands/shellGuard");
|
|
@@ -873,6 +874,17 @@ async function main() {
|
|
|
873
874
|
await (0, daemon_1.daemonCommand)(args, config);
|
|
874
875
|
break;
|
|
875
876
|
}
|
|
877
|
+
case 'watchdog': {
|
|
878
|
+
// Internal: 5-minute scheduled-task tick — daemon liveness beacon,
|
|
879
|
+
// crash post-mortem capture, and dead-daemon revival. Shell-quiet.
|
|
880
|
+
const args = {
|
|
881
|
+
apiUrl: flags['api-url'],
|
|
882
|
+
shieldId: flags['shield-id'],
|
|
883
|
+
shieldKey: flags['shield-key'],
|
|
884
|
+
};
|
|
885
|
+
await (0, watchdog_1.watchdogCommand)(args, config);
|
|
886
|
+
break;
|
|
887
|
+
}
|
|
876
888
|
case 'desktop-chat-guard': {
|
|
877
889
|
const args = {
|
|
878
890
|
apiUrl: flags['api-url'],
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EDR / antivirus presence probe (Windows).
|
|
3
|
+
*
|
|
4
|
+
* Detects which endpoint-security products run on a fleet machine so the
|
|
5
|
+
* console can explain machine-side interference (blocked PowerShell, killed
|
|
6
|
+
* daemons, quarantined installs) instead of showing a bare "stale" badge.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately shell-quiet: detection is read-only `reg.exe query` of known
|
|
9
|
+
* service keys — the same benign mechanism the posture probe already uses.
|
|
10
|
+
* NO PowerShell/WMI (spawning those is exactly the behavior EDRs flag).
|
|
11
|
+
* Results are cached in-process; the probe re-runs at most every 6 hours.
|
|
12
|
+
*/
|
|
13
|
+
export interface SecurityAgentsReport {
|
|
14
|
+
/** Human-readable product names detected on this machine. */
|
|
15
|
+
products: string[];
|
|
16
|
+
checkedAt: string;
|
|
17
|
+
}
|
|
18
|
+
/** Windows service name → product label. Service keys are stable across versions. */
|
|
19
|
+
export declare const KNOWN_SECURITY_SERVICES: Record<string, string>;
|
|
20
|
+
/**
|
|
21
|
+
* Pure core — service-existence check is injectable for offline tests.
|
|
22
|
+
* Returns undefined on non-Windows platforms (field omitted from payloads).
|
|
23
|
+
*/
|
|
24
|
+
export declare function detectSecurityAgents(serviceExists?: (service: string) => boolean, platform?: NodeJS.Platform): SecurityAgentsReport | undefined;
|
|
25
|
+
/** Cached probe for periodic callers (daemon heartbeat every 5 min must not re-spawn reg.exe each tick). */
|
|
26
|
+
export declare function getSecurityAgentsReport(): SecurityAgentsReport | undefined;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.KNOWN_SECURITY_SERVICES = void 0;
|
|
4
|
+
exports.detectSecurityAgents = detectSecurityAgents;
|
|
5
|
+
exports.getSecurityAgentsReport = getSecurityAgentsReport;
|
|
6
|
+
const child_process_1 = require("child_process");
|
|
7
|
+
/** Windows service name → product label. Service keys are stable across versions. */
|
|
8
|
+
exports.KNOWN_SECURITY_SERVICES = {
|
|
9
|
+
WinDefend: 'Microsoft Defender',
|
|
10
|
+
Sense: 'Microsoft Defender for Endpoint',
|
|
11
|
+
CSFalconService: 'CrowdStrike Falcon',
|
|
12
|
+
SentinelAgent: 'SentinelOne',
|
|
13
|
+
CylanceSvc: 'BlackBerry Cylance',
|
|
14
|
+
xagt: 'Trellix (FireEye) Endpoint',
|
|
15
|
+
CbDefense: 'Carbon Black Cloud',
|
|
16
|
+
CarbonBlack: 'Carbon Black EDR',
|
|
17
|
+
mfemms: 'Trellix (McAfee) ePO',
|
|
18
|
+
ekrn: 'ESET Endpoint',
|
|
19
|
+
EPSecurityService: 'Bitdefender Endpoint',
|
|
20
|
+
'Sophos Endpoint Defense Service': 'Sophos Intercept X',
|
|
21
|
+
klnagent: 'Kaspersky Endpoint',
|
|
22
|
+
AVP: 'Kaspersky Endpoint',
|
|
23
|
+
Amsp: 'Trend Micro',
|
|
24
|
+
ElasticEndpoint: 'Elastic Defend',
|
|
25
|
+
HuntressAgent: 'Huntress',
|
|
26
|
+
cyserver: 'Palo Alto Cortex XDR',
|
|
27
|
+
CiscoAMP: 'Cisco Secure Endpoint',
|
|
28
|
+
WRSVC: 'Webroot',
|
|
29
|
+
};
|
|
30
|
+
function windowsServiceExists(service) {
|
|
31
|
+
const result = (0, child_process_1.spawnSync)('reg', [
|
|
32
|
+
'query', `HKLM\\SYSTEM\\CurrentControlSet\\Services\\${service}`,
|
|
33
|
+
], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
|
|
34
|
+
return result.status === 0;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Pure core — service-existence check is injectable for offline tests.
|
|
38
|
+
* Returns undefined on non-Windows platforms (field omitted from payloads).
|
|
39
|
+
*/
|
|
40
|
+
function detectSecurityAgents(serviceExists = windowsServiceExists, platform = process.platform) {
|
|
41
|
+
if (platform !== 'win32')
|
|
42
|
+
return undefined;
|
|
43
|
+
const products = [];
|
|
44
|
+
for (const [service, product] of Object.entries(exports.KNOWN_SECURITY_SERVICES)) {
|
|
45
|
+
try {
|
|
46
|
+
if (serviceExists(service) && !products.includes(product))
|
|
47
|
+
products.push(product);
|
|
48
|
+
}
|
|
49
|
+
catch { /* one bad probe never kills the report */ }
|
|
50
|
+
}
|
|
51
|
+
return { products, checkedAt: new Date().toISOString() };
|
|
52
|
+
}
|
|
53
|
+
const CACHE_TTL_MS = 6 * 60 * 60_000;
|
|
54
|
+
let cached;
|
|
55
|
+
let cachedAt = 0;
|
|
56
|
+
/** Cached probe for periodic callers (daemon heartbeat every 5 min must not re-spawn reg.exe each tick). */
|
|
57
|
+
function getSecurityAgentsReport() {
|
|
58
|
+
if (process.platform !== 'win32')
|
|
59
|
+
return undefined;
|
|
60
|
+
if (!cached || Date.now() - cachedAt > CACHE_TTL_MS) {
|
|
61
|
+
cached = detectSecurityAgents();
|
|
62
|
+
cachedAt = Date.now();
|
|
63
|
+
}
|
|
64
|
+
return cached;
|
|
65
|
+
}
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -36,6 +36,20 @@ export interface FlushInput {
|
|
|
36
36
|
daemon?: boolean;
|
|
37
37
|
/** Windows Claude Desktop chat guard liveness (advisory prompt protection). */
|
|
38
38
|
desktopChatGuard?: boolean;
|
|
39
|
+
/** EDR/AV products detected on this machine (Windows service probe). */
|
|
40
|
+
securityAgents?: string[];
|
|
41
|
+
/** Could powershell.exe/pwsh.exe be spawned at all? (from real DPAPI work, never a dedicated spawn) */
|
|
42
|
+
powershellSpawnOk?: boolean;
|
|
43
|
+
/** Did the DPAPI-protected shield key actually decrypt? */
|
|
44
|
+
powershellDecryptOk?: boolean;
|
|
45
|
+
/** Post-mortem of a previous daemon that died without a clean shutdown. */
|
|
46
|
+
lastCrash?: {
|
|
47
|
+
version?: string;
|
|
48
|
+
startedAt?: string;
|
|
49
|
+
lastAliveAt?: string;
|
|
50
|
+
detectedAt: string;
|
|
51
|
+
detectedBy: 'daemon' | 'watchdog';
|
|
52
|
+
};
|
|
39
53
|
timeoutMs?: number;
|
|
40
54
|
}
|
|
41
55
|
/** Drain the spool to the backend in one batch (+ optional heartbeat). Returns accepted count. */
|
package/dist/telemetry.js
CHANGED
|
@@ -170,6 +170,13 @@ async function flushSpool(input) {
|
|
|
170
170
|
// Logging + Transcription). Keeps the fleet dashboard's coverage
|
|
171
171
|
// fresh on every ping — undefined (omitted) on other platforms.
|
|
172
172
|
shellAudit: (0, windowsAudit_1.getShellAuditReport)(),
|
|
173
|
+
// Machine-environment diagnostics: which EDR/AV runs here, can
|
|
174
|
+
// PowerShell spawn, and did a previous daemon die uncleanly —
|
|
175
|
+
// the data that explains a "stale" machine remotely.
|
|
176
|
+
securityAgents: input.securityAgents,
|
|
177
|
+
powershellSpawnOk: input.powershellSpawnOk,
|
|
178
|
+
powershellDecryptOk: input.powershellDecryptOk,
|
|
179
|
+
lastCrash: input.lastCrash,
|
|
173
180
|
}
|
|
174
181
|
: undefined,
|
|
175
182
|
events: batch,
|
package/dist/version.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullcourtdefense-cli",
|
|
3
|
-
"version": "1.21.
|
|
3
|
+
"version": "1.21.29",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"test:daemon-e2e": "npm run build && node scripts/test-daemon-e2e.js",
|
|
37
37
|
"test:daemon-discovery-args": "npm run build && node scripts/test-daemon-discovery-args.js",
|
|
38
38
|
"test:daemon-discover-stderr": "npm run build && node scripts/test-daemon-discover-stderr.js",
|
|
39
|
+
"test:watchdog-forensics": "npm run build && node scripts/test-watchdog-forensics.js",
|
|
39
40
|
"test:macos-daemon-path": "npm run build && node scripts/test-macos-daemon-path.js",
|
|
40
41
|
"test:machine-action-signing": "npm run build && node scripts/test-machine-action-signing.js",
|
|
41
42
|
"test:integrity": "npm run build && node scripts/test-integrity.js",
|