@crewhaus/tool-secure 0.7.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 +273 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +1006 -0
- package/dist/lib/entropy.d.ts +65 -0
- package/dist/lib/entropy.js +99 -0
- package/dist/lib/evidence.d.ts +55 -0
- package/dist/lib/evidence.js +134 -0
- package/dist/lib/files.d.ts +71 -0
- package/dist/lib/files.js +227 -0
- package/dist/lib/injection.d.ts +38 -0
- package/dist/lib/injection.js +396 -0
- package/dist/lib/pii.d.ts +91 -0
- package/dist/lib/pii.js +757 -0
- package/dist/lib/policy.d.ts +42 -0
- package/dist/lib/policy.js +138 -0
- package/dist/lib/redact.d.ts +41 -0
- package/dist/lib/redact.js +121 -0
- package/dist/lib/secrets.d.ts +111 -0
- package/dist/lib/secrets.js +329 -0
- package/dist/lib/text.d.ts +129 -0
- package/dist/lib/text.js +191 -0
- package/dist/lib/unicode.d.ts +106 -0
- package/dist/lib/unicode.js +431 -0
- package/dist/lib/url.d.ts +35 -0
- package/dist/lib/url.js +363 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +45 -0
package/dist/lib/url.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structural analysis of a URL, and allow-list matching.
|
|
3
|
+
*
|
|
4
|
+
* ## Nothing here fetches anything
|
|
5
|
+
*
|
|
6
|
+
* Every check reads the string. No DNS, no HTTP, no reputation service — so
|
|
7
|
+
* this says nothing about whether a host is malicious, only whether the URL
|
|
8
|
+
* is SHAPED like the ones used to mislead: credentials in the authority, an
|
|
9
|
+
* IP where a name should be, a punycode label, another URL smuggled through
|
|
10
|
+
* a redirect parameter, a `javascript:` payload.
|
|
11
|
+
*
|
|
12
|
+
* A URL with no findings is a URL that is structurally unremarkable. That is
|
|
13
|
+
* all. Plenty of ordinary-looking URLs are hostile and plenty of odd-looking
|
|
14
|
+
* ones are fine.
|
|
15
|
+
*
|
|
16
|
+
* ## Allow-list grammar
|
|
17
|
+
*
|
|
18
|
+
* A rule is matched, never guessed at. Exactly these forms:
|
|
19
|
+
*
|
|
20
|
+
* | kind | rule | matches |
|
|
21
|
+
* |---|---|---|
|
|
22
|
+
* | `url` | `example.com` | that host only, any http(s) URL |
|
|
23
|
+
* | `url` | `*.example.com` | any subdomain, NOT the bare host |
|
|
24
|
+
* | `url` | `https://example.com/api/` | host plus a path prefix, scheme pinned |
|
|
25
|
+
* | `emailDomain` | `example.com` | that domain only, case-insensitive |
|
|
26
|
+
* | `emailDomain` | `*.example.com` | any subdomain of it |
|
|
27
|
+
* | `path` | `src/` | that workspace-relative directory and everything under it |
|
|
28
|
+
*
|
|
29
|
+
* Deny by default: an empty allow-list allows nothing, and a rule that does
|
|
30
|
+
* not parse is an error rather than a rule that silently matches nothing.
|
|
31
|
+
*/
|
|
32
|
+
import { compareStrings } from "./text";
|
|
33
|
+
import { mixedScriptRuns } from "./unicode";
|
|
34
|
+
/** Schemes a link in a document is normally allowed to use. */
|
|
35
|
+
const BENIGN_SCHEMES = new Set(["http", "https", "mailto"]);
|
|
36
|
+
/** Schemes that execute or inline content rather than locate it. */
|
|
37
|
+
const ACTIVE_SCHEMES = new Set(["javascript", "data", "vbscript", "blob", "file"]);
|
|
38
|
+
/** Ports that are a service, not a website. */
|
|
39
|
+
const SENSITIVE_PORTS = new Set([
|
|
40
|
+
22, 23, 25, 110, 135, 139, 445, 1433, 1521, 3306, 3389, 5432, 5900, 6379, 9200, 11211, 27017,
|
|
41
|
+
]);
|
|
42
|
+
const REDIRECT_PARAMS = new Set([
|
|
43
|
+
"callback",
|
|
44
|
+
"continue",
|
|
45
|
+
"dest",
|
|
46
|
+
"destination",
|
|
47
|
+
"forward",
|
|
48
|
+
"goto",
|
|
49
|
+
"image_url",
|
|
50
|
+
"link",
|
|
51
|
+
"next",
|
|
52
|
+
"out",
|
|
53
|
+
"path",
|
|
54
|
+
"r",
|
|
55
|
+
"redir",
|
|
56
|
+
"redirect",
|
|
57
|
+
"redirect_uri",
|
|
58
|
+
"redirect_url",
|
|
59
|
+
"return",
|
|
60
|
+
"returnto",
|
|
61
|
+
"return_to",
|
|
62
|
+
"rurl",
|
|
63
|
+
"target",
|
|
64
|
+
"to",
|
|
65
|
+
"u",
|
|
66
|
+
"uri",
|
|
67
|
+
"url",
|
|
68
|
+
"window",
|
|
69
|
+
]);
|
|
70
|
+
/**
|
|
71
|
+
* Every rule id `analyzeUrl` can emit, so `checked` and `issues[].rule` are
|
|
72
|
+
* the same vocabulary — a caller can line an empty `issues` list up against
|
|
73
|
+
* this one and see exactly what "no findings" covered. Naming a category here
|
|
74
|
+
* that no issue ever reports under (`scheme` when the issue says
|
|
75
|
+
* `scheme.active`) makes the two lists un-joinable, which is the whole point
|
|
76
|
+
* of publishing the second one.
|
|
77
|
+
*/
|
|
78
|
+
export const URL_CHECKS = [
|
|
79
|
+
"parse",
|
|
80
|
+
"raw.whitespace-or-control",
|
|
81
|
+
"length",
|
|
82
|
+
"scheme.active",
|
|
83
|
+
"scheme.unexpected",
|
|
84
|
+
"authority.credentials",
|
|
85
|
+
"host.numeric",
|
|
86
|
+
"host.ip-literal",
|
|
87
|
+
"host.punycode",
|
|
88
|
+
"host.non-ascii",
|
|
89
|
+
"host.mixed-script",
|
|
90
|
+
"host.label-count",
|
|
91
|
+
"port.service",
|
|
92
|
+
"port.non-standard",
|
|
93
|
+
"query.redirect-parameter",
|
|
94
|
+
"query.embedded-url",
|
|
95
|
+
"encoding.double",
|
|
96
|
+
];
|
|
97
|
+
const IPV4_HOST = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
|
98
|
+
const ALL_DIGITS = /^\d+$/;
|
|
99
|
+
const HEX_HOST = /^0x[0-9a-f]+$/i;
|
|
100
|
+
/** Whitespace or an ASCII control character anywhere in the string. */
|
|
101
|
+
function hasWhitespaceOrControl(value) {
|
|
102
|
+
for (let i = 0; i < value.length; i++) {
|
|
103
|
+
const code = value.charCodeAt(i);
|
|
104
|
+
if (code <= 0x20 || code === 0x7f)
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
/** True when every code unit is ASCII. */
|
|
110
|
+
function isAscii(value) {
|
|
111
|
+
for (let i = 0; i < value.length; i++) {
|
|
112
|
+
if (value.charCodeAt(i) > 0x7f)
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
/** Analyse one URL string. Never throws for a caller mistake. */
|
|
118
|
+
export function analyzeUrl(raw) {
|
|
119
|
+
const issues = [];
|
|
120
|
+
if (hasWhitespaceOrControl(raw)) {
|
|
121
|
+
issues.push({
|
|
122
|
+
rule: "raw.whitespace-or-control",
|
|
123
|
+
severity: "medium",
|
|
124
|
+
detail: "the URL contains whitespace or control characters, which parsers disagree about",
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (raw.length > 2000) {
|
|
128
|
+
issues.push({
|
|
129
|
+
rule: "length",
|
|
130
|
+
severity: "low",
|
|
131
|
+
detail: `the URL is ${raw.length} characters; unusually long URLs hide their tail in a status bar`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
let url;
|
|
135
|
+
try {
|
|
136
|
+
url = new URL(raw);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return {
|
|
140
|
+
parsed: false,
|
|
141
|
+
issues: [
|
|
142
|
+
...issues,
|
|
143
|
+
{
|
|
144
|
+
rule: "parse",
|
|
145
|
+
severity: "medium",
|
|
146
|
+
detail: "not an absolute URL; a relative reference resolves against a base this tool cannot see",
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
checked: URL_CHECKS,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const scheme = url.protocol.replace(/:$/, "").toLowerCase();
|
|
153
|
+
if (ACTIVE_SCHEMES.has(scheme)) {
|
|
154
|
+
issues.push({
|
|
155
|
+
rule: "scheme.active",
|
|
156
|
+
severity: "high",
|
|
157
|
+
detail: `the "${scheme}:" scheme carries or executes content rather than locating it`,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
else if (!BENIGN_SCHEMES.has(scheme)) {
|
|
161
|
+
issues.push({
|
|
162
|
+
rule: "scheme.unexpected",
|
|
163
|
+
severity: "low",
|
|
164
|
+
detail: `scheme "${scheme}:" is neither http, https nor mailto`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
if (url.username !== "" || url.password !== "") {
|
|
168
|
+
issues.push({
|
|
169
|
+
rule: "authority.credentials",
|
|
170
|
+
severity: "high",
|
|
171
|
+
detail: url.password === ""
|
|
172
|
+
? "the authority carries a username, which pushes the real host past where a reader stops looking"
|
|
173
|
+
: "the authority carries a username and password",
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// The authority exactly as written. `url.hostname` is already normalized —
|
|
177
|
+
// for an http URL the parser turns `2130706433` into `127.0.0.1` — so the
|
|
178
|
+
// obfuscated form is only visible here.
|
|
179
|
+
const rawAuthority = raw.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "").split(/[/?#]/)[0] ?? "";
|
|
180
|
+
const rawHost = rawAuthority
|
|
181
|
+
.slice(rawAuthority.lastIndexOf("@") + 1)
|
|
182
|
+
.replace(/:\d*$/, "")
|
|
183
|
+
.toLowerCase();
|
|
184
|
+
const host = url.hostname.toLowerCase();
|
|
185
|
+
if (ALL_DIGITS.test(rawHost) || HEX_HOST.test(rawHost)) {
|
|
186
|
+
issues.push({
|
|
187
|
+
rule: "host.numeric",
|
|
188
|
+
severity: "high",
|
|
189
|
+
detail: `the host is written as a bare number ("${rawHost}"), a decimal or hex encoding of the IP address ${host} that hides it from a reader`,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
else if (IPV4_HOST.test(host)) {
|
|
193
|
+
issues.push({
|
|
194
|
+
rule: "host.ip-literal",
|
|
195
|
+
severity: "medium",
|
|
196
|
+
detail: "the host is an IPv4 literal, so there is no name to check and no certificate name to match",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
else if (host.startsWith("[")) {
|
|
200
|
+
issues.push({
|
|
201
|
+
rule: "host.ip-literal",
|
|
202
|
+
severity: "medium",
|
|
203
|
+
detail: "the host is an IPv6 literal",
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const labels = host.split(".");
|
|
207
|
+
for (const label of labels) {
|
|
208
|
+
if (label.startsWith("xn--")) {
|
|
209
|
+
issues.push({
|
|
210
|
+
rule: "host.punycode",
|
|
211
|
+
severity: "medium",
|
|
212
|
+
detail: `label "${label}" is punycode, so the host displays as non-ASCII characters that may imitate another name`,
|
|
213
|
+
});
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
// Compare against the raw input, because URL parsing already applied IDNA.
|
|
218
|
+
if (!isAscii(rawAuthority)) {
|
|
219
|
+
issues.push({
|
|
220
|
+
rule: "host.non-ascii",
|
|
221
|
+
severity: "medium",
|
|
222
|
+
detail: "the host as written contains non-ASCII characters",
|
|
223
|
+
});
|
|
224
|
+
const mixed = mixedScriptRuns(rawAuthority);
|
|
225
|
+
if (mixed.length > 0) {
|
|
226
|
+
issues.push({
|
|
227
|
+
rule: "host.mixed-script",
|
|
228
|
+
severity: "high",
|
|
229
|
+
detail: `the host mixes scripts (${mixed[0]?.scripts.join(" + ")}), the shape of a homograph imitation`,
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (labels.length > 5) {
|
|
234
|
+
issues.push({
|
|
235
|
+
rule: "host.label-count",
|
|
236
|
+
severity: "low",
|
|
237
|
+
detail: `${labels.length} labels: a long prefix can make a hostile registrable domain read as a path`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
if (url.port !== "") {
|
|
241
|
+
const port = Number(url.port);
|
|
242
|
+
if (SENSITIVE_PORTS.has(port)) {
|
|
243
|
+
issues.push({
|
|
244
|
+
rule: "port.service",
|
|
245
|
+
severity: "medium",
|
|
246
|
+
detail: `port ${port} is an administrative or database service, not a web server`,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
else if (port !== 80 && port !== 443) {
|
|
250
|
+
issues.push({
|
|
251
|
+
rule: "port.non-standard",
|
|
252
|
+
severity: "low",
|
|
253
|
+
detail: `explicit port ${port}`,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
for (const [key, value] of [...url.searchParams].sort((a, b) => compareStrings(a[0], b[0]) || compareStrings(a[1], b[1]))) {
|
|
258
|
+
const decoded = safeDecode(value);
|
|
259
|
+
const carriesUrl = /^(?:\/\/|[a-z][a-z0-9+.-]*:\/\/)/i.test(decoded);
|
|
260
|
+
if (!carriesUrl)
|
|
261
|
+
continue;
|
|
262
|
+
const named = REDIRECT_PARAMS.has(key.toLowerCase());
|
|
263
|
+
issues.push({
|
|
264
|
+
rule: named ? "query.redirect-parameter" : "query.embedded-url",
|
|
265
|
+
severity: named ? "high" : "medium",
|
|
266
|
+
detail: `parameter "${key}" carries another URL (${truncate(decoded, 80)})`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
if (/%25[0-9a-f]{2}/i.test(raw)) {
|
|
270
|
+
issues.push({
|
|
271
|
+
rule: "encoding.double",
|
|
272
|
+
severity: "medium",
|
|
273
|
+
detail: "double percent-encoding: what one layer decodes may differ from what the next one sees",
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
parsed: true,
|
|
278
|
+
scheme,
|
|
279
|
+
host,
|
|
280
|
+
port: url.port === "" ? undefined : url.port,
|
|
281
|
+
issues: issues.sort((a, b) => compareStrings(a.rule, b.rule) || compareStrings(a.detail, b.detail)),
|
|
282
|
+
checked: URL_CHECKS,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function safeDecode(value) {
|
|
286
|
+
try {
|
|
287
|
+
return decodeURIComponent(value);
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return value;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function truncate(value, max) {
|
|
294
|
+
return value.length <= max ? value : `${value.slice(0, max)}...`;
|
|
295
|
+
}
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// allow-lists
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
export class AllowRuleError extends Error {
|
|
300
|
+
name = "AllowRuleError";
|
|
301
|
+
}
|
|
302
|
+
/** A host matches a host rule exactly, or as a subdomain under `*.`. */
|
|
303
|
+
export function hostMatches(host, rule) {
|
|
304
|
+
const h = host.toLowerCase().replace(/\.$/, "");
|
|
305
|
+
const r = rule.toLowerCase().replace(/\.$/, "");
|
|
306
|
+
if (r.startsWith("*.")) {
|
|
307
|
+
const base = r.slice(2);
|
|
308
|
+
return h.endsWith(`.${base}`) && h.length > base.length + 1;
|
|
309
|
+
}
|
|
310
|
+
return h === r;
|
|
311
|
+
}
|
|
312
|
+
/** True when `pathname` is `prefix` or lives under it. */
|
|
313
|
+
function underPrefix(pathname, prefix) {
|
|
314
|
+
if (prefix === "/" || prefix === "")
|
|
315
|
+
return true;
|
|
316
|
+
const normalized = prefix.endsWith("/") ? prefix : `${prefix}/`;
|
|
317
|
+
return pathname === prefix || pathname.startsWith(normalized);
|
|
318
|
+
}
|
|
319
|
+
/** Does this URL satisfy this allow-list rule? */
|
|
320
|
+
export function urlMatchesRule(value, rule) {
|
|
321
|
+
const trimmed = rule.trim();
|
|
322
|
+
if (trimmed === "")
|
|
323
|
+
throw new AllowRuleError("an allow-list rule may not be empty");
|
|
324
|
+
let url;
|
|
325
|
+
try {
|
|
326
|
+
url = new URL(value);
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
|
|
332
|
+
let ruleUrl;
|
|
333
|
+
try {
|
|
334
|
+
ruleUrl = new URL(trimmed);
|
|
335
|
+
}
|
|
336
|
+
catch {
|
|
337
|
+
throw new AllowRuleError(`allow-list rule "${rule}" starts with a scheme but is not a valid URL`);
|
|
338
|
+
}
|
|
339
|
+
if (ruleUrl.protocol !== url.protocol)
|
|
340
|
+
return false;
|
|
341
|
+
if (!hostMatches(url.hostname, ruleUrl.hostname))
|
|
342
|
+
return false;
|
|
343
|
+
if (ruleUrl.port !== "" && ruleUrl.port !== url.port)
|
|
344
|
+
return false;
|
|
345
|
+
return underPrefix(url.pathname, ruleUrl.pathname);
|
|
346
|
+
}
|
|
347
|
+
const [hostPart = "", ...pathParts] = trimmed.split("/");
|
|
348
|
+
if (hostPart.includes(":")) {
|
|
349
|
+
throw new AllowRuleError(`allow-list rule "${rule}" has a port or scheme but no "://" — write it as https://host[:port][/prefix]`);
|
|
350
|
+
}
|
|
351
|
+
if (!hostMatches(url.hostname, hostPart))
|
|
352
|
+
return false;
|
|
353
|
+
const prefix = pathParts.join("/");
|
|
354
|
+
return prefix === "" ? true : underPrefix(url.pathname, `/${prefix}`);
|
|
355
|
+
}
|
|
356
|
+
/** The domain of an email address, lower-cased, or undefined if there is none. */
|
|
357
|
+
export function emailDomain(address) {
|
|
358
|
+
const at = address.lastIndexOf("@");
|
|
359
|
+
if (at < 0 || at === address.length - 1)
|
|
360
|
+
return undefined;
|
|
361
|
+
const domain = address.slice(at + 1).toLowerCase();
|
|
362
|
+
return domain.includes(" ") ? undefined : domain;
|
|
363
|
+
}
|
package/dist/paths.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
+
/** Raised when a caller-supplied path resolves outside the workspace root. */
|
|
3
|
+
export declare class ToolPermissionError extends CrewhausError {
|
|
4
|
+
readonly name = "ToolPermissionError";
|
|
5
|
+
readonly toolName: string;
|
|
6
|
+
readonly path: string;
|
|
7
|
+
constructor(toolName: string, attemptedPath: string);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A validated path, in both forms the tools need.
|
|
11
|
+
*
|
|
12
|
+
* `real` has every in-workspace symlink already resolved and is what I/O
|
|
13
|
+
* should use — operating on it closes most of the check-to-use window.
|
|
14
|
+
* `abs` is the lexical resolution, which is what a tool must use when the
|
|
15
|
+
* question is about the link ITSELF (`Stat` reporting a symlink target,
|
|
16
|
+
* `RemovePath` deleting a link rather than its destination).
|
|
17
|
+
*/
|
|
18
|
+
export type SafePath = {
|
|
19
|
+
readonly abs: string;
|
|
20
|
+
readonly real: string;
|
|
21
|
+
/** The path as the caller wrote it, for echoing back in results. */
|
|
22
|
+
readonly given: string;
|
|
23
|
+
/** Slash-separated path relative to the workspace root. */
|
|
24
|
+
readonly rel: string;
|
|
25
|
+
};
|
|
26
|
+
/** The workspace root, resolved. Every path is contained within it. */
|
|
27
|
+
export declare function workspaceRoot(): string;
|
|
28
|
+
export declare function resolveSafe(toolName: string, rel: string, root?: string): SafePath;
|
|
29
|
+
/** A path relative to the workspace root, with `/` separators on every OS. */
|
|
30
|
+
export declare function toPosix(p: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* True when `candidate` is `parent` or lives underneath it. Both must already
|
|
33
|
+
* be absolute. Used to keep an archive entry, or a symlink inside an
|
|
34
|
+
* extracted tree, from reaching outside its destination.
|
|
35
|
+
*/
|
|
36
|
+
export declare function isInside(parent: string, candidate: string): boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Reject an archive member name that would escape its destination: an
|
|
39
|
+
* absolute path, a Windows drive prefix, or any `..` segment. Checked on the
|
|
40
|
+
* NAME, before extraction, because after extraction the damage is done.
|
|
41
|
+
*/
|
|
42
|
+
export declare function archiveEntryEscapes(name: string): boolean;
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path containment — the gate every caller-supplied path in this package
|
|
3
|
+
* passes through before any syscall touches it.
|
|
4
|
+
*
|
|
5
|
+
* The approach is the one `@crewhaus/tool-fs` uses, and for the same reasons:
|
|
6
|
+
* a lexical check alone is fooled by a symlink that lives inside the
|
|
7
|
+
* workspace and points outside it (CWE-59), so the real path is checked too.
|
|
8
|
+
* The workspace root is `process.cwd()`, matching tool-fs, so a harness that
|
|
9
|
+
* trusts one tool's boundary gets the same boundary here.
|
|
10
|
+
*/
|
|
11
|
+
import { lstatSync, readlinkSync, realpathSync } from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { CrewhausError } from "@crewhaus/errors";
|
|
14
|
+
/** Raised when a caller-supplied path resolves outside the workspace root. */
|
|
15
|
+
export class ToolPermissionError extends CrewhausError {
|
|
16
|
+
name = "ToolPermissionError";
|
|
17
|
+
toolName;
|
|
18
|
+
path;
|
|
19
|
+
constructor(toolName, attemptedPath) {
|
|
20
|
+
super("tool", `tool "${toolName}" rejected path "${attemptedPath}": resolved location escapes the workspace root`);
|
|
21
|
+
this.toolName = toolName;
|
|
22
|
+
this.path = attemptedPath;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/** The workspace root, resolved. Every path is contained within it. */
|
|
26
|
+
export function workspaceRoot() {
|
|
27
|
+
return path.resolve(process.cwd());
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Resolve `rel` inside the workspace, or throw `ToolPermissionError`.
|
|
31
|
+
*
|
|
32
|
+
* The leaf is allowed not to exist — every tool here that creates something
|
|
33
|
+
* needs to validate a destination before it is made — so the deepest
|
|
34
|
+
* EXISTING ancestor is realpath'd and the missing tail re-appended. Any
|
|
35
|
+
* failure other than that walk fails closed.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* True when the NAME exists, whether or not it leads anywhere.
|
|
39
|
+
*
|
|
40
|
+
* `existsSync` FOLLOWS symlinks, so it answers false for a link whose target
|
|
41
|
+
* is missing — and that is exactly the case that matters: a dangling link is
|
|
42
|
+
* still a door, because `open(…, "w")` through `evil -> /outside/x` CREATES
|
|
43
|
+
* `/outside/x`. Probing with `lstat` keeps the link's name in the part of the
|
|
44
|
+
* path that gets resolved, rather than in the "does not exist yet" tail that
|
|
45
|
+
* is re-appended to the root verbatim and so passes the containment check.
|
|
46
|
+
*/
|
|
47
|
+
function nameExists(p) {
|
|
48
|
+
try {
|
|
49
|
+
lstatSync(p);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Where `target` would actually land, with every symlink on the way already
|
|
58
|
+
* followed — including one whose own target does not exist yet.
|
|
59
|
+
*
|
|
60
|
+
* `realpathSync` gives up with ENOENT on a dangling link, which would leave
|
|
61
|
+
* that link unresolved and let it stand in for a plain missing file. So the
|
|
62
|
+
* deepest ancestor that exists as a NAME is resolved, a dangling one is
|
|
63
|
+
* followed a hop by hand, and the components that do not exist are appended.
|
|
64
|
+
* The result is the path an `open` would create, which is the only path worth
|
|
65
|
+
* checking containment against.
|
|
66
|
+
*/
|
|
67
|
+
function resolveLocation(target, depth = 0) {
|
|
68
|
+
if (depth > 40)
|
|
69
|
+
throw new Error(`symlink chain at "${target}" is too long to resolve`);
|
|
70
|
+
let probe = target;
|
|
71
|
+
const tail = [];
|
|
72
|
+
while (!nameExists(probe)) {
|
|
73
|
+
tail.unshift(path.basename(probe));
|
|
74
|
+
const parent = path.dirname(probe);
|
|
75
|
+
if (parent === probe)
|
|
76
|
+
break; // reached the filesystem root
|
|
77
|
+
probe = parent;
|
|
78
|
+
}
|
|
79
|
+
let probeReal;
|
|
80
|
+
try {
|
|
81
|
+
probeReal = realpathSync(probe);
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
if (err.code !== "ENOENT")
|
|
85
|
+
throw err;
|
|
86
|
+
// The name is there but `realpath` cannot finish it: a symlink with a
|
|
87
|
+
// missing target. `readlinkSync` throws EINVAL on anything else, which
|
|
88
|
+
// fails closed.
|
|
89
|
+
const link = readlinkSync(probe);
|
|
90
|
+
// A RELATIVE target resolves against the directory that actually CONTAINS
|
|
91
|
+
// the link, which is not the link's lexical parent when that parent is
|
|
92
|
+
// itself reached through a symlink. `<root>/dirlink/x -> ../y` with
|
|
93
|
+
// `dirlink` pointing out of the root really lands at `<elsewhere>/y`, but
|
|
94
|
+
// measured from the lexical parent it reads as `<root>/y` — an in-root
|
|
95
|
+
// path the caller's path does not lead to. So the parent is made real
|
|
96
|
+
// first. An absolute target ignores the base.
|
|
97
|
+
const base = realpathSync(path.dirname(probe));
|
|
98
|
+
probeReal = resolveLocation(path.resolve(base, link), depth + 1);
|
|
99
|
+
}
|
|
100
|
+
return tail.length > 0 ? path.join(probeReal, ...tail) : probeReal;
|
|
101
|
+
}
|
|
102
|
+
export function resolveSafe(toolName, rel, root = workspaceRoot()) {
|
|
103
|
+
const rootResolved = path.resolve(root);
|
|
104
|
+
const abs = path.resolve(rootResolved, rel);
|
|
105
|
+
// 1) Lexical containment — rejects `..` and absolute escapes. The trailing
|
|
106
|
+
// separator avoids the `/root` vs `/root-sibling` prefix pitfall.
|
|
107
|
+
if (abs !== rootResolved && !abs.startsWith(`${rootResolved}${path.sep}`)) {
|
|
108
|
+
throw new ToolPermissionError(toolName, rel);
|
|
109
|
+
}
|
|
110
|
+
let real;
|
|
111
|
+
try {
|
|
112
|
+
const rootReal = realpathSync(rootResolved);
|
|
113
|
+
real = resolveLocation(abs);
|
|
114
|
+
// 2) Symlink-aware containment. The real path must land inside the real
|
|
115
|
+
// root, so an in-workspace symlink pointing at /etc is refused here.
|
|
116
|
+
if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) {
|
|
117
|
+
throw new ToolPermissionError(toolName, rel);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (err instanceof ToolPermissionError)
|
|
122
|
+
throw err;
|
|
123
|
+
throw new ToolPermissionError(toolName, rel);
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
abs,
|
|
127
|
+
real,
|
|
128
|
+
given: rel,
|
|
129
|
+
rel: toPosix(path.relative(rootResolved, abs)),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** A path relative to the workspace root, with `/` separators on every OS. */
|
|
133
|
+
export function toPosix(p) {
|
|
134
|
+
return path.sep === "/" ? p : p.split(path.sep).join("/");
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* True when `candidate` is `parent` or lives underneath it. Both must already
|
|
138
|
+
* be absolute. Used to keep an archive entry, or a symlink inside an
|
|
139
|
+
* extracted tree, from reaching outside its destination.
|
|
140
|
+
*/
|
|
141
|
+
export function isInside(parent, candidate) {
|
|
142
|
+
const p = path.resolve(parent);
|
|
143
|
+
const c = path.resolve(candidate);
|
|
144
|
+
return c === p || c.startsWith(`${p}${path.sep}`);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Reject an archive member name that would escape its destination: an
|
|
148
|
+
* absolute path, a Windows drive prefix, or any `..` segment. Checked on the
|
|
149
|
+
* NAME, before extraction, because after extraction the damage is done.
|
|
150
|
+
*/
|
|
151
|
+
export function archiveEntryEscapes(name) {
|
|
152
|
+
const normalized = name.replace(/\\/g, "/");
|
|
153
|
+
if (normalized.startsWith("/"))
|
|
154
|
+
return true;
|
|
155
|
+
if (/^[A-Za-z]:/.test(normalized))
|
|
156
|
+
return true;
|
|
157
|
+
return normalized.split("/").some((segment) => segment === "..");
|
|
158
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@crewhaus/tool-secure",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Deterministic security tools: PII detection and redaction, secret scanning, prompt-injection heuristics, policy checks and evidence hashing",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"test": "bun test src"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@crewhaus/errors": "0.7.0",
|
|
19
|
+
"@crewhaus/tool-builder": "0.7.0",
|
|
20
|
+
"@crewhaus/tool-catalog": "0.7.0",
|
|
21
|
+
"zod": "^3.23.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@crewhaus/tool-executor": "workspace:*"
|
|
25
|
+
},
|
|
26
|
+
"license": "Apache-2.0",
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "Max Meier",
|
|
29
|
+
"email": "max@crewhaus.ai",
|
|
30
|
+
"url": "https://crewhaus.ai"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/crewhaus/factory.git",
|
|
35
|
+
"directory": "packages/tool-secure"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/crewhaus/factory/tree/main/packages/tool-secure#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/crewhaus/factory/issues"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
45
|
+
}
|