fullcourtdefense-cli 1.21.32 → 1.21.34
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.js +130 -10
- package/dist/config.d.ts +31 -0
- package/dist/config.js +150 -10
- package/dist/credentialStore.d.ts +19 -0
- package/dist/credentialStore.js +136 -0
- package/dist/distress.d.ts +80 -0
- package/dist/distress.js +183 -0
- package/dist/runtimeConfig.d.ts +1 -1
- package/dist/runtimeConfig.js +11 -1
- package/dist/selfTest.d.ts +38 -0
- package/dist/selfTest.js +149 -0
- package/dist/selfUpdate.d.ts +8 -0
- package/dist/selfUpdate.js +63 -0
- package/dist/telemetry.d.ts +13 -0
- package/dist/telemetry.js +6 -0
- package/dist/version.json +1 -1
- package/package.json +8 -1
package/dist/commands/daemon.js
CHANGED
|
@@ -48,6 +48,8 @@ const os = __importStar(require("os"));
|
|
|
48
48
|
const path = __importStar(require("path"));
|
|
49
49
|
const child_process_1 = require("child_process");
|
|
50
50
|
const config_1 = require("../config");
|
|
51
|
+
const distress_1 = require("../distress");
|
|
52
|
+
const selfTest_1 = require("../selfTest");
|
|
51
53
|
const daemonForensics_1 = require("../daemonForensics");
|
|
52
54
|
const securityAgents_1 = require("../securityAgents");
|
|
53
55
|
const mcpGateway_1 = require("./mcpGateway");
|
|
@@ -345,9 +347,13 @@ async function runDaemon(args, config) {
|
|
|
345
347
|
// that would restart a crashed process instantly.
|
|
346
348
|
process.on('uncaughtException', error => {
|
|
347
349
|
log(`Uncaught exception (daemon continues): ${error?.stack || String(error)}`);
|
|
350
|
+
// Ledger the unknown: failures nobody predicted still become structured,
|
|
351
|
+
// fleet-visible telemetry (code unexpected_error) on the next heartbeat.
|
|
352
|
+
(0, distress_1.reportUnexpected)('daemon', error);
|
|
348
353
|
});
|
|
349
354
|
process.on('unhandledRejection', reason => {
|
|
350
355
|
log(`Unhandled rejection (daemon continues): ${reason?.stack || String(reason)}`);
|
|
356
|
+
(0, distress_1.reportUnexpected)('daemon', reason);
|
|
351
357
|
});
|
|
352
358
|
const creds = (0, config_1.resolveCliCredentials)(config, {
|
|
353
359
|
shieldId: args.shieldId,
|
|
@@ -536,7 +542,9 @@ async function runDaemon(args, config) {
|
|
|
536
542
|
return;
|
|
537
543
|
try {
|
|
538
544
|
const raw = fs.readFileSync(logFile(), 'utf8');
|
|
539
|
-
|
|
545
|
+
// 200 lines (~half a day of routine ticks): enough context to diagnose a
|
|
546
|
+
// failure loop remotely without asking the customer to paste anything.
|
|
547
|
+
const lines = raw.split(/\r?\n/).filter(line => line.trim()).slice(-200);
|
|
540
548
|
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
541
549
|
await fetch(`${creds.apiUrl}/api/cli/machines/log`, {
|
|
542
550
|
method: 'POST',
|
|
@@ -550,6 +558,64 @@ async function runDaemon(args, config) {
|
|
|
550
558
|
}
|
|
551
559
|
catch { /* log shipping is best-effort */ }
|
|
552
560
|
};
|
|
561
|
+
// Ship a structured diagnostics payload (support bundle / self-test report)
|
|
562
|
+
// to the machine record. Works key-less too — a machine whose credential
|
|
563
|
+
// store is broken is EXACTLY the one whose diagnostics we need (the backend
|
|
564
|
+
// verifies the machine binding and flags the reduced trust level).
|
|
565
|
+
const uploadDiagnostics = async (payload) => {
|
|
566
|
+
if (!creds.shieldId)
|
|
567
|
+
return false;
|
|
568
|
+
try {
|
|
569
|
+
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
570
|
+
const resp = await fetch(`${creds.apiUrl}/api/cli/machines/diagnostics`, {
|
|
571
|
+
method: 'POST',
|
|
572
|
+
headers: {
|
|
573
|
+
'Content-Type': 'application/json',
|
|
574
|
+
...(creds.shieldKey ? { 'x-shield-key': creds.shieldKey } : {}),
|
|
575
|
+
},
|
|
576
|
+
body: JSON.stringify({ shieldId: creds.shieldId, machineId: identity.machineId, ...payload }),
|
|
577
|
+
signal: AbortSignal.timeout(15_000),
|
|
578
|
+
});
|
|
579
|
+
return resp.ok;
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
log('Diagnostics upload failed (network) — the bundle stays available locally.');
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
// The complete remote support bundle: everything we asked Alex and Alin to
|
|
587
|
+
// paste by hand during the lptx1110 incident, collected in one action.
|
|
588
|
+
// Metadata + our own logs only — never customer files, prompts, or secrets.
|
|
589
|
+
const buildSupportBundle = () => {
|
|
590
|
+
const powershell = (0, config_1.getPowershellHealth)();
|
|
591
|
+
let integrity;
|
|
592
|
+
try {
|
|
593
|
+
integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
integrity = { error: error.message };
|
|
597
|
+
}
|
|
598
|
+
let daemonLogTail = [];
|
|
599
|
+
try {
|
|
600
|
+
daemonLogTail = fs.readFileSync(logFile(), 'utf8')
|
|
601
|
+
.split(/\r?\n/).map(line => line.trim()).filter(Boolean).slice(-200).map(line => line.slice(0, 400));
|
|
602
|
+
}
|
|
603
|
+
catch { /* log unreadable — the bundle reports everything else */ }
|
|
604
|
+
return {
|
|
605
|
+
cliVersion: cliVersion(),
|
|
606
|
+
platform: process.platform,
|
|
607
|
+
collectedAt: new Date().toISOString(),
|
|
608
|
+
daemonLogTail,
|
|
609
|
+
updaterLogTail: (0, selfUpdate_1.readUpdaterLogTail)(60),
|
|
610
|
+
distress: (0, distress_1.readDistressLedger)(),
|
|
611
|
+
credentialTrace: (0, config_1.getCredentialResolutionTrace)(),
|
|
612
|
+
powershell,
|
|
613
|
+
securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
|
|
614
|
+
integrity,
|
|
615
|
+
lastCrash: (0, daemonForensics_1.readPostmortem)(),
|
|
616
|
+
watchdogTaskInstalled: isWatchdogTaskInstalled(),
|
|
617
|
+
};
|
|
618
|
+
};
|
|
553
619
|
const reportMachineAction = async (actionId, status, detail) => {
|
|
554
620
|
if (!creds.shieldId)
|
|
555
621
|
return;
|
|
@@ -664,18 +730,37 @@ async function runDaemon(args, config) {
|
|
|
664
730
|
try {
|
|
665
731
|
let resultSummary = '';
|
|
666
732
|
if (action.type === 'health_check') {
|
|
667
|
-
|
|
733
|
+
// Deep self-test: actively EXERCISE every subsystem (DPAPI roundtrip,
|
|
734
|
+
// PowerShell mode, API reachability, authenticated fetch, hooks,
|
|
735
|
+
// updater task) instead of passively reporting. ~30s from the console
|
|
736
|
+
// to a per-subsystem pass/fail table.
|
|
737
|
+
log('Health check: running deep self-test across all subsystems…');
|
|
668
738
|
await uploadLogTail();
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
:
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
739
|
+
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
740
|
+
const report = await (0, selfTest_1.runDeepSelfTest)({
|
|
741
|
+
creds,
|
|
742
|
+
developerName: identity.developerName,
|
|
743
|
+
machineName: identity.hostname,
|
|
744
|
+
machineId: identity.machineId,
|
|
745
|
+
isWatchdogTaskInstalled,
|
|
746
|
+
log,
|
|
747
|
+
});
|
|
748
|
+
await uploadDiagnostics({ selfTest: report });
|
|
749
|
+
resultSummary = (0, selfTest_1.summarizeSelfTest)(report);
|
|
750
|
+
log(`Health check: ${resultSummary}`);
|
|
751
|
+
if (!report.ok)
|
|
677
752
|
throw new Error(resultSummary);
|
|
678
753
|
}
|
|
754
|
+
else if (action.type === 'collect_diagnostics') {
|
|
755
|
+
log('Collect diagnostics: assembling support bundle (logs + distress ledger + environment — metadata only)…');
|
|
756
|
+
await uploadLogTail();
|
|
757
|
+
const bundle = buildSupportBundle();
|
|
758
|
+
const uploaded = await uploadDiagnostics({ bundle });
|
|
759
|
+
if (!uploaded)
|
|
760
|
+
throw new Error('Support bundle could not be uploaded (network or backend rejection).');
|
|
761
|
+
log(`Collect diagnostics: bundle uploaded (${bundle.daemonLogTail.length} log lines, ${bundle.distress.length} distress entries).`);
|
|
762
|
+
resultSummary = `Support bundle uploaded: ${bundle.daemonLogTail.length} daemon log lines, ${bundle.updaterLogTail.length} updater log lines, ${bundle.distress.length} distress signal(s), credential trace, environment health.`;
|
|
763
|
+
}
|
|
679
764
|
else if (action.type === 'policy_refresh') {
|
|
680
765
|
if (!creds.shieldId)
|
|
681
766
|
throw new Error('Shield not configured on this machine.');
|
|
@@ -777,9 +862,41 @@ async function runDaemon(args, config) {
|
|
|
777
862
|
await uploadLogTail();
|
|
778
863
|
}
|
|
779
864
|
};
|
|
865
|
+
// Distress channel: a machine whose credential store is broken cannot fetch
|
|
866
|
+
// the authenticated bundle — which is how remote actions are delivered — so
|
|
867
|
+
// the one machine an admin most needs to reach is unreachable. This key-less
|
|
868
|
+
// poll returns SIGNED actions only; safety comes from the Ed25519 signature
|
|
869
|
+
// + org/machine binding verified in executeMachineAction (the transport was
|
|
870
|
+
// never the trust anchor). Rescue path: queue upgrade_cli / collect_diagnostics
|
|
871
|
+
// from the console and the bricked machine still receives it.
|
|
872
|
+
const pollDistressActions = async () => {
|
|
873
|
+
if (!creds.shieldId || creds.shieldKey)
|
|
874
|
+
return;
|
|
875
|
+
try {
|
|
876
|
+
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
877
|
+
const params = new URLSearchParams({ shieldId: creds.shieldId, machineId: identity.machineId });
|
|
878
|
+
const resp = await fetch(`${creds.apiUrl}/api/cli/machine-actions/pending?${params.toString()}`, {
|
|
879
|
+
method: 'GET',
|
|
880
|
+
signal: AbortSignal.timeout(8_000),
|
|
881
|
+
});
|
|
882
|
+
if (!resp.ok)
|
|
883
|
+
return;
|
|
884
|
+
const body = await resp.json().catch(() => ({}));
|
|
885
|
+
if (body.data?.machineAction) {
|
|
886
|
+
log(`Distress channel: received action ${body.data.machineAction.type} key-less — Ed25519 verification gates execution.`);
|
|
887
|
+
void executeMachineAction(body.data.machineAction);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
catch { /* offline — next poll retries */ }
|
|
891
|
+
};
|
|
780
892
|
const pollBundle = async () => {
|
|
781
893
|
if (!creds.shieldId)
|
|
782
894
|
return;
|
|
895
|
+
if (!creds.shieldKey) {
|
|
896
|
+
// Credential-broken machine: stay reachable via the key-less signed-
|
|
897
|
+
// action channel while credential recovery keeps retrying.
|
|
898
|
+
await pollDistressActions();
|
|
899
|
+
}
|
|
783
900
|
const identity = (0, machineIdentity_1.getMachineIdentity)();
|
|
784
901
|
try {
|
|
785
902
|
const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
|
|
@@ -882,6 +999,9 @@ async function runDaemon(args, config) {
|
|
|
882
999
|
securityAgents: (0, securityAgents_1.getSecurityAgentsReport)()?.products,
|
|
883
1000
|
powershellSpawnOk: powershell?.spawnOk,
|
|
884
1001
|
powershellDecryptOk: powershell?.decryptOk,
|
|
1002
|
+
powershellLanguageMode: powershell?.languageMode,
|
|
1003
|
+
shieldKeySource: (0, config_1.getCredentialResolutionTrace)()?.shieldKeySource,
|
|
1004
|
+
distress: (0, distress_1.readDistressSnapshot)(),
|
|
885
1005
|
lastCrash: unreportedCrash
|
|
886
1006
|
? {
|
|
887
1007
|
version: unreportedCrash.version,
|
package/dist/config.d.ts
CHANGED
|
@@ -30,8 +30,16 @@ export interface PowershellHealth {
|
|
|
30
30
|
spawnOk: boolean;
|
|
31
31
|
decryptOk: boolean;
|
|
32
32
|
checkedAt: string;
|
|
33
|
+
/** FullLanguage / ConstrainedLanguage / RestrictedLanguage — captured only when a decrypt fails (extra spawn is failure-path-only). */
|
|
34
|
+
languageMode?: string;
|
|
33
35
|
}
|
|
34
36
|
export declare function getPowershellHealth(): PowershellHealth | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* PowerShell language mode — the "is this a hardened WDAC/AppLocker fleet
|
|
39
|
+
* machine?" bit that explained the lptx1110 incident. Spawned only on the
|
|
40
|
+
* DPAPI failure path (and from the deep self-test), never on healthy runs.
|
|
41
|
+
*/
|
|
42
|
+
export declare function capturePowershellLanguageMode(): string | undefined;
|
|
35
43
|
/**
|
|
36
44
|
* PSCredential.GetNetworkCredential() instead of Marshal::SecureStringToBSTR:
|
|
37
45
|
* hardened fleets run PowerShell in Constrained Language Mode, where the
|
|
@@ -42,6 +50,16 @@ export declare function getPowershellHealth(): PowershellHealth | undefined;
|
|
|
42
50
|
* regression test runs this EXACT text inside a ConstrainedLanguage session.
|
|
43
51
|
*/
|
|
44
52
|
export declare const DPAPI_DECRYPT_SNIPPET = "$secure=ConvertTo-SecureString -String $env:FCD_DPAPI_VALUE; (New-Object System.Management.Automation.PSCredential('fcd', $secure)).GetNetworkCredential().Password";
|
|
53
|
+
/**
|
|
54
|
+
* Deep self-test probe: encrypt AND decrypt a throwaway value with the exact
|
|
55
|
+
* DPAPI snippets used for the shield key. Encryption alone can succeed on a
|
|
56
|
+
* machine that can never decrypt (the pre-v1.21.32 bricking bug) — only the
|
|
57
|
+
* full roundtrip proves the credential store works.
|
|
58
|
+
*/
|
|
59
|
+
export declare function dpapiRoundtripProbe(): {
|
|
60
|
+
ok: boolean;
|
|
61
|
+
detail: string;
|
|
62
|
+
};
|
|
45
63
|
export declare function getHomeConfigPath(): string;
|
|
46
64
|
export declare function loadConfig(configPath?: string): BotGuardConfig;
|
|
47
65
|
export declare function getDefaultConfigPath(): string;
|
|
@@ -76,6 +94,19 @@ export interface ResolvedCliCredentials {
|
|
|
76
94
|
* Pure — unit-tested.
|
|
77
95
|
*/
|
|
78
96
|
export declare function daemonHandoffShieldKey(env?: NodeJS.ProcessEnv): string | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* Which source supplied the shield key on the most recent resolution — the
|
|
99
|
+
* codes-only trace shipped in the collect_diagnostics bundle (never the key
|
|
100
|
+
* itself). `none_dpapi_broken` is the credential-broken state: a DPAPI blob
|
|
101
|
+
* exists but would not decrypt and no fallback source was available.
|
|
102
|
+
*/
|
|
103
|
+
export type ShieldKeySource = 'override' | 'daemon_handoff' | 'native_store' | 'dpapi' | 'plaintext_config' | 'env' | 'none' | 'none_dpapi_broken';
|
|
104
|
+
export interface CredentialResolutionTrace {
|
|
105
|
+
shieldKeySource: ShieldKeySource;
|
|
106
|
+
dpapiAttempted: boolean;
|
|
107
|
+
at: string;
|
|
108
|
+
}
|
|
109
|
+
export declare function getCredentialResolutionTrace(): CredentialResolutionTrace | undefined;
|
|
79
110
|
/** Merge saved ~/.fullcourtdefense.yml + env + optional CLI flag overrides. */
|
|
80
111
|
export declare function resolveCliCredentials(config: BotGuardConfig, overrides?: Partial<ResolvedCliCredentials>, options?: {
|
|
81
112
|
skipDpapi?: boolean;
|
package/dist/config.js
CHANGED
|
@@ -35,11 +35,14 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.DPAPI_DECRYPT_SNIPPET = void 0;
|
|
37
37
|
exports.getPowershellHealth = getPowershellHealth;
|
|
38
|
+
exports.capturePowershellLanguageMode = capturePowershellLanguageMode;
|
|
39
|
+
exports.dpapiRoundtripProbe = dpapiRoundtripProbe;
|
|
38
40
|
exports.getHomeConfigPath = getHomeConfigPath;
|
|
39
41
|
exports.loadConfig = loadConfig;
|
|
40
42
|
exports.getDefaultConfigPath = getDefaultConfigPath;
|
|
41
43
|
exports.saveShieldConfig = saveShieldConfig;
|
|
42
44
|
exports.daemonHandoffShieldKey = daemonHandoffShieldKey;
|
|
45
|
+
exports.getCredentialResolutionTrace = getCredentialResolutionTrace;
|
|
43
46
|
exports.resolveCliCredentials = resolveCliCredentials;
|
|
44
47
|
exports.resolveCliCredentialsShellFree = resolveCliCredentialsShellFree;
|
|
45
48
|
exports.isCliSetupComplete = isCliSetupComplete;
|
|
@@ -50,6 +53,8 @@ const fs = __importStar(require("fs"));
|
|
|
50
53
|
const os = __importStar(require("os"));
|
|
51
54
|
const path = __importStar(require("path"));
|
|
52
55
|
const child_process_1 = require("child_process");
|
|
56
|
+
const distress_1 = require("./distress");
|
|
57
|
+
const credentialStore_1 = require("./credentialStore");
|
|
53
58
|
const CONFIG_FILENAMES = [
|
|
54
59
|
'.fullcourtdefense.yml',
|
|
55
60
|
'.fullcourtdefense.yaml',
|
|
@@ -162,6 +167,30 @@ let powershellHealth;
|
|
|
162
167
|
function getPowershellHealth() {
|
|
163
168
|
return powershellHealth;
|
|
164
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* PowerShell language mode — the "is this a hardened WDAC/AppLocker fleet
|
|
172
|
+
* machine?" bit that explained the lptx1110 incident. Spawned only on the
|
|
173
|
+
* DPAPI failure path (and from the deep self-test), never on healthy runs.
|
|
174
|
+
*/
|
|
175
|
+
function capturePowershellLanguageMode() {
|
|
176
|
+
if (process.platform !== 'win32')
|
|
177
|
+
return undefined;
|
|
178
|
+
for (const shell of ['powershell.exe', 'pwsh.exe']) {
|
|
179
|
+
try {
|
|
180
|
+
const raw = (0, child_process_1.execFileSync)(shell, [
|
|
181
|
+
'-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass',
|
|
182
|
+
'-Command', '$ExecutionContext.SessionState.LanguageMode',
|
|
183
|
+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 15_000, windowsHide: true }).trim();
|
|
184
|
+
if (raw) {
|
|
185
|
+
if (powershellHealth)
|
|
186
|
+
powershellHealth.languageMode = raw;
|
|
187
|
+
return raw;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
catch { /* try the next shell */ }
|
|
191
|
+
}
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
165
194
|
function powershellDpapi(script, value) {
|
|
166
195
|
if (process.platform !== 'win32' || !value)
|
|
167
196
|
return undefined;
|
|
@@ -216,7 +245,44 @@ function protectShieldKeyForCurrentWindowsUser(value) {
|
|
|
216
245
|
*/
|
|
217
246
|
exports.DPAPI_DECRYPT_SNIPPET = "$secure=ConvertTo-SecureString -String $env:FCD_DPAPI_VALUE; (New-Object System.Management.Automation.PSCredential('fcd', $secure)).GetNetworkCredential().Password";
|
|
218
247
|
function unprotectShieldKeyForCurrentWindowsUser(value) {
|
|
219
|
-
|
|
248
|
+
const result = powershellDpapi(exports.DPAPI_DECRYPT_SNIPPET, value);
|
|
249
|
+
if (!result && value) {
|
|
250
|
+
// The machine HAS a DPAPI-protected key it cannot read — the exact
|
|
251
|
+
// credential-broken state that 401-bricked lptx1110. Emit a coded
|
|
252
|
+
// distress signal so the fleet console sees it without asking the user.
|
|
253
|
+
const health = powershellHealth;
|
|
254
|
+
if (health && !health.spawnOk) {
|
|
255
|
+
(0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.POWERSHELL_BLOCKED, 'powershell.exe and pwsh.exe both failed to start (EDR/AppLocker?)');
|
|
256
|
+
}
|
|
257
|
+
else {
|
|
258
|
+
const mode = capturePowershellLanguageMode();
|
|
259
|
+
(0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.DPAPI_DECRYPT_FAILED, mode ? `language mode: ${mode}` : 'decrypt returned nothing');
|
|
260
|
+
if (mode && mode !== 'FullLanguage') {
|
|
261
|
+
(0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.POWERSHELL_CONSTRAINED, `PowerShell language mode is ${mode}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Deep self-test probe: encrypt AND decrypt a throwaway value with the exact
|
|
269
|
+
* DPAPI snippets used for the shield key. Encryption alone can succeed on a
|
|
270
|
+
* machine that can never decrypt (the pre-v1.21.32 bricking bug) — only the
|
|
271
|
+
* full roundtrip proves the credential store works.
|
|
272
|
+
*/
|
|
273
|
+
function dpapiRoundtripProbe() {
|
|
274
|
+
if (process.platform !== 'win32')
|
|
275
|
+
return { ok: true, detail: 'not applicable (non-Windows)' };
|
|
276
|
+
const probe = `fcd-selftest-${Date.now()}`;
|
|
277
|
+
const encrypted = protectShieldKeyForCurrentWindowsUser(probe);
|
|
278
|
+
if (!encrypted) {
|
|
279
|
+
const health = powershellHealth;
|
|
280
|
+
return { ok: false, detail: health && !health.spawnOk ? 'PowerShell would not start (EDR/AppLocker?)' : 'DPAPI encrypt produced nothing' };
|
|
281
|
+
}
|
|
282
|
+
const decrypted = powershellDpapi(exports.DPAPI_DECRYPT_SNIPPET, encrypted);
|
|
283
|
+
if (decrypted === probe)
|
|
284
|
+
return { ok: true, detail: 'encrypt + decrypt roundtrip OK' };
|
|
285
|
+
return { ok: false, detail: decrypted ? 'decrypt returned a different value (profile mismatch?)' : 'decrypt returned nothing (CLM/EDR block?)' };
|
|
220
286
|
}
|
|
221
287
|
function mergeConfig(base, override) {
|
|
222
288
|
return {
|
|
@@ -274,8 +340,83 @@ const DEFAULT_API_URL = 'https://api.fullcourtdefense.ai';
|
|
|
274
340
|
function daemonHandoffShieldKey(env = process.env) {
|
|
275
341
|
return env.FCD_SHIELD_ID && env.FCD_SHIELD_KEY ? env.FCD_SHIELD_KEY : undefined;
|
|
276
342
|
}
|
|
343
|
+
let lastCredentialTrace;
|
|
344
|
+
function getCredentialResolutionTrace() {
|
|
345
|
+
return lastCredentialTrace;
|
|
346
|
+
}
|
|
347
|
+
// Once per process: hooks are short-lived (one attempt each) and the daemon
|
|
348
|
+
// long-lived (must not hammer the Credential Manager every 30s poll).
|
|
349
|
+
let nativeMigrationAttempted = false;
|
|
350
|
+
function maybeMigrateShieldKeyToNativeStore(shieldId, shieldKey) {
|
|
351
|
+
if (nativeMigrationAttempted || !(0, credentialStore_1.nativeCredentialStoreAvailable)())
|
|
352
|
+
return;
|
|
353
|
+
nativeMigrationAttempted = true;
|
|
354
|
+
try {
|
|
355
|
+
if ((0, credentialStore_1.readShieldKeyNative)(shieldId))
|
|
356
|
+
return; // already migrated
|
|
357
|
+
(0, credentialStore_1.saveShieldKeyNative)(shieldId, shieldKey); // read-back verified inside
|
|
358
|
+
}
|
|
359
|
+
catch { /* migration is opportunistic — legacy sources keep working */ }
|
|
360
|
+
}
|
|
277
361
|
/** Merge saved ~/.fullcourtdefense.yml + env + optional CLI flag overrides. */
|
|
278
362
|
function resolveCliCredentials(config, overrides = {}, options = {}) {
|
|
363
|
+
// Shield key: same precedence as the old || chain, evaluated stepwise so
|
|
364
|
+
// (a) the DPAPI decrypt stays lazy — no powershell.exe spawn when an
|
|
365
|
+
// earlier source wins — and (b) the winning source is recorded for the
|
|
366
|
+
// diagnostics trace.
|
|
367
|
+
let shieldKey = overrides.shieldKey;
|
|
368
|
+
let shieldKeySource = shieldKey ? 'override' : 'none';
|
|
369
|
+
let dpapiAttempted = false;
|
|
370
|
+
const resolvedShieldId = overrides.shieldId
|
|
371
|
+
|| config.shieldId
|
|
372
|
+
|| process.env.FCD_SHIELD_ID
|
|
373
|
+
|| process.env.FULLCOURTDEFENSE_SHIELD_ID
|
|
374
|
+
|| process.env.AGENTGUARD_SHIELD_ID;
|
|
375
|
+
if (!shieldKey) {
|
|
376
|
+
// Daemon handoff BEFORE the DPAPI decrypt: no powershell.exe spawn at
|
|
377
|
+
// all during daemon-driven sweeps (see daemonHandoffShieldKey docs).
|
|
378
|
+
shieldKey = daemonHandoffShieldKey();
|
|
379
|
+
if (shieldKey)
|
|
380
|
+
shieldKeySource = 'daemon_handoff';
|
|
381
|
+
}
|
|
382
|
+
if (!shieldKey && resolvedShieldId) {
|
|
383
|
+
// Native Credential Manager BEFORE the DPAPI/PowerShell decrypt: an
|
|
384
|
+
// in-process win32 API read that EDRs don't flag and CLM can't constrain.
|
|
385
|
+
// Silently absent on machines that never migrated (see the migration
|
|
386
|
+
// below) — the DPAPI chain still stands behind it.
|
|
387
|
+
shieldKey = (0, credentialStore_1.readShieldKeyNative)(resolvedShieldId);
|
|
388
|
+
if (shieldKey)
|
|
389
|
+
shieldKeySource = 'native_store';
|
|
390
|
+
}
|
|
391
|
+
if (!shieldKey && !options.skipDpapi && config.shieldKeyDpapi) {
|
|
392
|
+
dpapiAttempted = true;
|
|
393
|
+
shieldKey = unprotectShieldKeyForCurrentWindowsUser(config.shieldKeyDpapi);
|
|
394
|
+
if (shieldKey)
|
|
395
|
+
shieldKeySource = 'dpapi';
|
|
396
|
+
}
|
|
397
|
+
if (!shieldKey && config.shieldKey) {
|
|
398
|
+
shieldKey = config.shieldKey;
|
|
399
|
+
shieldKeySource = 'plaintext_config';
|
|
400
|
+
}
|
|
401
|
+
if (!shieldKey) {
|
|
402
|
+
shieldKey = process.env.FCD_SHIELD_KEY
|
|
403
|
+
|| process.env.FULLCOURTDEFENSE_SHIELD_KEY
|
|
404
|
+
|| process.env.AGENTGUARD_SHIELD_KEY;
|
|
405
|
+
if (shieldKey)
|
|
406
|
+
shieldKeySource = 'env';
|
|
407
|
+
}
|
|
408
|
+
if (!shieldKey && dpapiAttempted)
|
|
409
|
+
shieldKeySource = 'none_dpapi_broken';
|
|
410
|
+
lastCredentialTrace = { shieldKeySource, dpapiAttempted, at: new Date().toISOString() };
|
|
411
|
+
// Self-healing migration: whenever the key still resolves through a legacy
|
|
412
|
+
// source (DPAPI blob or plaintext config), copy it into the Credential
|
|
413
|
+
// Manager once. On fleets like lptx1110 the daemon's context CAN decrypt
|
|
414
|
+
// while IDE-spawned hooks CANNOT — after the daemon's first resolution the
|
|
415
|
+
// native entry exists and every process reads it without PowerShell.
|
|
416
|
+
if (shieldKey && resolvedShieldId
|
|
417
|
+
&& (shieldKeySource === 'dpapi' || shieldKeySource === 'plaintext_config')) {
|
|
418
|
+
maybeMigrateShieldKeyToNativeStore(resolvedShieldId, shieldKey);
|
|
419
|
+
}
|
|
279
420
|
return {
|
|
280
421
|
apiKey: overrides.apiKey
|
|
281
422
|
|| config.apiKey
|
|
@@ -292,15 +433,7 @@ function resolveCliCredentials(config, overrides = {}, options = {}) {
|
|
|
292
433
|
|| process.env.FCD_SHIELD_ID
|
|
293
434
|
|| process.env.FULLCOURTDEFENSE_SHIELD_ID
|
|
294
435
|
|| process.env.AGENTGUARD_SHIELD_ID,
|
|
295
|
-
shieldKey
|
|
296
|
-
// Daemon handoff BEFORE the DPAPI decrypt: no powershell.exe spawn at
|
|
297
|
-
// all during daemon-driven sweeps (see daemonHandoffShieldKey docs).
|
|
298
|
-
|| daemonHandoffShieldKey()
|
|
299
|
-
|| (options.skipDpapi ? undefined : unprotectShieldKeyForCurrentWindowsUser(config.shieldKeyDpapi || ''))
|
|
300
|
-
|| config.shieldKey
|
|
301
|
-
|| process.env.FCD_SHIELD_KEY
|
|
302
|
-
|| process.env.FULLCOURTDEFENSE_SHIELD_KEY
|
|
303
|
-
|| process.env.AGENTGUARD_SHIELD_KEY,
|
|
436
|
+
shieldKey,
|
|
304
437
|
};
|
|
305
438
|
}
|
|
306
439
|
/**
|
|
@@ -360,6 +493,13 @@ function writeSetupConfig(target, input) {
|
|
|
360
493
|
setTopLevel('apiKey', input.apiKey);
|
|
361
494
|
setTopLevel('organizationId', input.organizationId);
|
|
362
495
|
setTopLevel('shieldId', input.shieldId);
|
|
496
|
+
// Native Credential Manager write (read-back verified) — the store hooks
|
|
497
|
+
// read WITHOUT PowerShell. The DPAPI blob below is still written when it
|
|
498
|
+
// verifies: rolling back to a ≤1.21.33 CLI keeps working, and machines
|
|
499
|
+
// where the native binding is blocked keep their PowerShell path.
|
|
500
|
+
if (input.shieldKey && input.shieldId) {
|
|
501
|
+
(0, credentialStore_1.saveShieldKeyNative)(input.shieldId, input.shieldKey);
|
|
502
|
+
}
|
|
363
503
|
const protectedShieldKey = input.shieldKey && process.platform === 'win32'
|
|
364
504
|
? protectShieldKeyForCurrentWindowsUser(input.shieldKey)
|
|
365
505
|
: undefined;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare function nativeCredentialStoreAvailable(): boolean;
|
|
2
|
+
/** Read the shield key from the Credential Manager. Undefined when absent/unavailable. */
|
|
3
|
+
export declare function readShieldKeyNative(shieldId: string): string | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* Store the shield key, read-back verified. Returns false when the native
|
|
6
|
+
* store is unavailable or the verify failed — callers keep their fallback.
|
|
7
|
+
*/
|
|
8
|
+
export declare function saveShieldKeyNative(shieldId: string, shieldKey: string): boolean;
|
|
9
|
+
/** Remove the entry (unenroll / re-enroll cleanup). Best-effort. */
|
|
10
|
+
export declare function deleteShieldKeyNative(shieldId: string): void;
|
|
11
|
+
/**
|
|
12
|
+
* Deep self-test probe: write + read + delete a throwaway entry with the exact
|
|
13
|
+
* code paths used for the shield key. Proves the native store works end-to-end
|
|
14
|
+
* on THIS machine under the current EDR/policy regime.
|
|
15
|
+
*/
|
|
16
|
+
export declare function nativeStoreRoundtripProbe(): {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
detail: string;
|
|
19
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.nativeCredentialStoreAvailable = nativeCredentialStoreAvailable;
|
|
4
|
+
exports.readShieldKeyNative = readShieldKeyNative;
|
|
5
|
+
exports.saveShieldKeyNative = saveShieldKeyNative;
|
|
6
|
+
exports.deleteShieldKeyNative = deleteShieldKeyNative;
|
|
7
|
+
exports.nativeStoreRoundtripProbe = nativeStoreRoundtripProbe;
|
|
8
|
+
const distress_1 = require("./distress");
|
|
9
|
+
/**
|
|
10
|
+
* Native credential store — Windows Credential Manager via `@napi-rs/keyring`
|
|
11
|
+
* (a prebuilt N-API binding; in-process win32 API calls, no PowerShell).
|
|
12
|
+
*
|
|
13
|
+
* WHY: the lptx1110 incident class. The shield key at rest was DPAPI-protected
|
|
14
|
+
* and decrypted by spawning powershell.exe — exactly the pattern EDRs flag
|
|
15
|
+
* ("IDE process spawns PowerShell that touches credentials"). On hardened
|
|
16
|
+
* fleets PowerShell is additionally locked to Constrained Language Mode or
|
|
17
|
+
* blocked outright, so hooks resolve no key and 401 fail-closed. Credential
|
|
18
|
+
* Manager reads are ordinary in-process Windows API calls used by mainstream
|
|
19
|
+
* software — nothing to flag, nothing for CLM to constrain.
|
|
20
|
+
*
|
|
21
|
+
* SAFETY MODEL (this must never brick a machine):
|
|
22
|
+
* - the module is REQUIRED LAZILY inside try/catch — if the .node binary is
|
|
23
|
+
* missing, blocked, or ABI-incompatible, every call reports "unavailable"
|
|
24
|
+
* and the caller falls through to the existing DPAPI/PowerShell chain;
|
|
25
|
+
* - writes are read-back VERIFIED before the store is trusted;
|
|
26
|
+
* - enrollment keeps writing the DPAPI blob alongside — rolling back to an
|
|
27
|
+
* older CLI keeps working;
|
|
28
|
+
* - all failures land in the distress ledger, never as thrown errors.
|
|
29
|
+
*
|
|
30
|
+
* Windows-only for now: the EDR problem this solves is Windows-specific, and
|
|
31
|
+
* keeping other platforms untouched keeps the change surgical. (`keyring`
|
|
32
|
+
* itself supports macOS Keychain / libsecret when we choose to expand.)
|
|
33
|
+
*/
|
|
34
|
+
const SERVICE_NAME = 'FullCourtDefense';
|
|
35
|
+
/** One entry per shield: multiple enrollments on one machine never collide. */
|
|
36
|
+
function accountName(shieldId) {
|
|
37
|
+
return `shield-key:${shieldId}`;
|
|
38
|
+
}
|
|
39
|
+
let cachedEntryCtor;
|
|
40
|
+
/** Lazy, cached, never-throwing loader for the native binding. */
|
|
41
|
+
function loadEntryCtor() {
|
|
42
|
+
if (cachedEntryCtor !== undefined)
|
|
43
|
+
return cachedEntryCtor;
|
|
44
|
+
if (process.platform !== 'win32') {
|
|
45
|
+
cachedEntryCtor = null;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
50
|
+
const mod = require('@napi-rs/keyring');
|
|
51
|
+
cachedEntryCtor = typeof mod.Entry === 'function' ? mod.Entry : null;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// Missing/blocked native binary is an EXPECTED state (npm install layouts
|
|
55
|
+
// without optional deps, exotic ABIs) — log it once as distress detail so
|
|
56
|
+
// the fleet can see which machines lack the native store, then fall back.
|
|
57
|
+
(0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.NATIVE_STORE_UNAVAILABLE, `keyring module did not load: ${error instanceof Error ? error.message.slice(0, 120) : String(error)}`);
|
|
58
|
+
cachedEntryCtor = null;
|
|
59
|
+
}
|
|
60
|
+
return cachedEntryCtor;
|
|
61
|
+
}
|
|
62
|
+
function nativeCredentialStoreAvailable() {
|
|
63
|
+
return loadEntryCtor() !== null;
|
|
64
|
+
}
|
|
65
|
+
/** Read the shield key from the Credential Manager. Undefined when absent/unavailable. */
|
|
66
|
+
function readShieldKeyNative(shieldId) {
|
|
67
|
+
const Entry = loadEntryCtor();
|
|
68
|
+
if (!Entry || !shieldId)
|
|
69
|
+
return undefined;
|
|
70
|
+
try {
|
|
71
|
+
const value = new Entry(SERVICE_NAME, accountName(shieldId)).getPassword();
|
|
72
|
+
return value || undefined;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// "No entry" throws in keyring — that is the normal not-enrolled case.
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Store the shield key, read-back verified. Returns false when the native
|
|
81
|
+
* store is unavailable or the verify failed — callers keep their fallback.
|
|
82
|
+
*/
|
|
83
|
+
function saveShieldKeyNative(shieldId, shieldKey) {
|
|
84
|
+
const Entry = loadEntryCtor();
|
|
85
|
+
if (!Entry || !shieldId || !shieldKey)
|
|
86
|
+
return false;
|
|
87
|
+
try {
|
|
88
|
+
const entry = new Entry(SERVICE_NAME, accountName(shieldId));
|
|
89
|
+
entry.setPassword(shieldKey);
|
|
90
|
+
const verified = new Entry(SERVICE_NAME, accountName(shieldId)).getPassword() === shieldKey;
|
|
91
|
+
if (!verified) {
|
|
92
|
+
(0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.NATIVE_STORE_FAILED, 'write succeeded but read-back returned a different value');
|
|
93
|
+
}
|
|
94
|
+
return verified;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
(0, distress_1.reportDistress)('credentials', distress_1.DISTRESS.NATIVE_STORE_FAILED, `write failed: ${error instanceof Error ? error.message.slice(0, 120) : String(error)}`);
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** Remove the entry (unenroll / re-enroll cleanup). Best-effort. */
|
|
102
|
+
function deleteShieldKeyNative(shieldId) {
|
|
103
|
+
const Entry = loadEntryCtor();
|
|
104
|
+
if (!Entry || !shieldId)
|
|
105
|
+
return;
|
|
106
|
+
try {
|
|
107
|
+
new Entry(SERVICE_NAME, accountName(shieldId)).deletePassword();
|
|
108
|
+
}
|
|
109
|
+
catch { /* absent — fine */ }
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Deep self-test probe: write + read + delete a throwaway entry with the exact
|
|
113
|
+
* code paths used for the shield key. Proves the native store works end-to-end
|
|
114
|
+
* on THIS machine under the current EDR/policy regime.
|
|
115
|
+
*/
|
|
116
|
+
function nativeStoreRoundtripProbe() {
|
|
117
|
+
if (process.platform !== 'win32')
|
|
118
|
+
return { ok: true, detail: 'not applicable (non-Windows)' };
|
|
119
|
+
const Entry = loadEntryCtor();
|
|
120
|
+
if (!Entry)
|
|
121
|
+
return { ok: false, detail: 'native keyring module not loaded (missing binary or blocked) — DPAPI/PowerShell fallback in use' };
|
|
122
|
+
const probeAccount = `selftest:${Date.now()}`;
|
|
123
|
+
const probeValue = `fcd-native-probe-${Date.now()}`;
|
|
124
|
+
try {
|
|
125
|
+
const entry = new Entry(SERVICE_NAME, probeAccount);
|
|
126
|
+
entry.setPassword(probeValue);
|
|
127
|
+
const read = new Entry(SERVICE_NAME, probeAccount).getPassword();
|
|
128
|
+
entry.deletePassword();
|
|
129
|
+
return read === probeValue
|
|
130
|
+
? { ok: true, detail: 'Credential Manager write + read + delete roundtrip OK (no PowerShell involved)' }
|
|
131
|
+
: { ok: false, detail: 'roundtrip read returned a different value' };
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return { ok: false, detail: `roundtrip failed: ${error instanceof Error ? error.message.slice(0, 160) : String(error)}` };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Machine distress ledger — the CLI's flight recorder.
|
|
3
|
+
*
|
|
4
|
+
* Any component (credential resolution, bundle polling, self-update, hooks,
|
|
5
|
+
* daemon internals) reports a STABLE coded signal via `reportDistress()`.
|
|
6
|
+
* The ledger is:
|
|
7
|
+
* - persisted on disk (survives process churn — hooks are short-lived),
|
|
8
|
+
* - deduped (a failing subsystem repeats every few minutes; the ledger
|
|
9
|
+
* stores one entry per code+component with a count, not thousands),
|
|
10
|
+
* - shipped upstream on every daemon heartbeat (`readDistressSnapshot()`),
|
|
11
|
+
* - included in the collect_diagnostics support bundle,
|
|
12
|
+
* - echoed into daemon.log so the console's live log panel shows it.
|
|
13
|
+
*
|
|
14
|
+
* The code set is OPEN by design: known scenarios get named codes below,
|
|
15
|
+
* and failures nobody predicted are captured via `reportUnexpected()` —
|
|
16
|
+
* so even unknown bugs produce structured, fleet-visible telemetry instead
|
|
17
|
+
* of silence. Adding coverage for a new failure class is ONE call site.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is best-effort file I/O — distress reporting must never
|
|
20
|
+
* break the component doing the reporting (same discipline as
|
|
21
|
+
* daemonForensics.ts).
|
|
22
|
+
*/
|
|
23
|
+
/** Known distress codes. Open set — free-form codes are allowed. */
|
|
24
|
+
export declare const DISTRESS: {
|
|
25
|
+
/** DPAPI-protected shield key would not decrypt (EDR / Constrained Language Mode). */
|
|
26
|
+
readonly DPAPI_DECRYPT_FAILED: "dpapi_decrypt_failed";
|
|
27
|
+
/** powershell.exe / pwsh.exe could not even start (EDR/AppLocker block). */
|
|
28
|
+
readonly POWERSHELL_BLOCKED: "powershell_blocked";
|
|
29
|
+
/** PowerShell runs in ConstrainedLanguage/RestrictedLanguage mode. */
|
|
30
|
+
readonly POWERSHELL_CONSTRAINED: "powershell_constrained";
|
|
31
|
+
/** Control plane rejected our credentials (HTTP 401/403) — key wrong or unavailable. */
|
|
32
|
+
readonly AUTH_BROKEN: "auth_broken";
|
|
33
|
+
/** Control plane unreachable (network / DNS / proxy / timeout). */
|
|
34
|
+
readonly NETWORK_DOWN: "network_down";
|
|
35
|
+
/** Self-update triggered repeatedly for the same target without the version changing. */
|
|
36
|
+
readonly UPDATE_LOOP: "update_loop";
|
|
37
|
+
/** MSI updater scheduled task missing and could not be re-registered. */
|
|
38
|
+
readonly UPDATER_TASK_MISSING: "updater_task_missing";
|
|
39
|
+
/** Config file missing/unreadable — machine likely not enrolled or wiped. */
|
|
40
|
+
readonly CONFIG_MISSING: "config_missing";
|
|
41
|
+
/** Native Credential Manager binding did not load (missing/blocked .node) — PowerShell fallback in use. */
|
|
42
|
+
readonly NATIVE_STORE_UNAVAILABLE: "native_store_unavailable";
|
|
43
|
+
/** Native Credential Manager write/verify failed. */
|
|
44
|
+
readonly NATIVE_STORE_FAILED: "native_store_failed";
|
|
45
|
+
/** Anything nobody predicted — reported via reportUnexpected(). */
|
|
46
|
+
readonly UNEXPECTED: "unexpected_error";
|
|
47
|
+
};
|
|
48
|
+
export interface DistressEntry {
|
|
49
|
+
/** Stable machine-readable code (see DISTRESS, open set). */
|
|
50
|
+
code: string;
|
|
51
|
+
/** Component that reported (e.g. 'credentials', 'bundle', 'self-update', 'hook'). */
|
|
52
|
+
component: string;
|
|
53
|
+
/** Bounded human detail — never secrets, never file contents. */
|
|
54
|
+
detail?: string;
|
|
55
|
+
/** First occurrence in the current dedupe window (ISO). */
|
|
56
|
+
firstAt: string;
|
|
57
|
+
/** Most recent occurrence (ISO). */
|
|
58
|
+
lastAt: string;
|
|
59
|
+
/** Occurrences folded into this entry. */
|
|
60
|
+
count: number;
|
|
61
|
+
}
|
|
62
|
+
export declare function distressFile(): string;
|
|
63
|
+
/**
|
|
64
|
+
* Record a distress signal. Deduped: the same code+component within the
|
|
65
|
+
* dedupe window updates count/lastAt (echoed to daemon.log only on the first
|
|
66
|
+
* occurrence and then every 10th, to keep the log readable).
|
|
67
|
+
*/
|
|
68
|
+
export declare function reportDistress(component: string, code: string, detail?: string): void;
|
|
69
|
+
/** Structured capture for failures nobody predicted. */
|
|
70
|
+
export declare function reportUnexpected(component: string, error: unknown): void;
|
|
71
|
+
/**
|
|
72
|
+
* Recent distress for the heartbeat: entries seen within `windowMs`
|
|
73
|
+
* (default 24h), newest last, capped for transport.
|
|
74
|
+
*/
|
|
75
|
+
export declare function readDistressSnapshot(options?: {
|
|
76
|
+
windowMs?: number;
|
|
77
|
+
limit?: number;
|
|
78
|
+
}): DistressEntry[];
|
|
79
|
+
/** Full ledger for the collect_diagnostics support bundle. */
|
|
80
|
+
export declare function readDistressLedger(): DistressEntry[];
|
package/dist/distress.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
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.DISTRESS = void 0;
|
|
37
|
+
exports.distressFile = distressFile;
|
|
38
|
+
exports.reportDistress = reportDistress;
|
|
39
|
+
exports.reportUnexpected = reportUnexpected;
|
|
40
|
+
exports.readDistressSnapshot = readDistressSnapshot;
|
|
41
|
+
exports.readDistressLedger = readDistressLedger;
|
|
42
|
+
const fs = __importStar(require("fs"));
|
|
43
|
+
const os = __importStar(require("os"));
|
|
44
|
+
const path = __importStar(require("path"));
|
|
45
|
+
/**
|
|
46
|
+
* Machine distress ledger — the CLI's flight recorder.
|
|
47
|
+
*
|
|
48
|
+
* Any component (credential resolution, bundle polling, self-update, hooks,
|
|
49
|
+
* daemon internals) reports a STABLE coded signal via `reportDistress()`.
|
|
50
|
+
* The ledger is:
|
|
51
|
+
* - persisted on disk (survives process churn — hooks are short-lived),
|
|
52
|
+
* - deduped (a failing subsystem repeats every few minutes; the ledger
|
|
53
|
+
* stores one entry per code+component with a count, not thousands),
|
|
54
|
+
* - shipped upstream on every daemon heartbeat (`readDistressSnapshot()`),
|
|
55
|
+
* - included in the collect_diagnostics support bundle,
|
|
56
|
+
* - echoed into daemon.log so the console's live log panel shows it.
|
|
57
|
+
*
|
|
58
|
+
* The code set is OPEN by design: known scenarios get named codes below,
|
|
59
|
+
* and failures nobody predicted are captured via `reportUnexpected()` —
|
|
60
|
+
* so even unknown bugs produce structured, fleet-visible telemetry instead
|
|
61
|
+
* of silence. Adding coverage for a new failure class is ONE call site.
|
|
62
|
+
*
|
|
63
|
+
* Everything here is best-effort file I/O — distress reporting must never
|
|
64
|
+
* break the component doing the reporting (same discipline as
|
|
65
|
+
* daemonForensics.ts).
|
|
66
|
+
*/
|
|
67
|
+
/** Known distress codes. Open set — free-form codes are allowed. */
|
|
68
|
+
exports.DISTRESS = {
|
|
69
|
+
/** DPAPI-protected shield key would not decrypt (EDR / Constrained Language Mode). */
|
|
70
|
+
DPAPI_DECRYPT_FAILED: 'dpapi_decrypt_failed',
|
|
71
|
+
/** powershell.exe / pwsh.exe could not even start (EDR/AppLocker block). */
|
|
72
|
+
POWERSHELL_BLOCKED: 'powershell_blocked',
|
|
73
|
+
/** PowerShell runs in ConstrainedLanguage/RestrictedLanguage mode. */
|
|
74
|
+
POWERSHELL_CONSTRAINED: 'powershell_constrained',
|
|
75
|
+
/** Control plane rejected our credentials (HTTP 401/403) — key wrong or unavailable. */
|
|
76
|
+
AUTH_BROKEN: 'auth_broken',
|
|
77
|
+
/** Control plane unreachable (network / DNS / proxy / timeout). */
|
|
78
|
+
NETWORK_DOWN: 'network_down',
|
|
79
|
+
/** Self-update triggered repeatedly for the same target without the version changing. */
|
|
80
|
+
UPDATE_LOOP: 'update_loop',
|
|
81
|
+
/** MSI updater scheduled task missing and could not be re-registered. */
|
|
82
|
+
UPDATER_TASK_MISSING: 'updater_task_missing',
|
|
83
|
+
/** Config file missing/unreadable — machine likely not enrolled or wiped. */
|
|
84
|
+
CONFIG_MISSING: 'config_missing',
|
|
85
|
+
/** Native Credential Manager binding did not load (missing/blocked .node) — PowerShell fallback in use. */
|
|
86
|
+
NATIVE_STORE_UNAVAILABLE: 'native_store_unavailable',
|
|
87
|
+
/** Native Credential Manager write/verify failed. */
|
|
88
|
+
NATIVE_STORE_FAILED: 'native_store_failed',
|
|
89
|
+
/** Anything nobody predicted — reported via reportUnexpected(). */
|
|
90
|
+
UNEXPECTED: 'unexpected_error',
|
|
91
|
+
};
|
|
92
|
+
const MAX_ENTRIES = 50;
|
|
93
|
+
const MAX_DETAIL = 300;
|
|
94
|
+
/** Same code+component within this window increments count instead of appending. */
|
|
95
|
+
const DEDUPE_WINDOW_MS = 6 * 60 * 60_000;
|
|
96
|
+
function stateDir() {
|
|
97
|
+
return path.join(os.homedir(), '.fullcourtdefense');
|
|
98
|
+
}
|
|
99
|
+
function distressFile() {
|
|
100
|
+
return path.join(stateDir(), 'distress.json');
|
|
101
|
+
}
|
|
102
|
+
function readLedger() {
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(fs.readFileSync(distressFile(), 'utf8'));
|
|
105
|
+
return Array.isArray(parsed) ? parsed.filter(e => e && typeof e.code === 'string') : [];
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function writeLedger(entries) {
|
|
112
|
+
try {
|
|
113
|
+
fs.mkdirSync(stateDir(), { recursive: true });
|
|
114
|
+
fs.writeFileSync(distressFile(), JSON.stringify(entries.slice(-MAX_ENTRIES), null, 2), 'utf8');
|
|
115
|
+
}
|
|
116
|
+
catch { /* best-effort */ }
|
|
117
|
+
}
|
|
118
|
+
/** Echo into daemon.log so the console's live log panel carries the signal. */
|
|
119
|
+
function echoToDaemonLog(entry) {
|
|
120
|
+
try {
|
|
121
|
+
const file = path.join(stateDir(), 'daemon.log');
|
|
122
|
+
const line = `[${entry.lastAt}] DISTRESS ${entry.code} (${entry.component})${entry.detail ? `: ${entry.detail}` : ''}${entry.count > 1 ? ` [x${entry.count}]` : ''}`;
|
|
123
|
+
fs.appendFileSync(file, `${line}\n`, 'utf8');
|
|
124
|
+
}
|
|
125
|
+
catch { /* best-effort */ }
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Record a distress signal. Deduped: the same code+component within the
|
|
129
|
+
* dedupe window updates count/lastAt (echoed to daemon.log only on the first
|
|
130
|
+
* occurrence and then every 10th, to keep the log readable).
|
|
131
|
+
*/
|
|
132
|
+
function reportDistress(component, code, detail) {
|
|
133
|
+
try {
|
|
134
|
+
const now = new Date().toISOString();
|
|
135
|
+
const entries = readLedger();
|
|
136
|
+
const trimmedDetail = detail ? detail.replace(/\s+/g, ' ').trim().slice(0, MAX_DETAIL) : undefined;
|
|
137
|
+
const existing = entries.find(e => e.code === code
|
|
138
|
+
&& e.component === component
|
|
139
|
+
&& Date.now() - Date.parse(e.lastAt) < DEDUPE_WINDOW_MS);
|
|
140
|
+
if (existing) {
|
|
141
|
+
existing.count += 1;
|
|
142
|
+
existing.lastAt = now;
|
|
143
|
+
if (trimmedDetail)
|
|
144
|
+
existing.detail = trimmedDetail;
|
|
145
|
+
writeLedger(entries);
|
|
146
|
+
if (existing.count % 10 === 0)
|
|
147
|
+
echoToDaemonLog(existing);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const entry = {
|
|
151
|
+
code,
|
|
152
|
+
component,
|
|
153
|
+
detail: trimmedDetail,
|
|
154
|
+
firstAt: now,
|
|
155
|
+
lastAt: now,
|
|
156
|
+
count: 1,
|
|
157
|
+
};
|
|
158
|
+
entries.push(entry);
|
|
159
|
+
writeLedger(entries);
|
|
160
|
+
echoToDaemonLog(entry);
|
|
161
|
+
}
|
|
162
|
+
catch { /* distress reporting must never break the reporter */ }
|
|
163
|
+
}
|
|
164
|
+
/** Structured capture for failures nobody predicted. */
|
|
165
|
+
function reportUnexpected(component, error) {
|
|
166
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
167
|
+
reportDistress(component, exports.DISTRESS.UNEXPECTED, message);
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Recent distress for the heartbeat: entries seen within `windowMs`
|
|
171
|
+
* (default 24h), newest last, capped for transport.
|
|
172
|
+
*/
|
|
173
|
+
function readDistressSnapshot(options = {}) {
|
|
174
|
+
const windowMs = options.windowMs ?? 24 * 60 * 60_000;
|
|
175
|
+
const limit = options.limit ?? 20;
|
|
176
|
+
return readLedger()
|
|
177
|
+
.filter(e => Date.now() - Date.parse(e.lastAt) < windowMs)
|
|
178
|
+
.slice(-limit);
|
|
179
|
+
}
|
|
180
|
+
/** Full ledger for the collect_diagnostics support bundle. */
|
|
181
|
+
function readDistressLedger() {
|
|
182
|
+
return readLedger();
|
|
183
|
+
}
|
package/dist/runtimeConfig.d.ts
CHANGED
|
@@ -40,7 +40,7 @@ export interface RuntimeBundle {
|
|
|
40
40
|
/** One constrained, auditable action queued for the resident daemon. */
|
|
41
41
|
machineAction?: {
|
|
42
42
|
id: string;
|
|
43
|
-
type: 'health_check' | 'policy_refresh' | 'discovery_scan' | 'repair_protection' | 'upgrade_cli';
|
|
43
|
+
type: 'health_check' | 'policy_refresh' | 'discovery_scan' | 'repair_protection' | 'upgrade_cli' | 'collect_diagnostics';
|
|
44
44
|
reason: string;
|
|
45
45
|
createdAt: string;
|
|
46
46
|
expiresAt: string;
|
package/dist/runtimeConfig.js
CHANGED
|
@@ -39,6 +39,7 @@ exports.getCachedExtraScanRoots = getCachedExtraScanRoots;
|
|
|
39
39
|
const fs = __importStar(require("fs"));
|
|
40
40
|
const os = __importStar(require("os"));
|
|
41
41
|
const path = __importStar(require("path"));
|
|
42
|
+
const distress_1 = require("./distress");
|
|
42
43
|
const CACHE_PATH = path.join(os.homedir(), '.fullcourtdefense-runtime.json');
|
|
43
44
|
const DEFAULT_TTL_MS = 60_000;
|
|
44
45
|
const REFRESH_TIMEOUT_MS = 1_500; // tight: the hook must stay fast
|
|
@@ -94,6 +95,11 @@ async function getRuntimeBundle(input) {
|
|
|
94
95
|
writeCacheFile(cache);
|
|
95
96
|
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, honeypot: cached.honeypot, machineAction: cached.machineAction, source: 'cache' };
|
|
96
97
|
}
|
|
98
|
+
if (resp.status === 401 || resp.status === 403) {
|
|
99
|
+
// The backend REACHED us and rejected the key — a credential problem,
|
|
100
|
+
// not a network one. The two need opposite fixes; code them apart.
|
|
101
|
+
(0, distress_1.reportDistress)('bundle', distress_1.DISTRESS.AUTH_BROKEN, `bundle fetch HTTP ${resp.status}${input.shieldKey ? '' : ' (no shield key available)'}`);
|
|
102
|
+
}
|
|
97
103
|
if (resp.ok) {
|
|
98
104
|
const body = await resp.json().catch(() => ({}));
|
|
99
105
|
if (body.success && body.data && isMode(body.data.mode)) {
|
|
@@ -129,7 +135,11 @@ async function getRuntimeBundle(input) {
|
|
|
129
135
|
}
|
|
130
136
|
}
|
|
131
137
|
}
|
|
132
|
-
catch {
|
|
138
|
+
catch (error) {
|
|
139
|
+
// Fetch threw — DNS/proxy/timeout, i.e. the control plane is unreachable.
|
|
140
|
+
(0, distress_1.reportDistress)('bundle', distress_1.DISTRESS.NETWORK_DOWN, error instanceof Error ? error.message : String(error));
|
|
141
|
+
/* fall through to cache / default */
|
|
142
|
+
}
|
|
133
143
|
if (cached) {
|
|
134
144
|
return { mode: cached.mode, version: cached.version, policyHash: cached.policyHash, policyCount: cached.policyCount, pollIntervalMs: cached.pollIntervalMs, failClosed: cached.failClosed, suspended: cached.suspended, extraScanRoots: cached.extraScanRoots, disabledScanRoots: cached.disabledScanRoots, autoUpdate: cached.autoUpdate, honeypot: cached.honeypot, machineAction: cached.machineAction, source: 'cache' };
|
|
135
145
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ResolvedCliCredentials } from './config';
|
|
2
|
+
/**
|
|
3
|
+
* Deep self-test — the upgraded `health_check` remote action.
|
|
4
|
+
*
|
|
5
|
+
* Instead of passively reporting state, every subsystem the fleet depends on
|
|
6
|
+
* is actively EXERCISED: DPAPI roundtrip (encrypt AND decrypt a probe value),
|
|
7
|
+
* PowerShell spawn + language mode, control-plane reachability, authenticated
|
|
8
|
+
* bundle fetch, IDE hook/gateway integrity, and the MSI updater scheduled
|
|
9
|
+
* task. One click in the console answers in ~30 seconds what the lptx1110
|
|
10
|
+
* incident took a day of customer back-and-forth to establish.
|
|
11
|
+
*
|
|
12
|
+
* Deliberate privacy boundary: metadata only — never scans customer files,
|
|
13
|
+
* never includes prompts, keys, or file contents.
|
|
14
|
+
*/
|
|
15
|
+
export interface SelfTestCheck {
|
|
16
|
+
/** Stable machine-readable id (console renders these as a checklist). */
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
ok: boolean;
|
|
20
|
+
detail: string;
|
|
21
|
+
}
|
|
22
|
+
export interface SelfTestReport {
|
|
23
|
+
ok: boolean;
|
|
24
|
+
checks: SelfTestCheck[];
|
|
25
|
+
ranAt: string;
|
|
26
|
+
}
|
|
27
|
+
export declare function runDeepSelfTest(input: {
|
|
28
|
+
creds: ResolvedCliCredentials;
|
|
29
|
+
developerName?: string;
|
|
30
|
+
machineName?: string;
|
|
31
|
+
machineId?: string;
|
|
32
|
+
/** Injected from the daemon (avoids a module cycle); omit to skip the watchdog check. */
|
|
33
|
+
isWatchdogTaskInstalled?: () => boolean;
|
|
34
|
+
/** Progress logger — each check logs as it completes so the live log tail shows movement. */
|
|
35
|
+
log?: (message: string) => void;
|
|
36
|
+
}): Promise<SelfTestReport>;
|
|
37
|
+
/** Compact one-line summary for the machine-action result card. */
|
|
38
|
+
export declare function summarizeSelfTest(report: SelfTestReport): string;
|
package/dist/selfTest.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runDeepSelfTest = runDeepSelfTest;
|
|
4
|
+
exports.summarizeSelfTest = summarizeSelfTest;
|
|
5
|
+
const child_process_1 = require("child_process");
|
|
6
|
+
const config_1 = require("./config");
|
|
7
|
+
const credentialStore_1 = require("./credentialStore");
|
|
8
|
+
const runtimeConfig_1 = require("./runtimeConfig");
|
|
9
|
+
const integrity_1 = require("./integrity");
|
|
10
|
+
const selfUpdate_1 = require("./selfUpdate");
|
|
11
|
+
/** Windows scheduled-task probe: exists + (when readable) its last result code. */
|
|
12
|
+
function queryScheduledTask(taskName) {
|
|
13
|
+
const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/V', '/FO', 'LIST'], {
|
|
14
|
+
encoding: 'utf8', windowsHide: true, timeout: 10_000,
|
|
15
|
+
});
|
|
16
|
+
if (query.status !== 0)
|
|
17
|
+
return { exists: false };
|
|
18
|
+
// English-locale field; on other locales we still know the task exists.
|
|
19
|
+
const lastResult = query.stdout.split(/\r?\n/).find(line => /^Last Result:/i.test(line.trim()))?.split(':')[1]?.trim();
|
|
20
|
+
return { exists: true, lastResult };
|
|
21
|
+
}
|
|
22
|
+
async function runDeepSelfTest(input) {
|
|
23
|
+
const log = input.log || (() => { });
|
|
24
|
+
const checks = [];
|
|
25
|
+
const add = (check) => {
|
|
26
|
+
checks.push(check);
|
|
27
|
+
log(`Self-test: ${check.ok ? 'PASS' : 'FAIL'} ${check.label} — ${check.detail}`);
|
|
28
|
+
};
|
|
29
|
+
// 1. PowerShell spawn + language mode (Windows only).
|
|
30
|
+
if (process.platform === 'win32') {
|
|
31
|
+
const mode = (0, config_1.capturePowershellLanguageMode)();
|
|
32
|
+
if (!mode) {
|
|
33
|
+
add({ id: 'powershell', label: 'PowerShell available', ok: false, detail: 'powershell.exe/pwsh.exe did not answer (EDR/AppLocker block?)' });
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
add({
|
|
37
|
+
id: 'powershell',
|
|
38
|
+
label: 'PowerShell available',
|
|
39
|
+
ok: true,
|
|
40
|
+
detail: `language mode: ${mode}${mode !== 'FullLanguage' ? ' (hardened — CLM-safe code paths required)' : ''}`,
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
// 2. Native Credential Manager roundtrip — the PowerShell-free primary
|
|
44
|
+
// store (CLI ≥1.21.34). A failure here is NOT fatal while DPAPI works,
|
|
45
|
+
// but the console should see which machines run on the fallback.
|
|
46
|
+
const native = (0, credentialStore_1.nativeStoreRoundtripProbe)();
|
|
47
|
+
add({ id: 'native_store', label: 'Credential Manager (native) roundtrip', ok: native.ok, detail: native.detail });
|
|
48
|
+
// 3. DPAPI roundtrip — encrypt AND decrypt a probe value with the exact
|
|
49
|
+
// snippets the CLI uses for the shield key. This is the check that would
|
|
50
|
+
// have identified the lptx1110 credential break in one click. Kept as its
|
|
51
|
+
// own check: it is the fallback store for machines without the native
|
|
52
|
+
// binding and the rollback path to ≤1.21.33.
|
|
53
|
+
const probe = (0, config_1.dpapiRoundtripProbe)();
|
|
54
|
+
add({ id: 'dpapi_roundtrip', label: 'Credential store (DPAPI) roundtrip', ok: probe.ok, detail: probe.detail });
|
|
55
|
+
}
|
|
56
|
+
// 3. Credentials resolved on THIS machine right now.
|
|
57
|
+
const trace = (() => {
|
|
58
|
+
if (input.creds.shieldId && input.creds.shieldKey)
|
|
59
|
+
return { ok: true, detail: `shield key from: ${(0, config_1.getCredentialResolutionTrace)()?.shieldKeySource || 'unknown'}` };
|
|
60
|
+
if (!input.creds.shieldId)
|
|
61
|
+
return { ok: false, detail: 'no shield ID — machine looks unenrolled' };
|
|
62
|
+
return { ok: false, detail: `no shield key (source: ${(0, config_1.getCredentialResolutionTrace)()?.shieldKeySource || 'unknown'})` };
|
|
63
|
+
})();
|
|
64
|
+
add({ id: 'credentials', label: 'Shield credentials resolve', ...trace });
|
|
65
|
+
// 4. Control plane reachable (unauthenticated ping — separates network from auth).
|
|
66
|
+
try {
|
|
67
|
+
const resp = await fetch(`${input.creds.apiUrl}/api/health/ping`, { signal: AbortSignal.timeout(8_000) });
|
|
68
|
+
add({ id: 'api_reachable', label: 'Control plane reachable', ok: resp.ok, detail: `GET /api/health/ping → HTTP ${resp.status}` });
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
add({ id: 'api_reachable', label: 'Control plane reachable', ok: false, detail: `network error: ${error instanceof Error ? error.message : String(error)}` });
|
|
72
|
+
}
|
|
73
|
+
// 5. Authenticated bundle fetch — the credentials actually WORK against the backend.
|
|
74
|
+
if (input.creds.shieldId) {
|
|
75
|
+
try {
|
|
76
|
+
const bundle = await (0, runtimeConfig_1.getRuntimeBundle)({
|
|
77
|
+
apiUrl: input.creds.apiUrl,
|
|
78
|
+
shieldId: input.creds.shieldId,
|
|
79
|
+
shieldKey: input.creds.shieldKey,
|
|
80
|
+
developerName: input.developerName,
|
|
81
|
+
machineName: input.machineName,
|
|
82
|
+
machineId: input.machineId,
|
|
83
|
+
force: true,
|
|
84
|
+
ttlMs: 0,
|
|
85
|
+
});
|
|
86
|
+
add({
|
|
87
|
+
id: 'bundle_auth',
|
|
88
|
+
label: 'Authenticated policy fetch',
|
|
89
|
+
ok: bundle.source === 'server',
|
|
90
|
+
detail: bundle.source === 'server'
|
|
91
|
+
? `live bundle from server (mode ${bundle.mode}, ${bundle.policyCount ?? 0} policies)`
|
|
92
|
+
: `no live bundle — running on ${bundle.source} (auth rejected or offline; see distress codes)`,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
add({ id: 'bundle_auth', label: 'Authenticated policy fetch', ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// 6. Protection surfaces: IDE hooks + MCP gateway wraps + daemon.
|
|
100
|
+
try {
|
|
101
|
+
const integrity = (0, integrity_1.getLocalIntegrityReport)({ requireDaemon: true });
|
|
102
|
+
add({
|
|
103
|
+
id: 'protection_integrity',
|
|
104
|
+
label: 'Hooks & gateway integrity',
|
|
105
|
+
ok: integrity.ok,
|
|
106
|
+
detail: integrity.ok
|
|
107
|
+
? `healthy (${integrity.protectedMcpConfigs}/${integrity.discoveredMcpConfigs} MCP configs wrapped)`
|
|
108
|
+
: integrity.reasons.join(', '),
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
add({ id: 'protection_integrity', label: 'Hooks & gateway integrity', ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
113
|
+
}
|
|
114
|
+
// 7. Updater scheduled task (MSI installs only — npm installs update in place).
|
|
115
|
+
if (process.platform === 'win32' && (0, selfUpdate_1.detectInstallKind)() === 'msi') {
|
|
116
|
+
const task = queryScheduledTask(selfUpdate_1.MSI_UPDATER_TASK_NAME);
|
|
117
|
+
add({
|
|
118
|
+
id: 'updater_task',
|
|
119
|
+
label: 'Silent-update task registered',
|
|
120
|
+
ok: task.exists,
|
|
121
|
+
detail: task.exists
|
|
122
|
+
? `"${selfUpdate_1.MSI_UPDATER_TASK_NAME}" present${task.lastResult !== undefined ? `, last result ${task.lastResult}` : ''}`
|
|
123
|
+
: `"${selfUpdate_1.MSI_UPDATER_TASK_NAME}" missing — silent updates dead until reinstall/self-heal`,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// 8. Watchdog liveness task (injected — daemon-owned logic).
|
|
127
|
+
if (input.isWatchdogTaskInstalled) {
|
|
128
|
+
try {
|
|
129
|
+
const installed = input.isWatchdogTaskInstalled();
|
|
130
|
+
add({
|
|
131
|
+
id: 'watchdog_task',
|
|
132
|
+
label: 'Watchdog task registered',
|
|
133
|
+
ok: installed,
|
|
134
|
+
detail: installed ? 'out-of-process liveness beacon present' : 'watchdog task missing (self-heals at next daemon boot)',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
add({ id: 'watchdog_task', label: 'Watchdog task registered', ok: false, detail: error instanceof Error ? error.message : String(error) });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { ok: checks.every(check => check.ok), checks, ranAt: new Date().toISOString() };
|
|
142
|
+
}
|
|
143
|
+
/** Compact one-line summary for the machine-action result card. */
|
|
144
|
+
function summarizeSelfTest(report) {
|
|
145
|
+
const failed = report.checks.filter(check => !check.ok);
|
|
146
|
+
if (failed.length === 0)
|
|
147
|
+
return `Deep self-test passed — all ${report.checks.length} subsystems healthy.`;
|
|
148
|
+
return `Deep self-test: ${report.checks.length - failed.length}/${report.checks.length} passed — FAILED: ${failed.map(check => `${check.id} (${check.detail})`).join('; ')}`;
|
|
149
|
+
}
|
package/dist/selfUpdate.d.ts
CHANGED
|
@@ -26,6 +26,14 @@ export interface SelfUpdateResult {
|
|
|
26
26
|
detail: string;
|
|
27
27
|
}
|
|
28
28
|
export declare function resolveNpmCommand(platform?: NodeJS.Platform, execPath?: string, exists?: typeof fs.existsSync): string;
|
|
29
|
+
/**
|
|
30
|
+
* Updater script log (%ProgramData%\FullCourtDefense\updater.log) — the
|
|
31
|
+
* elevated task's own words. Read by the diagnostics bundle and the update-
|
|
32
|
+
* loop detector so a silently failing updater is explainable from the console
|
|
33
|
+
* (Alin's machine triggered the task every 10 minutes for hours with zero
|
|
34
|
+
* fleet-visible evidence of WHY).
|
|
35
|
+
*/
|
|
36
|
+
export declare function readUpdaterLogTail(maxLines?: number): string[];
|
|
29
37
|
/**
|
|
30
38
|
* Upgrade this machine to targetVersion if it is newer than currentVersion.
|
|
31
39
|
* Cheap no-op when already current, when a kick is still pending, or when the
|
package/dist/selfUpdate.js
CHANGED
|
@@ -37,11 +37,14 @@ exports.MSI_UPDATER_TASK_NAME = void 0;
|
|
|
37
37
|
exports.detectInstallKind = detectInstallKind;
|
|
38
38
|
exports.compareCliVersions = compareCliVersions;
|
|
39
39
|
exports.resolveNpmCommand = resolveNpmCommand;
|
|
40
|
+
exports.readUpdaterLogTail = readUpdaterLogTail;
|
|
40
41
|
exports.maybeSelfUpdate = maybeSelfUpdate;
|
|
41
42
|
exports.installedMsiVersion = installedMsiVersion;
|
|
42
43
|
const fs = __importStar(require("fs"));
|
|
44
|
+
const os = __importStar(require("os"));
|
|
43
45
|
const path = __importStar(require("path"));
|
|
44
46
|
const child_process_1 = require("child_process");
|
|
47
|
+
const distress_1 = require("./distress");
|
|
45
48
|
/**
|
|
46
49
|
* Silent CLI self-update.
|
|
47
50
|
*
|
|
@@ -159,6 +162,7 @@ function startMsiSelfUpdate(targetVersion, log) {
|
|
|
159
162
|
const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', exports.MSI_UPDATER_TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
160
163
|
if (query.status !== 0 && !tryRegisterMsiUpdaterTask(log)) {
|
|
161
164
|
log('Self-update: MSI updater task is not registered on this machine — reinstall the MSI to enable silent updates.');
|
|
165
|
+
(0, distress_1.reportDistress)('self-update', distress_1.DISTRESS.UPDATER_TASK_MISSING, `"${exports.MSI_UPDATER_TASK_NAME}" scheduled task missing and self-registration failed`);
|
|
162
166
|
return {
|
|
163
167
|
started: false,
|
|
164
168
|
kind: 'msi',
|
|
@@ -177,6 +181,55 @@ function startMsiSelfUpdate(targetVersion, log) {
|
|
|
177
181
|
detail: `Could not trigger the "${exports.MSI_UPDATER_TASK_NAME}" task immediately; it still runs on its daily schedule.`,
|
|
178
182
|
};
|
|
179
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Updater script log (%ProgramData%\FullCourtDefense\updater.log) — the
|
|
186
|
+
* elevated task's own words. Read by the diagnostics bundle and the update-
|
|
187
|
+
* loop detector so a silently failing updater is explainable from the console
|
|
188
|
+
* (Alin's machine triggered the task every 10 minutes for hours with zero
|
|
189
|
+
* fleet-visible evidence of WHY).
|
|
190
|
+
*/
|
|
191
|
+
function readUpdaterLogTail(maxLines = 40) {
|
|
192
|
+
if (process.platform !== 'win32')
|
|
193
|
+
return [];
|
|
194
|
+
try {
|
|
195
|
+
const file = path.join(process.env.ProgramData || 'C:\\ProgramData', 'FullCourtDefense', 'updater.log');
|
|
196
|
+
return fs.readFileSync(file, 'utf8')
|
|
197
|
+
.split(/\r?\n/)
|
|
198
|
+
.map(line => line.trim())
|
|
199
|
+
.filter(Boolean)
|
|
200
|
+
.slice(-maxLines)
|
|
201
|
+
.map(line => line.slice(0, 400));
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function updateAttemptsFile() {
|
|
208
|
+
return path.join(os.homedir(), '.fullcourtdefense', 'update-attempts.json');
|
|
209
|
+
}
|
|
210
|
+
/** Attempts to reach `target` from an unchanged installed version — persisted across daemon restarts. */
|
|
211
|
+
function recordUpdateAttempt(target, fromVersion) {
|
|
212
|
+
try {
|
|
213
|
+
let record;
|
|
214
|
+
try {
|
|
215
|
+
const parsed = JSON.parse(fs.readFileSync(updateAttemptsFile(), 'utf8'));
|
|
216
|
+
if (parsed && parsed.target === target && parsed.fromVersion === fromVersion)
|
|
217
|
+
record = parsed;
|
|
218
|
+
}
|
|
219
|
+
catch { /* no prior record for this target/version pair */ }
|
|
220
|
+
const next = record
|
|
221
|
+
? { ...record, count: record.count + 1 }
|
|
222
|
+
: { target, fromVersion, count: 1, firstAt: new Date().toISOString() };
|
|
223
|
+
fs.mkdirSync(path.dirname(updateAttemptsFile()), { recursive: true });
|
|
224
|
+
fs.writeFileSync(updateAttemptsFile(), JSON.stringify(next, null, 2), 'utf8');
|
|
225
|
+
return next.count;
|
|
226
|
+
}
|
|
227
|
+
catch {
|
|
228
|
+
return 1;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** How many times an update to the same target was kicked without the version moving. */
|
|
232
|
+
const UPDATE_LOOP_THRESHOLD = 3;
|
|
180
233
|
let updateInFlightSince = 0;
|
|
181
234
|
let updateInFlightTarget;
|
|
182
235
|
// A normal MSI finishes in ~5 minutes. Ten minutes avoids duplicate kicks
|
|
@@ -205,6 +258,16 @@ function maybeSelfUpdate(input) {
|
|
|
205
258
|
updateInFlightSince = Date.now();
|
|
206
259
|
updateInFlightTarget = input.targetVersion;
|
|
207
260
|
log(`Self-update: CLI ${input.currentVersion || 'unknown'} -> ${input.targetVersion} (org auto-update).`);
|
|
261
|
+
// Update-loop detection: kicking the updater repeatedly while the installed
|
|
262
|
+
// version never changes means the elevated task is failing silently (EDR
|
|
263
|
+
// block, download failure, msiexec denial). Surface it as coded distress —
|
|
264
|
+
// with the updater's own log line — instead of looping invisibly for hours.
|
|
265
|
+
const attempts = recordUpdateAttempt(input.targetVersion, input.currentVersion || 'unknown');
|
|
266
|
+
if (attempts >= UPDATE_LOOP_THRESHOLD) {
|
|
267
|
+
const updaterTail = readUpdaterLogTail(3);
|
|
268
|
+
(0, distress_1.reportDistress)('self-update', distress_1.DISTRESS.UPDATE_LOOP, `target ${input.targetVersion} triggered ${attempts}x, still on ${input.currentVersion || 'unknown'}`
|
|
269
|
+
+ (updaterTail.length ? ` — updater.log: ${updaterTail[updaterTail.length - 1]}` : ' — updater.log unreadable (task may never have run)'));
|
|
270
|
+
}
|
|
208
271
|
const kind = detectInstallKind();
|
|
209
272
|
const result = kind === 'msi'
|
|
210
273
|
? startMsiSelfUpdate(input.targetVersion, log)
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -42,6 +42,19 @@ export interface FlushInput {
|
|
|
42
42
|
powershellSpawnOk?: boolean;
|
|
43
43
|
/** Did the DPAPI-protected shield key actually decrypt? */
|
|
44
44
|
powershellDecryptOk?: boolean;
|
|
45
|
+
/** PowerShell language mode (FullLanguage/ConstrainedLanguage/…) — captured on decrypt failures and self-tests. */
|
|
46
|
+
powershellLanguageMode?: string;
|
|
47
|
+
/** Which source supplied the shield key on the last resolution (codes only — see config.ts ShieldKeySource). */
|
|
48
|
+
shieldKeySource?: string;
|
|
49
|
+
/** Recent coded distress signals from the on-disk ledger (see distress.ts). */
|
|
50
|
+
distress?: Array<{
|
|
51
|
+
code: string;
|
|
52
|
+
component: string;
|
|
53
|
+
detail?: string;
|
|
54
|
+
firstAt: string;
|
|
55
|
+
lastAt: string;
|
|
56
|
+
count: number;
|
|
57
|
+
}>;
|
|
45
58
|
/** Post-mortem of a previous daemon that died without a clean shutdown. */
|
|
46
59
|
lastCrash?: {
|
|
47
60
|
version?: string;
|
package/dist/telemetry.js
CHANGED
|
@@ -176,6 +176,12 @@ async function flushSpool(input) {
|
|
|
176
176
|
securityAgents: input.securityAgents,
|
|
177
177
|
powershellSpawnOk: input.powershellSpawnOk,
|
|
178
178
|
powershellDecryptOk: input.powershellDecryptOk,
|
|
179
|
+
powershellLanguageMode: input.powershellLanguageMode,
|
|
180
|
+
shieldKeySource: input.shieldKeySource,
|
|
181
|
+
// Coded distress ledger: the machine's own explanation of WHY
|
|
182
|
+
// something is failing (dpapi_decrypt_failed, update_loop, …) —
|
|
183
|
+
// the console reads root causes instead of asking the customer.
|
|
184
|
+
distress: input.distress,
|
|
179
185
|
lastCrash: input.lastCrash,
|
|
180
186
|
}
|
|
181
187
|
: undefined,
|
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.34",
|
|
4
4
|
"description": "Full Court Defense CLI — security scanning for AI agents from your terminal",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -43,6 +43,12 @@
|
|
|
43
43
|
"test:posture-overhaul": "npm run build && node scripts/test-posture-overhaul.js",
|
|
44
44
|
"test:scan-root-compaction": "npm run build && node scripts/test-scan-root-compaction.js",
|
|
45
45
|
"test:dpapi-config": "npm run build && node scripts/test-dpapi-config.js",
|
|
46
|
+
"test:distress-ledger": "npm run build && node scripts/test-distress-ledger.js",
|
|
47
|
+
"test:deep-selftest": "npm run build && node scripts/test-deep-selftest.js",
|
|
48
|
+
"test:update-loop": "npm run build && node scripts/test-update-loop-detection.js",
|
|
49
|
+
"test:bricked-rescue": "npm run build && node scripts/test-bricked-machine-rescue.js",
|
|
50
|
+
"test:native-credstore": "npm run build && node scripts/test-native-credential-store.js",
|
|
51
|
+
"test:real-life": "npm run build && node scripts/test-real-life-scenarios.js",
|
|
46
52
|
"test:desktop-chat-guard": "npm run build && node scripts/test-desktop-chat-guard.js",
|
|
47
53
|
"test:clipboard-scan": "npm run build && node scripts/test-clipboard-scan.js",
|
|
48
54
|
"build:msi": "powershell -NoProfile -ExecutionPolicy Bypass -File installer/windows/Build-Msi.ps1",
|
|
@@ -69,6 +75,7 @@
|
|
|
69
75
|
"node": ">=18.0.0"
|
|
70
76
|
},
|
|
71
77
|
"dependencies": {
|
|
78
|
+
"@napi-rs/keyring": "^1.3.0",
|
|
72
79
|
"yaml": "^2.8.4"
|
|
73
80
|
}
|
|
74
81
|
}
|