fullcourtdefense-cli 1.35.2 → 1.35.3
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 +602 -0
- 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.js +7 -2
- package/dist/index.js +4 -0
- package/dist/version.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What a shell script DOES, one plain-words line per step — for the approval
|
|
4
|
+
* dialog and the IDE message. A developer asked to approve
|
|
5
|
+
*
|
|
6
|
+
* $prefix="gs://b/prod-export"; gcloud firestore export $prefix --project acme-prod; gcloud firestore import $prefix --project acme-staging
|
|
7
|
+
*
|
|
8
|
+
* should read "Exports Firestore data from project acme-prod to bucket b ·
|
|
9
|
+
* Imports Firestore data into project acme-staging from bucket b", not a
|
|
10
|
+
* truncated command line.
|
|
11
|
+
*
|
|
12
|
+
* Fixed templates only, keyed on the program and its verb — no model, no
|
|
13
|
+
* scoring, nothing that could decide anything. This module never influences a
|
|
14
|
+
* verdict; it only words one. A program the grammar does not know is reported
|
|
15
|
+
* as exactly that ("Runs `foo bar`"), never guessed. Pure: no I/O.
|
|
16
|
+
*/
|
|
17
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
|
+
exports.splitCommandSteps = splitCommandSteps;
|
|
19
|
+
exports.describeShellCommand = describeShellCommand;
|
|
20
|
+
exports.describeShellCommandLines = describeShellCommandLines;
|
|
21
|
+
const MAX_STEPS = 14;
|
|
22
|
+
const MAX_LINE = 220;
|
|
23
|
+
/** Quote-aware split on `;`, `&&`, `||`, newlines. Pipes stay inside a step. */
|
|
24
|
+
function splitCommandSteps(command) {
|
|
25
|
+
const steps = [];
|
|
26
|
+
let current = '';
|
|
27
|
+
let quote = null;
|
|
28
|
+
let depth = 0;
|
|
29
|
+
for (let i = 0; i < command.length; i++) {
|
|
30
|
+
const ch = command[i];
|
|
31
|
+
if (quote) {
|
|
32
|
+
current += ch;
|
|
33
|
+
if (ch === quote)
|
|
34
|
+
quote = null;
|
|
35
|
+
else if (ch === '\\' && quote === '"' && i + 1 < command.length)
|
|
36
|
+
current += command[++i];
|
|
37
|
+
else if (ch === '`' && quote === '"' && i + 1 < command.length)
|
|
38
|
+
current += command[++i];
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (ch === '"' || ch === "'") {
|
|
42
|
+
quote = ch;
|
|
43
|
+
current += ch;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (ch === '{' || ch === '(')
|
|
47
|
+
depth++;
|
|
48
|
+
if (ch === '}' || ch === ')')
|
|
49
|
+
depth = Math.max(0, depth - 1);
|
|
50
|
+
const sep = depth === 0 && (ch === '\n' || ch === ';' || ((ch === '&' || ch === '|') && command[i + 1] === ch));
|
|
51
|
+
if (sep) {
|
|
52
|
+
if (ch === '&' || ch === '|')
|
|
53
|
+
i++;
|
|
54
|
+
steps.push(current);
|
|
55
|
+
current = '';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
current += ch;
|
|
59
|
+
}
|
|
60
|
+
steps.push(current);
|
|
61
|
+
return steps.map(step => step.trim()).filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
/** Tokens with quotes stripped; `--flag=value` kept whole. */
|
|
64
|
+
function tokens(segment) {
|
|
65
|
+
const out = segment.match(/"(?:[^"\\]|\\.)*"|'[^']*'|[^\s]+/g) || [];
|
|
66
|
+
return out.map(token => token.replace(/^["']|["']$/g, ''));
|
|
67
|
+
}
|
|
68
|
+
function shortValue(value, max = 80) {
|
|
69
|
+
const text = value.replace(/\s+/g, ' ').trim();
|
|
70
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
71
|
+
}
|
|
72
|
+
function bucketOf(uri) {
|
|
73
|
+
const m = /^(?:gs|s3|az):\/\/([^/\s]+)/i.exec(uri);
|
|
74
|
+
return m ? m[1] : uri;
|
|
75
|
+
}
|
|
76
|
+
function isStorageUri(token) {
|
|
77
|
+
return /^(?:gs|s3|az):\/\//i.test(token);
|
|
78
|
+
}
|
|
79
|
+
function unquote(value) {
|
|
80
|
+
return value.replace(/^["']|["']$/g, '');
|
|
81
|
+
}
|
|
82
|
+
function flagValue(toks, name) {
|
|
83
|
+
for (let i = 0; i < toks.length; i++) {
|
|
84
|
+
if (toks[i] === name && toks[i + 1] && !toks[i + 1].startsWith('-'))
|
|
85
|
+
return unquote(toks[i + 1]);
|
|
86
|
+
if (toks[i].startsWith(`${name}=`))
|
|
87
|
+
return unquote(toks[i].slice(name.length + 1));
|
|
88
|
+
}
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
function operandsAfter(toks, from) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (let i = from; i < toks.length; i++) {
|
|
94
|
+
const token = toks[i];
|
|
95
|
+
if (token.startsWith('-')) {
|
|
96
|
+
// `--flag value` (no `=`): skip the value unless it is clearly an operand
|
|
97
|
+
if (!token.includes('=') && toks[i + 1] && !toks[i + 1].startsWith('-') && !isStorageUri(toks[i + 1]) && !/^\$/.test(toks[i + 1]))
|
|
98
|
+
i++;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (/^\d?>&?\d?$/.test(token) || /^[<>]+/.test(token))
|
|
102
|
+
break; // redirection
|
|
103
|
+
out.push(token);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
/** Product names for the first gcloud group word, as Google writes them. */
|
|
108
|
+
const GCLOUD_SERVICE_NAMES = {
|
|
109
|
+
firestore: 'Firestore', storage: 'Cloud Storage', run: 'Cloud Run', compute: 'Compute Engine', sql: 'Cloud SQL', pubsub: 'Pub/Sub',
|
|
110
|
+
secrets: 'Secret Manager', iam: 'IAM', projects: 'project', functions: 'Cloud Functions', container: 'GKE', logging: 'Cloud Logging',
|
|
111
|
+
artifacts: 'Artifact Registry', scheduler: 'Cloud Scheduler', tasks: 'Cloud Tasks', kms: 'Cloud KMS', builds: 'Cloud Build', auth: 'auth', config: 'config',
|
|
112
|
+
};
|
|
113
|
+
const GCLOUD_READ = /^(?:describe|list|ls|get-iam-policy|get-value|get-ancestors|read|check|versions)$/;
|
|
114
|
+
const GCLOUD_WRITE = /^(?:create|update|patch|replace|enable|disable|deploy|apply|start|stop|restart|resume|suspend|scale|promote|migrate|rollback|add-[a-z-]+|remove-[a-z-]+|set-[a-z-]+|unset|clear|activate|revoke|copy)$/;
|
|
115
|
+
function humanVerb(verb) {
|
|
116
|
+
const map = {
|
|
117
|
+
describe: 'Reads details of', list: 'Lists', ls: 'Lists', 'get-iam-policy': 'Reads the access policy of', 'get-value': 'Reads the setting',
|
|
118
|
+
read: 'Reads', check: 'Checks', versions: 'Lists versions of',
|
|
119
|
+
create: 'Creates', update: 'Updates', patch: 'Updates', replace: 'Replaces', enable: 'Enables', disable: 'Disables', deploy: 'Deploys',
|
|
120
|
+
apply: 'Applies', start: 'Starts', stop: 'Stops', restart: 'Restarts', resume: 'Resumes', suspend: 'Suspends', scale: 'Scales',
|
|
121
|
+
promote: 'Promotes', migrate: 'Migrates', rollback: 'Rolls back', unset: 'Unsets', clear: 'Clears', activate: 'Activates', revoke: 'Revokes', copy: 'Copies',
|
|
122
|
+
delete: 'Deletes',
|
|
123
|
+
};
|
|
124
|
+
if (map[verb])
|
|
125
|
+
return map[verb];
|
|
126
|
+
if (/^add-iam-policy-binding$/.test(verb))
|
|
127
|
+
return 'Grants a role on';
|
|
128
|
+
if (/^remove-iam-policy-binding$/.test(verb))
|
|
129
|
+
return 'Removes a role from';
|
|
130
|
+
if (/^add-/.test(verb))
|
|
131
|
+
return `Adds ${verb.slice(4).replace(/-/g, ' ')} to`;
|
|
132
|
+
if (/^remove-/.test(verb))
|
|
133
|
+
return `Removes ${verb.slice(7).replace(/-/g, ' ')} from`;
|
|
134
|
+
if (/^set-/.test(verb))
|
|
135
|
+
return `Sets ${verb.slice(4).replace(/-/g, ' ')} on`;
|
|
136
|
+
return `Runs \`${verb}\` on`;
|
|
137
|
+
}
|
|
138
|
+
function describeGcloud(toks) {
|
|
139
|
+
let i = 1;
|
|
140
|
+
while (i < toks.length && toks[i].startsWith('-'))
|
|
141
|
+
i++;
|
|
142
|
+
if (/^(?:alpha|beta)$/.test(toks[i] || ''))
|
|
143
|
+
i++;
|
|
144
|
+
const groups = [];
|
|
145
|
+
let verb;
|
|
146
|
+
for (; i < toks.length; i++) {
|
|
147
|
+
const token = toks[i];
|
|
148
|
+
if (token.startsWith('-'))
|
|
149
|
+
continue;
|
|
150
|
+
if (GCLOUD_READ.test(token) || GCLOUD_WRITE.test(token) || token === 'delete' || /^(?:export|import|cp|rsync|mv|cat|rm|login|print-access-token|print-identity-token|run|exec|ssh|scp)$/.test(token)) {
|
|
151
|
+
verb = token;
|
|
152
|
+
i++;
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
if (!/^[a-z][a-z0-9-]*$/.test(token))
|
|
156
|
+
break;
|
|
157
|
+
groups.push(token);
|
|
158
|
+
}
|
|
159
|
+
const project = flagValue(toks, '--project');
|
|
160
|
+
const where = project ? ` in project ${project}` : '';
|
|
161
|
+
const ops = verb ? operandsAfter(toks, i) : [];
|
|
162
|
+
const resource = groups.map((group, index) => (index === 0 && GCLOUD_SERVICE_NAMES[group] ? GCLOUD_SERVICE_NAMES[group] : group)).join(' ');
|
|
163
|
+
const first = ops[0];
|
|
164
|
+
const isVar = (value) => Boolean(value && /^\$/.test(value));
|
|
165
|
+
const place = (value) => (isStorageUri(value) ? `storage bucket ${bucketOf(value)}` : isVar(value) ? `the location in ${value}` : shortValue(value, 60));
|
|
166
|
+
const named = (value) => (value ? (isStorageUri(value) ? `storage bucket ${bucketOf(value)}` : `${resource || 'resource'} ${shortValue(value, 60)}`) : resource || 'resources');
|
|
167
|
+
if (!verb)
|
|
168
|
+
return groups.length ? `Runs \`gcloud ${groups.join(' ')}\`${where}` : 'Runs `gcloud`';
|
|
169
|
+
if (verb === 'export' && first)
|
|
170
|
+
return `Exports ${resource} data${project ? ` from project ${project}` : ''} to ${place(first)}`;
|
|
171
|
+
if (verb === 'import' && first)
|
|
172
|
+
return `Imports ${resource} data${project ? ` into project ${project}` : ''} from ${place(first)}`;
|
|
173
|
+
if ((verb === 'cp' || verb === 'rsync' || verb === 'mv') && ops.length >= 2) {
|
|
174
|
+
const last = ops[ops.length - 1];
|
|
175
|
+
const src = ops.slice(0, -1).join(', ');
|
|
176
|
+
if (isStorageUri(last) && !isStorageUri(ops[0]))
|
|
177
|
+
return `Uploads ${shortValue(src, 60)} to storage bucket ${bucketOf(last)}`;
|
|
178
|
+
if (isStorageUri(ops[0]) && !isStorageUri(last))
|
|
179
|
+
return `Downloads from storage bucket ${bucketOf(ops[0])} to ${shortValue(last, 60)}`;
|
|
180
|
+
return `Copies ${shortValue(src, 60)} to ${shortValue(last, 60)}`;
|
|
181
|
+
}
|
|
182
|
+
if (verb === 'cat' && first)
|
|
183
|
+
return `Reads the contents of ${isStorageUri(first) ? `an object in storage bucket ${bucketOf(first)}` : shortValue(first, 60)}`;
|
|
184
|
+
if (verb === 'rm')
|
|
185
|
+
return `Deletes ${named(first)}`;
|
|
186
|
+
if (/^print-(?:access|identity)-token$/.test(verb))
|
|
187
|
+
return 'Prints a cloud access token to the terminal';
|
|
188
|
+
if (verb === 'login')
|
|
189
|
+
return 'Signs in to Google Cloud';
|
|
190
|
+
if (verb === 'add-iam-policy-binding' || verb === 'remove-iam-policy-binding') {
|
|
191
|
+
const role = flagValue(toks, '--role');
|
|
192
|
+
const member = flagValue(toks, '--member');
|
|
193
|
+
const who = member ? shortValue(member.replace(/^(?:serviceAccount|user|group):/i, ''), 70) : 'a principal';
|
|
194
|
+
return `${verb.startsWith('add') ? 'Grants' : 'Removes'} ${role ? `role ${role}` : 'a role'} ${verb.startsWith('add') ? 'to' : 'from'} ${who} on ${named(first)}${where}`;
|
|
195
|
+
}
|
|
196
|
+
if (verb === 'delete')
|
|
197
|
+
return `Deletes ${named(first)}${where}`;
|
|
198
|
+
return `${humanVerb(verb)} ${named(first)}${where}`;
|
|
199
|
+
}
|
|
200
|
+
function describeTransfer(program, toks) {
|
|
201
|
+
const ops = operandsAfter(toks, program === 'aws' ? 3 : /^gsutil$/i.test(program) ? (toks[1] === '-m' ? 3 : 2) : 1);
|
|
202
|
+
if (ops.length < 2)
|
|
203
|
+
return null;
|
|
204
|
+
const last = ops[ops.length - 1];
|
|
205
|
+
const src = ops.slice(0, -1).join(', ');
|
|
206
|
+
const remote = (value) => isStorageUri(value) || /^[\w.-]+@[\w.-]+:/.test(value) || /^[\w.-]+:(?:[/~]|$)/.test(value);
|
|
207
|
+
if (remote(last) && !remote(ops[0]))
|
|
208
|
+
return `Uploads ${shortValue(src, 60)} to ${isStorageUri(last) ? `storage bucket ${bucketOf(last)}` : shortValue(last, 60)}`;
|
|
209
|
+
if (remote(ops[0]) && !remote(last))
|
|
210
|
+
return `Downloads from ${isStorageUri(ops[0]) ? `storage bucket ${bucketOf(ops[0])}` : shortValue(ops[0], 60)} to ${shortValue(last, 60)}`;
|
|
211
|
+
return `Copies ${shortValue(src, 60)} to ${shortValue(last, 60)}`;
|
|
212
|
+
}
|
|
213
|
+
function hostOf(text) {
|
|
214
|
+
const m = /https?:\/\/([^/\s"'<>`]+)/i.exec(text);
|
|
215
|
+
return m ? m[1] : undefined;
|
|
216
|
+
}
|
|
217
|
+
function describeHttp(program, segment, toks) {
|
|
218
|
+
const host = hostOf(segment);
|
|
219
|
+
const method = (/(?:\s-X|\s--request|\s-Method)[\s=]+["']?([A-Za-z]+)/i.exec(segment)?.[1] || '').toUpperCase();
|
|
220
|
+
const body = /\s(?:-d|--data(?:-raw|-binary|-urlencode)?|-F|--form|-T|--upload-file|--json|-Body|-InFile)\b/i.test(segment);
|
|
221
|
+
const target = host ? ` ${host}` : toks[1] ? ` ${shortValue(toks[1], 60)}` : '';
|
|
222
|
+
if (method && method !== 'GET' || body)
|
|
223
|
+
return `Sends ${method && method !== 'GET' ? `a ${method} request` : 'data'} to${target}`;
|
|
224
|
+
return `Fetches${target} over HTTP`;
|
|
225
|
+
}
|
|
226
|
+
function describePipeTail(stages) {
|
|
227
|
+
if (stages.length === 0)
|
|
228
|
+
return '';
|
|
229
|
+
const interpreter = stages.some(stage => /^\s*(?:sudo\s+)?(?:(?:ba|z|da|k)?sh|pwsh|powershell(?:\.exe)?|python3?|node|perl|ruby)\b/i.test(stage) || /\bInvoke-Expression\b|\biex\b/i.test(stage));
|
|
230
|
+
if (interpreter)
|
|
231
|
+
return ', then RUNS the output as code';
|
|
232
|
+
const writesFile = stages.some(stage => /^\s*(?:Out-File|Set-Content|Add-Content|tee)\b/i.test(stage));
|
|
233
|
+
if (writesFile)
|
|
234
|
+
return ', and writes the output to a file';
|
|
235
|
+
return '';
|
|
236
|
+
}
|
|
237
|
+
function describeOne(segmentIn) {
|
|
238
|
+
const segment = segmentIn.trim();
|
|
239
|
+
const [head, ...pipe] = splitPipe(segment);
|
|
240
|
+
const tail = describePipeTail(pipe);
|
|
241
|
+
const step = (text, known = true) => ({ segment, text: shortValue(`${text}${tail}`, MAX_LINE), known });
|
|
242
|
+
// Redirection — `> file` / `>> file` — judged on the command words, never inside a quoted string.
|
|
243
|
+
const maskedHead = head.replace(/"(?:[^"\\]|\\.)*"|'[^']*'/g, '""');
|
|
244
|
+
const redirectTo = /(?:^|[^>0-9-])>{1,2}\s*([^\s&|;]+)/.exec(maskedHead)?.[1];
|
|
245
|
+
const redirectNote = redirectTo && !/^\$null$|^nul$|^\/dev\/null$|^&\d$/i.test(redirectTo) ? `, writing the output to ${shortValue(redirectTo, 40)}` : '';
|
|
246
|
+
// `if (…) { body }` / `else { body }`: the body is what runs; say so conditionally.
|
|
247
|
+
const block = /^(if|elseif|else|try|catch|finally|foreach|while|for)\b[^{]*\{(.*)\}\s*$/is.exec(head);
|
|
248
|
+
if (block) {
|
|
249
|
+
const bodySteps = splitCommandSteps(block[2]).map(part => describeOne(part).text);
|
|
250
|
+
const lead = /^(?:if|elseif)$/i.test(block[1]) ? 'If the condition holds' : /^else$/i.test(block[1]) ? 'Otherwise' : /^(?:try)$/i.test(block[1]) ? 'Tries' : /^(?:catch)$/i.test(block[1]) ? 'On error' : /^finally$/i.test(block[1]) ? 'Finally' : 'For each item';
|
|
251
|
+
return { segment, text: shortValue(`${lead}: ${bodySteps.join(' · ') || 'nothing'}${tail}`, MAX_LINE), known: true };
|
|
252
|
+
}
|
|
253
|
+
// Assignment: `$x = <cmd>` / `$x=<cmd>` / `NAME=value`
|
|
254
|
+
const assign = /^\$([\w:]+)\s*=\s*(.+)$/s.exec(head) || /^([A-Za-z_]\w*)=(.+)$/s.exec(head);
|
|
255
|
+
if (assign) {
|
|
256
|
+
const name = assign[1];
|
|
257
|
+
const rhs = assign[2].trim();
|
|
258
|
+
if (/^\$ErrorActionPreference$|^\$ProgressPreference$|^\$VerbosePreference$|^\$WarningPreference$|^\$InformationPreference$/i.test(`$${name}`))
|
|
259
|
+
return step('Sets a PowerShell error/output preference');
|
|
260
|
+
// A quoted string (interpolating or not), a number, a boolean, a literal array / hashtable.
|
|
261
|
+
const literal = /^(?:"(?:[^"\\`]|\\.|`.)*"|'[^']*'|\d+|\$true|\$false|@\([^)]*\)|@\{[^}]*\})$/s.test(rhs);
|
|
262
|
+
if (literal) {
|
|
263
|
+
const bare = rhs.replace(/^["']|["']$/g, '');
|
|
264
|
+
const storage = /(?:gs|s3|az):\/\/([^/\s"'$]+)/i.exec(bare);
|
|
265
|
+
if (storage)
|
|
266
|
+
return step(`Sets variable ${name} to a location in storage bucket ${storage[1]}`);
|
|
267
|
+
const interpolates = /\$\w+|\$\{|\$\(/.test(bare);
|
|
268
|
+
return step(`Sets variable ${name}${interpolates ? ' from other values' : ` to ${shortValue(bare, 60)}`}`);
|
|
269
|
+
}
|
|
270
|
+
const inner = rhs.replace(/^\(\s*/, '').replace(/\)\s*(?:\.\w+\(\))*$/, '');
|
|
271
|
+
const innerStep = describeOne(inner);
|
|
272
|
+
return { segment, text: shortValue(`Stores in ${name}: ${innerStep.text.charAt(0).toLowerCase()}${innerStep.text.slice(1)}${tail}`, MAX_LINE), known: innerStep.known };
|
|
273
|
+
}
|
|
274
|
+
const toks = tokens(head);
|
|
275
|
+
if (toks.length === 0)
|
|
276
|
+
return step('(empty)', false);
|
|
277
|
+
let p = 0;
|
|
278
|
+
while (p < toks.length && (/^[A-Za-z_]\w*=/.test(toks[p]) || /^(?:sudo|time|nohup|command|builtin|exec|nice|env)$/i.test(toks[p])))
|
|
279
|
+
p++;
|
|
280
|
+
const programToken = toks[p] || '';
|
|
281
|
+
const program = programToken.replace(/^.*[\\/]/, '').replace(/\.(?:exe|cmd|bat)$/i, '').toLowerCase();
|
|
282
|
+
const rest = toks.slice(p);
|
|
283
|
+
// Control-flow / block structure
|
|
284
|
+
if (/^(?:if|elseif|else|try|catch|finally|foreach|for|while|switch|function|param|do|done|fi|then|\}|\{|\)|\()$/i.test(program) || /^[{}()]+$/.test(head))
|
|
285
|
+
return step('Script structure (no action by itself)');
|
|
286
|
+
switch (program) {
|
|
287
|
+
case 'start-sleep':
|
|
288
|
+
case 'sleep': {
|
|
289
|
+
const secs = flagValue(rest, '-Seconds') || flagValue(rest, '-s') || rest[1];
|
|
290
|
+
return step(`Waits ${secs ? `${secs} seconds` : 'a moment'}`);
|
|
291
|
+
}
|
|
292
|
+
case 'write-host':
|
|
293
|
+
case 'write-output':
|
|
294
|
+
case 'echo':
|
|
295
|
+
case 'printf':
|
|
296
|
+
case 'write-verbose':
|
|
297
|
+
case 'write-warning':
|
|
298
|
+
return step(`Prints a message${redirectNote}`);
|
|
299
|
+
case 'cd':
|
|
300
|
+
case 'set-location':
|
|
301
|
+
case 'sl':
|
|
302
|
+
case 'pushd':
|
|
303
|
+
case 'push-location':
|
|
304
|
+
return step(`Changes directory to ${shortValue(rest[1] || '~', 60)}`);
|
|
305
|
+
case 'exit':
|
|
306
|
+
case 'return': return step('Ends the script');
|
|
307
|
+
case 'gcloud': {
|
|
308
|
+
const text = describeGcloud(rest);
|
|
309
|
+
return text ? step(`${text}${redirectNote}`) : step(`Runs \`gcloud\``, false);
|
|
310
|
+
}
|
|
311
|
+
case 'gsutil': {
|
|
312
|
+
if (/^(?:cp|rsync|mv)$/.test(rest[1] === '-m' ? rest[2] : rest[1])) {
|
|
313
|
+
const t = describeTransfer('gsutil', rest);
|
|
314
|
+
if (t)
|
|
315
|
+
return step(t);
|
|
316
|
+
}
|
|
317
|
+
if (rest[1] === 'ls')
|
|
318
|
+
return step(`Lists ${rest[2] && isStorageUri(rest[2]) ? `storage bucket ${bucketOf(rest[2])}` : 'storage buckets'}`);
|
|
319
|
+
if (rest[1] === 'rm')
|
|
320
|
+
return step(`Deletes objects in storage bucket ${rest[2] ? bucketOf(rest[2]) : ''}`);
|
|
321
|
+
return step(`Runs \`gsutil ${rest[1] || ''}\``, false);
|
|
322
|
+
}
|
|
323
|
+
case 'aws': {
|
|
324
|
+
if (rest[1] === 's3' && /^(?:cp|sync|mv)$/.test(rest[2] || '')) {
|
|
325
|
+
const t = describeTransfer('aws', rest);
|
|
326
|
+
if (t)
|
|
327
|
+
return step(t);
|
|
328
|
+
}
|
|
329
|
+
if (rest[1] === 's3' && rest[2] === 'ls')
|
|
330
|
+
return step('Lists S3 buckets or objects');
|
|
331
|
+
if (/^(?:describe|list|get)-/.test(rest[2] || ''))
|
|
332
|
+
return step(`Reads ${rest[1]} ${(rest[2] || '').replace(/^(?:describe|list|get)-/, '').replace(/-/g, ' ')}`);
|
|
333
|
+
if (/^(?:delete|terminate)-/.test(rest[2] || ''))
|
|
334
|
+
return step(`Deletes ${rest[1]} ${(rest[2] || '').replace(/^(?:delete|terminate)-/, '').replace(/-/g, ' ')}`);
|
|
335
|
+
if (/^(?:create|update|put|register)-/.test(rest[2] || ''))
|
|
336
|
+
return step(`Changes ${rest[1]} ${(rest[2] || '').replace(/-/g, ' ')}`);
|
|
337
|
+
return step(`Runs \`aws ${[rest[1], rest[2]].filter(Boolean).join(' ')}\``, false);
|
|
338
|
+
}
|
|
339
|
+
case 'kubectl': {
|
|
340
|
+
const verb = rest[1] || '';
|
|
341
|
+
if (/^(?:get|describe|logs|top|explain|api-resources|version)$/.test(verb))
|
|
342
|
+
return step(`Reads Kubernetes ${rest[2] || verb}${flagValue(rest, '-n') || flagValue(rest, '--namespace') ? ` in namespace ${flagValue(rest, '-n') || flagValue(rest, '--namespace')}` : ''}`);
|
|
343
|
+
if (verb === 'delete')
|
|
344
|
+
return step(`Deletes Kubernetes ${rest.slice(2).filter(t => !t.startsWith('-')).join(' ')}`);
|
|
345
|
+
if (/^(?:apply|create|patch|scale|rollout|set|label|annotate|edit|replace)$/.test(verb))
|
|
346
|
+
return step(`Changes Kubernetes resources (${verb})`);
|
|
347
|
+
if (verb === 'exec')
|
|
348
|
+
return step('Runs a command inside a Kubernetes pod', false);
|
|
349
|
+
return step(`Runs \`kubectl ${verb}\``, false);
|
|
350
|
+
}
|
|
351
|
+
case 'git': {
|
|
352
|
+
const sub = rest.find((t, idx) => idx > 0 && !t.startsWith('-') && !/^(?:-c|-C)$/.test(rest[idx - 1] || '')) || '';
|
|
353
|
+
const map = {
|
|
354
|
+
status: 'Reads git status', diff: 'Shows git differences', log: 'Reads git history', show: 'Shows a git object', blame: 'Reads git blame',
|
|
355
|
+
fetch: 'Fetches from the git remote', pull: 'Pulls from the git remote', clone: 'Clones a git repository',
|
|
356
|
+
add: 'Stages files in git', commit: 'Creates a git commit', push: 'Pushes commits to the git remote', checkout: 'Switches git branch or files',
|
|
357
|
+
switch: 'Switches git branch', branch: 'Reads or changes git branches', stash: 'Stashes working changes', merge: 'Merges git branches',
|
|
358
|
+
rebase: 'Rebases git history', reset: 'Resets git state', clean: 'Removes untracked files', tag: 'Reads or creates git tags', remote: 'Reads or changes git remotes',
|
|
359
|
+
};
|
|
360
|
+
return step(map[sub] || `Runs \`git ${sub}\``, Boolean(map[sub]));
|
|
361
|
+
}
|
|
362
|
+
case 'gh': {
|
|
363
|
+
const sub = rest.slice(1, 3).filter(t => !t.startsWith('-')).join(' ');
|
|
364
|
+
if (/\b(?:view|list|status|checks|diff)\b/.test(sub))
|
|
365
|
+
return step(`Reads GitHub ${sub.split(' ')[0]}`);
|
|
366
|
+
if (/^api\b/.test(sub))
|
|
367
|
+
return step(/\s-X\s+(?!GET)|--method\s+(?!GET)|\s-[fF]\s|--field|--input/i.test(head) ? 'Writes through the GitHub API' : 'Reads from the GitHub API');
|
|
368
|
+
if (/\b(?:create|edit|merge|close|delete|comment)\b/.test(sub))
|
|
369
|
+
return step(`Changes GitHub ${sub.split(' ')[0]} (${sub.split(' ')[1]})`);
|
|
370
|
+
if (/^auth token\b/.test(sub))
|
|
371
|
+
return step('Prints the GitHub token');
|
|
372
|
+
return step(`Runs \`gh ${sub}\``, false);
|
|
373
|
+
}
|
|
374
|
+
case 'npm':
|
|
375
|
+
case 'pnpm':
|
|
376
|
+
case 'yarn': {
|
|
377
|
+
const sub = rest[1] || '';
|
|
378
|
+
if (sub === 'run')
|
|
379
|
+
return step(`Runs the ${program} script "${rest[2] || ''}"`);
|
|
380
|
+
if (/^(?:test|t)$/.test(sub))
|
|
381
|
+
return step(`Runs ${program} tests`);
|
|
382
|
+
if (/^(?:install|ci|i|add)$/.test(sub))
|
|
383
|
+
return step(`Installs ${program} packages`);
|
|
384
|
+
if (/^(?:publish)$/.test(sub))
|
|
385
|
+
return step(`Publishes an ${program} package`);
|
|
386
|
+
if (/^(?:view|ls|list|outdated|--version|-v|version)$/.test(sub))
|
|
387
|
+
return step(`Reads ${program} package info`);
|
|
388
|
+
if (/^(?:build|typecheck|lint|dev|start)$/.test(sub))
|
|
389
|
+
return step(`Runs the ${program} script "${sub}"`);
|
|
390
|
+
return step(`Runs \`${program} ${sub}\``, false);
|
|
391
|
+
}
|
|
392
|
+
case 'node':
|
|
393
|
+
case 'npx':
|
|
394
|
+
case 'python':
|
|
395
|
+
case 'python3':
|
|
396
|
+
case 'tsx':
|
|
397
|
+
case 'ts-node':
|
|
398
|
+
return step(`Runs ${program} ${rest[1] ? `(${shortValue(rest.slice(1).join(' '), 60)})` : ''}`, false);
|
|
399
|
+
case 'curl':
|
|
400
|
+
case 'wget':
|
|
401
|
+
case 'invoke-webrequest':
|
|
402
|
+
case 'iwr':
|
|
403
|
+
case 'invoke-restmethod':
|
|
404
|
+
case 'irm':
|
|
405
|
+
return step(describeHttp(program, head, rest));
|
|
406
|
+
case 'rm':
|
|
407
|
+
case 'remove-item':
|
|
408
|
+
case 'ri':
|
|
409
|
+
case 'del':
|
|
410
|
+
case 'erase':
|
|
411
|
+
case 'rmdir':
|
|
412
|
+
case 'rd': {
|
|
413
|
+
const targets = rest.slice(1).filter(t => !t.startsWith('-'));
|
|
414
|
+
const recursive = /\s-r|\s-recurse|\s-rf|\s-fr/i.test(head) || program === 'rmdir' || program === 'rd';
|
|
415
|
+
return step(`Deletes ${recursive ? 'the folder ' : ''}${shortValue(targets.join(', ') || 'files', 60)}`);
|
|
416
|
+
}
|
|
417
|
+
case 'cp':
|
|
418
|
+
case 'copy':
|
|
419
|
+
case 'copy-item':
|
|
420
|
+
case 'cpi':
|
|
421
|
+
case 'mv':
|
|
422
|
+
case 'move':
|
|
423
|
+
case 'move-item':
|
|
424
|
+
case 'mi':
|
|
425
|
+
case 'ren':
|
|
426
|
+
case 'rename-item': {
|
|
427
|
+
const targets = rest.slice(1).filter(t => !t.startsWith('-'));
|
|
428
|
+
return step(`${/^(?:mv|move|move-item|mi|ren|rename-item)$/.test(program) ? 'Moves' : 'Copies'} ${shortValue(targets[0] || '', 40)} to ${shortValue(targets[targets.length - 1] || '', 40)}`);
|
|
429
|
+
}
|
|
430
|
+
case 'mkdir':
|
|
431
|
+
case 'new-item':
|
|
432
|
+
case 'ni':
|
|
433
|
+
case 'touch': return step(`Creates ${shortValue(rest.slice(1).filter(t => !t.startsWith('-')).join(', '), 60)}`);
|
|
434
|
+
case 'cat':
|
|
435
|
+
case 'get-content':
|
|
436
|
+
case 'gc':
|
|
437
|
+
case 'type':
|
|
438
|
+
case 'head':
|
|
439
|
+
case 'tail':
|
|
440
|
+
case 'less':
|
|
441
|
+
case 'more':
|
|
442
|
+
return step(`Reads the file ${shortValue(rest.slice(1).filter(t => !t.startsWith('-'))[0] || '', 60)}${redirectNote}`);
|
|
443
|
+
case 'ls':
|
|
444
|
+
case 'dir':
|
|
445
|
+
case 'get-childitem':
|
|
446
|
+
case 'gci':
|
|
447
|
+
case 'tree':
|
|
448
|
+
case 'find':
|
|
449
|
+
case 'fd':
|
|
450
|
+
return step(`Lists files${rest[1] && !rest[1].startsWith('-') ? ` in ${shortValue(rest[1], 60)}` : ''}`);
|
|
451
|
+
case 'grep':
|
|
452
|
+
case 'rg':
|
|
453
|
+
case 'select-string':
|
|
454
|
+
case 'sls':
|
|
455
|
+
case 'ag':
|
|
456
|
+
case 'findstr':
|
|
457
|
+
return step('Searches text in files');
|
|
458
|
+
case 'test-path':
|
|
459
|
+
case 'get-item':
|
|
460
|
+
case 'gi':
|
|
461
|
+
case 'resolve-path': return step(`Checks ${shortValue(rest[1] || 'a path', 60)}`);
|
|
462
|
+
case 'get-cimInstance':
|
|
463
|
+
case 'get-ciminstance':
|
|
464
|
+
case 'get-process':
|
|
465
|
+
case 'ps':
|
|
466
|
+
case 'tasklist': return step('Reads running processes');
|
|
467
|
+
case 'stop-process':
|
|
468
|
+
case 'kill':
|
|
469
|
+
case 'taskkill': return step(`Stops a process${rest.some(t => /^-Id$/i.test(t)) ? ` (pid ${flagValue(rest, '-Id')})` : ''}`);
|
|
470
|
+
case 'docker': {
|
|
471
|
+
const sub = rest[1] || '';
|
|
472
|
+
if (/^(?:ps|images|logs|inspect|version|info)$/.test(sub))
|
|
473
|
+
return step(`Reads Docker ${sub}`);
|
|
474
|
+
if (/^(?:build|push|pull|run|compose|exec|rm|rmi|system)$/.test(sub))
|
|
475
|
+
return step(`Runs \`docker ${sub}${rest[2] && !rest[2].startsWith('-') ? ` ${rest[2]}` : ''}\``, sub !== 'exec' && sub !== 'run');
|
|
476
|
+
return step(`Runs \`docker ${sub}\``, false);
|
|
477
|
+
}
|
|
478
|
+
case 'terraform': {
|
|
479
|
+
const sub = rest[1] || '';
|
|
480
|
+
if (/^(?:plan|validate|fmt|show|output|state)$/.test(sub))
|
|
481
|
+
return step(`Reads Terraform ${sub}`);
|
|
482
|
+
if (sub === 'apply')
|
|
483
|
+
return step('Applies Terraform changes to infrastructure');
|
|
484
|
+
if (sub === 'destroy')
|
|
485
|
+
return step('DESTROYS Terraform-managed infrastructure');
|
|
486
|
+
return step(`Runs \`terraform ${sub}\``, false);
|
|
487
|
+
}
|
|
488
|
+
case 'firebase': return step(`Runs \`firebase ${rest[1] || ''}\``, /^(?:deploy)$/.test(rest[1] || ''));
|
|
489
|
+
case 'ssh':
|
|
490
|
+
case 'scp':
|
|
491
|
+
case 'rsync':
|
|
492
|
+
case 'sftp': {
|
|
493
|
+
if (program !== 'ssh') {
|
|
494
|
+
const t = describeTransfer(program, rest);
|
|
495
|
+
if (t)
|
|
496
|
+
return step(t);
|
|
497
|
+
}
|
|
498
|
+
return step(`Connects to ${shortValue(rest.find((t, idx) => idx > 0 && !t.startsWith('-')) || 'a remote host', 60)} over SSH`, false);
|
|
499
|
+
}
|
|
500
|
+
case 'psql':
|
|
501
|
+
case 'mysql':
|
|
502
|
+
case 'sqlcmd':
|
|
503
|
+
case 'sqlite3':
|
|
504
|
+
case 'mongosh': {
|
|
505
|
+
const verb = /\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE|GRANT|REVOKE)\b/i.exec(head)?.[1]?.toUpperCase();
|
|
506
|
+
return step(verb ? `Runs a database ${verb}` : `Opens a ${program} database session`, Boolean(verb));
|
|
507
|
+
}
|
|
508
|
+
case 'select-object':
|
|
509
|
+
case 'select':
|
|
510
|
+
case 'format-table':
|
|
511
|
+
case 'ft':
|
|
512
|
+
case 'format-list':
|
|
513
|
+
case 'fl':
|
|
514
|
+
case 'out-string':
|
|
515
|
+
case 'convertfrom-json':
|
|
516
|
+
case 'convertto-json':
|
|
517
|
+
case 'sort-object':
|
|
518
|
+
case 'sort':
|
|
519
|
+
case 'where-object':
|
|
520
|
+
case 'where':
|
|
521
|
+
case 'foreach-object':
|
|
522
|
+
case 'foreach':
|
|
523
|
+
case 'measure-object':
|
|
524
|
+
case 'out-null':
|
|
525
|
+
return step('Formats or filters output');
|
|
526
|
+
case 'invoke-expression':
|
|
527
|
+
case 'iex': return step('RUNS a string as code', false);
|
|
528
|
+
default:
|
|
529
|
+
if (/^\$/.test(programToken))
|
|
530
|
+
return step(pipe.length ? `Uses ${shortValue(programToken, 40)}${tail}` : `Reads variable ${shortValue(programToken, 40)}`, false);
|
|
531
|
+
return step(`Runs \`${shortValue([program, rest[1]].filter(t => t && !t.startsWith('-')).join(' '), 50)}\``, false);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
/** Split one step on top-level `|` (quote-aware). */
|
|
535
|
+
function splitPipe(segment) {
|
|
536
|
+
const parts = [];
|
|
537
|
+
let current = '';
|
|
538
|
+
let quote = null;
|
|
539
|
+
let depth = 0;
|
|
540
|
+
for (let i = 0; i < segment.length; i++) {
|
|
541
|
+
const ch = segment[i];
|
|
542
|
+
if (quote) {
|
|
543
|
+
current += ch;
|
|
544
|
+
if (ch === quote)
|
|
545
|
+
quote = null;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
if (ch === '"' || ch === "'") {
|
|
549
|
+
quote = ch;
|
|
550
|
+
current += ch;
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
if (ch === '{' || ch === '(')
|
|
554
|
+
depth++;
|
|
555
|
+
if (ch === '}' || ch === ')')
|
|
556
|
+
depth = Math.max(0, depth - 1);
|
|
557
|
+
if (ch === '|' && depth === 0 && segment[i + 1] !== '|') {
|
|
558
|
+
parts.push(current);
|
|
559
|
+
current = '';
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
current += ch;
|
|
563
|
+
}
|
|
564
|
+
parts.push(current);
|
|
565
|
+
return parts.map(part => part.trim()).filter(Boolean);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* `$prefix = "gs://bucket/…"` two steps earlier, then `export $prefix`: say the
|
|
569
|
+
* bucket, not the variable. Only literal storage locations are resolved — a
|
|
570
|
+
* value computed from a command stays "the location in $var".
|
|
571
|
+
*/
|
|
572
|
+
function resolveStorageVariables(steps) {
|
|
573
|
+
const buckets = new Map();
|
|
574
|
+
return steps.map(step => {
|
|
575
|
+
const assign = /^\$?([\w:]+)\s*=\s*(?:"|')?((?:gs|s3|az):\/\/([^/\s"'$]+))/i.exec(step.segment);
|
|
576
|
+
if (assign)
|
|
577
|
+
buckets.set(assign[1], assign[3]);
|
|
578
|
+
let text = step.text;
|
|
579
|
+
for (const [name, bucket] of buckets)
|
|
580
|
+
text = text.replace(new RegExp(`the location in \\$${name}\\b`, 'g'), `storage bucket ${bucket}`);
|
|
581
|
+
return { ...step, text };
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
/** One plain-words line per step, in order. */
|
|
585
|
+
function describeShellCommand(command) {
|
|
586
|
+
const steps = splitCommandSteps(command);
|
|
587
|
+
const out = resolveStorageVariables(steps.slice(0, MAX_STEPS).map(segment => {
|
|
588
|
+
try {
|
|
589
|
+
return describeOne(segment);
|
|
590
|
+
}
|
|
591
|
+
catch {
|
|
592
|
+
return { segment, text: 'Runs a command the grammar does not recognise', known: false };
|
|
593
|
+
}
|
|
594
|
+
}));
|
|
595
|
+
if (steps.length > MAX_STEPS)
|
|
596
|
+
out.push({ segment: '', text: `… and ${steps.length - MAX_STEPS} more step${steps.length - MAX_STEPS === 1 ? '' : 's'}`, known: false });
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
/** The step lines as the explanation prints them (`- …`). */
|
|
600
|
+
function describeShellCommandLines(command) {
|
|
601
|
+
return describeShellCommand(command).map(step => step.text);
|
|
602
|
+
}
|
|
@@ -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.
|