@blogic-cz/agent-tools 1.6.20 → 1.7.1
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 +8 -2
- package/dist/api-tool/client.d.ts +8 -0
- package/dist/api-tool/client.d.ts.map +1 -0
- package/dist/api-tool/errors.d.ts +14 -0
- package/dist/api-tool/errors.d.ts.map +1 -0
- package/dist/api-tool/index.d.ts +3 -0
- package/dist/api-tool/index.d.ts.map +1 -0
- package/dist/api-tool/main.d.ts +2 -0
- package/dist/api-tool/main.d.ts.map +1 -0
- package/dist/api-tool/proxy-entry.d.ts +3 -0
- package/dist/api-tool/proxy-entry.d.ts.map +1 -0
- package/dist/api-tool/proxy.d.ts +15 -0
- package/dist/api-tool/proxy.d.ts.map +1 -0
- package/dist/api-tool/retention.d.ts +9 -0
- package/dist/api-tool/retention.d.ts.map +1 -0
- package/dist/api-tool/server.d.ts +6 -0
- package/dist/api-tool/server.d.ts.map +1 -0
- package/dist/api-tool/store.d.ts +5 -0
- package/dist/api-tool/store.d.ts.map +1 -0
- package/dist/api-tool/transport.d.ts +26 -0
- package/dist/api-tool/transport.d.ts.map +1 -0
- package/dist/api-tool/types.d.ts +136 -0
- package/dist/api-tool/types.d.ts.map +1 -0
- package/dist/credential-guard/index.d.ts.map +1 -1
- package/docs/rest-api-proxy.md +136 -0
- package/package.json +16 -2
- package/src/api-tool/client.ts +56 -0
- package/src/api-tool/errors.ts +41 -0
- package/src/api-tool/index.ts +4 -0
- package/src/api-tool/main.ts +58 -0
- package/src/api-tool/proxy-entry.ts +19 -0
- package/src/api-tool/proxy.ts +298 -0
- package/src/api-tool/retention.ts +58 -0
- package/src/api-tool/server.ts +114 -0
- package/src/api-tool/store.ts +93 -0
- package/src/api-tool/transport.ts +167 -0
- package/src/api-tool/types.ts +202 -0
- package/src/credential-guard/index.ts +91 -3
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { request as httpsRequest } from "node:https";
|
|
3
|
+
import { request as httpRequest } from "node:http";
|
|
4
|
+
import type { SecureContextOptions } from "node:tls";
|
|
5
|
+
import { isIP } from "node:net";
|
|
6
|
+
import { ApiError, apiError } from "./errors";
|
|
7
|
+
|
|
8
|
+
export type TransportRequest = {
|
|
9
|
+
url: URL;
|
|
10
|
+
method: string;
|
|
11
|
+
headers: Readonly<Record<string, string>>;
|
|
12
|
+
body: string;
|
|
13
|
+
maxBytes: number;
|
|
14
|
+
signal: AbortSignal;
|
|
15
|
+
allowedPrivateAddresses: readonly string[];
|
|
16
|
+
developmentLoopback?: boolean;
|
|
17
|
+
};
|
|
18
|
+
export type TransportResult = { status: number; body: string };
|
|
19
|
+
export type Transport = (request: TransportRequest) => Promise<TransportResult>;
|
|
20
|
+
export type Resolver = (host: string) => Promise<readonly { address: string; family: number }[]>;
|
|
21
|
+
|
|
22
|
+
export function beforeAbort<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
23
|
+
if (signal.aborted) {
|
|
24
|
+
void operation.catch(() => undefined);
|
|
25
|
+
return Promise.reject(apiError("DEADLINE_EXCEEDED"));
|
|
26
|
+
}
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const abort = () => reject(apiError("DEADLINE_EXCEEDED"));
|
|
29
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
30
|
+
operation.then(
|
|
31
|
+
(value) => {
|
|
32
|
+
signal.removeEventListener("abort", abort);
|
|
33
|
+
return resolve(value);
|
|
34
|
+
},
|
|
35
|
+
(error) => {
|
|
36
|
+
signal.removeEventListener("abort", abort);
|
|
37
|
+
return reject(error);
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function allowedAddress(
|
|
44
|
+
address: string,
|
|
45
|
+
privateAllow: readonly string[],
|
|
46
|
+
developmentLoopback = false,
|
|
47
|
+
): boolean {
|
|
48
|
+
if (developmentLoopback && address === "127.0.0.1") return true;
|
|
49
|
+
const family = isIP(address);
|
|
50
|
+
if (!family) return false;
|
|
51
|
+
if (family === 6) {
|
|
52
|
+
const normalized = new URL(`http://[${address}]`).hostname.slice(1, -1);
|
|
53
|
+
const first = Number.parseInt(normalized.split(":")[0] || "0", 16);
|
|
54
|
+
if (
|
|
55
|
+
normalized.startsWith("::ffff:") ||
|
|
56
|
+
(first & 0xffc0) === 0xfe80 ||
|
|
57
|
+
normalized === "fd00:ec2::254"
|
|
58
|
+
)
|
|
59
|
+
return false;
|
|
60
|
+
if (privateAllow.includes(normalized)) return true;
|
|
61
|
+
const second = Number.parseInt(normalized.split(":")[1] || "0", 16);
|
|
62
|
+
return (
|
|
63
|
+
/^[23][0-9a-f]{3}:/.test(normalized) &&
|
|
64
|
+
!(normalized.startsWith("2001:") && (second <= 0x1ff || second === 0xdb8)) &&
|
|
65
|
+
!normalized.startsWith("2002:") &&
|
|
66
|
+
!(normalized.startsWith("3fff:") && second <= 0xfff)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
const parts = address.split(".").map(Number);
|
|
70
|
+
const [a = 0, b = 0] = parts;
|
|
71
|
+
if ((a === 169 && b === 254) || address === "168.63.129.16" || a === 0 || a >= 224) return false;
|
|
72
|
+
if (privateAllow.includes(address)) return true;
|
|
73
|
+
return !(
|
|
74
|
+
a === 10 ||
|
|
75
|
+
a === 127 ||
|
|
76
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
77
|
+
(a === 192 && (b === 168 || b === 0 || b === 2)) ||
|
|
78
|
+
(a === 198 && (b === 18 || b === 19 || b === 51)) ||
|
|
79
|
+
(a === 203 && b === 0) ||
|
|
80
|
+
(a === 100 && b >= 64 && b <= 127)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const MAX_PENDING_DNS_LOOKUPS = 64;
|
|
85
|
+
export function makePinnedTransport(
|
|
86
|
+
resolve: Resolver = (host) => lookup(host, { all: true, verbatim: true }),
|
|
87
|
+
trust: Pick<SecureContextOptions, "ca"> = {},
|
|
88
|
+
): Transport {
|
|
89
|
+
let pendingLookups = 0;
|
|
90
|
+
return async (input) => {
|
|
91
|
+
const host = input.url.hostname.replace(/^\[|\]$/g, "");
|
|
92
|
+
if (
|
|
93
|
+
input.url.protocol !== "https:" &&
|
|
94
|
+
!(input.developmentLoopback && input.url.protocol === "http:" && host === "127.0.0.1")
|
|
95
|
+
)
|
|
96
|
+
throw apiError("DESTINATION_DENIED");
|
|
97
|
+
if (input.signal.aborted) throw apiError("DEADLINE_EXCEEDED");
|
|
98
|
+
let addresses: Awaited<ReturnType<Resolver>>;
|
|
99
|
+
try {
|
|
100
|
+
if (isIP(host)) addresses = [{ address: host, family: isIP(host) }];
|
|
101
|
+
else {
|
|
102
|
+
if (pendingLookups >= MAX_PENDING_DNS_LOOKUPS) throw apiError("BUDGET_EXHAUSTED");
|
|
103
|
+
pendingLookups++;
|
|
104
|
+
const operation = Promise.resolve()
|
|
105
|
+
.then(() => resolve(host))
|
|
106
|
+
.finally(() => {
|
|
107
|
+
pendingLookups--;
|
|
108
|
+
});
|
|
109
|
+
addresses = await beforeAbort(operation, input.signal);
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof ApiError && error.code === "BUDGET_EXHAUSTED") throw error;
|
|
113
|
+
throw apiError(input.signal.aborted ? "DEADLINE_EXCEEDED" : "DESTINATION_DENIED");
|
|
114
|
+
}
|
|
115
|
+
if (input.signal.aborted) throw apiError("DEADLINE_EXCEEDED");
|
|
116
|
+
const pinned = addresses[0];
|
|
117
|
+
if (
|
|
118
|
+
!pinned ||
|
|
119
|
+
addresses.some(
|
|
120
|
+
(item) =>
|
|
121
|
+
!allowedAddress(item.address, input.allowedPrivateAddresses, input.developmentLoopback),
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
throw apiError("DESTINATION_DENIED");
|
|
125
|
+
return new Promise((resolveResult, reject) => {
|
|
126
|
+
const request = (input.url.protocol === "https:" ? httpsRequest : httpRequest)(
|
|
127
|
+
input.url,
|
|
128
|
+
{
|
|
129
|
+
method: input.method,
|
|
130
|
+
headers: input.headers,
|
|
131
|
+
signal: input.signal,
|
|
132
|
+
agent: false,
|
|
133
|
+
family: pinned.family,
|
|
134
|
+
ca: trust.ca,
|
|
135
|
+
rejectUnauthorized: true,
|
|
136
|
+
lookup: (_hostname, _options, callback) => callback(null, pinned.address, pinned.family),
|
|
137
|
+
},
|
|
138
|
+
(response) => {
|
|
139
|
+
const status = response.statusCode ?? 0;
|
|
140
|
+
if (status >= 300 && status < 400) {
|
|
141
|
+
response.destroy();
|
|
142
|
+
reject(apiError("DESTINATION_DENIED"));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const chunks: Buffer[] = [];
|
|
146
|
+
let bytes = 0;
|
|
147
|
+
response.on("data", (chunk: Buffer) => {
|
|
148
|
+
bytes += chunk.length;
|
|
149
|
+
if (bytes > input.maxBytes) {
|
|
150
|
+
response.destroy();
|
|
151
|
+
reject(apiError("LIMIT_EXCEEDED"));
|
|
152
|
+
} else chunks.push(chunk);
|
|
153
|
+
});
|
|
154
|
+
response.on("error", () => reject(apiError("UPSTREAM_FAILED")));
|
|
155
|
+
response.on("end", () =>
|
|
156
|
+
resolveResult({ status, body: Buffer.concat(chunks).toString("utf8") }),
|
|
157
|
+
);
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
request.on("error", () =>
|
|
161
|
+
reject(apiError(input.signal.aborted ? "DEADLINE_EXCEEDED" : "UPSTREAM_FAILED")),
|
|
162
|
+
);
|
|
163
|
+
request.end(input.body);
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export const pinnedTransport = makePinnedTransport();
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { allowedAddress } from "./transport";
|
|
3
|
+
import { Schema } from "effect";
|
|
4
|
+
import { apiError } from "./errors";
|
|
5
|
+
|
|
6
|
+
export const Method = Schema.Literals(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
7
|
+
const Auth = Schema.Union([
|
|
8
|
+
Schema.Struct({ kind: Schema.Literal("bearer"), secretRef: Schema.String }),
|
|
9
|
+
Schema.Struct({
|
|
10
|
+
kind: Schema.Literal("apiKey"),
|
|
11
|
+
header: Schema.String,
|
|
12
|
+
secretRef: Schema.String,
|
|
13
|
+
}),
|
|
14
|
+
Schema.Struct({
|
|
15
|
+
kind: Schema.Literal("basic"),
|
|
16
|
+
usernameRef: Schema.String,
|
|
17
|
+
passwordRef: Schema.String,
|
|
18
|
+
}),
|
|
19
|
+
Schema.Struct({
|
|
20
|
+
kind: Schema.Literal("login"),
|
|
21
|
+
path: Schema.String,
|
|
22
|
+
fields: Schema.Record(Schema.String, Schema.String),
|
|
23
|
+
tokenField: Schema.String,
|
|
24
|
+
}),
|
|
25
|
+
]);
|
|
26
|
+
export const ProfileSchema = Schema.Struct({
|
|
27
|
+
origin: Schema.String,
|
|
28
|
+
auth: Auth,
|
|
29
|
+
methods: Schema.optionalKey(Schema.Array(Method)),
|
|
30
|
+
pathPrefixes: Schema.Array(Schema.String),
|
|
31
|
+
deniedPathPrefixes: Schema.Array(Schema.String),
|
|
32
|
+
allowedPrivateAddresses: Schema.Array(Schema.String),
|
|
33
|
+
disclosure: Schema.Union([
|
|
34
|
+
Schema.Struct({ kind: Schema.Literal("raw") }),
|
|
35
|
+
Schema.Struct({ kind: Schema.Literal("fields"), fields: Schema.Array(Schema.String) }),
|
|
36
|
+
]),
|
|
37
|
+
maxRequestBytes: Schema.Number,
|
|
38
|
+
maxResponseBytes: Schema.Number,
|
|
39
|
+
timeoutMs: Schema.Number,
|
|
40
|
+
});
|
|
41
|
+
const GrantSchema = Schema.Struct({
|
|
42
|
+
digest: Schema.String,
|
|
43
|
+
expiresAt: Schema.Number,
|
|
44
|
+
revoked: Schema.Boolean,
|
|
45
|
+
profiles: Schema.Array(Schema.String),
|
|
46
|
+
maxRequests: Schema.Number,
|
|
47
|
+
maxBytes: Schema.Number,
|
|
48
|
+
maxConcurrent: Schema.Number,
|
|
49
|
+
lifetimeMs: Schema.Number,
|
|
50
|
+
});
|
|
51
|
+
export const OperatorSchema = Schema.Struct({
|
|
52
|
+
listen: Schema.Union([
|
|
53
|
+
Schema.Struct({
|
|
54
|
+
kind: Schema.Literal("tls"),
|
|
55
|
+
host: Schema.String,
|
|
56
|
+
port: Schema.Number,
|
|
57
|
+
certFile: Schema.String,
|
|
58
|
+
keyFile: Schema.String,
|
|
59
|
+
separateIdentityAcknowledged: Schema.Literal(true),
|
|
60
|
+
}),
|
|
61
|
+
Schema.Struct({
|
|
62
|
+
kind: Schema.Literal("developmentLoopback"),
|
|
63
|
+
host: Schema.Literal("127.0.0.1"),
|
|
64
|
+
port: Schema.Number,
|
|
65
|
+
}),
|
|
66
|
+
]),
|
|
67
|
+
secretsFile: Schema.String,
|
|
68
|
+
grantUseDirectory: Schema.String,
|
|
69
|
+
profiles: Schema.Record(Schema.String, ProfileSchema),
|
|
70
|
+
grants: Schema.Array(GrantSchema),
|
|
71
|
+
maxConcurrent: Schema.Number,
|
|
72
|
+
});
|
|
73
|
+
export const ClientSchema = Schema.Struct({
|
|
74
|
+
proxyOrigin: Schema.String,
|
|
75
|
+
capability: Schema.String,
|
|
76
|
+
developmentLoopback: Schema.optionalKey(Schema.Boolean),
|
|
77
|
+
});
|
|
78
|
+
export const RequestSchema = Schema.Struct({
|
|
79
|
+
profile: Schema.String,
|
|
80
|
+
method: Method,
|
|
81
|
+
path: Schema.String,
|
|
82
|
+
query: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)),
|
|
83
|
+
body: Schema.optionalKey(Schema.Json),
|
|
84
|
+
});
|
|
85
|
+
export const ResultSchema = Schema.Struct({ status: Schema.Number, data: Schema.Json });
|
|
86
|
+
export type OperatorConfig = typeof OperatorSchema.Type;
|
|
87
|
+
export type ClientConfig = typeof ClientSchema.Type;
|
|
88
|
+
export type ApiRequest = typeof RequestSchema.Type;
|
|
89
|
+
export type ApiResult = typeof ResultSchema.Type;
|
|
90
|
+
export type Profile = typeof ProfileSchema.Type;
|
|
91
|
+
export type Grant = typeof GrantSchema.Type;
|
|
92
|
+
|
|
93
|
+
export function decode<S extends Schema.ConstraintDecoder<unknown>>(
|
|
94
|
+
schema: S,
|
|
95
|
+
value: unknown,
|
|
96
|
+
): S["Type"] {
|
|
97
|
+
try {
|
|
98
|
+
return Schema.decodeUnknownSync(schema, { onExcessProperty: "error" })(value);
|
|
99
|
+
} catch {
|
|
100
|
+
throw apiError("CONFIG_INVALID");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export const MAX_PATH_BYTES = 16_384;
|
|
104
|
+
export const MAX_RESULT_ENVELOPE_BYTES = 10_000_000;
|
|
105
|
+
export function relativePath(path: string): string {
|
|
106
|
+
if (
|
|
107
|
+
path.length > MAX_PATH_BYTES ||
|
|
108
|
+
!/^\/[A-Za-z0-9_~!$&'()+,=:@./-]*$/.test(path) ||
|
|
109
|
+
path.includes("//") ||
|
|
110
|
+
path.split("/").some((segment) => segment.endsWith("."))
|
|
111
|
+
)
|
|
112
|
+
throw apiError("POLICY_DENIED");
|
|
113
|
+
return path;
|
|
114
|
+
}
|
|
115
|
+
export const matchesPrefix = (path: string, prefix: string): boolean => {
|
|
116
|
+
const normalized = prefix === "/" ? prefix : prefix.replace(/\/$/, "");
|
|
117
|
+
return normalized === "/" || path === normalized || path.startsWith(`${normalized}/`);
|
|
118
|
+
};
|
|
119
|
+
export function originUrl(origin: string, developmentLoopback = false): URL {
|
|
120
|
+
let url: URL;
|
|
121
|
+
try {
|
|
122
|
+
url = new URL(origin);
|
|
123
|
+
} catch {
|
|
124
|
+
throw apiError("CONFIG_INVALID");
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
url.origin !== origin ||
|
|
128
|
+
url.username ||
|
|
129
|
+
url.password ||
|
|
130
|
+
(url.protocol !== "https:" &&
|
|
131
|
+
!(developmentLoopback && url.protocol === "http:" && url.hostname === "127.0.0.1"))
|
|
132
|
+
)
|
|
133
|
+
throw apiError("CONFIG_INVALID");
|
|
134
|
+
return url;
|
|
135
|
+
}
|
|
136
|
+
const bounded = (n: number, max: number): boolean => Number.isSafeInteger(n) && n > 0 && n <= max;
|
|
137
|
+
export function validateOperator(value: unknown): OperatorConfig {
|
|
138
|
+
const config = decode(OperatorSchema, value);
|
|
139
|
+
if (
|
|
140
|
+
!(
|
|
141
|
+
bounded(config.listen.port, 65535) ||
|
|
142
|
+
(config.listen.kind === "developmentLoopback" && config.listen.port === 0)
|
|
143
|
+
) ||
|
|
144
|
+
!bounded(config.maxConcurrent, 64) ||
|
|
145
|
+
!config.grants.length ||
|
|
146
|
+
config.grants.length > 100 ||
|
|
147
|
+
Object.keys(config.profiles).length > 100 ||
|
|
148
|
+
!Object.keys(config.profiles).length
|
|
149
|
+
)
|
|
150
|
+
throw apiError("CONFIG_INVALID");
|
|
151
|
+
const digests = new Set<string>();
|
|
152
|
+
for (const grant of config.grants) {
|
|
153
|
+
if (
|
|
154
|
+
!/^[a-f0-9]{64}$/.test(grant.digest) ||
|
|
155
|
+
digests.has(grant.digest) ||
|
|
156
|
+
!bounded(grant.expiresAt, Number.MAX_SAFE_INTEGER) ||
|
|
157
|
+
!bounded(grant.maxRequests, 10000) ||
|
|
158
|
+
!bounded(grant.maxBytes, 1_000_000_000) ||
|
|
159
|
+
!bounded(grant.maxConcurrent, 64) ||
|
|
160
|
+
!bounded(grant.lifetimeMs, 86400000) ||
|
|
161
|
+
!grant.profiles.length ||
|
|
162
|
+
grant.profiles.some((p) => !Object.hasOwn(config.profiles, p))
|
|
163
|
+
)
|
|
164
|
+
throw apiError("CONFIG_INVALID");
|
|
165
|
+
digests.add(grant.digest);
|
|
166
|
+
}
|
|
167
|
+
for (const profile of Object.values(config.profiles)) {
|
|
168
|
+
originUrl(profile.origin);
|
|
169
|
+
for (const address of profile.allowedPrivateAddresses) {
|
|
170
|
+
if (
|
|
171
|
+
!isIP(address) ||
|
|
172
|
+
!allowedAddress(address, [address]) ||
|
|
173
|
+
(isIP(address) === 6 && new URL(`http://[${address}]`).hostname.slice(1, -1) !== address)
|
|
174
|
+
)
|
|
175
|
+
throw apiError("CONFIG_INVALID");
|
|
176
|
+
}
|
|
177
|
+
if (
|
|
178
|
+
!profile.pathPrefixes.length ||
|
|
179
|
+
!bounded(profile.maxRequestBytes, 1_000_000) ||
|
|
180
|
+
!bounded(profile.maxResponseBytes, 10_000_000) ||
|
|
181
|
+
!bounded(profile.timeoutMs, 60000) ||
|
|
182
|
+
profile.methods?.length === 0
|
|
183
|
+
)
|
|
184
|
+
throw apiError("CONFIG_INVALID");
|
|
185
|
+
for (const prefix of [...profile.pathPrefixes, ...profile.deniedPathPrefixes])
|
|
186
|
+
relativePath(prefix);
|
|
187
|
+
if (profile.auth.kind === "apiKey" && !/^(?:x-[a-z0-9-]+|api-key)$/i.test(profile.auth.header))
|
|
188
|
+
throw apiError("CONFIG_INVALID");
|
|
189
|
+
if (profile.auth.kind === "login") {
|
|
190
|
+
relativePath(profile.auth.path);
|
|
191
|
+
const minimumBody = JSON.stringify(
|
|
192
|
+
Object.fromEntries(Object.keys(profile.auth.fields).map((field) => [field, ""])),
|
|
193
|
+
);
|
|
194
|
+
if (
|
|
195
|
+
Buffer.byteLength(profile.auth.path) + Buffer.byteLength(minimumBody) >
|
|
196
|
+
profile.maxRequestBytes
|
|
197
|
+
)
|
|
198
|
+
throw apiError("CONFIG_INVALID");
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return config;
|
|
202
|
+
}
|
|
@@ -1602,6 +1602,9 @@ function hasArgumentBraceExpansion(text: string): boolean {
|
|
|
1602
1602
|
}
|
|
1603
1603
|
|
|
1604
1604
|
function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
1605
|
+
const apiData = apiRequestDataIndexes(argv);
|
|
1606
|
+
if (apiData)
|
|
1607
|
+
return argv.some((arg, index) => !apiData.has(index) && hasArgumentBraceExpansion(arg));
|
|
1605
1608
|
// LogQL is data for this wrapper; its quoted label selectors are never shell-evaluated.
|
|
1606
1609
|
if (
|
|
1607
1610
|
argv[0]?.split("/").at(-1) === "bun" &&
|
|
@@ -1621,6 +1624,52 @@ function hasCommandArgumentBraceExpansion(argv: string[]): boolean {
|
|
|
1621
1624
|
: hasArgumentBraceExpansion(argv.join(" "));
|
|
1622
1625
|
}
|
|
1623
1626
|
|
|
1627
|
+
function apiRequestDataIndexes(argv: string[]): Set<number> | undefined {
|
|
1628
|
+
const start =
|
|
1629
|
+
argv[0] === "bun" && argv[1] === "run" && argv[2] === "api-tool"
|
|
1630
|
+
? 3
|
|
1631
|
+
: ["api-tool", "agent-tools-api"].includes(argv[0] ?? "")
|
|
1632
|
+
? 1
|
|
1633
|
+
: -1;
|
|
1634
|
+
if (start < 0 || argv[start] !== "request" || argv.length > 18) return undefined;
|
|
1635
|
+
const data = new Set<number>();
|
|
1636
|
+
const seen = new Set<string>();
|
|
1637
|
+
for (let index = start + 1; index < argv.length; index++) {
|
|
1638
|
+
const [flag = "", ...attached] = (argv[index] ?? "").split("=");
|
|
1639
|
+
if (
|
|
1640
|
+
!["--config", "--profile", "--path", "--method", "--query", "--body"].includes(flag) ||
|
|
1641
|
+
seen.has(flag)
|
|
1642
|
+
)
|
|
1643
|
+
return undefined;
|
|
1644
|
+
seen.add(flag);
|
|
1645
|
+
const value = attached.length ? attached.join("=") : argv[++index];
|
|
1646
|
+
if (!value || value.startsWith("--")) return undefined;
|
|
1647
|
+
if (flag === "--query" || flag === "--body") {
|
|
1648
|
+
try {
|
|
1649
|
+
JSON.parse(value);
|
|
1650
|
+
} catch {
|
|
1651
|
+
return undefined;
|
|
1652
|
+
}
|
|
1653
|
+
data.add(index);
|
|
1654
|
+
} else if (flag === "--method") {
|
|
1655
|
+
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(value)) return undefined;
|
|
1656
|
+
} else if (flag === "--profile") {
|
|
1657
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(value)) return undefined;
|
|
1658
|
+
} else if (flag === "--config") {
|
|
1659
|
+
if (!/^(?:\.?\.?\/)?[A-Za-z0-9_./-]+$/.test(value)) return undefined;
|
|
1660
|
+
} else if (flag === "--path") {
|
|
1661
|
+
if (
|
|
1662
|
+
value.length > 16384 ||
|
|
1663
|
+
!/^\/[A-Za-z0-9_~!$&'()+,=:@./-]*$/.test(value) ||
|
|
1664
|
+
value.includes("//") ||
|
|
1665
|
+
value.split("/").some((part) => part.endsWith("."))
|
|
1666
|
+
)
|
|
1667
|
+
return undefined;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
return data;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1624
1673
|
function literalSqlArgumentIndex(argv: string[]): number | undefined {
|
|
1625
1674
|
if (
|
|
1626
1675
|
argv[0]?.split("/").at(-1) !== "bun" ||
|
|
@@ -1647,9 +1696,11 @@ function literalSqlArgumentIndex(argv: string[]): number | undefined {
|
|
|
1647
1696
|
return sql;
|
|
1648
1697
|
}
|
|
1649
1698
|
|
|
1650
|
-
/** echo and printf print their arguments; grep -o, rg and git grep print matched pattern text. */
|
|
1651
1699
|
function isLiteralTextProducer(argv: string[]): boolean {
|
|
1652
|
-
return
|
|
1700
|
+
return (
|
|
1701
|
+
(argv[0]?.split("/").at(-1) === "git" && argv[1] === "commit") ||
|
|
1702
|
+
(isPassiveTextCommand(argv) && argv[0]?.split("/").at(-1) !== "head")
|
|
1703
|
+
);
|
|
1653
1704
|
}
|
|
1654
1705
|
|
|
1655
1706
|
function awkProgramIndex(argv: string[]): number | undefined {
|
|
@@ -2412,6 +2463,22 @@ function isReadonlySedPrint(argv: string[]): boolean {
|
|
|
2412
2463
|
);
|
|
2413
2464
|
}
|
|
2414
2465
|
|
|
2466
|
+
function gitCommitExecutionArguments(argv: string[]): string[] {
|
|
2467
|
+
if (argv[0]?.split("/").at(-1) !== "git" || argv[1] !== "commit") return argv;
|
|
2468
|
+
const executionArguments = [...argv];
|
|
2469
|
+
for (let index = 2; index < argv.length && argv[index] !== "--"; index++) {
|
|
2470
|
+
const argument = argv[index] ?? "";
|
|
2471
|
+
if (argument === "-m" || argument === "--message") {
|
|
2472
|
+
if (index + 1 < argv.length) executionArguments[++index] = "";
|
|
2473
|
+
} else if (argument.startsWith("--message=")) {
|
|
2474
|
+
executionArguments[index] = "--message=";
|
|
2475
|
+
} else if (argument.startsWith("-m") && argument.length > 2) {
|
|
2476
|
+
executionArguments[index] = "-m";
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
return executionArguments;
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2415
2482
|
function hasStaticEnvironmentRead(
|
|
2416
2483
|
argv: string[],
|
|
2417
2484
|
allowedNames: Set<string>,
|
|
@@ -2468,7 +2535,7 @@ function hasStaticEnvironmentRead(
|
|
|
2468
2535
|
"jq",
|
|
2469
2536
|
"docker",
|
|
2470
2537
|
].includes(name ?? "") &&
|
|
2471
|
-
mentionsEnvironmentRead(argv.join(" ")))
|
|
2538
|
+
mentionsEnvironmentRead(gitCommitExecutionArguments(argv).join(" ")))
|
|
2472
2539
|
);
|
|
2473
2540
|
}
|
|
2474
2541
|
|
|
@@ -2551,6 +2618,15 @@ function hasEnvironmentRead(
|
|
|
2551
2618
|
// ponytail: complex shell syntax stays conservative; use a shell AST if more exceptions are needed.
|
|
2552
2619
|
const parsed = parseStaticShellCommands(command);
|
|
2553
2620
|
if (parsed === "brace-expansion") return true;
|
|
2621
|
+
if (
|
|
2622
|
+
parsed &&
|
|
2623
|
+
typeof parsed !== "string" &&
|
|
2624
|
+
hasEnvironmentMutationPrefix(command) &&
|
|
2625
|
+
parsed.pipelines
|
|
2626
|
+
.flat()
|
|
2627
|
+
.some((words) => apiRequestDataIndexes(unwrapStaticCommand(words)) !== undefined)
|
|
2628
|
+
)
|
|
2629
|
+
return true;
|
|
2554
2630
|
if (!parsed) {
|
|
2555
2631
|
const operands = parseStaticShellCommands(command, true);
|
|
2556
2632
|
if (
|
|
@@ -2603,6 +2679,18 @@ function hasEnvironmentRead(
|
|
|
2603
2679
|
) {
|
|
2604
2680
|
return true;
|
|
2605
2681
|
}
|
|
2682
|
+
if (
|
|
2683
|
+
parsed.redirects.length > 0 &&
|
|
2684
|
+
unwrapped.some(
|
|
2685
|
+
(producer, index) =>
|
|
2686
|
+
producer[0]?.split("/").at(-1) === "git" &&
|
|
2687
|
+
producer[1] === "commit" &&
|
|
2688
|
+
mentionsEnvironmentRead(producer.join(" ")) &&
|
|
2689
|
+
unwrapped.slice(index + 1).some((consumer) => !isPassiveTextCommand(consumer)),
|
|
2690
|
+
)
|
|
2691
|
+
) {
|
|
2692
|
+
return true;
|
|
2693
|
+
}
|
|
2606
2694
|
if (
|
|
2607
2695
|
parsed.redirects.length > 0 &&
|
|
2608
2696
|
pipelines.some((pipeline) =>
|