@blogic-cz/agent-tools 0.18.1 → 1.0.0
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/README.md +23 -6
- package/dist/az-tool/config.d.ts +60 -12
- package/dist/az-tool/config.d.ts.map +1 -1
- package/dist/az-tool/errors.d.ts +10 -1
- package/dist/az-tool/errors.d.ts.map +1 -1
- package/dist/az-tool/profile.d.ts +16 -0
- package/dist/az-tool/profile.d.ts.map +1 -0
- package/dist/az-tool/security.d.ts +20 -3
- package/dist/az-tool/security.d.ts.map +1 -1
- package/dist/az-tool/service.d.ts +9 -4
- package/dist/az-tool/service.d.ts.map +1 -1
- package/dist/az-tool/types.d.ts +10 -60
- package/dist/az-tool/types.d.ts.map +1 -1
- package/dist/{az-tool → azdo-tool}/build.d.ts +9 -9
- package/dist/azdo-tool/build.d.ts.map +1 -0
- package/dist/azdo-tool/config.d.ts +13 -0
- package/dist/azdo-tool/config.d.ts.map +1 -0
- package/dist/azdo-tool/errors.d.ts +43 -0
- package/dist/azdo-tool/errors.d.ts.map +1 -0
- package/dist/azdo-tool/extract-option-value.d.ts.map +1 -0
- package/dist/azdo-tool/index.d.ts +3 -0
- package/dist/azdo-tool/index.d.ts.map +1 -0
- package/dist/azdo-tool/security.d.ts +4 -0
- package/dist/azdo-tool/security.d.ts.map +1 -0
- package/dist/azdo-tool/service.d.ts +15 -0
- package/dist/azdo-tool/service.d.ts.map +1 -0
- package/dist/azdo-tool/transformers.d.ts.map +1 -0
- package/dist/azdo-tool/types.d.ts +64 -0
- package/dist/azdo-tool/types.d.ts.map +1 -0
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/loader.d.ts +1 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/types.d.ts +18 -1
- package/dist/config/types.d.ts.map +1 -1
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/dist/shared/azure-credentials.d.ts +20 -0
- package/dist/shared/azure-credentials.d.ts.map +1 -0
- package/dist/shared/binary-preflight.d.ts +1 -1
- package/package.json +8 -2
- package/schemas/agent-tools.schema.json +42 -1
- package/src/az-tool/config.ts +135 -25
- package/src/az-tool/errors.ts +14 -1
- package/src/az-tool/index.ts +67 -134
- package/src/az-tool/profile.ts +48 -0
- package/src/az-tool/security.ts +248 -89
- package/src/az-tool/service.ts +137 -242
- package/src/az-tool/types.ts +11 -64
- package/src/{az-tool → azdo-tool}/build.ts +11 -11
- package/src/azdo-tool/config.ts +33 -0
- package/src/azdo-tool/errors.ts +41 -0
- package/src/azdo-tool/index.ts +222 -0
- package/src/azdo-tool/security.ts +157 -0
- package/src/azdo-tool/service.ts +322 -0
- package/src/azdo-tool/types.ts +67 -0
- package/src/config/index.ts +1 -0
- package/src/config/loader.ts +10 -1
- package/src/config/types.ts +19 -1
- package/src/credential-guard/index.ts +7 -2
- package/src/shared/azure-credentials.ts +50 -0
- package/src/shared/binary-preflight.ts +1 -1
- package/dist/az-tool/build.d.ts.map +0 -1
- package/dist/az-tool/extract-option-value.d.ts.map +0 -1
- package/dist/az-tool/transformers.d.ts.map +0 -1
- /package/dist/{az-tool → azdo-tool}/extract-option-value.d.ts +0 -0
- /package/dist/{az-tool → azdo-tool}/transformers.d.ts +0 -0
- /package/src/{az-tool → azdo-tool}/extract-option-value.ts +0 -0
- /package/src/{az-tool → azdo-tool}/transformers.ts +0 -0
package/src/az-tool/security.ts
CHANGED
|
@@ -1,130 +1,289 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { AzSecurityCheckOptions, AzSecurityCheckResult } from "./types";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
4
|
+
AZURE_DEVOPS_GROUPS,
|
|
5
|
+
BLOCKED_VERBS,
|
|
6
|
+
CONTROLLED_FLAGS,
|
|
7
|
+
CREDENTIAL_BLOCKED_SEGMENTS,
|
|
8
|
+
CREDENTIAL_BLOCKED_VERBS,
|
|
9
|
+
KEYVAULT_GROUP,
|
|
10
|
+
KEYVAULT_NAME_ONLY_SUBGROUPS,
|
|
11
|
+
KEYVAULT_PUBLIC_SUBGROUPS,
|
|
12
|
+
READ_ONLY_VERBS,
|
|
13
|
+
READ_ONLY_VERB_PREFIXES,
|
|
14
|
+
REJECTED_ADDRESSING_FLAGS,
|
|
15
|
+
RESOURCE_GROUP_FLAGS,
|
|
16
|
+
RESOURCE_GROUP_IN_ID_PATTERN,
|
|
10
17
|
} from "./config";
|
|
11
|
-
import { extractOptionValue } from "./extract-option-value";
|
|
12
18
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Tokenize an az command line into argv. Returns undefined for anything
|
|
21
|
+
* carrying shell syntax, so a command can never expand, chain, or redirect.
|
|
22
|
+
* Quoted regions keep their contents verbatim, which leaves `--query
|
|
23
|
+
* "[?name=='web']"` usable.
|
|
24
|
+
*/
|
|
25
|
+
export function parseAzCommand(cmd: string): string[] | undefined {
|
|
26
|
+
const argv: string[] = [];
|
|
27
|
+
let word = "";
|
|
28
|
+
let quote: "'" | '"' | undefined;
|
|
29
|
+
let escaped = false;
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
+
for (const char of cmd) {
|
|
32
|
+
if (escaped) {
|
|
33
|
+
word += char;
|
|
34
|
+
escaped = false;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
if (char === "\n" || char === "\r") return undefined;
|
|
38
|
+
if (char === "\\") {
|
|
39
|
+
escaped = true;
|
|
40
|
+
continue;
|
|
31
41
|
}
|
|
42
|
+
if (quote) {
|
|
43
|
+
if (char === quote) quote = undefined;
|
|
44
|
+
else word += char;
|
|
45
|
+
} else if (char === "'" || char === '"') {
|
|
46
|
+
quote = char;
|
|
47
|
+
} else if (/[$`;&|<>()]/.test(char)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
} else if (/\s/.test(char)) {
|
|
50
|
+
if (word) {
|
|
51
|
+
argv.push(word);
|
|
52
|
+
word = "";
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
word += char;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
32
58
|
|
|
33
|
-
|
|
59
|
+
if (quote || escaped) return undefined;
|
|
60
|
+
if (word) argv.push(word);
|
|
61
|
+
return argv.length ? argv : undefined;
|
|
62
|
+
}
|
|
34
63
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
/**
|
|
65
|
+
* The leading run of non-flag tokens: the command group path plus its verb.
|
|
66
|
+
* Everything from the first flag onwards is argument territory, so a flag
|
|
67
|
+
* value can never be mistaken for a verb.
|
|
68
|
+
*/
|
|
69
|
+
function commandHead(argv: readonly string[]): string[] {
|
|
70
|
+
const head: string[] = [];
|
|
71
|
+
for (const arg of argv) {
|
|
72
|
+
if (arg.startsWith("-")) break;
|
|
73
|
+
head.push(arg.toLowerCase());
|
|
74
|
+
}
|
|
75
|
+
return head;
|
|
76
|
+
}
|
|
42
77
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
78
|
+
export function isReadOnlyVerb(verb: string): boolean {
|
|
79
|
+
if ((READ_ONLY_VERBS as readonly string[]).includes(verb)) return true;
|
|
80
|
+
return READ_ONLY_VERB_PREFIXES.some((prefix) => verb.startsWith(prefix));
|
|
81
|
+
}
|
|
47
82
|
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
allowed: false,
|
|
51
|
-
command: cmd,
|
|
52
|
-
reason: invokeSecurityCheck.reason,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
83
|
+
const isListVerb = (verb: string): boolean => verb === "list" || verb.startsWith("list-");
|
|
55
84
|
|
|
56
|
-
|
|
57
|
-
|
|
85
|
+
/** The flag part of an argument, so `--flag=value` and `--flag value` compare alike. */
|
|
86
|
+
const flagName = (arg: string): string => arg.split("=", 1)[0] ?? "";
|
|
58
87
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
/**
|
|
89
|
+
* Every resource group a command names via -g / --resource-group, in either the
|
|
90
|
+
* spaced or the `=` form. Empty when the command names none, which for
|
|
91
|
+
* subscription-wide reads like `vm list` is the normal case.
|
|
92
|
+
*
|
|
93
|
+
* All occurrences are collected, not just the first: the Azure CLI is argparse
|
|
94
|
+
* based, so a repeated flag silently takes the LAST value. Validating only the
|
|
95
|
+
* first would let `-g allowed -g denied` through while az ran against `denied`.
|
|
96
|
+
*/
|
|
97
|
+
export function extractResourceGroups(argv: readonly string[]): string[] {
|
|
98
|
+
const groups: string[] = [];
|
|
62
99
|
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
);
|
|
100
|
+
for (let i = 0; i < argv.length; i++) {
|
|
101
|
+
const arg = argv[i];
|
|
102
|
+
if (arg === undefined) continue;
|
|
67
103
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
104
|
+
const equalsIndex = arg.indexOf("=");
|
|
105
|
+
const flag = equalsIndex === -1 ? arg : arg.slice(0, equalsIndex);
|
|
106
|
+
if (!(RESOURCE_GROUP_FLAGS as readonly string[]).includes(flag)) continue;
|
|
107
|
+
|
|
108
|
+
if (equalsIndex !== -1) {
|
|
109
|
+
const inlineValue = arg.slice(equalsIndex + 1);
|
|
110
|
+
if (inlineValue.length > 0) groups.push(inlineValue);
|
|
111
|
+
continue;
|
|
74
112
|
}
|
|
75
113
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
114
|
+
const next = argv[i + 1];
|
|
115
|
+
if (next !== undefined && !next.startsWith("-")) {
|
|
116
|
+
groups.push(next);
|
|
117
|
+
i++;
|
|
118
|
+
}
|
|
81
119
|
}
|
|
82
120
|
|
|
83
|
-
for (
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
command: cmd,
|
|
88
|
-
reason: `Command contains blocked operation '${commandWords[i]}' after allowed operation. Only read-only operations allowed: ${ALLOWED_SUBCOMMANDS.join(", ")}`,
|
|
89
|
-
};
|
|
121
|
+
for (const arg of argv) {
|
|
122
|
+
for (const match of arg.matchAll(RESOURCE_GROUP_IN_ID_PATTERN)) {
|
|
123
|
+
const group = match[1];
|
|
124
|
+
if (group !== undefined) groups.push(group);
|
|
90
125
|
}
|
|
91
126
|
}
|
|
92
127
|
|
|
93
|
-
return
|
|
128
|
+
return groups;
|
|
94
129
|
}
|
|
95
130
|
|
|
96
|
-
|
|
97
|
-
|
|
131
|
+
/**
|
|
132
|
+
* Why a command's group path is treated as credential-bearing, or undefined if
|
|
133
|
+
* it is not. Key Vault is handled separately from plain segment matching: its
|
|
134
|
+
* `key` and `certificate` subgroups expose public material only, and its
|
|
135
|
+
* `secret` subgroup only leaks on `show`, not on a listing.
|
|
136
|
+
*/
|
|
137
|
+
function credentialDenialReason(groupPath: readonly string[], verb: string): string | undefined {
|
|
138
|
+
if (groupPath[0] === KEYVAULT_GROUP) {
|
|
139
|
+
const subgroup = groupPath[1];
|
|
98
140
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
141
|
+
if (
|
|
142
|
+
subgroup !== undefined &&
|
|
143
|
+
(KEYVAULT_PUBLIC_SUBGROUPS as readonly string[]).includes(subgroup)
|
|
144
|
+
) {
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (
|
|
149
|
+
subgroup !== undefined &&
|
|
150
|
+
(KEYVAULT_NAME_ONLY_SUBGROUPS as readonly string[]).includes(subgroup) &&
|
|
151
|
+
!isListVerb(verb)
|
|
152
|
+
) {
|
|
153
|
+
return `'keyvault ${subgroup} ${verb}' returns secret values.`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return undefined;
|
|
104
157
|
}
|
|
105
158
|
|
|
106
|
-
|
|
159
|
+
const segment = groupPath.find((part) =>
|
|
160
|
+
(CREDENTIAL_BLOCKED_SEGMENTS as readonly string[]).includes(part),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return segment ? `'${segment}' addresses credential material and is blocked.` : undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function isAzCommandAllowed(
|
|
167
|
+
cmd: string,
|
|
168
|
+
options?: AzSecurityCheckOptions,
|
|
169
|
+
): AzSecurityCheckResult {
|
|
170
|
+
const argv = parseAzCommand(cmd);
|
|
171
|
+
|
|
172
|
+
if (!argv) {
|
|
107
173
|
return {
|
|
108
174
|
allowed: false,
|
|
109
|
-
|
|
175
|
+
command: cmd,
|
|
176
|
+
reason: "Empty, malformed, or shell syntax command.",
|
|
177
|
+
hint: "Pass a single az command without pipes, redirects, substitution, or chaining.",
|
|
110
178
|
};
|
|
111
179
|
}
|
|
112
180
|
|
|
113
|
-
const
|
|
114
|
-
|
|
181
|
+
const head = commandHead(argv);
|
|
182
|
+
const verb = head.at(-1);
|
|
183
|
+
|
|
184
|
+
if (!verb) {
|
|
115
185
|
return {
|
|
116
186
|
allowed: false,
|
|
117
|
-
|
|
187
|
+
command: cmd,
|
|
188
|
+
reason: "Command must start with an Azure CLI command group.",
|
|
189
|
+
hint: 'Example: "vm list", "webapp show --name my-app --resource-group my-rg".',
|
|
118
190
|
};
|
|
119
191
|
}
|
|
120
192
|
|
|
121
|
-
const
|
|
122
|
-
|
|
193
|
+
const groupPath = head.slice(0, -1);
|
|
194
|
+
const group = head[0];
|
|
195
|
+
|
|
196
|
+
const denied = (reason: string, hint?: string): AzSecurityCheckResult => ({
|
|
197
|
+
allowed: false,
|
|
198
|
+
command: cmd,
|
|
199
|
+
verb,
|
|
200
|
+
reason,
|
|
201
|
+
...(hint ? { hint } : {}),
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
if (group && (AZURE_DEVOPS_GROUPS as readonly string[]).includes(group)) {
|
|
205
|
+
return denied(
|
|
206
|
+
`'${group}' is an Azure DevOps command group, not an Azure platform one.`,
|
|
207
|
+
`Use azdo-tool instead: bun azdo-tool cmd --cmd "${cmd}"`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if ((CREDENTIAL_BLOCKED_VERBS as readonly string[]).includes(verb)) {
|
|
212
|
+
return denied(
|
|
213
|
+
`'${verb}' returns credential material and is blocked.`,
|
|
214
|
+
"Read secrets from the Azure portal or Key Vault directly, outside the agent session.",
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const credentialReason = credentialDenialReason(groupPath, verb);
|
|
219
|
+
if (credentialReason) {
|
|
220
|
+
return denied(
|
|
221
|
+
credentialReason,
|
|
222
|
+
"Read secrets from the Azure portal or Key Vault directly, outside the agent session.",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if ((BLOCKED_VERBS as readonly string[]).includes(verb)) {
|
|
227
|
+
return denied(
|
|
228
|
+
`'${verb}' is a mutating operation blocked for AI agents. Only read-only operations are allowed: ${READ_ONLY_VERBS.join(", ")}, and the ${READ_ONLY_VERB_PREFIXES.join("/")} families.`,
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!isReadOnlyVerb(verb)) {
|
|
233
|
+
return denied(
|
|
234
|
+
`Unknown Azure CLI verb '${verb}'. Only known read-only operations are allowed: ${READ_ONLY_VERBS.join(", ")}, and the ${READ_ONLY_VERB_PREFIXES.join("/")} families.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const controlledFlag = argv.find((arg) =>
|
|
239
|
+
(CONTROLLED_FLAGS as readonly string[]).includes(flagName(arg)),
|
|
240
|
+
);
|
|
241
|
+
if (controlledFlag) {
|
|
242
|
+
return denied(
|
|
243
|
+
`Flag '${flagName(controlledFlag)}' is controlled by the selected profile.`,
|
|
244
|
+
"Remove the override and select the intended azurePlatform profile with --profile instead.",
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const addressingFlag = argv.find((arg) =>
|
|
249
|
+
(REJECTED_ADDRESSING_FLAGS as readonly string[]).includes(flagName(arg)),
|
|
250
|
+
);
|
|
251
|
+
if (addressingFlag) {
|
|
252
|
+
return denied(
|
|
253
|
+
`Flag '${flagName(addressingFlag)}' takes a full ARM resource ID, which carries its own subscription and resource group.`,
|
|
254
|
+
"Address the resource within the pinned scope instead, using --resource-group and --name.",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// An empty or absent allowlist means the whole subscription is in scope.
|
|
259
|
+
const allowedResourceGroups = options?.allowedResourceGroups;
|
|
260
|
+
if (allowedResourceGroups && allowedResourceGroups.length > 0) {
|
|
261
|
+
const namedGroups = extractResourceGroups(argv);
|
|
262
|
+
const offendingGroup = namedGroups.find(
|
|
263
|
+
(named) =>
|
|
264
|
+
!allowedResourceGroups.some((allowed) => allowed.toLowerCase() === named.toLowerCase()),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
if (offendingGroup !== undefined) {
|
|
268
|
+
return {
|
|
269
|
+
allowed: false,
|
|
270
|
+
command: cmd,
|
|
271
|
+
verb,
|
|
272
|
+
resourceGroup: offendingGroup,
|
|
273
|
+
reason: `Resource group '${offendingGroup}' is not allowed by this profile.`,
|
|
274
|
+
hint: `Allowed resource groups: ${allowedResourceGroups.join(", ")}.`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const [firstGroup] = namedGroups;
|
|
123
279
|
return {
|
|
124
|
-
allowed:
|
|
125
|
-
|
|
280
|
+
allowed: true,
|
|
281
|
+
command: cmd,
|
|
282
|
+
argv,
|
|
283
|
+
verb,
|
|
284
|
+
...(firstGroup ? { resourceGroup: firstGroup } : {}),
|
|
126
285
|
};
|
|
127
286
|
}
|
|
128
287
|
|
|
129
|
-
return { allowed: true };
|
|
288
|
+
return { allowed: true, command: cmd, argv, verb };
|
|
130
289
|
}
|