fullcourtdefense-cli 1.36.3 → 1.36.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.
|
@@ -309,6 +309,25 @@ export declare function inferToolContext(toolName: string, args: Record<string,
|
|
|
309
309
|
operation: string;
|
|
310
310
|
context: Record<string, string>;
|
|
311
311
|
};
|
|
312
|
+
/**
|
|
313
|
+
* What kind of site a destination host is, for the few kinds that change how an
|
|
314
|
+
* upload should be read. A curated list, not a reputation feed: a host is in it
|
|
315
|
+
* only when its PURPOSE is the category (a paste site exists to publish text; a
|
|
316
|
+
* request catcher exists to capture whatever is sent to it). Deliberately absent:
|
|
317
|
+
* general cloud storage, code hosting and gists, which are everyday developer
|
|
318
|
+
* destinations and would turn the category into noise.
|
|
319
|
+
*/
|
|
320
|
+
export type DestinationCategory = 'paste' | 'file_drop' | 'request_capture' | 'tunnel' | 'messaging' | 'news';
|
|
321
|
+
export declare const DESTINATION_CATEGORY_HOSTS: Readonly<Record<DestinationCategory, readonly string[]>>;
|
|
322
|
+
/** Categories whose purpose is to receive data from strangers: a first upload there is high risk. */
|
|
323
|
+
export declare const HIGH_RISK_DESTINATION_CATEGORIES: ReadonlySet<DestinationCategory>;
|
|
324
|
+
/** The listed host itself or any subdomain of it (`www.pastebin.com`, `x.m.pipedream.net`); never a look-alike (`notpastebin.com`). */
|
|
325
|
+
export declare function destinationCategory(host: string | undefined | null): DestinationCategory | undefined;
|
|
326
|
+
/** A host's category and whether that category exists to receive data from anyone — the shape the anomaly scorer takes. */
|
|
327
|
+
export declare function classifyDestinationHost(host: string): {
|
|
328
|
+
category: DestinationCategory;
|
|
329
|
+
dataDrop: boolean;
|
|
330
|
+
} | undefined;
|
|
312
331
|
/** Exported for reproducible detector artifacts; policy decisions are not packaged. */
|
|
313
332
|
export declare function inferBundledToolContext(toolName: string, args: Record<string, any>): {
|
|
314
333
|
operation: string;
|
|
@@ -24,14 +24,18 @@ __export(actionPolicyEngine_exports, {
|
|
|
24
24
|
ACTION_POLICY_GRANT_MAX_USES: () => ACTION_POLICY_GRANT_MAX_USES,
|
|
25
25
|
CONTENT_FACT_FIELDS: () => CONTENT_FACT_FIELDS,
|
|
26
26
|
DEFAULT_ACTION_POLICY_APPROVAL: () => DEFAULT_ACTION_POLICY_APPROVAL,
|
|
27
|
+
DESTINATION_CATEGORY_HOSTS: () => DESTINATION_CATEGORY_HOSTS,
|
|
27
28
|
GCLOUD_DELETE_VERB_RE: () => GCLOUD_DELETE_VERB_RE,
|
|
28
29
|
GCLOUD_READ_VERB_RE: () => GCLOUD_READ_VERB_RE,
|
|
29
30
|
GCLOUD_WRITE_VERB_RE: () => GCLOUD_WRITE_VERB_RE,
|
|
31
|
+
HIGH_RISK_DESTINATION_CATEGORIES: () => HIGH_RISK_DESTINATION_CATEGORIES,
|
|
30
32
|
MACHINE_KINDS: () => MACHINE_KINDS,
|
|
31
33
|
WORKSTATION_ASK_APPROVAL: () => WORKSTATION_ASK_APPROVAL,
|
|
32
34
|
approvalExceptionError: () => approvalExceptionError,
|
|
33
35
|
canonicalAgentClient: () => canonicalAgentClient,
|
|
36
|
+
classifyDestinationHost: () => classifyDestinationHost,
|
|
34
37
|
deriveToolOperations: () => deriveToolOperations,
|
|
38
|
+
destinationCategory: () => destinationCategory,
|
|
35
39
|
detectSecretKind: () => detectSecretKind,
|
|
36
40
|
effectiveApprovalScope: () => effectiveApprovalScope,
|
|
37
41
|
evaluateActionPolicies: () => evaluateActionPolicies,
|
|
@@ -6888,6 +6892,7 @@ var OPERATION_MATCH_SKIP_FIELDS = /* @__PURE__ */ new Set([
|
|
|
6888
6892
|
"destination.host",
|
|
6889
6893
|
"destination.type",
|
|
6890
6894
|
"destination.repository",
|
|
6895
|
+
"destination.category",
|
|
6891
6896
|
"destinationHost",
|
|
6892
6897
|
"host",
|
|
6893
6898
|
"hostname",
|
|
@@ -8767,8 +8772,145 @@ function inferToolContext(toolName, args) {
|
|
|
8767
8772
|
}
|
|
8768
8773
|
applyRemoteControlFact(toolName, args, context);
|
|
8769
8774
|
applyWebReadFacts(toolName, args, context);
|
|
8775
|
+
applyDestinationCategoryFact(context);
|
|
8770
8776
|
return { operation, context };
|
|
8771
8777
|
}
|
|
8778
|
+
var DESTINATION_CATEGORY_HOSTS = {
|
|
8779
|
+
paste: [
|
|
8780
|
+
"pastebin.com",
|
|
8781
|
+
"paste.ee",
|
|
8782
|
+
"hastebin.com",
|
|
8783
|
+
"rentry.co",
|
|
8784
|
+
"rentry.org",
|
|
8785
|
+
"dpaste.org",
|
|
8786
|
+
"dpaste.com",
|
|
8787
|
+
"paste.rs",
|
|
8788
|
+
"termbin.com",
|
|
8789
|
+
"controlc.com",
|
|
8790
|
+
"justpaste.it",
|
|
8791
|
+
"privatebin.net",
|
|
8792
|
+
"ix.io",
|
|
8793
|
+
"sprunge.us",
|
|
8794
|
+
"pastes.io",
|
|
8795
|
+
"paste.mozilla.org"
|
|
8796
|
+
],
|
|
8797
|
+
file_drop: [
|
|
8798
|
+
"transfer.sh",
|
|
8799
|
+
"file.io",
|
|
8800
|
+
"0x0.st",
|
|
8801
|
+
"temp.sh",
|
|
8802
|
+
"bashupload.com",
|
|
8803
|
+
"gofile.io",
|
|
8804
|
+
"catbox.moe",
|
|
8805
|
+
"uguu.se",
|
|
8806
|
+
"tmpfiles.org",
|
|
8807
|
+
"filebin.net",
|
|
8808
|
+
"wetransfer.com",
|
|
8809
|
+
"we.tl",
|
|
8810
|
+
"sendspace.com",
|
|
8811
|
+
"mediafire.com",
|
|
8812
|
+
"mega.nz",
|
|
8813
|
+
"anonfiles.com",
|
|
8814
|
+
"oshi.at",
|
|
8815
|
+
"pixeldrain.com",
|
|
8816
|
+
"ufile.io",
|
|
8817
|
+
"keep.sh",
|
|
8818
|
+
"filetransfer.io"
|
|
8819
|
+
],
|
|
8820
|
+
request_capture: [
|
|
8821
|
+
"webhook.site",
|
|
8822
|
+
"pipedream.net",
|
|
8823
|
+
"beeceptor.com",
|
|
8824
|
+
"hookbin.com",
|
|
8825
|
+
"requestcatcher.com",
|
|
8826
|
+
"interact.sh",
|
|
8827
|
+
"oast.fun",
|
|
8828
|
+
"oast.pro",
|
|
8829
|
+
"oast.live",
|
|
8830
|
+
"oast.site",
|
|
8831
|
+
"oast.online",
|
|
8832
|
+
"oast.me",
|
|
8833
|
+
"oastify.com",
|
|
8834
|
+
"burpcollaborator.net",
|
|
8835
|
+
"dnslog.cn",
|
|
8836
|
+
"ceye.io"
|
|
8837
|
+
],
|
|
8838
|
+
tunnel: [
|
|
8839
|
+
"ngrok.io",
|
|
8840
|
+
"ngrok.app",
|
|
8841
|
+
"ngrok.dev",
|
|
8842
|
+
"ngrok-free.app",
|
|
8843
|
+
"ngrok-free.dev",
|
|
8844
|
+
"trycloudflare.com",
|
|
8845
|
+
"loca.lt",
|
|
8846
|
+
"localtunnel.me",
|
|
8847
|
+
"serveo.net",
|
|
8848
|
+
"localhost.run",
|
|
8849
|
+
"lhr.life",
|
|
8850
|
+
"pinggy.io",
|
|
8851
|
+
"pinggy.link",
|
|
8852
|
+
"bore.pub"
|
|
8853
|
+
],
|
|
8854
|
+
messaging: ["discord.com", "discordapp.com", "api.telegram.org", "hooks.slack.com"],
|
|
8855
|
+
news: [
|
|
8856
|
+
"ynet.co.il",
|
|
8857
|
+
"ynetnews.com",
|
|
8858
|
+
"walla.co.il",
|
|
8859
|
+
"mako.co.il",
|
|
8860
|
+
"n12.co.il",
|
|
8861
|
+
"haaretz.co.il",
|
|
8862
|
+
"haaretz.com",
|
|
8863
|
+
"themarker.com",
|
|
8864
|
+
"maariv.co.il",
|
|
8865
|
+
"globes.co.il",
|
|
8866
|
+
"calcalist.co.il",
|
|
8867
|
+
"israelhayom.co.il",
|
|
8868
|
+
"kan.org.il",
|
|
8869
|
+
"timesofisrael.com",
|
|
8870
|
+
"jpost.com",
|
|
8871
|
+
"i24news.tv",
|
|
8872
|
+
"bbc.com",
|
|
8873
|
+
"bbc.co.uk",
|
|
8874
|
+
"cnn.com",
|
|
8875
|
+
"nytimes.com",
|
|
8876
|
+
"reuters.com",
|
|
8877
|
+
"apnews.com",
|
|
8878
|
+
"theguardian.com",
|
|
8879
|
+
"washingtonpost.com",
|
|
8880
|
+
"wsj.com",
|
|
8881
|
+
"bloomberg.com",
|
|
8882
|
+
"ft.com",
|
|
8883
|
+
"foxnews.com",
|
|
8884
|
+
"nbcnews.com",
|
|
8885
|
+
"cbsnews.com",
|
|
8886
|
+
"npr.org",
|
|
8887
|
+
"aljazeera.com"
|
|
8888
|
+
]
|
|
8889
|
+
};
|
|
8890
|
+
var HIGH_RISK_DESTINATION_CATEGORIES = /* @__PURE__ */ new Set(["paste", "file_drop", "request_capture", "tunnel"]);
|
|
8891
|
+
var DESTINATION_CATEGORY_BY_HOST = new Map(
|
|
8892
|
+
Object.entries(DESTINATION_CATEGORY_HOSTS).flatMap(([category, hosts]) => hosts.map((host) => [host, category]))
|
|
8893
|
+
);
|
|
8894
|
+
function destinationCategory(host) {
|
|
8895
|
+
let candidate = String(host || "").trim().toLowerCase().replace(/\.$/, "");
|
|
8896
|
+
while (candidate) {
|
|
8897
|
+
const category = DESTINATION_CATEGORY_BY_HOST.get(candidate);
|
|
8898
|
+
if (category) return category;
|
|
8899
|
+
const dot = candidate.indexOf(".");
|
|
8900
|
+
if (dot < 0) return void 0;
|
|
8901
|
+
candidate = candidate.slice(dot + 1);
|
|
8902
|
+
}
|
|
8903
|
+
return void 0;
|
|
8904
|
+
}
|
|
8905
|
+
function classifyDestinationHost(host) {
|
|
8906
|
+
const category = destinationCategory(host);
|
|
8907
|
+
return category ? { category, dataDrop: HIGH_RISK_DESTINATION_CATEGORIES.has(category) } : void 0;
|
|
8908
|
+
}
|
|
8909
|
+
function applyDestinationCategoryFact(context) {
|
|
8910
|
+
delete context["destination.category"];
|
|
8911
|
+
const category = destinationCategory(context["destination.domain"]);
|
|
8912
|
+
if (category) context["destination.category"] = category;
|
|
8913
|
+
}
|
|
8772
8914
|
function applyRemoteControlFact(toolName, args, context) {
|
|
8773
8915
|
delete context["toolArgs.remoteControl"];
|
|
8774
8916
|
const shellText = remoteControlProvider ? shellCommandTextOf(toolName, args) : void 0;
|
|
@@ -8920,6 +9062,7 @@ function inferBundledToolContext(toolName, args) {
|
|
|
8920
9062
|
}
|
|
8921
9063
|
applyRemoteControlFact(toolName, args, context);
|
|
8922
9064
|
applyWebReadFacts(toolName, args, context);
|
|
9065
|
+
applyDestinationCategoryFact(context);
|
|
8923
9066
|
return { operation, context };
|
|
8924
9067
|
}
|
|
8925
9068
|
var RESERVED_INPUT_FACTS = /* @__PURE__ */ new Set([
|
|
@@ -9021,14 +9164,18 @@ function inventoryToolMatchesActionPolicy(toolName, toolActions, policies) {
|
|
|
9021
9164
|
ACTION_POLICY_GRANT_MAX_USES,
|
|
9022
9165
|
CONTENT_FACT_FIELDS,
|
|
9023
9166
|
DEFAULT_ACTION_POLICY_APPROVAL,
|
|
9167
|
+
DESTINATION_CATEGORY_HOSTS,
|
|
9024
9168
|
GCLOUD_DELETE_VERB_RE,
|
|
9025
9169
|
GCLOUD_READ_VERB_RE,
|
|
9026
9170
|
GCLOUD_WRITE_VERB_RE,
|
|
9171
|
+
HIGH_RISK_DESTINATION_CATEGORIES,
|
|
9027
9172
|
MACHINE_KINDS,
|
|
9028
9173
|
WORKSTATION_ASK_APPROVAL,
|
|
9029
9174
|
approvalExceptionError,
|
|
9030
9175
|
canonicalAgentClient,
|
|
9176
|
+
classifyDestinationHost,
|
|
9031
9177
|
deriveToolOperations,
|
|
9178
|
+
destinationCategory,
|
|
9032
9179
|
detectSecretKind,
|
|
9033
9180
|
effectiveApprovalScope,
|
|
9034
9181
|
evaluateActionPolicies,
|
package/dist/commandNarrative.js
CHANGED
|
@@ -21,6 +21,7 @@ exports.tokenDestinations = tokenDestinations;
|
|
|
21
21
|
exports.capturedValueDestinations = capturedValueDestinations;
|
|
22
22
|
exports.describeShellCommand = describeShellCommand;
|
|
23
23
|
exports.describeShellCommandLines = describeShellCommandLines;
|
|
24
|
+
const deterministicGuard_1 = require("./commands/deterministicGuard");
|
|
24
25
|
const MAX_STEPS = 14;
|
|
25
26
|
const MAX_LINE = 220;
|
|
26
27
|
/** Token printers whose output can be assigned; `issuer` = who accepts the token (identity tokens go to their audience). */
|
|
@@ -643,7 +644,7 @@ function describeOne(segmentIn, captured, fns = new Map()) {
|
|
|
643
644
|
case 'python3':
|
|
644
645
|
case 'tsx':
|
|
645
646
|
case 'ts-node':
|
|
646
|
-
return step(`Runs ${program} ${rest[1] ? `(${shortValue(rest.slice(1).join(' '), 60)})` : ''}`, false);
|
|
647
|
+
return step(`Runs ${program} ${rest[1] ? `(${/[\\/]/.test(rest[1]) && !rest[1].startsWith('-') ? [shortPath(rest[1]), ...rest.slice(2)].join(' ').slice(0, 80) : shortValue(rest.slice(1).join(' '), 60)})` : ''}`, false);
|
|
647
648
|
case 'curl':
|
|
648
649
|
case 'wget':
|
|
649
650
|
case 'invoke-webrequest':
|
|
@@ -678,7 +679,10 @@ function describeOne(segmentIn, captured, fns = new Map()) {
|
|
|
678
679
|
case 'mkdir':
|
|
679
680
|
case 'new-item':
|
|
680
681
|
case 'ni':
|
|
681
|
-
case 'touch':
|
|
682
|
+
case 'touch': {
|
|
683
|
+
const made = rest.slice(1).filter(t => !t.startsWith('-'));
|
|
684
|
+
return step(`Creates ${made.length === 1 ? shortPath(made[0]) : shortValue(made.join(', '), 60)}`);
|
|
685
|
+
}
|
|
682
686
|
case 'cat':
|
|
683
687
|
case 'get-content':
|
|
684
688
|
case 'gc':
|
|
@@ -686,8 +690,22 @@ function describeOne(segmentIn, captured, fns = new Map()) {
|
|
|
686
690
|
case 'head':
|
|
687
691
|
case 'tail':
|
|
688
692
|
case 'less':
|
|
689
|
-
case 'more':
|
|
690
|
-
|
|
693
|
+
case 'more': {
|
|
694
|
+
// `cat > f` / `cat 2>/dev/null f`: redirections and their targets are not files read.
|
|
695
|
+
const operands = [];
|
|
696
|
+
for (let k = 1; k < rest.length; k++) {
|
|
697
|
+
if (/^(?:\d*>{1,2}|&>|<)$/.test(rest[k])) {
|
|
698
|
+
k++;
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if (/^(?:\d*>|&>|<)/.test(rest[k]) || rest[k].startsWith('-'))
|
|
702
|
+
continue;
|
|
703
|
+
operands.push(rest[k]);
|
|
704
|
+
}
|
|
705
|
+
if (!operands.length && redirectTo)
|
|
706
|
+
return step(`Writes its input to ${shortValue(redirectTo, 60)}`);
|
|
707
|
+
return step(`Reads the file ${shortValue(operands[0] || '', 60)}${redirectNote}`);
|
|
708
|
+
}
|
|
691
709
|
case 'ls':
|
|
692
710
|
case 'dir':
|
|
693
711
|
case 'get-childitem':
|
|
@@ -948,13 +966,75 @@ function noteTokenDestinations(steps) {
|
|
|
948
966
|
return { ...step, text: shortValue(`${step.text}, then sends it to ${described.join('; ')}`, MAX_LINE) };
|
|
949
967
|
});
|
|
950
968
|
}
|
|
969
|
+
const HEREDOC_OPENER = /(?:^|[^<])<<-?[ \t]*(?:'[^'\n]*'|"[^"\n]*"|\\?[^\s;&|<>()'"]+)/g;
|
|
970
|
+
/**
|
|
971
|
+
* A heredoc body is the input of the step that opens it, not steps of its own:
|
|
972
|
+
* drop every body (and its delimiter line) and hand each opener step its body.
|
|
973
|
+
*/
|
|
974
|
+
function stepsWithHeredocs(command) {
|
|
975
|
+
const heredocs = (0, deterministicGuard_1.posixHeredocs)(command);
|
|
976
|
+
if (!heredocs.length)
|
|
977
|
+
return splitCommandSteps(command).map(segment => ({ segment, heredocs: [] }));
|
|
978
|
+
let code = '';
|
|
979
|
+
let at = 0;
|
|
980
|
+
for (const heredoc of heredocs) {
|
|
981
|
+
code += command.slice(at, heredoc.bodyStart);
|
|
982
|
+
at = Math.min(command.length, heredoc.end + 1);
|
|
983
|
+
}
|
|
984
|
+
code += command.slice(at);
|
|
985
|
+
let next = 0;
|
|
986
|
+
return splitCommandSteps(code).map(segment => {
|
|
987
|
+
const opened = segment.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, m => (/<</.test(m) ? m : '""')).match(HEREDOC_OPENER) || [];
|
|
988
|
+
const mine = heredocs.slice(next, next + opened.length);
|
|
989
|
+
next += opened.length;
|
|
990
|
+
return { segment, heredocs: mine };
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
/** A long path shortened from the front: the file name is what the reader needs. */
|
|
994
|
+
function shortPath(path, max = 60) {
|
|
995
|
+
if (path.length <= max)
|
|
996
|
+
return path;
|
|
997
|
+
const parts = path.split(/[\\/]/);
|
|
998
|
+
let out = parts.pop() || '';
|
|
999
|
+
while (parts.length && out.length + parts[parts.length - 1].length + 3 <= max)
|
|
1000
|
+
out = `${parts.pop()}/${out}`;
|
|
1001
|
+
return `…/${out}`.slice(0, max + 2);
|
|
1002
|
+
}
|
|
1003
|
+
function lineCount(body) {
|
|
1004
|
+
return body ? body.split('\n').length : 0;
|
|
1005
|
+
}
|
|
1006
|
+
/** What a step does with the heredoc bodies it opens. */
|
|
1007
|
+
function describeHeredocStep(segment, heredocs, fns) {
|
|
1008
|
+
const [heredoc] = heredocs;
|
|
1009
|
+
const lines = lineCount(heredoc.body);
|
|
1010
|
+
const size = `${lines}-line`;
|
|
1011
|
+
const masked = segment.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, m => m.replace(/[>|]/g, '_'));
|
|
1012
|
+
const target = /(?:^|[^>0-9-])>{1,2}\s*("[^"]*"|'[^']*'|[^\s&|;<]+)/.exec(masked);
|
|
1013
|
+
const file = target ? unquote(segment.slice(target.index + target[0].length - target[1].length, target.index + target[0].length)) : '';
|
|
1014
|
+
const words = tokens(segment).filter(t => !/^<</.test(t));
|
|
1015
|
+
const program = (words[0] || '').toLowerCase().replace(/^.*[\\/]/, '');
|
|
1016
|
+
const { kind, program: reader } = heredoc.receiver;
|
|
1017
|
+
const into = program === 'cat' ? file : program === 'tee' ? (file || words.slice(1).find(t => !t.startsWith('-')) || '') : '';
|
|
1018
|
+
// Writing the body to a file is this step; a later step that runs the file says so itself.
|
|
1019
|
+
if (into && !/\|/.test(masked)) {
|
|
1020
|
+
const what = kind === 'shell' ? `a ${size} shell script` : kind === 'language' ? `a ${size} ${reader} script` : `${lines} line${lines === 1 ? '' : 's'} of text`;
|
|
1021
|
+
return { segment, text: shortValue(`Writes ${what} to ${shortPath(into)}`, MAX_LINE), known: true };
|
|
1022
|
+
}
|
|
1023
|
+
if (kind === 'shell')
|
|
1024
|
+
return { segment, text: shortValue(`Runs a ${size} script with ${reader || program}`, MAX_LINE), known: true };
|
|
1025
|
+
if (kind === 'language')
|
|
1026
|
+
return { segment, text: shortValue(`Runs a ${size} ${reader} script`, MAX_LINE), known: true };
|
|
1027
|
+
const base = describeOne(segment.replace(HEREDOC_OPENER, m => m.replace(/<<[\s\S]*$/, '')), undefined, fns);
|
|
1028
|
+
return { ...base, segment, text: shortValue(`${base.text} (given ${lines} line${lines === 1 ? '' : 's'} of input text)`, MAX_LINE) };
|
|
1029
|
+
}
|
|
951
1030
|
/** One plain-words line per step, in order. */
|
|
952
1031
|
function describeShellCommand(command) {
|
|
953
|
-
const
|
|
1032
|
+
const parts = stepsWithHeredocs(command);
|
|
1033
|
+
const steps = parts.map(part => part.segment);
|
|
954
1034
|
const fns = localFunctions(command);
|
|
955
|
-
const described =
|
|
1035
|
+
const described = parts.map(({ segment, heredocs }) => {
|
|
956
1036
|
try {
|
|
957
|
-
return describeOne(segment, undefined, fns);
|
|
1037
|
+
return heredocs.length ? describeHeredocStep(segment, heredocs, fns) : describeOne(segment, undefined, fns);
|
|
958
1038
|
}
|
|
959
1039
|
catch {
|
|
960
1040
|
return { segment, text: 'Runs a command the grammar does not recognise', known: false };
|
|
@@ -215,6 +215,17 @@ export declare function isPowerShellLine(text: string): boolean;
|
|
|
215
215
|
* still carries fixture strings and docs.
|
|
216
216
|
*/
|
|
217
217
|
export declare function unquotedCode(text: string): string;
|
|
218
|
+
/** POSIX heredocs of a command line, in order: body span, delimiter-line end, and receiver. */
|
|
219
|
+
export declare function posixHeredocs(text: string): Array<{
|
|
220
|
+
bodyStart: number;
|
|
221
|
+
bodyEnd: number;
|
|
222
|
+
end: number;
|
|
223
|
+
body: string;
|
|
224
|
+
receiver: {
|
|
225
|
+
kind: 'shell' | 'language' | 'data';
|
|
226
|
+
program: string;
|
|
227
|
+
};
|
|
228
|
+
}>;
|
|
218
229
|
export declare function stripInertDataSegments(text: string): string;
|
|
219
230
|
export declare function scanDeterministicToolCall(toolName: string, toolArgs: Record<string, unknown>, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
220
231
|
export declare function scanDeterministicTextResponse(text: string, options?: LocalSafetyScanOptions): DeterministicFinding | undefined;
|
|
@@ -48,6 +48,7 @@ exports.getShellAstMode = getShellAstMode;
|
|
|
48
48
|
exports.takeShellAstComparison = takeShellAstComparison;
|
|
49
49
|
exports.isPowerShellLine = isPowerShellLine;
|
|
50
50
|
exports.unquotedCode = unquotedCode;
|
|
51
|
+
exports.posixHeredocs = posixHeredocs;
|
|
51
52
|
exports.stripInertDataSegments = stripInertDataSegments;
|
|
52
53
|
exports.scanDeterministicToolCall = scanDeterministicToolCall;
|
|
53
54
|
exports.scanDeterministicTextResponse = scanDeterministicTextResponse;
|
|
@@ -1056,9 +1057,10 @@ function destructiveCommandDecision(value, commandContext) {
|
|
|
1056
1057
|
if (astHit)
|
|
1057
1058
|
return astHit;
|
|
1058
1059
|
// The AST parsed the literal command only. A hit that exists only in a
|
|
1059
|
-
// decoded or de-obfuscated surface (`echo <base64> | base64 -d | sh`)
|
|
1060
|
-
// outside what it saw, so it
|
|
1061
|
-
|
|
1060
|
+
// decoded or de-obfuscated surface (`echo <base64> | base64 -d | sh`) or in
|
|
1061
|
+
// a heredoc body a shell runs (`bash <<'EOF'`) is outside what it saw, so it
|
|
1062
|
+
// cannot clear it.
|
|
1063
|
+
if (regexRm && !RM_SCOPE_ITEMS.has(destructiveCommandReason(withoutActingHeredocBodies(value), { literalOnly: true })?.itemId || ''))
|
|
1062
1064
|
return regexHit;
|
|
1063
1065
|
if (regexRm) {
|
|
1064
1066
|
// The AST cleared the rm-scope hit; other destructive rules still apply.
|
|
@@ -1519,7 +1521,7 @@ function isSensitivePathContext(toolName, candidate) {
|
|
|
1519
1521
|
// remote executor earlier in the same statement) are kept.
|
|
1520
1522
|
// 5. Secret-literal rules and honeypot/custom rules never use this view —
|
|
1521
1523
|
// a token pasted into a commit message still leaks.
|
|
1522
|
-
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|\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?)\s+(?:-c|\/c|-e|-command|-scriptblock)\b|\b(?:ssh|wsl(?:\.exe)?|chroot)\b|\b(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b/i;
|
|
1524
|
+
const DATA_TO_INTERPRETER = /\b(?:iex|icm|invoke-expression|invoke-command)\b|\[scriptblock\]::create\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|\b(?:sh|bash|zsh|dash|ksh|csh|pwsh|powershell(?:\.exe)?|cmd(?:\.exe)?)\s+(?:-c|\/c|-e|-command|-scriptblock)\b|\b(?:ssh|wsl(?:\.exe)?|chroot)\b|\b(?:docker|kubectl|podman|nerdctl)(?:\.exe)?\s+(?:exec|run)\b/i;
|
|
1523
1525
|
// A LANGUAGE interpreter's `-c` / `-e` argument is CODE in another language, not
|
|
1524
1526
|
// a shell command line. `node -e "console.log('rm -rf /')"` prints a string;
|
|
1525
1527
|
// `python -c "print('DROP TABLE')"` prints a string. That code only reaches the
|
|
@@ -1544,33 +1546,42 @@ function interpreterFileArguments(text) {
|
|
|
1544
1546
|
if (text.length > 32_000)
|
|
1545
1547
|
return '';
|
|
1546
1548
|
const paths = [];
|
|
1547
|
-
|
|
1549
|
+
const budget = { calls: 0 };
|
|
1548
1550
|
text.replace(/"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'/g, (whole, dq, sq, offset) => {
|
|
1549
1551
|
if (!LANG_INTERPRETER_CODE_BEFORE.test(text.slice(Math.max(0, offset - 80), offset)))
|
|
1550
1552
|
return whole;
|
|
1551
|
-
|
|
1552
|
-
// Preserve offsets while masking string contents: a call printed inside a
|
|
1553
|
-
// string is not an invocation; a sensitive path in another argument is data.
|
|
1554
|
-
const instructions = code.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, literal => ' '.repeat(literal.length));
|
|
1555
|
-
for (const call of instructions.matchAll(/\b(?:open|fopen|readFile(?:Sync)?|createReadStream|ReadAllText|ReadAllBytes)\s*\(/g)) {
|
|
1556
|
-
if (++calls > 48)
|
|
1557
|
-
break;
|
|
1558
|
-
const start = (call.index || 0) + call[0].length;
|
|
1559
|
-
let depth = 1;
|
|
1560
|
-
for (let end = start; end < instructions.length; end++) {
|
|
1561
|
-
const ch = instructions[end];
|
|
1562
|
-
if ((ch === ')' && --depth === 0) || (ch === ',' && depth === 1)) {
|
|
1563
|
-
paths.push(code.slice(start, end));
|
|
1564
|
-
break;
|
|
1565
|
-
}
|
|
1566
|
-
if (ch === '(')
|
|
1567
|
-
depth++;
|
|
1568
|
-
}
|
|
1569
|
-
}
|
|
1553
|
+
collectFileCallArguments(dq !== undefined ? dq : (sq || ''), paths, budget);
|
|
1570
1554
|
return whole;
|
|
1571
1555
|
});
|
|
1556
|
+
// Heredoc bodies a language interpreter runs (`python3 - <<'EOF'`, or
|
|
1557
|
+
// `cat > x.js <<'EOF' … node x.js`) are the same inline code.
|
|
1558
|
+
for (const heredoc of heredocView(text)?.heredocs || []) {
|
|
1559
|
+
if (heredoc.receiver.kind === 'language')
|
|
1560
|
+
collectFileCallArguments(heredoc.body, paths, budget);
|
|
1561
|
+
}
|
|
1572
1562
|
return paths.join('\n');
|
|
1573
1563
|
}
|
|
1564
|
+
const FILE_CALL_RE = /\b(?:open|fopen|readFile(?:Sync)?|createReadStream|ReadAllText|ReadAllBytes|readTextFile|file_get_contents|copyFile(?:Sync)?|copyfile|copy2?|Path|File\.read)\s*\(/g;
|
|
1565
|
+
function collectFileCallArguments(code, paths, budget) {
|
|
1566
|
+
// Preserve offsets while masking string contents: a call printed inside a
|
|
1567
|
+
// string is not an invocation; a sensitive path in another argument is data.
|
|
1568
|
+
const instructions = code.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g, literal => ' '.repeat(literal.length));
|
|
1569
|
+
for (const call of instructions.matchAll(FILE_CALL_RE)) {
|
|
1570
|
+
if (++budget.calls > 48)
|
|
1571
|
+
break;
|
|
1572
|
+
const start = (call.index || 0) + call[0].length;
|
|
1573
|
+
let depth = 1;
|
|
1574
|
+
for (let end = start; end < instructions.length; end++) {
|
|
1575
|
+
const ch = instructions[end];
|
|
1576
|
+
if ((ch === ')' && --depth === 0) || (ch === ',' && depth === 1)) {
|
|
1577
|
+
paths.push(code.slice(start, end));
|
|
1578
|
+
break;
|
|
1579
|
+
}
|
|
1580
|
+
if (ch === '(')
|
|
1581
|
+
depth++;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1574
1585
|
// $( … ) always executes; backtick pairs only with ≥2 chars of content
|
|
1575
1586
|
// (PowerShell escapes like `n / `t are single-char and inert).
|
|
1576
1587
|
const INTERPOLATES = /\$\(|`[^`]{2,}`/;
|
|
@@ -1717,16 +1728,414 @@ function replaceQuotedSpans(src, fn) {
|
|
|
1717
1728
|
}
|
|
1718
1729
|
return out + src.slice(from);
|
|
1719
1730
|
}
|
|
1731
|
+
// Stdin of these is executed as shell/SQL code; `done`/`fi`/`esac`/`}`/`)`
|
|
1732
|
+
// close a compound command whose body acts on what it reads (`while read c; do $c; done <<EOF`).
|
|
1733
|
+
const SHELL_STDIN_PROGRAMS = /^(?:sh|bash|zsh|dash|ksh|mksh|ash|csh|tcsh|fish|busybox|pwsh|powershell|cmd|ssh|wsl|chroot|su|eval|source|\.|xargs|parallel|at|batch|crontab|expect|tclsh|wish|osascript|script|psql|mysql|mariadb|sqlite3|sqlcmd|mongosh|mongo|redis-cli|cqlsh|clickhouse-client|duckdb|snowsql|done|fi|esac|\}|\))$/;
|
|
1734
|
+
const LANGUAGE_STDIN_PROGRAMS = /^(?:node|nodejs|deno|bun|python[0-9.]*|py|pypy[0-9.]*|perl|ruby|php|lua|luajit|tsx|ts-node|rscript|julia|groovy)$/;
|
|
1735
|
+
const CONTAINER_CLI_PROGRAMS = /^(?:docker|podman|nerdctl|kubectl|oc)$/;
|
|
1736
|
+
const RECEIVER_RANK = { data: 0, language: 1, shell: 2 };
|
|
1737
|
+
function programName(token) {
|
|
1738
|
+
return token.toLowerCase().replace(/^.*[\\/]/, '').replace(/\.(?:exe|cmd|bat)$/, '');
|
|
1739
|
+
}
|
|
1740
|
+
/** Words of one stage without assignments and redirections (`> f`, `2>&1`, `<<'EOF'`). */
|
|
1741
|
+
function stageWords(stage) {
|
|
1742
|
+
const words = [];
|
|
1743
|
+
const tokens = segmentTokens(stage.trim());
|
|
1744
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
1745
|
+
const token = tokens[i];
|
|
1746
|
+
if (/^(?:\d*>{1,2}|&>{1,2}|<{1,3}-?|\d*<)$/.test(token)) {
|
|
1747
|
+
i++;
|
|
1748
|
+
continue;
|
|
1749
|
+
}
|
|
1750
|
+
if (/^(?:\d*>|&>|<)/.test(token))
|
|
1751
|
+
continue;
|
|
1752
|
+
if (!words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
|
|
1753
|
+
continue;
|
|
1754
|
+
words.push(token);
|
|
1755
|
+
}
|
|
1756
|
+
return words;
|
|
1757
|
+
}
|
|
1758
|
+
/** How one pipeline stage treats the bytes on its stdin. */
|
|
1759
|
+
function stdinReceiver(stage) {
|
|
1760
|
+
const words = stageWords(stage);
|
|
1761
|
+
for (let i = 0; i < words.length; i++) {
|
|
1762
|
+
const program = programName(words[i]);
|
|
1763
|
+
if (SHELL_STDIN_PROGRAMS.test(program))
|
|
1764
|
+
return { kind: 'shell', program };
|
|
1765
|
+
if (LANGUAGE_STDIN_PROGRAMS.test(program))
|
|
1766
|
+
return { kind: 'language', program };
|
|
1767
|
+
if (CONTAINER_CLI_PROGRAMS.test(program)) {
|
|
1768
|
+
return words.slice(i + 1).some(word => /^(?:exec|run|attach|debug)$/i.test(word)) ? { kind: 'shell', program } : { kind: 'data', program };
|
|
1769
|
+
}
|
|
1770
|
+
if (AST_TRANSPARENT_WRAPPERS.has(program)) {
|
|
1771
|
+
// `sudo -u root bash`, `env X=1 node`, `timeout 5 sh`: the reader is a later word, past the wrapper's own options.
|
|
1772
|
+
let reader = { kind: 'data', program };
|
|
1773
|
+
for (const word of words.slice(i + 1))
|
|
1774
|
+
reader = strongerReceiver(reader, stdinReceiver(word));
|
|
1775
|
+
return reader;
|
|
1776
|
+
}
|
|
1777
|
+
return { kind: 'data', program };
|
|
1778
|
+
}
|
|
1779
|
+
return { kind: 'data', program: '' };
|
|
1780
|
+
}
|
|
1781
|
+
function strongerReceiver(a, b) {
|
|
1782
|
+
return RECEIVER_RANK[b.kind] > RECEIVER_RANK[a.kind] ? b : a;
|
|
1783
|
+
}
|
|
1784
|
+
/** Files the opener stage writes its stdin to (`cat > f`, `tee f`). */
|
|
1785
|
+
function stageWriteTargets(stage) {
|
|
1786
|
+
const targets = redirectTargets(stage);
|
|
1787
|
+
const words = stageWords(stage);
|
|
1788
|
+
if (programName(words[0] || '') === 'tee')
|
|
1789
|
+
targets.push(...words.slice(1).filter(word => !word.startsWith('-')));
|
|
1790
|
+
return targets.map(target => programName(target)).filter(Boolean);
|
|
1791
|
+
}
|
|
1792
|
+
/** The strongest program among `statements` that runs one of `files` (`node x.js`, `bash x.sh`, `./x.sh`). */
|
|
1793
|
+
function laterRunnerOf(files, statements) {
|
|
1794
|
+
let runner = { kind: 'data', program: '' };
|
|
1795
|
+
if (!files.length)
|
|
1796
|
+
return runner;
|
|
1797
|
+
for (const stages of statements) {
|
|
1798
|
+
for (const stage of stages) {
|
|
1799
|
+
const words = stageWords(stage);
|
|
1800
|
+
if (!words.some(word => files.includes(programName(word))))
|
|
1801
|
+
continue;
|
|
1802
|
+
const direct = files.includes(programName(words[0] || ''));
|
|
1803
|
+
runner = strongerReceiver(runner, direct ? { kind: 'shell', program: programName(words[0]) } : stdinReceiver(stage));
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
return runner;
|
|
1807
|
+
}
|
|
1808
|
+
/**
|
|
1809
|
+
* Every heredoc of a POSIX command line, with its exact body and its stages.
|
|
1810
|
+
* `undefined` when the line does not lex (an unclosed quote or substitution):
|
|
1811
|
+
* callers then keep every body as code.
|
|
1812
|
+
*/
|
|
1813
|
+
function lexHeredocs(text) {
|
|
1814
|
+
const spans = [];
|
|
1815
|
+
const pending = [];
|
|
1816
|
+
const frame = (closer, stageStart) => ({ closer, quote: '', depth: 0, afterPipe: false, stageStart, stages: [], open: [] });
|
|
1817
|
+
const stack = [frame('', 0)];
|
|
1818
|
+
/** `i` at the first `(` of `((`: index of the closing `))` (arithmetic holds no heredoc; `<<` there is a shift). */
|
|
1819
|
+
const arithmeticEnd = (i) => {
|
|
1820
|
+
let depth = 0;
|
|
1821
|
+
for (let k = i; k < text.length; k++) {
|
|
1822
|
+
if (text[k] === '(')
|
|
1823
|
+
depth++;
|
|
1824
|
+
else if (text[k] === ')' && --depth === 0)
|
|
1825
|
+
return k;
|
|
1826
|
+
}
|
|
1827
|
+
return -1;
|
|
1828
|
+
};
|
|
1829
|
+
const codeSlice = (from, to) => {
|
|
1830
|
+
let out = '';
|
|
1831
|
+
let at = from;
|
|
1832
|
+
for (const span of spans) {
|
|
1833
|
+
if (span.end <= at || span.bodyStart >= to)
|
|
1834
|
+
continue;
|
|
1835
|
+
out += text.slice(at, Math.max(at, span.bodyStart));
|
|
1836
|
+
at = Math.max(at, span.end);
|
|
1837
|
+
}
|
|
1838
|
+
return out + text.slice(at, Math.max(at, to));
|
|
1839
|
+
};
|
|
1840
|
+
const endStage = (ctx, at) => { ctx.stages.push(codeSlice(ctx.stageStart, at)); ctx.stageStart = at + 1; };
|
|
1841
|
+
const endStatement = (ctx, at) => {
|
|
1842
|
+
endStage(ctx, at);
|
|
1843
|
+
for (const { span, stage } of ctx.open)
|
|
1844
|
+
span.stages = ctx.stages.slice(stage);
|
|
1845
|
+
ctx.stages = [];
|
|
1846
|
+
ctx.open = [];
|
|
1847
|
+
};
|
|
1848
|
+
const readBodies = (newline) => {
|
|
1849
|
+
let pos = newline + 1;
|
|
1850
|
+
for (const { span, delimiter, stripTabs } of pending.splice(0)) {
|
|
1851
|
+
span.bodyStart = pos;
|
|
1852
|
+
span.bodyEnd = span.end = text.length;
|
|
1853
|
+
while (pos < text.length) {
|
|
1854
|
+
const lineEnd = text.indexOf('\n', pos);
|
|
1855
|
+
const stop = lineEnd < 0 ? text.length : lineEnd;
|
|
1856
|
+
let line = text.slice(pos, stop).replace(/\r$/, '');
|
|
1857
|
+
if (stripTabs)
|
|
1858
|
+
line = line.replace(/^\t+/, '');
|
|
1859
|
+
if (line === delimiter) {
|
|
1860
|
+
span.bodyEnd = pos;
|
|
1861
|
+
span.end = stop;
|
|
1862
|
+
pos = stop + 1;
|
|
1863
|
+
break;
|
|
1864
|
+
}
|
|
1865
|
+
pos = stop + 1;
|
|
1866
|
+
}
|
|
1867
|
+
span.body = text.slice(span.bodyStart, span.bodyEnd < text.length ? Math.max(span.bodyStart, span.bodyEnd - 1) : text.length);
|
|
1868
|
+
spans.push(span);
|
|
1869
|
+
}
|
|
1870
|
+
return pos;
|
|
1871
|
+
};
|
|
1872
|
+
for (let i = 0; i < text.length; i++) {
|
|
1873
|
+
const ctx = stack[stack.length - 1];
|
|
1874
|
+
const c = text[i];
|
|
1875
|
+
const next = text[i + 1];
|
|
1876
|
+
if (ctx.quote === "'") {
|
|
1877
|
+
if (c === "'")
|
|
1878
|
+
ctx.quote = '';
|
|
1879
|
+
continue;
|
|
1880
|
+
}
|
|
1881
|
+
if (c === '\\') {
|
|
1882
|
+
i++;
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
if (ctx.quote === '"') {
|
|
1886
|
+
if (c === '"')
|
|
1887
|
+
ctx.quote = '';
|
|
1888
|
+
else if (c === '$' && next === '(' && text[i + 2] === '(') {
|
|
1889
|
+
const e = arithmeticEnd(i + 1);
|
|
1890
|
+
if (e < 0)
|
|
1891
|
+
return undefined;
|
|
1892
|
+
i = e;
|
|
1893
|
+
}
|
|
1894
|
+
else if (c === '$' && next === '(') {
|
|
1895
|
+
stack.push(frame(')', i + 2));
|
|
1896
|
+
i++;
|
|
1897
|
+
}
|
|
1898
|
+
else if (c === '`')
|
|
1899
|
+
stack.push(frame('`', i + 1));
|
|
1900
|
+
continue;
|
|
1901
|
+
}
|
|
1902
|
+
if (c !== '|' && c !== '\n' && !/\s/.test(c))
|
|
1903
|
+
ctx.afterPipe = false;
|
|
1904
|
+
if (c === '#' && (i === 0 || /[\s;&|(]/.test(text[i - 1]))) {
|
|
1905
|
+
const nl = text.indexOf('\n', i);
|
|
1906
|
+
i = (nl < 0 ? text.length : nl) - 1;
|
|
1907
|
+
continue;
|
|
1908
|
+
}
|
|
1909
|
+
if (c === "'" || c === '"') {
|
|
1910
|
+
ctx.quote = c;
|
|
1911
|
+
continue;
|
|
1912
|
+
}
|
|
1913
|
+
if ((c === '$' && next === '(' && text[i + 2] === '(') || (c === '(' && next === '(')) {
|
|
1914
|
+
const e = arithmeticEnd(c === '$' ? i + 1 : i);
|
|
1915
|
+
if (e < 0)
|
|
1916
|
+
return undefined;
|
|
1917
|
+
i = e;
|
|
1918
|
+
continue;
|
|
1919
|
+
}
|
|
1920
|
+
if (c === '$' && next === '(') {
|
|
1921
|
+
stack.push(frame(')', i + 2));
|
|
1922
|
+
i++;
|
|
1923
|
+
continue;
|
|
1924
|
+
}
|
|
1925
|
+
if (c === '`') {
|
|
1926
|
+
if (ctx.closer === '`') {
|
|
1927
|
+
endStatement(ctx, i);
|
|
1928
|
+
stack.pop();
|
|
1929
|
+
}
|
|
1930
|
+
else
|
|
1931
|
+
stack.push(frame('`', i + 1));
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1934
|
+
if (c === '(') {
|
|
1935
|
+
endStatement(ctx, i);
|
|
1936
|
+
ctx.depth++;
|
|
1937
|
+
continue;
|
|
1938
|
+
}
|
|
1939
|
+
if (c === ')') {
|
|
1940
|
+
endStatement(ctx, i);
|
|
1941
|
+
if (ctx.depth > 0)
|
|
1942
|
+
ctx.depth--;
|
|
1943
|
+
else if (ctx.closer === ')' && stack.length > 1)
|
|
1944
|
+
stack.pop();
|
|
1945
|
+
continue;
|
|
1946
|
+
}
|
|
1947
|
+
if (c === '{' && (next === undefined || /\s/.test(next))) {
|
|
1948
|
+
endStatement(ctx, i);
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
if (c === ';' || (c === '&' && next !== '>' && text[i - 1] !== '>' && text[i - 1] !== '<')) {
|
|
1952
|
+
endStatement(ctx, i);
|
|
1953
|
+
if ((c === '&' && next === '&') || (c === ';' && next === ';'))
|
|
1954
|
+
i++;
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
if (c === '|') {
|
|
1958
|
+
if (next === '|') {
|
|
1959
|
+
endStatement(ctx, i);
|
|
1960
|
+
i++;
|
|
1961
|
+
}
|
|
1962
|
+
else {
|
|
1963
|
+
endStage(ctx, i);
|
|
1964
|
+
ctx.afterPipe = true;
|
|
1965
|
+
if (next === '&')
|
|
1966
|
+
i++;
|
|
1967
|
+
}
|
|
1968
|
+
continue;
|
|
1969
|
+
}
|
|
1970
|
+
if (c === '\n') {
|
|
1971
|
+
// `cat <<'EOF' |` continues the pipeline on the line after the body.
|
|
1972
|
+
if (!ctx.afterPipe)
|
|
1973
|
+
endStatement(ctx, i);
|
|
1974
|
+
if (pending.length) {
|
|
1975
|
+
const after = readBodies(i);
|
|
1976
|
+
if (ctx.stageStart <= after)
|
|
1977
|
+
ctx.stageStart = after;
|
|
1978
|
+
i = after - 1;
|
|
1979
|
+
}
|
|
1980
|
+
continue;
|
|
1981
|
+
}
|
|
1982
|
+
if (c === '<' && next === '<' && text[i - 1] !== '<') {
|
|
1983
|
+
if (text[i + 2] === '<') {
|
|
1984
|
+
i += 2;
|
|
1985
|
+
continue;
|
|
1986
|
+
} // `<<<` here-string: quoted-span pass
|
|
1987
|
+
let j = i + 2;
|
|
1988
|
+
const stripTabs = text[j] === '-';
|
|
1989
|
+
if (stripTabs)
|
|
1990
|
+
j++;
|
|
1991
|
+
while (text[j] === ' ' || text[j] === '\t')
|
|
1992
|
+
j++;
|
|
1993
|
+
let delimiter = '';
|
|
1994
|
+
let literal = false;
|
|
1995
|
+
while (j < text.length && !/[\s;&|<>()]/.test(text[j])) {
|
|
1996
|
+
const q = text[j];
|
|
1997
|
+
if (q === "'" || q === '"') {
|
|
1998
|
+
const close = text.indexOf(q, j + 1);
|
|
1999
|
+
if (close < 0)
|
|
2000
|
+
return undefined;
|
|
2001
|
+
delimiter += text.slice(j + 1, close);
|
|
2002
|
+
literal = true;
|
|
2003
|
+
j = close + 1;
|
|
2004
|
+
}
|
|
2005
|
+
else if (q === '\\') {
|
|
2006
|
+
delimiter += text[j + 1] || '';
|
|
2007
|
+
literal = true;
|
|
2008
|
+
j += 2;
|
|
2009
|
+
}
|
|
2010
|
+
else {
|
|
2011
|
+
delimiter += q;
|
|
2012
|
+
j++;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
if (!delimiter) {
|
|
2016
|
+
i = j - 1;
|
|
2017
|
+
continue;
|
|
2018
|
+
}
|
|
2019
|
+
const span = { bodyStart: text.length, bodyEnd: text.length, end: text.length, body: '', literal, stages: [] };
|
|
2020
|
+
pending.push({ span, delimiter, stripTabs });
|
|
2021
|
+
ctx.open.push({ span, stage: ctx.stages.length });
|
|
2022
|
+
i = j - 1;
|
|
2023
|
+
continue;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
if (stack.length > 1 || stack[0].quote)
|
|
2027
|
+
return undefined;
|
|
2028
|
+
if (pending.length)
|
|
2029
|
+
readBodies(text.length - 1);
|
|
2030
|
+
endStatement(stack[0], text.length);
|
|
2031
|
+
return spans;
|
|
2032
|
+
}
|
|
2033
|
+
/** Heredocs of a POSIX line, each with the receiver that decides what its body is. */
|
|
2034
|
+
function heredocView(text) {
|
|
2035
|
+
if (!text.includes('<<') || text.length > 256_000)
|
|
2036
|
+
return undefined;
|
|
2037
|
+
const spans = lexHeredocs(text);
|
|
2038
|
+
if (!spans || !spans.length)
|
|
2039
|
+
return undefined;
|
|
2040
|
+
let skeleton = '';
|
|
2041
|
+
let at = 0;
|
|
2042
|
+
for (const span of spans) {
|
|
2043
|
+
skeleton += text.slice(at, span.bodyStart);
|
|
2044
|
+
at = span.end;
|
|
2045
|
+
}
|
|
2046
|
+
skeleton += text.slice(at);
|
|
2047
|
+
const statements = splitUnquotedPipelines(skeleton);
|
|
2048
|
+
const heredocs = spans.map(span => {
|
|
2049
|
+
let receiver = { kind: 'data', program: '' };
|
|
2050
|
+
for (const stage of span.stages)
|
|
2051
|
+
receiver = strongerReceiver(receiver, stdinReceiver(stage));
|
|
2052
|
+
receiver = strongerReceiver(receiver, laterRunnerOf(stageWriteTargets(span.stages[0] || ''), statements));
|
|
2053
|
+
return { ...span, receiver };
|
|
2054
|
+
});
|
|
2055
|
+
return { heredocs, skeleton };
|
|
2056
|
+
}
|
|
2057
|
+
/** POSIX heredocs of a command line, in order: body span, delimiter-line end, and receiver. */
|
|
2058
|
+
function posixHeredocs(text) {
|
|
2059
|
+
return (heredocView(text)?.heredocs || []).map(({ bodyStart, bodyEnd, end, body, receiver }) => ({ bodyStart, bodyEnd, end, body, receiver }));
|
|
2060
|
+
}
|
|
2061
|
+
/** Does this heredoc body run as code where it lands? */
|
|
2062
|
+
function heredocBodyActs(heredoc) {
|
|
2063
|
+
// Unquoted delimiter: `$( … )` / backticks in the body run before any receiver sees it.
|
|
2064
|
+
if (!heredoc.literal && INTERPOLATES.test(heredoc.body))
|
|
2065
|
+
return true;
|
|
2066
|
+
if (heredoc.receiver.kind === 'shell')
|
|
2067
|
+
return true;
|
|
2068
|
+
if (heredoc.receiver.kind === 'language') {
|
|
2069
|
+
return CODE_SPAWNS_PROCESS.test(heredoc.body)
|
|
2070
|
+
|| (/^(?:perl|ruby)$/.test(heredoc.receiver.program) && /`[^`]*`|%x[\[{(]/.test(heredoc.body))
|
|
2071
|
+
|| interpreterCodeRequestsMetadata(heredoc.body);
|
|
2072
|
+
}
|
|
2073
|
+
return false;
|
|
2074
|
+
}
|
|
2075
|
+
/**
|
|
2076
|
+
* Each body becomes `inert` when it is data, or `acting(index)` when it runs
|
|
2077
|
+
* (left as is without `acting`). Delimiter lines stay, so the result still
|
|
2078
|
+
* lexes. Returns the bodies that run so a caller can put them back verbatim.
|
|
2079
|
+
*/
|
|
2080
|
+
function replaceHeredocBodies(text, view, inert, acting) {
|
|
2081
|
+
let out = '';
|
|
2082
|
+
let at = 0;
|
|
2083
|
+
const kept = [];
|
|
2084
|
+
for (const heredoc of view.heredocs) {
|
|
2085
|
+
const acts = heredocBodyActs(heredoc);
|
|
2086
|
+
if (acts && !acting)
|
|
2087
|
+
continue;
|
|
2088
|
+
out += text.slice(at, heredoc.bodyStart) + (acts ? acting(kept.push(heredoc.body) - 1) : inert);
|
|
2089
|
+
at = heredoc.bodyStart + heredoc.body.length;
|
|
2090
|
+
}
|
|
2091
|
+
return { text: out + text.slice(at), acting: kept };
|
|
2092
|
+
}
|
|
2093
|
+
/** The line as a bash parser sees it: bodies that RUN are opaque text there, so blank them. */
|
|
2094
|
+
function withoutActingHeredocBodies(text) {
|
|
2095
|
+
const view = heredocView(text);
|
|
2096
|
+
if (!view)
|
|
2097
|
+
return text;
|
|
2098
|
+
let out = '';
|
|
2099
|
+
let at = 0;
|
|
2100
|
+
for (const heredoc of view.heredocs) {
|
|
2101
|
+
if (!heredocBodyActs(heredoc))
|
|
2102
|
+
continue;
|
|
2103
|
+
out += text.slice(at, heredoc.bodyStart);
|
|
2104
|
+
at = heredoc.bodyStart + heredoc.body.length;
|
|
2105
|
+
}
|
|
2106
|
+
return out + text.slice(at);
|
|
2107
|
+
}
|
|
2108
|
+
const ACTING_BODY_MARK = (index) => `fcd_acting_heredoc_${index}_`;
|
|
2109
|
+
/** Receiver of a `<<<` here-string whose quoted word starts at `offset`. */
|
|
2110
|
+
function hereStringReceiver(src, offset) {
|
|
2111
|
+
const head = src.slice(src.lastIndexOf('\n', offset - 1) + 1, offset).replace(/<<<\s*$/, '');
|
|
2112
|
+
const statements = splitUnquotedPipelines(head);
|
|
2113
|
+
const stages = statements[statements.length - 1] || [];
|
|
2114
|
+
return stdinReceiver(stages[stages.length - 1] || '');
|
|
2115
|
+
}
|
|
1720
2116
|
function stripInertDataSegments(text) {
|
|
1721
|
-
|
|
2117
|
+
const heredocs = heredocView(text);
|
|
2118
|
+
// A data body (`cat > notes.md <<'EOF' … ssh … EOF`) cannot make the line an
|
|
2119
|
+
// interpreter feed; only the code around it, and bodies that run, can.
|
|
2120
|
+
const executable = heredocs ? replaceHeredocBodies(text, heredocs, '').text : text;
|
|
2121
|
+
if (DATA_TO_INTERPRETER.test(unquotedCode(executable)))
|
|
1722
2122
|
return text;
|
|
2123
|
+
// A body that runs is code line for line: park it so the quoted-span pass
|
|
2124
|
+
// below cannot read `os.system('rm -rf ~')` as prose, then put it back.
|
|
2125
|
+
const parked = heredocs ? replaceHeredocBodies(text, heredocs, INERT_DATA, ACTING_BODY_MARK) : { text, acting: [] };
|
|
2126
|
+
let out = stripQuotedData(parked.text);
|
|
2127
|
+
parked.acting.forEach((body, index) => {
|
|
2128
|
+
const mark = ACTING_BODY_MARK(index);
|
|
2129
|
+
out = out.includes(mark) ? out.replace(mark, () => body) : `${out}\n${body}`;
|
|
2130
|
+
});
|
|
2131
|
+
return out;
|
|
2132
|
+
}
|
|
2133
|
+
/** Here-strings and quoted prose of a line whose heredoc bodies are already resolved. */
|
|
2134
|
+
function stripQuotedData(text) {
|
|
1723
2135
|
let out = text;
|
|
1724
2136
|
// PowerShell here-strings: @'…'@ is fully literal; @"…"@ interpolates $( ).
|
|
1725
2137
|
out = replaceDelimited(out, "@'", "'@", () => INERT_DATA);
|
|
1726
2138
|
out = replaceDelimited(out, '@"', '"@', m => (m.includes('$(') ? m : INERT_DATA));
|
|
1727
|
-
// POSIX heredocs: quoted delimiter (<<'EOF') is literal; unquoted interpolates.
|
|
1728
|
-
out = out.replace(/<<-?\s*'(\w+)'[\s\S]*?(?:\n\1\b|$)/g, INERT_DATA);
|
|
1729
|
-
out = out.replace(/<<-?\s*"?(\w+)"?[\s\S]*?(?:\n\1\b|$)/g, m => (INTERPOLATES.test(m) ? m : INERT_DATA));
|
|
1730
2139
|
// Generic quoted-span pass: whitespace inside quotes ⇒ free text ⇒ inert,
|
|
1731
2140
|
// unless the span sits in a code position (invariants 2–4 above).
|
|
1732
2141
|
const src = out;
|
|
@@ -1736,6 +2145,14 @@ function stripInertDataSegments(text) {
|
|
|
1736
2145
|
if (!/\s/.test(body))
|
|
1737
2146
|
return whole; // single token: argv, not prose
|
|
1738
2147
|
const before = src.slice(Math.max(0, offset - 80), offset);
|
|
2148
|
+
// `bash <<< "…"` feeds the quoted word to stdin: it is what its reader makes of it.
|
|
2149
|
+
if (/<<<\s*$/.test(before)) {
|
|
2150
|
+
const receiver = hereStringReceiver(src, offset);
|
|
2151
|
+
if (receiver.kind === 'shell')
|
|
2152
|
+
return whole;
|
|
2153
|
+
if (receiver.kind === 'language')
|
|
2154
|
+
return CODE_SPAWNS_PROCESS.test(body) || interpreterCodeRequestsMetadata(body) ? whole : INERT_DATA;
|
|
2155
|
+
}
|
|
1739
2156
|
// Language-interpreter code: data unless it spawns a process (see above).
|
|
1740
2157
|
if (LANG_INTERPRETER_CODE_BEFORE.test(before))
|
|
1741
2158
|
return interpreterCodeSpawnsProcess(before, body) || interpreterCodeRequestsMetadata(body) ? whole : INERT_DATA;
|
package/dist/version.json
CHANGED