fullcourtdefense-cli 1.35.2 → 1.35.5
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/actionPolicyEngine.js +88 -8
- package/dist/approvalPrompt.d.ts +3 -0
- package/dist/approvalPrompt.js +20 -1
- package/dist/blockExplanation.js +17 -0
- package/dist/commandNarrative.d.ts +29 -0
- package/dist/commandNarrative.js +605 -0
- package/dist/commands/deterministicGuard.d.ts +7 -0
- package/dist/commands/deterministicGuard.js +146 -13
- package/dist/commands/shellGuard.js +1 -1
- package/dist/commands/workloadProtect.d.ts +10 -0
- package/dist/commands/workloadProtect.js +27 -4
- package/dist/detectorBaseline.d.ts +2 -2
- package/dist/detectorBaseline.js +2 -2
- package/dist/devConfirm.d.ts +30 -13
- package/dist/devConfirm.js +194 -38
- package/dist/index.js +4 -0
- package/dist/version.json +1 -1
- package/package.json +3 -1
|
@@ -35,10 +35,12 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.CREDENTIAL_COMMAND_RULES = exports.DEFAULT_DISABLED_BUILT_INS = void 0;
|
|
37
37
|
exports.toLocalSafetyRuleAction = toLocalSafetyRuleAction;
|
|
38
|
+
exports.unquotedCode = unquotedCode;
|
|
38
39
|
exports.stripInertDataSegments = stripInertDataSegments;
|
|
39
40
|
exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
40
41
|
exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
|
|
41
42
|
exports.scanDeterministicPrompt = scanDeterministicPrompt;
|
|
43
|
+
exports.referencedScripts = referencedScripts;
|
|
42
44
|
exports.resolveDeterministicOutcome = resolveDeterministicOutcome;
|
|
43
45
|
exports.resolveDeterministicTextResponse = resolveDeterministicTextResponse;
|
|
44
46
|
const fs = __importStar(require("fs"));
|
|
@@ -698,9 +700,14 @@ exports.CREDENTIAL_COMMAND_RULES = [
|
|
|
698
700
|
pattern: String.raw `\bvault\s+(?:kv\s+get|read)\b` },
|
|
699
701
|
];
|
|
700
702
|
const CREDENTIAL_COMMAND_REGEXES = exports.CREDENTIAL_COMMAND_RULES.map(rule => ({ rule, re: new RegExp(rule.pattern, 'i') }));
|
|
701
|
-
function credentialCommandReason(value) {
|
|
703
|
+
function credentialCommandReason(value, options) {
|
|
702
704
|
return matchCommand(value, text => {
|
|
703
705
|
for (const { rule, re } of CREDENTIAL_COMMAND_REGEXES) {
|
|
706
|
+
// Skip suppressed / org-disabled items so a second credential command on
|
|
707
|
+
// the same line is still found after resolveDeterministicOutcome hides
|
|
708
|
+
// the first ask (gh then gcloud, not "gh forever").
|
|
709
|
+
if (!isEnabled(rule.itemId, options))
|
|
710
|
+
continue;
|
|
704
711
|
if (re.test(text))
|
|
705
712
|
return { itemId: rule.itemId, reason: rule.reason };
|
|
706
713
|
}
|
|
@@ -724,6 +731,11 @@ function destructiveCommandReason(value) {
|
|
|
724
731
|
// (`/tmp/...`, `/var/cache/...`, `./build`) are everyday cleanup.
|
|
725
732
|
if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?\/\*?["']?(?:\s|$|[;&|)])/.test(lower))
|
|
726
733
|
return { itemId: 'rm_rf_root', reason: 'recursive force delete of filesystem root' };
|
|
734
|
+
// Home-ROOT only (`~`, `$HOME`, `%USERPROFILE%`, optional `/` or `/*`).
|
|
735
|
+
// A subdirectory (`~/.cache`, `~/node_modules`) is everyday cleanup.
|
|
736
|
+
if (/\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+["']?(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:\/\*?)?["']?(?:\s|$|[;&|)])/.test(lower)) {
|
|
737
|
+
return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
|
|
738
|
+
}
|
|
727
739
|
// Bare `*` deletes the current directory tree — destructive on the HOST,
|
|
728
740
|
// but inside a container exec/run it is the container workdir (cleanup).
|
|
729
741
|
const bareStar = /\b(?:sudo\s+)?rm\s+-(?=[a-z]*r)(?=[a-z]*f)[a-z]+\s+\*(?:\s|$|[;&|)])/.exec(lower);
|
|
@@ -746,6 +758,10 @@ function destructiveCommandReason(value) {
|
|
|
746
758
|
&& /remove-item\b[^;|&]{0,80}?["']?\b[a-z]:[\\/]?["']?(?=\s|$|[;&|])/i.test(text)) {
|
|
747
759
|
return { itemId: 'windows_drive_delete', reason: 'recursive Windows drive delete' };
|
|
748
760
|
}
|
|
761
|
+
if (lower.includes('remove-item') && lower.includes('-recurse') && lower.includes('-force')
|
|
762
|
+
&& /remove-item\b[^;|&]{0,80}?["']?(?:~|\$home|\$\{home\}|%userprofile%|\$env:userprofile)(?:[\\/]\*?)?["']?(?=\s|$|[;&|])/i.test(lower)) {
|
|
763
|
+
return { itemId: 'rm_rf_home', reason: 'recursive force delete of the home directory' };
|
|
764
|
+
}
|
|
749
765
|
if (/: *\(\) *\{ *: *\| *: *& *\} *; *:/.test(text))
|
|
750
766
|
return { itemId: 'fork_bomb', reason: 'fork bomb' };
|
|
751
767
|
if (/\bmkfs(?:\.[a-z0-9]+)?\s+\/dev\//i.test(text))
|
|
@@ -875,8 +891,12 @@ function isWebLookupTarget(toolName, candidate) {
|
|
|
875
891
|
* must not block — the unconditional match over-blocked ordinary development work.
|
|
876
892
|
*/
|
|
877
893
|
function isMetadataRequestContext(toolName, candidate) {
|
|
878
|
-
if (isCommandContext(toolName, candidate))
|
|
894
|
+
if (isCommandContext(toolName, candidate)) {
|
|
895
|
+
// `rg "169.254.169.254" scripts/` searches for the address; it does not request it.
|
|
896
|
+
if (commandOnlySearchesMetadata(candidate.value))
|
|
897
|
+
return false;
|
|
879
898
|
return true;
|
|
899
|
+
}
|
|
880
900
|
if (isWebLookupTarget(toolName, candidate))
|
|
881
901
|
return true;
|
|
882
902
|
if (OUTBOUND_TOOL_HINT.test(toolName))
|
|
@@ -884,6 +904,76 @@ function isMetadataRequestContext(toolName, candidate) {
|
|
|
884
904
|
const lastKey = candidate.keyPath.split('.').pop() || '';
|
|
885
905
|
return /(?:url|uri|endpoint|host|address|webhook)/i.test(lastKey);
|
|
886
906
|
}
|
|
907
|
+
const METADATA_SEARCH_PROGRAMS = new Set([
|
|
908
|
+
'rg', 'grep', 'egrep', 'fgrep', 'findstr', 'ack', 'ag', 'select-string', 'sls',
|
|
909
|
+
]);
|
|
910
|
+
/**
|
|
911
|
+
* Split on unquoted `;` / `&&` / `||` / `|` / newline so a pipe cannot hide a
|
|
912
|
+
* later client (`rg … | curl http://169.254…`) while `rg "a|b"` stays one segment.
|
|
913
|
+
*/
|
|
914
|
+
function splitUnquotedCommandSegments(command) {
|
|
915
|
+
const out = [];
|
|
916
|
+
let cur = '';
|
|
917
|
+
let quote = null;
|
|
918
|
+
for (let i = 0; i < command.length; i++) {
|
|
919
|
+
const c = command[i];
|
|
920
|
+
if (quote) {
|
|
921
|
+
cur += c;
|
|
922
|
+
if (c === '\\' && i + 1 < command.length) {
|
|
923
|
+
cur += command[++i];
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (c === quote)
|
|
927
|
+
quote = null;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
931
|
+
quote = c;
|
|
932
|
+
cur += c;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (c === '\n' || c === ';') {
|
|
936
|
+
out.push(cur);
|
|
937
|
+
cur = '';
|
|
938
|
+
continue;
|
|
939
|
+
}
|
|
940
|
+
if (c === '&' && command[i + 1] === '&') {
|
|
941
|
+
out.push(cur);
|
|
942
|
+
cur = '';
|
|
943
|
+
i += 1;
|
|
944
|
+
continue;
|
|
945
|
+
}
|
|
946
|
+
if (c === '|') {
|
|
947
|
+
if (command[i + 1] === '|')
|
|
948
|
+
i += 1;
|
|
949
|
+
out.push(cur);
|
|
950
|
+
cur = '';
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
cur += c;
|
|
954
|
+
}
|
|
955
|
+
if (cur)
|
|
956
|
+
out.push(cur);
|
|
957
|
+
return out;
|
|
958
|
+
}
|
|
959
|
+
/** True when every segment that mentions a metadata endpoint is a searcher, not a client. */
|
|
960
|
+
function commandOnlySearchesMetadata(command) {
|
|
961
|
+
if (!containsMetadataEndpoint(command))
|
|
962
|
+
return false;
|
|
963
|
+
const segments = splitUnquotedCommandSegments(command);
|
|
964
|
+
let saw = false;
|
|
965
|
+
for (const raw of segments) {
|
|
966
|
+
const seg = raw.trim();
|
|
967
|
+
if (!seg || !containsMetadataEndpoint(seg))
|
|
968
|
+
continue;
|
|
969
|
+
saw = true;
|
|
970
|
+
const token = (seg.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/) || [''])[0];
|
|
971
|
+
const program = token.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.exe$/, '');
|
|
972
|
+
if (!METADATA_SEARCH_PROGRAMS.has(program))
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
return saw;
|
|
976
|
+
}
|
|
887
977
|
/**
|
|
888
978
|
* Sensitive credential PATHS only matter where the string is used AS a path (a file access,
|
|
889
979
|
* a path-typed argument) or inside a command. File CONTENTS being written/edited routinely
|
|
@@ -978,8 +1068,36 @@ const INERT_DATA = ' fcd_inert_data ';
|
|
|
978
1068
|
// Code position immediately before a quoted span: interpreter code flags or a
|
|
979
1069
|
// remote/nested executor earlier in the same statement (no ; & | between).
|
|
980
1070
|
const CODE_CONTEXT_BEFORE = /(?:(?:^|[;&|(]|\s)(?:-c|\/c|-e|-command|-scriptblock|-filter)[= ]\s*|(?:^|[;&|(]\s*)(?:\S*[\\/])?(?:sudo\s+|doas\s+)?(?:ssh|wsl(?:\.exe)?|chroot|su|screen|tmux)(?:\s[^;&|"']*)?\s|(?:\S*[\\/])?(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b[^;&|"']*\s)$/i;
|
|
1071
|
+
/**
|
|
1072
|
+
* Code-only view of a line: each `'…'` / `"…"` / `` `…` `` span becomes a
|
|
1073
|
+
* space. Detectors that ask "does this EXECUTE" must look here; the raw line
|
|
1074
|
+
* still carries fixture strings and docs.
|
|
1075
|
+
*/
|
|
1076
|
+
function unquotedCode(text) {
|
|
1077
|
+
let out = '';
|
|
1078
|
+
for (let i = 0; i < text.length; i++) {
|
|
1079
|
+
const c = text[i];
|
|
1080
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
1081
|
+
const q = c;
|
|
1082
|
+
i += 1;
|
|
1083
|
+
while (i < text.length) {
|
|
1084
|
+
if (text[i] === '\\') {
|
|
1085
|
+
i += 2;
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (text[i] === q)
|
|
1089
|
+
break;
|
|
1090
|
+
i += 1;
|
|
1091
|
+
}
|
|
1092
|
+
out += ' ';
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
out += c;
|
|
1096
|
+
}
|
|
1097
|
+
return out;
|
|
1098
|
+
}
|
|
981
1099
|
function stripInertDataSegments(text) {
|
|
982
|
-
if (DATA_TO_INTERPRETER.test(text))
|
|
1100
|
+
if (DATA_TO_INTERPRETER.test(unquotedCode(text)))
|
|
983
1101
|
return text;
|
|
984
1102
|
let out = text;
|
|
985
1103
|
// PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
|
|
@@ -1201,22 +1319,27 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
|
|
|
1201
1319
|
// stripInertDataSegments invariants). This is the generic FP guard for
|
|
1202
1320
|
// "wrote ABOUT a dangerous command" vs "ran one".
|
|
1203
1321
|
const value = stripInertDataSegments(rawValue);
|
|
1322
|
+
// Script files: path/metadata facts are judged on RAW lines, then
|
|
1323
|
+
// `scriptActsOnFinding` keeps only unquoted access (fetch/http.get/curl).
|
|
1324
|
+
// Shell-prose strip would eat `execSync('curl -s http://169.254…')` and miss
|
|
1325
|
+
// a real call. Command-shaped rules below still use the stripped view.
|
|
1326
|
+
const actingView = toolName === 'script_file' ? rawValue : value;
|
|
1204
1327
|
// NOTE: a disabled item must FALL THROUGH to the later rules, not end the
|
|
1205
1328
|
// scan — otherwise turning off (or gating) a path rule would mask a real
|
|
1206
1329
|
// reverse shell / destructive command in the same value.
|
|
1207
1330
|
const sensitivePath = containsSensitiveCredentialPath(commandContext
|
|
1208
|
-
? `${credentialAccessCommandText(
|
|
1331
|
+
? `${credentialAccessCommandText(actingView)}\n${interpreterFileArguments(rawValue)}` : actingView);
|
|
1209
1332
|
if (sensitivePath) {
|
|
1210
1333
|
const finding = builtIn(sensitivePath.itemId, 'sensitive_files', 'sensitive_file', 'local-sensitive-credential-path',
|
|
1211
1334
|
// Verdict-neutral: the same finding is a warning on a monitor machine and
|
|
1212
1335
|
// a block on an enforcing one — the console shows the verdict, not this text.
|
|
1213
|
-
`Local agent access to ${sensitivePath.label}.`,
|
|
1336
|
+
`Local agent access to ${sensitivePath.label}.`, actingView, sensitivePath.label, options);
|
|
1214
1337
|
if (finding)
|
|
1215
1338
|
return finding;
|
|
1216
1339
|
}
|
|
1217
|
-
const metadata = containsMetadataEndpoint(
|
|
1340
|
+
const metadata = containsMetadataEndpoint(actingView);
|
|
1218
1341
|
if (metadata) {
|
|
1219
|
-
const finding = builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-cloud-metadata-ssrf', `Blocked request to ${metadata.label}.`,
|
|
1342
|
+
const finding = builtIn(metadata.itemId, 'metadata_ssrf', 'metadata_ssrf', 'local-cloud-metadata-ssrf', `Blocked request to ${metadata.label}.`, actingView, metadata.value, options);
|
|
1220
1343
|
if (finding)
|
|
1221
1344
|
return finding;
|
|
1222
1345
|
}
|
|
@@ -1224,7 +1347,7 @@ function scanTextValue(toolName, rawValue, options, commandContext = false) {
|
|
|
1224
1347
|
if (reverseShell) {
|
|
1225
1348
|
return builtIn(reverseShell.itemId, 'reverse_shells', 'reverse_shell', 'local-reverse-shell', `Blocked ${reverseShell.reason}.`, value, reverseShell.reason, options);
|
|
1226
1349
|
}
|
|
1227
|
-
const credentialCmd = credentialCommandReason(value);
|
|
1350
|
+
const credentialCmd = credentialCommandReason(value, options);
|
|
1228
1351
|
if (credentialCmd) {
|
|
1229
1352
|
const finding = builtIn(credentialCmd.itemId, 'credential_commands', 'credential_command', 'local-credential-command', `Blocked credential-revealing command: ${credentialCmd.reason}.`, value, credentialCmd.reason, options);
|
|
1230
1353
|
if (finding)
|
|
@@ -1434,13 +1557,23 @@ function referencedScripts(command, cwd) {
|
|
|
1434
1557
|
// Collision-hardened forms: `ssh` must be a command start (NOT the `.ssh` of the
|
|
1435
1558
|
// flagged path itself), `type` the cmd.exe read form (`type file`, not `type:` in
|
|
1436
1559
|
// JSON/TS fixtures), `open` a call (`open(`), `requests.*` a python HTTP call.
|
|
1437
|
-
const SCRIPT_ACCESS_VERB = /\b(?:cat|gc|get-content|get-item|copy(?:-item)?|cp|xcopy|robocopy|move(?:-item)?|mv|scp|sftp|rsync|curl|wget|invoke-webrequest|invoke-restmethod|iwr|irm|openssl|tar|zip|7z|certutil|base64|readfile(?:sync)?|createreadstream|read_text|read_bytes|fopen|urlopen|fetch|upload|download)\b|\btype\s+\S|(?:^|[^.\w])ssh(?:-add|-keygen|\.exe)?\s|\bopen\s*\(|\brequests\.\w+\s*\(/i;
|
|
1560
|
+
const SCRIPT_ACCESS_VERB = /\b(?:cat|gc|get-content|get-item|copy(?:-item)?|cp|xcopy|robocopy|move(?:-item)?|mv|scp|sftp|rsync|curl|wget|invoke-webrequest|invoke-restmethod|iwr|irm|openssl|tar|zip|7z|certutil|base64|readfile(?:sync)?|createreadstream|read_text|read_bytes|fopen|urlopen|fetch|upload|download|exec(?:sync|file(?:sync)?)?|spawn(?:sync)?)\b|\btype\s+\S|(?:^|[^.\w])ssh(?:-add|-keygen|\.exe)?\s|\bopen\s*\(|\brequests\.\w+\s*\(/i;
|
|
1561
|
+
/** `http.get(` / `require(…).get(` — the call is code; the URL may be a literal. */
|
|
1562
|
+
const SCRIPT_ACCESS_CALL = /\.\s*(?:get|request|post|put|patch|delete|fetch|exec(?:sync|file(?:sync)?)?|spawn(?:sync)?)\s*\(/i;
|
|
1438
1563
|
/**
|
|
1439
1564
|
* A path/metadata finding inside a referenced script only counts when some LINE both
|
|
1440
|
-
* contains the flagged pattern AND acts on it
|
|
1441
|
-
*
|
|
1442
|
-
*
|
|
1565
|
+
* contains the flagged pattern AND acts on it in CODE. The entire matched span
|
|
1566
|
+
* sitting inside one string literal (`'curl http://169.254…'`) is data — that
|
|
1567
|
+
* was the live IDE false positive on `node scripts/test-execution-view.js`.
|
|
1568
|
+
* `fetch('http://169.254…')` / `http.get('…')` still fire: the CALL is unquoted.
|
|
1443
1569
|
*/
|
|
1570
|
+
function lineActsOnFinding(line) {
|
|
1571
|
+
const code = unquotedCode(line)
|
|
1572
|
+
.replace(/\/\/[^\n]*/g, ' ')
|
|
1573
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
1574
|
+
.replace(/(^|[^$\w])#[^\n]*/g, '$1 ');
|
|
1575
|
+
return SCRIPT_ACCESS_VERB.test(code) || SCRIPT_ACCESS_CALL.test(code);
|
|
1576
|
+
}
|
|
1444
1577
|
function scriptActsOnFinding(content, category) {
|
|
1445
1578
|
const matcher = category === 'sensitive_file'
|
|
1446
1579
|
? (line) => Boolean(containsSensitiveCredentialPath(line))
|
|
@@ -1448,7 +1581,7 @@ function scriptActsOnFinding(content, category) {
|
|
|
1448
1581
|
for (const rawLine of content.split(/\r?\n/)) {
|
|
1449
1582
|
if (!rawLine || rawLine.length > 4000)
|
|
1450
1583
|
continue;
|
|
1451
|
-
if (matcher(rawLine) &&
|
|
1584
|
+
if (matcher(rawLine) && lineActsOnFinding(rawLine))
|
|
1452
1585
|
return true;
|
|
1453
1586
|
}
|
|
1454
1587
|
return false;
|
|
@@ -198,7 +198,7 @@ const BUILTIN_RULES = [
|
|
|
198
198
|
compileSpec({ id: 'rm_rf_root', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of filesystem root',
|
|
199
199
|
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [T_UNIX_ROOT] }),
|
|
200
200
|
compileSpec({ id: 'rm_rf_home', category: 'destructive_command', severity: 'critical', reason: 'recursive delete of the home directory',
|
|
201
|
-
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [String.raw `["']?(?:~|\$HOME)(
|
|
201
|
+
command: ['rm'], flags: [String.raw `-[a-z]*r[a-z]*|--recursive`], target: [String.raw `["']?(?:~|\$HOME|\$\{HOME\}|%USERPROFILE%|\$env:USERPROFILE)(?:[\\/]\*?)?["']?(?:\s|$|[;&|])`] }),
|
|
202
202
|
compileSpec({ id: 'windows_drive_delete', category: 'destructive_command', severity: 'critical', reason: 'recursive Windows drive delete',
|
|
203
203
|
command: ['del', 'erase'], flags: [String.raw `/s\b`], target: [T_WIN_DRIVE] }),
|
|
204
204
|
compileSpec({ id: 'windows_drive_rmdir', category: 'destructive_command', severity: 'critical', reason: 'recursive Windows drive remove directory',
|
|
@@ -63,6 +63,13 @@ export interface WorkloadProtectArgs {
|
|
|
63
63
|
httpGateway?: string;
|
|
64
64
|
/** Agent name for API-destination attribution (default: the workload name). */
|
|
65
65
|
agentName?: string;
|
|
66
|
+
/**
|
|
67
|
+
* The connection this workload was protected THROUGH — the console section
|
|
68
|
+
* it files under (heroku | kubernetes | aws | gcp). Set by the snippet the
|
|
69
|
+
* console generated (FCD_ENROLLED_VIA) or detected from the platform; never
|
|
70
|
+
* derived from the cloud the container happens to run on.
|
|
71
|
+
*/
|
|
72
|
+
enrolledVia?: string;
|
|
66
73
|
}
|
|
67
74
|
export interface WorkloadContext {
|
|
68
75
|
kind: string;
|
|
@@ -70,7 +77,10 @@ export interface WorkloadContext {
|
|
|
70
77
|
environment?: string;
|
|
71
78
|
instanceId?: string;
|
|
72
79
|
image?: string;
|
|
80
|
+
/** Connection implied by the platform's own env (Heroku dyno, Cloud Run, Lambda, Kubernetes). */
|
|
81
|
+
enrolledVia?: string;
|
|
73
82
|
}
|
|
83
|
+
export declare function resolveEnrolledVia(explicit: string | undefined, env: NodeJS.ProcessEnv, detected: string | undefined): string | undefined;
|
|
74
84
|
/**
|
|
75
85
|
* Detect the workload from standard platform env vars. Explicit flags /
|
|
76
86
|
* FCD_WORKLOAD_* always win; detection only fills the gaps.
|
|
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
36
|
exports.assertWorkloadSubstrate = assertWorkloadSubstrate;
|
|
37
|
+
exports.resolveEnrolledVia = resolveEnrolledVia;
|
|
37
38
|
exports.detectWorkloadContext = detectWorkloadContext;
|
|
38
39
|
exports.workloadProtectCommand = workloadProtectCommand;
|
|
39
40
|
const fs = __importStar(require("fs"));
|
|
@@ -64,6 +65,16 @@ function assertWorkloadSubstrate(kind, rt, options = {}) {
|
|
|
64
65
|
+ 'workload-protect is for containers, pods and functions. On a laptop or long-lived VM run `fullcourtdefense onboard` instead, '
|
|
65
66
|
+ 'or pass --force true if this really is a disposable workload host.');
|
|
66
67
|
}
|
|
68
|
+
/** Connections the console has a section for; anything else is the generic snippet. */
|
|
69
|
+
const ENROLLED_VIA_VALUES = new Set(['heroku', 'kubernetes', 'aws', 'gcp', 'snippet']);
|
|
70
|
+
function resolveEnrolledVia(explicit, env, detected) {
|
|
71
|
+
for (const candidate of [explicit, env.FCD_ENROLLED_VIA, detected]) {
|
|
72
|
+
const clean = (candidate || '').trim().toLowerCase();
|
|
73
|
+
if (clean && ENROLLED_VIA_VALUES.has(clean))
|
|
74
|
+
return clean;
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
67
78
|
/** Cloud coding agents that run as unattended workloads (env markers they set). */
|
|
68
79
|
const CLOUD_AGENT_MARKERS = [
|
|
69
80
|
['CURSOR_AGENT', 'cursor-cloud-agent'],
|
|
@@ -86,22 +97,32 @@ function detectWorkloadContext(env = process.env, fsProbe = fs) {
|
|
|
86
97
|
}
|
|
87
98
|
}
|
|
88
99
|
if (env.AWS_LAMBDA_FUNCTION_NAME) {
|
|
89
|
-
return { kind: 'serverless', name: explicitName || env.AWS_LAMBDA_FUNCTION_NAME, environment: environment || env.AWS_LAMBDA_FUNCTION_VERSION, instanceId: env.AWS_LAMBDA_LOG_STREAM_NAME, image };
|
|
100
|
+
return { kind: 'serverless', name: explicitName || env.AWS_LAMBDA_FUNCTION_NAME, environment: environment || env.AWS_LAMBDA_FUNCTION_VERSION, instanceId: env.AWS_LAMBDA_LOG_STREAM_NAME, image, enrolledVia: 'aws' };
|
|
90
101
|
}
|
|
91
102
|
if (env.K_SERVICE) {
|
|
92
103
|
// Cloud Run / Knative
|
|
93
|
-
return { kind: 'serverless', name: explicitName || env.K_SERVICE, environment, instanceId: env.K_REVISION, image };
|
|
104
|
+
return { kind: 'serverless', name: explicitName || env.K_SERVICE, environment, instanceId: env.K_REVISION, image, enrolledVia: 'gcp' };
|
|
94
105
|
}
|
|
95
106
|
if (env.FUNCTIONS_WORKER_RUNTIME && env.WEBSITE_SITE_NAME) {
|
|
96
107
|
// Azure Functions
|
|
97
108
|
return { kind: 'serverless', name: explicitName || env.WEBSITE_SITE_NAME, environment, instanceId: env.WEBSITE_INSTANCE_ID, image };
|
|
98
109
|
}
|
|
110
|
+
if (env.ECS_CONTAINER_METADATA_URI_V4 || env.ECS_CONTAINER_METADATA_URI) {
|
|
111
|
+
// ECS / Fargate task: the service name comes from the task definition env.
|
|
112
|
+
return { kind: 'container', name: explicitName || env.SERVICE_NAME || env.APP_NAME, environment, instanceId: env.HOSTNAME, image, enrolledVia: 'aws' };
|
|
113
|
+
}
|
|
99
114
|
if (env.KUBERNETES_SERVICE_HOST) {
|
|
100
115
|
// Pod names are "<deployment>-<replicaset-hash>-<pod-hash>"; the stable
|
|
101
116
|
// workload name comes from the Downward API when the chart exposes it.
|
|
102
117
|
const podName = env.HOSTNAME || env.POD_NAME || undefined;
|
|
103
118
|
const name = explicitName || env.WORKLOAD_NAME || env.APP_NAME || env.DEPLOYMENT_NAME || (podName ? podName.replace(/(-[a-z0-9]{8,10})?-[a-z0-9]{5}$/, '') : undefined);
|
|
104
|
-
return { kind: 'kubernetes', name, environment: environment || env.POD_NAMESPACE || env.NAMESPACE, instanceId: podName, image };
|
|
119
|
+
return { kind: 'kubernetes', name, environment: environment || env.POD_NAMESPACE || env.NAMESPACE, instanceId: podName, image, enrolledVia: 'kubernetes' };
|
|
120
|
+
}
|
|
121
|
+
if (env.DYNO) {
|
|
122
|
+
// Heroku dyno (buildpack or container stack). HEROKU_APP_NAME exists only
|
|
123
|
+
// with the runtime-dyno-metadata feature; the console snippet sets
|
|
124
|
+
// FCD_WORKLOAD_NAME so the row is named after the app either way.
|
|
125
|
+
return { kind: 'container', name: explicitName || env.HEROKU_APP_NAME, environment, instanceId: env.DYNO, image, enrolledVia: 'heroku' };
|
|
105
126
|
}
|
|
106
127
|
if (fsProbe.existsSync('/.dockerenv') || env.container || env.DOCKER_CONTAINER) {
|
|
107
128
|
return { kind: 'container', name: explicitName || env.SERVICE_NAME || env.APP_NAME, environment, instanceId: env.HOSTNAME, image };
|
|
@@ -137,8 +158,9 @@ async function workloadProtectCommand(args, config) {
|
|
|
137
158
|
// substrate, not to the `--kind` the caller claimed.
|
|
138
159
|
const hostRuntime = (0, hostRuntime_1.getHostRuntime)();
|
|
139
160
|
assertWorkloadSubstrate(kind, hostRuntime, { force: args.force === 'true' });
|
|
161
|
+
const enrolledVia = resolveEnrolledVia(args.enrolledVia, process.env, detected.enrolledVia);
|
|
140
162
|
console.log('\x1b[1m\x1b[36mFullCourtDefense — workload runtime protection\x1b[0m');
|
|
141
|
-
console.log(` Workload: ${kind}/${name}${environment ? `/${environment}` : ''}`);
|
|
163
|
+
console.log(` Workload: ${kind}/${name}${environment ? `/${environment}` : ''}${enrolledVia ? ` · via ${enrolledVia}` : ''}`);
|
|
142
164
|
if (instanceId)
|
|
143
165
|
console.log(` Instance: ${instanceId}`);
|
|
144
166
|
console.log(` Detected: ${(0, hostRuntime_1.describeHostRuntime)(hostRuntime)}`);
|
|
@@ -156,6 +178,7 @@ async function workloadProtectCommand(args, config) {
|
|
|
156
178
|
// `npm run`, never when the binary runs from a container entrypoint.
|
|
157
179
|
cliVersion: (0, cliVersion_1.cliVersion)(),
|
|
158
180
|
hostRuntime,
|
|
181
|
+
enrolledVia,
|
|
159
182
|
}),
|
|
160
183
|
});
|
|
161
184
|
const data = (await resp.json().catch(() => ({})));
|