fullcourtdefense-cli 1.25.2 → 1.25.4
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/cmdGuard.js +4 -1
- package/dist/commands/daemon.d.ts +20 -2
- package/dist/commands/daemon.js +120 -43
- package/dist/commands/deterministicGuard.d.ts +1 -0
- package/dist/commands/deterministicGuard.js +51 -6
- package/dist/commands/posixShellGuard.js +4 -1
- package/dist/commands/shellGuard.js +29 -1
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -118,7 +118,10 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
118
118
|
`const NODE_PATH=${JSON.stringify(nodePath)};`,
|
|
119
119
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
120
120
|
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
121
|
-
|
|
121
|
+
`// Execution view (aligned with the CLI's stripInertDataSegments): commit messages, issue/PR bodies, and`,
|
|
122
|
+
`// here-strings/heredocs are DATA — inert unless the line can feed an interpreter or the region interpolates.`,
|
|
123
|
+
`function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]*\`/;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');o=o.replace(/((?:^|\\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\\s*)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')/gi,(w,f,q)=>q[0]==="'"?f+' fcd_inert_data ':K.test(q)?w:f+' fcd_inert_data ');return o;}`,
|
|
124
|
+
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
122
125
|
`function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'cmd_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){spawnSync(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{stdio:'ignore',windowsHide:true});}}catch{}}`,
|
|
123
126
|
`function delegate(args){const r=spawnSync(process.env.ComSpec||'cmd.exe',['/d','/c',...args],{stdio:'inherit',windowsHide:true});process.exit(typeof r.status==='number'?r.status:1);}`,
|
|
124
127
|
`const args=process.argv.slice(2);if(!args.length)process.exit(0);if(process.env.FCD_CMD_GUARD==='off')delegate(args);`,
|
|
@@ -38,14 +38,27 @@ export type TaskLogonType = 'S4U' | 'InteractiveToken';
|
|
|
38
38
|
export declare function taskUserId(): string;
|
|
39
39
|
/** Local wall-clock timestamp (no ms, no tz) as Task Scheduler expects. */
|
|
40
40
|
export declare function taskLocalTimestamp(date: Date): string;
|
|
41
|
+
/** Full path to conhost.exe — the signed Windows binary whose `--headless`
|
|
42
|
+
* flag hosts a console child on a hidden pseudoconsole (no window, ever). */
|
|
43
|
+
export declare function conhostPath(): string;
|
|
41
44
|
/**
|
|
42
45
|
* Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
|
|
43
46
|
*
|
|
44
47
|
* @param command CLI subcommand + args ('daemon' | 'watchdog' | 'discover …').
|
|
45
48
|
* @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
|
|
46
49
|
* time trigger for the 5-minute watchdog.
|
|
47
|
-
* @param logonType S4U (windowless, needs elevation to register) or
|
|
48
|
-
* InteractiveToken (registers unelevated
|
|
50
|
+
* @param logonType S4U (windowless by session, needs elevation to register) or
|
|
51
|
+
* InteractiveToken (registers unelevated — the action is
|
|
52
|
+
* wrapped in `conhost --headless` so it is ALSO windowless).
|
|
53
|
+
*
|
|
54
|
+
* Why the conhost wrapper: an InteractiveToken task runs console apps on the
|
|
55
|
+
* interactive desktop — a visible window on EVERY trigger (the "console flash
|
|
56
|
+
* every 5 minutes" complaint). Current Windows builds deny S4U registration to
|
|
57
|
+
* everyone except the task's own user running elevated (even SYSTEM gets
|
|
58
|
+
* Access denied — verified empirically; the updater's SYSTEM repair pass can
|
|
59
|
+
* no longer fix it). `conhost.exe --headless` sidesteps the whole fight: the
|
|
60
|
+
* child gets a hidden pseudoconsole, no window ever exists, registration
|
|
61
|
+
* stays unelevated, and no script host is involved (Defender-safe).
|
|
49
62
|
*/
|
|
50
63
|
export declare function buildTaskXml(command: string, triggers: string, logonType?: TaskLogonType): string;
|
|
51
64
|
/**
|
|
@@ -74,6 +87,11 @@ export declare function windowsTaskReferencesScriptHost(taskName: string): boole
|
|
|
74
87
|
* the 1.22.1 window-flash bug (console window on every trigger). Migration
|
|
75
88
|
* trigger for the windowless S4U principal. */
|
|
76
89
|
export declare function windowsTaskRunsInteractive(taskName: string): boolean;
|
|
90
|
+
/** True when triggering the task would open a VISIBLE console window:
|
|
91
|
+
* InteractiveToken principal AND a bare console action (not wrapped in
|
|
92
|
+
* `conhost --headless`). This — not the principal alone — is the flash
|
|
93
|
+
* condition; a conhost-wrapped InteractiveToken task is fully windowless. */
|
|
94
|
+
export declare function windowsTaskRunsVisibly(taskName: string): boolean;
|
|
77
95
|
/** Snapshot of the resident daemon for out-of-process callers (watchdog/status). */
|
|
78
96
|
export interface DaemonRuntimeState {
|
|
79
97
|
alive: boolean;
|
package/dist/commands/daemon.js
CHANGED
|
@@ -39,11 +39,13 @@ exports.discoverSweepCredentialEnv = discoverSweepCredentialEnv;
|
|
|
39
39
|
exports.cliVersion = cliVersion;
|
|
40
40
|
exports.taskUserId = taskUserId;
|
|
41
41
|
exports.taskLocalTimestamp = taskLocalTimestamp;
|
|
42
|
+
exports.conhostPath = conhostPath;
|
|
42
43
|
exports.buildTaskXml = buildTaskXml;
|
|
43
44
|
exports.buildTaskXmlVariants = buildTaskXmlVariants;
|
|
44
45
|
exports.registerHiddenTask = registerHiddenTask;
|
|
45
46
|
exports.windowsTaskReferencesScriptHost = windowsTaskReferencesScriptHost;
|
|
46
47
|
exports.windowsTaskRunsInteractive = windowsTaskRunsInteractive;
|
|
48
|
+
exports.windowsTaskRunsVisibly = windowsTaskRunsVisibly;
|
|
47
49
|
exports.daemonRuntimeState = daemonRuntimeState;
|
|
48
50
|
exports.spawnDetachedDaemon = spawnDetachedDaemon;
|
|
49
51
|
exports.pidIsAlive = pidIsAlive;
|
|
@@ -1261,23 +1263,41 @@ function taskLocalTimestamp(date) {
|
|
|
1261
1263
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
|
1262
1264
|
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
|
1263
1265
|
}
|
|
1266
|
+
/** Full path to conhost.exe — the signed Windows binary whose `--headless`
|
|
1267
|
+
* flag hosts a console child on a hidden pseudoconsole (no window, ever). */
|
|
1268
|
+
function conhostPath() {
|
|
1269
|
+
return path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'conhost.exe');
|
|
1270
|
+
}
|
|
1264
1271
|
/**
|
|
1265
1272
|
* Task Scheduler XML that runs `node.exe <cliEntry> <command>` hidden.
|
|
1266
1273
|
*
|
|
1267
1274
|
* @param command CLI subcommand + args ('daemon' | 'watchdog' | 'discover …').
|
|
1268
1275
|
* @param triggers Inner <Triggers> XML — logon for the daemon, a repeating
|
|
1269
1276
|
* time trigger for the 5-minute watchdog.
|
|
1270
|
-
* @param logonType S4U (windowless, needs elevation to register) or
|
|
1271
|
-
* InteractiveToken (registers unelevated
|
|
1277
|
+
* @param logonType S4U (windowless by session, needs elevation to register) or
|
|
1278
|
+
* InteractiveToken (registers unelevated — the action is
|
|
1279
|
+
* wrapped in `conhost --headless` so it is ALSO windowless).
|
|
1280
|
+
*
|
|
1281
|
+
* Why the conhost wrapper: an InteractiveToken task runs console apps on the
|
|
1282
|
+
* interactive desktop — a visible window on EVERY trigger (the "console flash
|
|
1283
|
+
* every 5 minutes" complaint). Current Windows builds deny S4U registration to
|
|
1284
|
+
* everyone except the task's own user running elevated (even SYSTEM gets
|
|
1285
|
+
* Access denied — verified empirically; the updater's SYSTEM repair pass can
|
|
1286
|
+
* no longer fix it). `conhost.exe --headless` sidesteps the whole fight: the
|
|
1287
|
+
* child gets a hidden pseudoconsole, no window ever exists, registration
|
|
1288
|
+
* stays unelevated, and no script host is involved (Defender-safe).
|
|
1272
1289
|
*/
|
|
1273
1290
|
function buildTaskXml(command, triggers, logonType = 'S4U') {
|
|
1274
1291
|
const userId = xmlEscape(taskUserId());
|
|
1275
|
-
const
|
|
1276
|
-
const
|
|
1292
|
+
const headless = logonType === 'InteractiveToken';
|
|
1293
|
+
const execCommand = headless ? xmlEscape(conhostPath()) : xmlEscape(process.execPath);
|
|
1294
|
+
const args = headless
|
|
1295
|
+
? xmlEscape(`--headless "${process.execPath}" "${cliEntry()}" ${command}`)
|
|
1296
|
+
: xmlEscape(`"${cliEntry()}" ${command}`);
|
|
1277
1297
|
return `<?xml version="1.0" encoding="UTF-16"?>
|
|
1278
1298
|
<Task version="1.2" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
|
|
1279
1299
|
<RegistrationInfo>
|
|
1280
|
-
<Description>FullCourtDefense ${xmlEscape(command)} — runs node directly, windowless, no script host.</Description>
|
|
1300
|
+
<Description>FullCourtDefense ${xmlEscape(command)} — runs node ${headless ? 'under conhost --headless' : 'directly'}, windowless, no script host.</Description>
|
|
1281
1301
|
</RegistrationInfo>
|
|
1282
1302
|
<Triggers>
|
|
1283
1303
|
${triggers}
|
|
@@ -1307,7 +1327,7 @@ ${triggers}
|
|
|
1307
1327
|
</Settings>
|
|
1308
1328
|
<Actions Context="Author">
|
|
1309
1329
|
<Exec>
|
|
1310
|
-
<Command>${
|
|
1330
|
+
<Command>${execCommand}</Command>
|
|
1311
1331
|
<Arguments>${args}</Arguments>
|
|
1312
1332
|
</Exec>
|
|
1313
1333
|
</Actions>
|
|
@@ -1383,10 +1403,10 @@ function registerHiddenTask(taskName, xml, command, scheduleArgs) {
|
|
|
1383
1403
|
}
|
|
1384
1404
|
catch { /* try next variant, then /TR */ }
|
|
1385
1405
|
}
|
|
1386
|
-
// Fallback: node
|
|
1387
|
-
// under a path with spaces (Program Files), so
|
|
1388
|
-
// pass the whole /TR string through.
|
|
1389
|
-
const tr =
|
|
1406
|
+
// Fallback: node via /TR under conhost --headless (no wscript, no VBS, no
|
|
1407
|
+
// window). node/CLI may live under a path with spaces (Program Files), so
|
|
1408
|
+
// quote each and let schtasks pass the whole /TR string through.
|
|
1409
|
+
const tr = `${conhostPath()} --headless "${process.execPath}" "${cliEntry()}" ${command}`;
|
|
1390
1410
|
const fallback = (0, child_process_1.spawnSync)('schtasks', [
|
|
1391
1411
|
'/Create', '/TN', taskName, '/TR', tr, ...scheduleArgs, '/F',
|
|
1392
1412
|
], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
@@ -1421,16 +1441,30 @@ function windowsTaskReferencesScriptHost(taskName) {
|
|
|
1421
1441
|
* the 1.22.1 window-flash bug (console window on every trigger). Migration
|
|
1422
1442
|
* trigger for the windowless S4U principal. */
|
|
1423
1443
|
function windowsTaskRunsInteractive(taskName) {
|
|
1444
|
+
return windowsTaskXmlState(taskName).interactive;
|
|
1445
|
+
}
|
|
1446
|
+
/** True when triggering the task would open a VISIBLE console window:
|
|
1447
|
+
* InteractiveToken principal AND a bare console action (not wrapped in
|
|
1448
|
+
* `conhost --headless`). This — not the principal alone — is the flash
|
|
1449
|
+
* condition; a conhost-wrapped InteractiveToken task is fully windowless. */
|
|
1450
|
+
function windowsTaskRunsVisibly(taskName) {
|
|
1451
|
+
const state = windowsTaskXmlState(taskName);
|
|
1452
|
+
return state.interactive && !state.headless;
|
|
1453
|
+
}
|
|
1454
|
+
function windowsTaskXmlState(taskName) {
|
|
1424
1455
|
try {
|
|
1425
1456
|
const query = (0, child_process_1.spawnSync)('schtasks', ['/Query', '/TN', taskName, '/XML'], {
|
|
1426
1457
|
encoding: 'utf8', windowsHide: true, timeout: 10_000,
|
|
1427
1458
|
});
|
|
1428
1459
|
if (query.status !== 0 || typeof query.stdout !== 'string')
|
|
1429
|
-
return false;
|
|
1430
|
-
return
|
|
1460
|
+
return { interactive: false, headless: false };
|
|
1461
|
+
return {
|
|
1462
|
+
interactive: /<LogonType>\s*InteractiveToken\s*<\/LogonType>/i.test(query.stdout),
|
|
1463
|
+
headless: /conhost(\.exe)?<\/Command>[\s\S]*?--headless/i.test(query.stdout),
|
|
1464
|
+
};
|
|
1431
1465
|
}
|
|
1432
1466
|
catch {
|
|
1433
|
-
return false;
|
|
1467
|
+
return { interactive: false, headless: false };
|
|
1434
1468
|
}
|
|
1435
1469
|
}
|
|
1436
1470
|
/**
|
|
@@ -1456,7 +1490,38 @@ function upgradeTaskWindowless(taskName, s4uXml, logFn) {
|
|
|
1456
1490
|
logFn(`Self-heal: "${taskName}" migrated to the windowless S4U principal (no more console-window flash).`);
|
|
1457
1491
|
return true;
|
|
1458
1492
|
}
|
|
1459
|
-
logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs elevation) —
|
|
1493
|
+
logFn(`Self-heal: could not migrate "${taskName}" to S4U (needs same-user elevation) — re-registering it headless instead.`);
|
|
1494
|
+
return false;
|
|
1495
|
+
}
|
|
1496
|
+
catch {
|
|
1497
|
+
return false; /* keep the existing working task */
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Re-register an InteractiveToken task with its action wrapped in
|
|
1502
|
+
* `conhost --headless` — windowless without touching the principal, so the
|
|
1503
|
+
* SAME user can do it unelevated (unlike S4U, which current Windows only
|
|
1504
|
+
* grants to the task's own user running elevated — even SYSTEM is denied).
|
|
1505
|
+
* Returns true when the task no longer opens a window on trigger.
|
|
1506
|
+
*/
|
|
1507
|
+
function rewrapTaskHeadless(taskName, interactiveHeadlessXml, logFn) {
|
|
1508
|
+
const base = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
1509
|
+
const dir = path.join(base, 'FullCourtDefense');
|
|
1510
|
+
try {
|
|
1511
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1512
|
+
}
|
|
1513
|
+
catch { /* ignore */ }
|
|
1514
|
+
const xmlPath = path.join(dir, `${taskName.replace(/[^A-Za-z0-9]+/g, '_')}.task.xml`);
|
|
1515
|
+
try {
|
|
1516
|
+
fs.writeFileSync(xmlPath, `\ufeff${interactiveHeadlessXml}`, 'utf16le');
|
|
1517
|
+
const created = (0, child_process_1.spawnSync)('schtasks', ['/Create', '/TN', taskName, '/XML', xmlPath, '/F'], {
|
|
1518
|
+
stdio: 'ignore', windowsHide: true, timeout: 20_000,
|
|
1519
|
+
});
|
|
1520
|
+
if (created.status === 0) {
|
|
1521
|
+
logFn(`Self-heal: "${taskName}" re-registered with a conhost --headless action (windowless from its next trigger).`);
|
|
1522
|
+
return true;
|
|
1523
|
+
}
|
|
1524
|
+
logFn(`Self-heal: could not re-register "${taskName}" headless — asking the elevated updater task to migrate it.`);
|
|
1460
1525
|
return false;
|
|
1461
1526
|
}
|
|
1462
1527
|
catch {
|
|
@@ -1478,14 +1543,20 @@ let updaterMigrationKicked = false;
|
|
|
1478
1543
|
function kickUpdaterTaskForMigration(logFn) {
|
|
1479
1544
|
if (updaterMigrationKicked)
|
|
1480
1545
|
return;
|
|
1481
|
-
updaterMigrationKicked = true;
|
|
1482
1546
|
try {
|
|
1483
1547
|
const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', selfUpdate_1.MSI_UPDATER_TASK_NAME], {
|
|
1484
1548
|
stdio: 'ignore', windowsHide: true, timeout: 20_000,
|
|
1485
1549
|
});
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1550
|
+
// Latch ONLY on a successful kick. On a fresh MSI install the daemon can
|
|
1551
|
+
// boot before the SYSTEM updater task exists — burning the one-shot latch
|
|
1552
|
+
// on that failure left the machine flashing until the 03:07 daily run.
|
|
1553
|
+
if (run.status === 0) {
|
|
1554
|
+
updaterMigrationKicked = true;
|
|
1555
|
+
logFn(`Self-heal: elevated updater task triggered — its repair pass migrates the tasks now.`);
|
|
1556
|
+
}
|
|
1557
|
+
else {
|
|
1558
|
+
logFn(`Self-heal: updater task unavailable for the task migration — will retry on a later self-heal; the daily run (03:07) is the backstop.`);
|
|
1559
|
+
}
|
|
1489
1560
|
}
|
|
1490
1561
|
catch { /* best-effort — the daily schedule remains the backstop */ }
|
|
1491
1562
|
}
|
|
@@ -1528,21 +1599,20 @@ function startDaemonNowWindows(viaTask = false) {
|
|
|
1528
1599
|
}
|
|
1529
1600
|
}
|
|
1530
1601
|
catch { /* not running */ }
|
|
1531
|
-
if (viaTask && !
|
|
1602
|
+
if (viaTask && !windowsTaskRunsVisibly(TASK_NAME)) {
|
|
1532
1603
|
// Start through the scheduled task so the daemon runs with the task's
|
|
1533
1604
|
// LIMITED (non-elevated) token. Launching directly from an elevated MSI
|
|
1534
1605
|
// custom action would leave an elevated daemon that a normal-user CLI can
|
|
1535
1606
|
// never stop or supersede.
|
|
1536
1607
|
//
|
|
1537
|
-
// ONLY when the task
|
|
1538
|
-
//
|
|
1539
|
-
//
|
|
1540
|
-
//
|
|
1541
|
-
//
|
|
1542
|
-
//
|
|
1543
|
-
//
|
|
1544
|
-
//
|
|
1545
|
-
// caller IS elevated, S4U registration succeeded and this branch is taken.
|
|
1608
|
+
// ONLY when the task cannot open a window (S4U principal, or an
|
|
1609
|
+
// InteractiveToken action wrapped in conhost --headless): starting a
|
|
1610
|
+
// bare-console InteractiveToken task runs node.exe on the interactive
|
|
1611
|
+
// desktop, and since the daemon never exits, that console window stays
|
|
1612
|
+
// OPEN on the user's screen — the "open console at the end of
|
|
1613
|
+
// installation" customer complaint. A visibly-registered task takes the
|
|
1614
|
+
// hidden WMI path below instead; self-heal re-registers it headless on
|
|
1615
|
+
// the next daemon boot.
|
|
1546
1616
|
const run = (0, child_process_1.spawnSync)('schtasks', ['/Run', '/TN', TASK_NAME], { stdio: 'ignore', windowsHide: true, timeout: 20_000 });
|
|
1547
1617
|
if (run.status === 0)
|
|
1548
1618
|
return;
|
|
@@ -1567,11 +1637,11 @@ function installWindows() {
|
|
|
1567
1637
|
let ok = taskOk;
|
|
1568
1638
|
if (!ok) {
|
|
1569
1639
|
// Fallback: per-user Run key — no elevation needed, runs at every logon.
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1640
|
+
// conhost --headless keeps the logon launch windowless (no wscript, no
|
|
1641
|
+
// script-host persistence fingerprint).
|
|
1572
1642
|
const reg = (0, child_process_1.spawnSync)('reg', [
|
|
1573
1643
|
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1574
|
-
'/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1644
|
+
'/d', `"${conhostPath()}" --headless "${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1575
1645
|
], { stdio: 'ignore', windowsHide: true });
|
|
1576
1646
|
ok = reg.status === 0;
|
|
1577
1647
|
}
|
|
@@ -1616,7 +1686,7 @@ function ensureWindowsAutostartHealthy(logFn) {
|
|
|
1616
1686
|
if (!created) {
|
|
1617
1687
|
(0, child_process_1.spawnSync)('reg', [
|
|
1618
1688
|
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1619
|
-
'/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1689
|
+
'/d', `"${conhostPath()}" --headless "${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1620
1690
|
], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1621
1691
|
}
|
|
1622
1692
|
if (daemonTask.status !== 0)
|
|
@@ -1630,17 +1700,24 @@ function ensureWindowsAutostartHealthy(logFn) {
|
|
|
1630
1700
|
logFn('Self-heal: could not (re)create the watchdog task — daemon revival relies on logon autostart only.');
|
|
1631
1701
|
}
|
|
1632
1702
|
}
|
|
1633
|
-
// Migration
|
|
1634
|
-
// window on every trigger.
|
|
1635
|
-
//
|
|
1636
|
-
//
|
|
1637
|
-
//
|
|
1703
|
+
// Migration: an InteractiveToken task with a bare console action flashes a
|
|
1704
|
+
// window on every trigger. Try the S4U principal first (works when this
|
|
1705
|
+
// daemon happens to be elevated — current Windows denies S4U registration
|
|
1706
|
+
// to everyone else, INCLUDING SYSTEM, so the updater usually cannot help).
|
|
1707
|
+
// When S4U is denied, re-register the task as InteractiveToken with the
|
|
1708
|
+
// action wrapped in `conhost --headless` — same-user unelevated
|
|
1709
|
+
// registration always works and is just as windowless. The updater kick
|
|
1710
|
+
// stays as the final backstop for machines where even that failed.
|
|
1638
1711
|
let migrationDenied = false;
|
|
1639
|
-
if (daemonTask.status === 0 && !legacyDaemon &&
|
|
1640
|
-
|
|
1712
|
+
if (daemonTask.status === 0 && !legacyDaemon && windowsTaskRunsVisibly(TASK_NAME)) {
|
|
1713
|
+
const daemonXml = buildDaemonTaskXml();
|
|
1714
|
+
migrationDenied = !(upgradeTaskWindowless(TASK_NAME, daemonXml[0], logFn)
|
|
1715
|
+
|| rewrapTaskHeadless(TASK_NAME, daemonXml[1], logFn)) || migrationDenied;
|
|
1641
1716
|
}
|
|
1642
|
-
if (watchdogQuery.status === 0 && !legacyWatchdog &&
|
|
1643
|
-
|
|
1717
|
+
if (watchdogQuery.status === 0 && !legacyWatchdog && windowsTaskRunsVisibly(WATCHDOG_TASK_NAME)) {
|
|
1718
|
+
const watchdogXml = buildWatchdogTaskXml();
|
|
1719
|
+
migrationDenied = !(upgradeTaskWindowless(WATCHDOG_TASK_NAME, watchdogXml[0], logFn)
|
|
1720
|
+
|| rewrapTaskHeadless(WATCHDOG_TASK_NAME, watchdogXml[1], logFn)) || migrationDenied;
|
|
1644
1721
|
}
|
|
1645
1722
|
if (migrationDenied)
|
|
1646
1723
|
kickUpdaterTaskForMigration(logFn);
|
|
@@ -1648,9 +1725,9 @@ function ensureWindowsAutostartHealthy(logFn) {
|
|
|
1648
1725
|
if (windowsRunKeyReferencesScriptHost()) {
|
|
1649
1726
|
(0, child_process_1.spawnSync)('reg', [
|
|
1650
1727
|
'add', WINDOWS_RUN_KEY, '/v', WINDOWS_RUN_VALUE, '/t', 'REG_SZ',
|
|
1651
|
-
'/d', `"${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1728
|
+
'/d', `"${conhostPath()}" --headless "${process.execPath}" "${cliEntry()}" daemon`, '/f',
|
|
1652
1729
|
], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
|
|
1653
|
-
logFn('Self-heal: rewrote legacy wscript Run-key entry to run node
|
|
1730
|
+
logFn('Self-heal: rewrote legacy wscript Run-key entry to run node headless.');
|
|
1654
1731
|
}
|
|
1655
1732
|
removeLegacyWindowsLaunchers();
|
|
1656
1733
|
}
|
|
@@ -55,6 +55,7 @@ export interface LocalSafetyScanOptions {
|
|
|
55
55
|
*/
|
|
56
56
|
trustedScriptPaths?: string[];
|
|
57
57
|
}
|
|
58
|
+
export declare function stripInertDataSegments(text: string): string;
|
|
58
59
|
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
59
60
|
export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
60
61
|
export declare function scanDeterministicPrompt(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.stripInertDataSegments = stripInertDataSegments;
|
|
36
37
|
exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
37
38
|
exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
|
|
38
39
|
exports.scanDeterministicPrompt = scanDeterministicPrompt;
|
|
@@ -615,10 +616,12 @@ function credentialCommandReason(value) {
|
|
|
615
616
|
function destructiveCommandReason(value) {
|
|
616
617
|
return matchCommand(value, (text, lower) => {
|
|
617
618
|
// Flag cluster is order-independent (`-rf`, `-fr`, `-rfv`), and the target
|
|
618
|
-
// may be the root `/`, the root wildcard `/*`, or a bare `*`.
|
|
619
|
-
|
|
619
|
+
// may be the root `/`, the root wildcard `/*`, or a bare `*`. Terminators
|
|
620
|
+
// include `)` so command substitution — `$(rm -rf /)` runs BEFORE the
|
|
621
|
+
// outer command — is caught too.
|
|
622
|
+
if (/\brm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
|
|
620
623
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
621
|
-
if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|])/.test(lower))
|
|
624
|
+
if (/\bsudo\s+rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+(?:["']?\/\*?["']?|\*)(?:\s|$|[;&|)])/.test(lower))
|
|
622
625
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
623
626
|
// Windows recursive quiet drive delete via del/erase/rd/rmdir. Flag order
|
|
624
627
|
// is independent (`/s /q` and `/q /s` both wipe), and the dequoted variant
|
|
@@ -779,13 +782,55 @@ function isSensitivePathContext(toolName, candidate) {
|
|
|
779
782
|
const lastKey = candidate.keyPath.split('.').pop() || '';
|
|
780
783
|
return /(?:path|file|filename|dir|directory|target|source|src|dest|location|glob|pattern|uri|url)/i.test(lastKey);
|
|
781
784
|
}
|
|
782
|
-
|
|
783
|
-
|
|
785
|
+
// ---- execution view: message/document DATA inside a command is inert ---------
|
|
786
|
+
// Kills the "wrote ABOUT a dangerous command" FP class: commit messages,
|
|
787
|
+
// issue/PR bodies, here-strings, doc heredocs quoting `terraform destroy` or
|
|
788
|
+
// `rm -rf /` are data, not execution. Safety invariants:
|
|
789
|
+
// 1. NOTHING is stripped when the text can feed an interpreter (| sh, iex,
|
|
790
|
+
// eval, xargs, base64 -d, -EncodedCommand, FromBase64String) — data that
|
|
791
|
+
// flows into an interpreter IS execution.
|
|
792
|
+
// 2. A region containing $( ) or `…` pairs is kept — interpolation executes
|
|
793
|
+
// BEFORE the outer command.
|
|
794
|
+
// 3. Secret-literal rules and honeypot/custom rules never use this view —
|
|
795
|
+
// a token pasted into a commit message still leaks.
|
|
796
|
+
const DATA_TO_INTERPRETER = /\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|\|\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b/i;
|
|
797
|
+
const INTERPOLATES = /\$\(|`[^`]*`/;
|
|
798
|
+
const INERT_DATA = ' fcd_inert_data ';
|
|
799
|
+
const TEXT_SINK_FLAG_ARG = /((?:^|\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\s*)("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/gi;
|
|
800
|
+
function stripInertDataSegments(text) {
|
|
801
|
+
if (DATA_TO_INTERPRETER.test(text))
|
|
802
|
+
return text;
|
|
803
|
+
let out = text;
|
|
804
|
+
// PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
|
|
805
|
+
out = out.replace(/@'[\s\S]*?'@/g, INERT_DATA);
|
|
806
|
+
out = out.replace(/@"[\s\S]*?"@/g, m => (m.includes('$(') ? m : INERT_DATA));
|
|
807
|
+
// POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
|
|
808
|
+
out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
|
|
809
|
+
out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
|
|
810
|
+
// Quoted message/text-flag arguments (commit messages, issue/PR bodies…).
|
|
811
|
+
out = out.replace(TEXT_SINK_FLAG_ARG, (whole, flag, quoted) => {
|
|
812
|
+
if (quoted.startsWith("'"))
|
|
813
|
+
return flag + INERT_DATA;
|
|
814
|
+
return INTERPOLATES.test(quoted) ? whole : flag + INERT_DATA;
|
|
815
|
+
});
|
|
816
|
+
return out;
|
|
817
|
+
}
|
|
818
|
+
function scanTextValue(toolName, rawValue, options) {
|
|
819
|
+
// Honeypot decoys and org custom patterns scan the RAW value: a decoy path
|
|
820
|
+
// even inside message data is exfiltration staging, and custom rules define
|
|
821
|
+
// their own scope.
|
|
822
|
+
const honeypot = honeypotTouch(rawValue, options);
|
|
784
823
|
if (honeypot)
|
|
785
824
|
return honeypot;
|
|
786
|
-
const custom = customBlock(
|
|
825
|
+
const custom = customBlock(rawValue, options);
|
|
787
826
|
if (custom)
|
|
788
827
|
return custom;
|
|
828
|
+
// Every rule below asks "can this text ACT?" — so it sees the execution
|
|
829
|
+
// view: quoted message/document data (commit -m, issue --body, here-strings,
|
|
830
|
+
// heredocs) is inert unless it can flow into an interpreter (see
|
|
831
|
+
// stripInertDataSegments invariants). This is the generic FP guard for
|
|
832
|
+
// "wrote ABOUT a dangerous command" vs "ran one".
|
|
833
|
+
const value = stripInertDataSegments(rawValue);
|
|
789
834
|
// NOTE: a disabled item must FALL THROUGH to the later rules, not end the
|
|
790
835
|
// scan — otherwise turning off (or gating) a path rule would mask a real
|
|
791
836
|
// reverse shell / destructive command in the same value.
|
|
@@ -104,7 +104,10 @@ function buildGuardJs(nodePath, cliEntry) {
|
|
|
104
104
|
`const CLI_ENTRY=${JSON.stringify(cliEntry)};`,
|
|
105
105
|
`const BLOCK_CODE=${BLOCK_CODE};`,
|
|
106
106
|
`function loadRules(){try{const raw=JSON.parse(fs.readFileSync(RULES_PATH,'utf8'));const rules=(raw.rules||[]).map(r=>{try{return{id:r.id,category:r.category,severity:r.severity,reason:r.reason,source:r.source,action:r.action==='warn'?'warn':'block',re:new RegExp(r.pattern,'i')};}catch{return null;}}).filter(Boolean);return{mode:raw.mode==='block'?'block':'monitor',rules};}catch{return{mode:'monitor',rules:[]};}}`,
|
|
107
|
-
|
|
107
|
+
`// Execution view (aligned with the CLI's stripInertDataSegments): commit messages, issue/PR bodies, and`,
|
|
108
|
+
`// here-strings/heredocs are DATA — inert unless the line can feed an interpreter or the region interpolates.`,
|
|
109
|
+
`function execView(line){if(/\\b(?:iex|invoke-expression|invoke-command)\\b|\\beval\\b|\\bxargs\\b|\\bbase64\\b[^\\n]*(?:-d\\b|--decode\\b)|frombase64string|-encodedcommand\\b|\\s-enc\\s|\\|\\s*&?\\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\\.exe)?|cmd(?:\\.exe)?|node|python[0-9.]*|perl|ruby)\\b/i.test(line))return line;const K=/\\$\\(|\`[^\`]*\`/;let o=line;o=o.replace(/@'[\\s\\S]*?'@/g,' fcd_inert_data ');o=o.replace(/@"[\\s\\S]*?"@/g,m=>m.includes('$(')?m:' fcd_inert_data ');o=o.replace(/<<-?\\s*'(\\w+)'[\\s\\S]*?(?:\\n\\1\\b|$)/g,' fcd_inert_data ');o=o.replace(/<<-?\\s*"?(\\w+)"?[\\s\\S]*?(?:\\n\\1\\b|$)/g,m=>K.test(m)?m:' fcd_inert_data ');o=o.replace(/((?:^|\\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\\s*)("(?:[^"\\\\]|\\\\.)*"|'(?:[^'\\\\]|\\\\.)*')/gi,(w,f,q)=>q[0]==="'"?f+' fcd_inert_data ':K.test(q)?w:f+' fcd_inert_data ');return o;}`,
|
|
110
|
+
`function matchLine(line,rules){const n=line.replace(/\\s+/g,' ').trim();const x=execView(line);const xn=x.replace(/\\s+/g,' ').trim();let warnHit=null;for(const r of rules){try{const t=r.source==='custom'?line:x;const tn=r.source==='custom'?n:xn;if(r.re.test(tn)||r.re.test(t)){if(r.action!=='warn')return r;if(!warnHit)warnHit=r;}}catch{}}return warnHit;}`,
|
|
108
111
|
`function triggerFlush(){try{if(fs.existsSync(NODE_PATH)&&fs.existsSync(CLI_ENTRY)){const c=spawn(NODE_PATH,[CLI_ENTRY,'flush-spool','--heartbeat','true'],{detached:true,stdio:'ignore'});c.unref();}}catch{}}`,
|
|
109
112
|
`function spoolEvent(rule,line,decision){try{const ev=line.trim();const evidence=ev.length>180?ev.slice(0,180)+'...':ev;const event={eventId:crypto.randomUUID(),type:'verdict',decision,toolName:'shell_terminal',operation:'shell_command',reason:'Shell guard: '+rule.reason,ruleId:rule.id,category:rule.category,severity:rule.severity,source:rule.source,evidence,occurredAt:new Date().toISOString()};fs.appendFileSync(SPOOL_PATH,JSON.stringify(event)+'\\n',{encoding:'utf8',mode:0o600});triggerFlush();}catch{}}`,
|
|
110
113
|
`let argv=process.argv.slice(2);if(argv[0]==='--')argv=argv.slice(1);const line=argv.join(' ');`,
|
|
@@ -485,14 +485,42 @@ function buildGuardPs1(nodePath, cliEntry) {
|
|
|
485
485
|
` }`,
|
|
486
486
|
`} catch { $global:FcdGuardRules = @() }`,
|
|
487
487
|
``,
|
|
488
|
+
`# Execution view (aligned with the CLI's stripInertDataSegments): message/`,
|
|
489
|
+
`# document DATA inside a command is inert — commit messages, issue/PR bodies,`,
|
|
490
|
+
`# here-strings. Never strips when the line can feed an interpreter (| sh,`,
|
|
491
|
+
`# iex, eval, xargs, base64 -d, -EncodedCommand) and keeps any region that`,
|
|
492
|
+
`# interpolates $( ) or backtick pairs — those execute BEFORE the command.`,
|
|
493
|
+
`function global:Get-FcdExecView {`,
|
|
494
|
+
` param([string]$CommandLine)`,
|
|
495
|
+
String.raw ` if ($CommandLine -match '(?i)\b(?:iex|invoke-expression|invoke-command)\b|\beval\b|\bxargs\b|\bbase64\b[^\n]*(?:-d\b|--decode\b)|frombase64string|-encodedcommand\b|\s-enc\s|\|\s*&?\s*(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?|node|python[0-9.]*|perl|ruby)\b') { return $CommandLine }`,
|
|
496
|
+
` $out = $CommandLine`,
|
|
497
|
+
" $keepIfInterpolates = [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Value -match '\\$\\(|" + '`[^`]*`' + "') { $m.Value } else { ' fcd_inert_data ' } }",
|
|
498
|
+
String.raw ` $out = [regex]::Replace($out, "@'[\s\S]*?'@", ' fcd_inert_data ')`,
|
|
499
|
+
` $out = [regex]::Replace($out, '@"[\\s\\S]*?"@', $keepIfInterpolates)`,
|
|
500
|
+
String.raw ` $out = [regex]::Replace($out, "<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)", ' fcd_inert_data ')`,
|
|
501
|
+
String.raw ` $out = [regex]::Replace($out, '<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)', $keepIfInterpolates)`,
|
|
502
|
+
String.raw ` $flagArg = '((?:^|\s)(?:-m|-b|--message|--body|--title|--description|--notes?|--comment|--caption|--summary|--subject|-value|-message)[= ]\s*)("(?:[^"\\]|\\.)*"|''(?:[^''\\]|\\.)*'')'`,
|
|
503
|
+
" $out = [regex]::Replace($out, $flagArg, [System.Text.RegularExpressions.MatchEvaluator]{ param($m) if ($m.Groups[2].Value.StartsWith(\"'\")) { $m.Groups[1].Value + ' fcd_inert_data ' } elseif ($m.Groups[2].Value -match '\\$\\(|" + '`[^`]*`' + "') { $m.Value } else { $m.Groups[1].Value + ' fcd_inert_data ' } }, 'IgnoreCase')",
|
|
504
|
+
` return $out`,
|
|
505
|
+
`}`,
|
|
506
|
+
``,
|
|
488
507
|
`function global:Test-FcdShellGuard {`,
|
|
489
508
|
` param([string]$CommandLine)`,
|
|
490
509
|
` if ([string]::IsNullOrWhiteSpace($CommandLine)) { return $null }`,
|
|
491
510
|
` $normalized = ($CommandLine -replace '\\s+', ' ').Trim()`,
|
|
511
|
+
` $execView = $CommandLine`,
|
|
512
|
+
` try { $execView = Get-FcdExecView -CommandLine $CommandLine } catch { }`,
|
|
513
|
+
` $execNormalized = ($execView -replace '\\s+', ' ').Trim()`,
|
|
492
514
|
` $warnHit = $null`,
|
|
493
515
|
` foreach ($rule in $global:FcdGuardRules) {`,
|
|
494
516
|
` try {`,
|
|
495
|
-
`
|
|
517
|
+
` # Built-in rules ask "can this text ACT?" — they see the execution`,
|
|
518
|
+
` # view, so a commit message quoting a dangerous command is inert.`,
|
|
519
|
+
` # Console custom rules define their own scope — they see the raw line.`,
|
|
520
|
+
` $isCustom = ([string]$rule.Source -eq 'custom')`,
|
|
521
|
+
` $target = if ($isCustom) { $CommandLine } else { $execView }`,
|
|
522
|
+
` $targetNorm = if ($isCustom) { $normalized } else { $execNormalized }`,
|
|
523
|
+
` if ($rule.Regex.IsMatch($targetNorm) -or $rule.Regex.IsMatch($target)) {`,
|
|
496
524
|
` # A block rule always outranks a warn rule matching the same line.`,
|
|
497
525
|
` if ($rule.Action -ne 'warn') { return $rule }`,
|
|
498
526
|
` if ($null -eq $warnHit) { $warnHit = $rule }`,
|
package/dist/version.json
CHANGED