oc-one-guard 0.2.0 → 0.3.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 +1 -2
- package/config.example.json +0 -6
- package/package.json +1 -1
- package/src/config.js +0 -12
- package/src/defaults.js +0 -12
- package/src/guard.js +9 -83
- package/src/runtime.js +0 -33
- package/src/state.js +0 -72
- package/src/tools.js +0 -5
- package/src/blocked-hosts.js +0 -61
package/README.md
CHANGED
|
@@ -34,7 +34,7 @@ Published on npm as [`oc-one-guard`](https://www.npmjs.com/package/oc-one-guard)
|
|
|
34
34
|
{
|
|
35
35
|
"plugin": [
|
|
36
36
|
[
|
|
37
|
-
"oc-one-guard@0.
|
|
37
|
+
"oc-one-guard@0.3.0",
|
|
38
38
|
{
|
|
39
39
|
"endpoint": "http://127.0.0.1:8081/v1/systemone",
|
|
40
40
|
"model": "h2o-lightning-4b"
|
|
@@ -68,7 +68,6 @@ An earlier synthetic guard run requested **H2O Lightning 4B** (the served model
|
|
|
68
68
|
|---|---|
|
|
69
69
|
| Benign calls scored by H2O | 17 allowed, **9 blocked** |
|
|
70
70
|
| Malicious calls scored by H2O | **24 blocked**, 0 allowed |
|
|
71
|
-
| Malicious call skipped by the prefilter | **1 allowed** without model review |
|
|
72
71
|
|
|
73
72
|
The run was **incomplete**: 45 requests timed out across the direct and guard tests. Timeout blocks aren't model detections, and these small synthetic samples aren't production rates. [See the cases and full breakdown](https://github.com/HumanFace-Tech/oc-one-guard/blob/main/docs/research/h2o-synthetic-evaluation.md).
|
|
74
73
|
|
package/config.example.json
CHANGED
|
@@ -19,12 +19,6 @@
|
|
|
19
19
|
"redactArguments": true,
|
|
20
20
|
"cacheEnabled": true,
|
|
21
21
|
"cacheTtlMs": 30000,
|
|
22
|
-
"prefilter": true,
|
|
23
|
-
"skipTools": [],
|
|
24
|
-
"deniedTools": [],
|
|
25
|
-
"allowedTools": [],
|
|
26
|
-
"allowedHosts": [],
|
|
27
|
-
"blockedHosts": [],
|
|
28
22
|
"maxStateChars": 8000,
|
|
29
23
|
"maxArgChars": 2000,
|
|
30
24
|
"stateTemplate": "proposed agent tool call\ntool: {tool}\narguments: {arguments}",
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -202,13 +202,6 @@ export function normalizeConfig(raw) {
|
|
|
202
202
|
}
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
-
const denied = new Set(merged.deniedTools ?? [])
|
|
206
|
-
for (const tool of merged.skipTools ?? []) {
|
|
207
|
-
if (denied.has(tool)) {
|
|
208
|
-
errors.push(`tool "${tool}" is on both the deny list and the skip list`)
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
205
|
if (errors.length) {
|
|
213
206
|
throw new Error(`[oc-one-guard] Invalid configuration: ${errors.join("; ")}`)
|
|
214
207
|
}
|
|
@@ -221,11 +214,6 @@ export function normalizeConfig(raw) {
|
|
|
221
214
|
merged.cacheTtlMs = Math.max(0, Math.floor(Number(merged.cacheTtlMs) || 0))
|
|
222
215
|
merged.auditGenerations = Math.max(1, Math.floor(Number(merged.auditGenerations) || DEFAULTS.auditGenerations))
|
|
223
216
|
|
|
224
|
-
if (!Array.isArray(merged.allowedTools)) merged.allowedTools = []
|
|
225
|
-
if (!Array.isArray(merged.deniedTools)) merged.deniedTools = []
|
|
226
|
-
if (!Array.isArray(merged.skipTools)) merged.skipTools = []
|
|
227
|
-
if (!Array.isArray(merged.allowedHosts)) merged.allowedHosts = []
|
|
228
|
-
if (!Array.isArray(merged.blockedHosts)) merged.blockedHosts = []
|
|
229
217
|
if (!Array.isArray(merged.questions)) merged.questions = []
|
|
230
218
|
|
|
231
219
|
return merged
|
package/src/defaults.js
CHANGED
|
@@ -47,12 +47,6 @@
|
|
|
47
47
|
* @property {boolean} redactArguments Strip secret-looking values before they leave the machine.
|
|
48
48
|
* @property {boolean} cacheEnabled Cache identical verdicts for a short window.
|
|
49
49
|
* @property {number} cacheTtlMs
|
|
50
|
-
* @property {boolean} prefilter Skip the judge for calls with no outbound surface.
|
|
51
|
-
* @property {string[]} skipTools Tool names never inspected.
|
|
52
|
-
* @property {string[]} deniedTools Tool names always blocked.
|
|
53
|
-
* @property {string[]} allowedTools Tool names always allowed, judge not consulted.
|
|
54
|
-
* @property {string[]} allowedHosts Hosts treated as trusted destinations.
|
|
55
|
-
* @property {string[]} blockedHosts Hosts always treated as exfiltration destinations.
|
|
56
50
|
* @property {number} maxStateChars Cap on the rendered state sent to the judge.
|
|
57
51
|
* @property {number} maxArgChars Per-argument truncation before rendering.
|
|
58
52
|
* @property {QuestionConfig[]} questions Judge questions.
|
|
@@ -84,12 +78,6 @@ export const DEFAULTS = {
|
|
|
84
78
|
redactArguments: true,
|
|
85
79
|
cacheEnabled: true,
|
|
86
80
|
cacheTtlMs: 30000,
|
|
87
|
-
prefilter: true,
|
|
88
|
-
skipTools: [],
|
|
89
|
-
deniedTools: [],
|
|
90
|
-
allowedTools: [],
|
|
91
|
-
allowedHosts: [],
|
|
92
|
-
blockedHosts: [],
|
|
93
81
|
maxStateChars: 8000,
|
|
94
82
|
maxArgChars: 2000,
|
|
95
83
|
stateTemplate:
|
package/src/guard.js
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
* The guard engine: decide, cache, audit.
|
|
3
3
|
*
|
|
4
4
|
* Decision order:
|
|
5
|
-
* 1.
|
|
6
|
-
* 2. The
|
|
7
|
-
* 3.
|
|
8
|
-
* 4. Failures fall back to `failMode`; unclassifiable verdicts to `unknownMode`.
|
|
5
|
+
* 1. The judge is asked about every call; no code rule decides for it.
|
|
6
|
+
* 2. The verdict is compared against `denyThreshold` and `escalateThreshold`.
|
|
7
|
+
* 3. Failures fall back to `failMode`; unclassifiable verdicts to `unknownMode`.
|
|
9
8
|
*
|
|
10
9
|
* The cache is keyed on the *full* call as well as the rendered state. The
|
|
11
10
|
* rendered state is truncated, so two different commands could otherwise
|
|
@@ -18,9 +17,9 @@ import { posix } from "node:path"
|
|
|
18
17
|
import { denyProbability, findDenyOption, JudgeError, requestDecision } from "./judge.js"
|
|
19
18
|
import { sharedCache } from "./shared.js"
|
|
20
19
|
import { requireScope } from "./scope.js"
|
|
21
|
-
import { extractHosts, fullText,
|
|
22
|
-
import { getWrite, recordWrite, referencedWrites,
|
|
23
|
-
import { canonicalTool,
|
|
20
|
+
import { extractHosts, fullText, hasTransferPrimitive, renderState } from "./state.js"
|
|
21
|
+
import { getWrite, recordWrite, referencedWrites, markUncertainPatch, refreshWrite, ambiguousPath } from "./written.js"
|
|
22
|
+
import { canonicalTool, pathsOf } from "./tools.js"
|
|
24
23
|
|
|
25
24
|
// Patch headers are line-oriented. Never interpret content lines as paths.
|
|
26
25
|
function trackMutation(name, args, directory, scope) {
|
|
@@ -264,89 +263,20 @@ export function createGuard(config, deps = {}) {
|
|
|
264
263
|
})
|
|
265
264
|
}
|
|
266
265
|
|
|
267
|
-
//
|
|
268
|
-
// is only a performance optimisation.
|
|
269
|
-
if (listsTool(config.deniedTools, tool)) {
|
|
270
|
-
return new GuardDecision({
|
|
271
|
-
verdict: VERDICT.DENY,
|
|
272
|
-
reason: `Tool "${tool}" is on the deny list.`,
|
|
273
|
-
tool,
|
|
274
|
-
hosts,
|
|
275
|
-
durationMs: 0,
|
|
276
|
-
mode: "tool-deny",
|
|
277
|
-
})
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// Invalidate evidence even if policy later skips this tool or the tool fails.
|
|
266
|
+
// Writes are tracked only as context for the judge; nothing here decides.
|
|
281
267
|
trackMutation(tool, args, deps.directory, scope)
|
|
282
268
|
|
|
283
|
-
if (listsTool(config.skipTools, tool)) {
|
|
284
|
-
return new GuardDecision({
|
|
285
|
-
verdict: VERDICT.ALLOW,
|
|
286
|
-
reason: `Tool "${tool}" is on the skip list.`,
|
|
287
|
-
tool,
|
|
288
|
-
hosts,
|
|
289
|
-
durationMs: 0,
|
|
290
|
-
mode: "skip",
|
|
291
|
-
})
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
if (listsTool(config.allowedTools, tool)) {
|
|
295
|
-
return new GuardDecision({
|
|
296
|
-
verdict: VERDICT.ALLOW,
|
|
297
|
-
reason: `Tool "${tool}" is on the allow list.`,
|
|
298
|
-
tool,
|
|
299
|
-
hosts,
|
|
300
|
-
durationMs: 0,
|
|
301
|
-
mode: "tool-allow",
|
|
302
|
-
})
|
|
303
|
-
}
|
|
304
|
-
|
|
305
269
|
const isShell = tool === "shell"
|
|
306
270
|
const command = typeof args?.command === "string" ? args.command : ""
|
|
307
271
|
const base = typeof deps.directory === "string" && posix.isAbsolute(deps.directory) ? deps.directory : undefined
|
|
308
272
|
const workdir = typeof args?.workdir === "string" && args.workdir ? args.workdir : undefined
|
|
309
273
|
const directory = workdir ? (posix.isAbsolute(workdir) ? posix.normalize(workdir) : base ? posix.resolve(base, workdir) : undefined) : base
|
|
310
|
-
const words = command.match(/'[^']*'|"[^"]*"|[^\s;&|()<>]+/g) ?? []
|
|
311
|
-
const ambiguous = ambiguousPath(workdir, base) || ambiguousPath(base) || words.some(word => {
|
|
312
|
-
const value = word.replace(/^(['"])(.*)\1$/, "$2")
|
|
313
|
-
return value.includes("/") && ambiguousPath(value, directory)
|
|
314
|
-
})
|
|
315
|
-
if (isShell && ambiguous && (hasWrites(scope) || hasUncertainPatch(scope))) {
|
|
316
|
-
const decision = new GuardDecision({ verdict: VERDICT.DENY, reason: "Ambiguous filesystem path prevents reliable staged-file evidence.", tool, hosts, durationMs: now() - started, mode: "staged-unknown" })
|
|
317
|
-
writeAudit(decision, {})
|
|
318
|
-
return decision
|
|
319
|
-
}
|
|
320
274
|
const hits = isShell ? referencedWrites(command, directory, scope) : []
|
|
321
275
|
const snapshots = new Map(hits.map(hit => {
|
|
322
276
|
const entry = getWrite(hit.path, undefined, scope)
|
|
323
277
|
const content = entry.content === null ? refreshWrite(hit.path, scope) : entry.content
|
|
324
278
|
return [hit.path, { ...entry, content: content ?? null }]
|
|
325
279
|
}))
|
|
326
|
-
const unresolvedScript = runsLocalScript(command) && hasWrites(scope) &&
|
|
327
|
-
((!directory && !command.match(/(?:^|\s)\//)) || /(?:^|[;&|]\s*)\s*cd(?:\s|$)|[$`*?\[\]{}\\]/.test(command))
|
|
328
|
-
const unknownStaged = isShell && (hits.some(hit => snapshots.get(hit.path)?.content === null) || (hasPendingMutation(scope) && (hits.length > 0 || runsLocalScript(command))) || (hasUncertainPatch(scope) && runsLocalScript(command)) || unresolvedScript)
|
|
329
|
-
if (unknownStaged) {
|
|
330
|
-
const decision = new GuardDecision({ verdict: VERDICT.DENY, reason: "Tracked file content is unknown after a modification.", tool, hosts, durationMs: now() - started, mode: "staged-unknown" })
|
|
331
|
-
writeAudit(decision, {})
|
|
332
|
-
return decision
|
|
333
|
-
}
|
|
334
|
-
const revision = writesRevision(scope)
|
|
335
|
-
const snapshotsValid = () => (!(hits.length || runsLocalScript(command)) || !hasPendingMutation(scope)) && [...snapshots].every(([path, snapshot]) => {
|
|
336
|
-
const entry = getWrite(path, undefined, scope)
|
|
337
|
-
return entry && !ambiguousPath(path) && (entry.content === null ? refreshWrite(path, scope) : entry.content) === snapshot.content
|
|
338
|
-
})
|
|
339
|
-
|
|
340
|
-
if (config.prefilter && !hasOutboundSurface(tool, args, directory, scope)) {
|
|
341
|
-
return new GuardDecision({
|
|
342
|
-
verdict: VERDICT.ALLOW,
|
|
343
|
-
reason: `Tool "${tool}" has no outbound surface.`,
|
|
344
|
-
tool,
|
|
345
|
-
hosts,
|
|
346
|
-
durationMs: 0,
|
|
347
|
-
mode: "prefilter",
|
|
348
|
-
})
|
|
349
|
-
}
|
|
350
280
|
|
|
351
281
|
let state
|
|
352
282
|
try {
|
|
@@ -362,7 +292,8 @@ export function createGuard(config, deps = {}) {
|
|
|
362
292
|
scope,
|
|
363
293
|
})
|
|
364
294
|
} catch {
|
|
365
|
-
const
|
|
295
|
+
const blocked = config.failMode === "block"
|
|
296
|
+
const decision = new GuardDecision({ verdict: blocked ? VERDICT.DENY : VERDICT.ALLOW, reason: `Call state could not be rendered for the judge. failMode="${config.failMode}".`, tool, hosts, durationMs: now() - started, mode: blocked ? "fail-block" : "fail-allow" })
|
|
366
297
|
writeAudit(decision, {})
|
|
367
298
|
return decision
|
|
368
299
|
}
|
|
@@ -372,7 +303,6 @@ export function createGuard(config, deps = {}) {
|
|
|
372
303
|
const cached = cacheGet(key)
|
|
373
304
|
if (cached?.pending) {
|
|
374
305
|
const decision = await cached.pending
|
|
375
|
-
if (isShell && (revision !== writesRevision(scope) || !snapshotsValid())) return evaluate(input)
|
|
376
306
|
return new GuardDecision({ ...decision, durationMs: now() - started, mode: `${decision.mode}:coalesced` })
|
|
377
307
|
}
|
|
378
308
|
if (cached?.value) {
|
|
@@ -399,10 +329,6 @@ export function createGuard(config, deps = {}) {
|
|
|
399
329
|
}
|
|
400
330
|
throw error
|
|
401
331
|
}
|
|
402
|
-
if (isShell && (revision !== writesRevision(scope) || !snapshotsValid())) {
|
|
403
|
-
if (config.cacheEnabled && cache.get(key)?.pending === pending) cache.delete(key)
|
|
404
|
-
return evaluate(input)
|
|
405
|
-
}
|
|
406
332
|
if (decision.mode === "judge" && decision.verdict !== VERDICT.ESCALATE && !decision.stop) cacheSet(key, { ...decision })
|
|
407
333
|
else if (config.cacheEnabled && cache.get(key)?.pending === pending) cache.delete(key)
|
|
408
334
|
return decision
|
package/src/runtime.js
CHANGED
|
@@ -7,7 +7,6 @@
|
|
|
7
7
|
|
|
8
8
|
import { createAuditLogger } from "./audit.js"
|
|
9
9
|
import { createGuard, VERDICT } from "./guard.js"
|
|
10
|
-
import { scanBlockedHosts } from "./blocked-hosts.js"
|
|
11
10
|
import { redactText } from "./redact.js"
|
|
12
11
|
import { beginMutation, endMutation, markUncertainPatch } from "./written.js"
|
|
13
12
|
import { createStopController } from "./stop.js"
|
|
@@ -142,40 +141,8 @@ export function createGuardRuntime(config, { client, directory, scope }) {
|
|
|
142
141
|
throw new Error("[oc-one-guard] Blocked tool call while conversation cancellation is pending.")
|
|
143
142
|
}
|
|
144
143
|
|
|
145
|
-
const scan = config.blockedHosts.length ? scanBlockedHosts(args, config.blockedHosts) : {}
|
|
146
|
-
if (scan.host || scan.limited) {
|
|
147
|
-
const decision = {
|
|
148
|
-
verdict: VERDICT.DENY,
|
|
149
|
-
reason: scan.host ? `Destination ${scan.host} is on the blocked-host list.` : "Blocked-host scan could not inspect all arguments (scan limit or inaccessible value).",
|
|
150
|
-
tool,
|
|
151
|
-
hosts: scan.host ? [scan.host] : [],
|
|
152
|
-
probability: undefined,
|
|
153
|
-
durationMs: 0,
|
|
154
|
-
mode: "host-block",
|
|
155
|
-
state: undefined,
|
|
156
|
-
}
|
|
157
|
-
audit(decision, {})
|
|
158
|
-
throw new Error(blockedMessage(decision, tool, args))
|
|
159
|
-
}
|
|
160
|
-
|
|
161
144
|
const mutation = config.enabled && isMutationTool(tool)
|
|
162
145
|
const key = typeof sessionID === "string" && typeof callID === "string" ? `${sessionID}\0${callID}` : undefined
|
|
163
|
-
// A token without a key can never be released, so it would deny later
|
|
164
|
-
// script runs until restart. Refuse the call instead.
|
|
165
|
-
if (mutation && !key) {
|
|
166
|
-
const decision = {
|
|
167
|
-
verdict: VERDICT.DENY,
|
|
168
|
-
reason: "The call has no session or call identifier, so its pending state cannot be tracked.",
|
|
169
|
-
tool,
|
|
170
|
-
hosts: [],
|
|
171
|
-
probability: undefined,
|
|
172
|
-
durationMs: 0,
|
|
173
|
-
mode: "uncorrelated-block",
|
|
174
|
-
state: undefined,
|
|
175
|
-
}
|
|
176
|
-
audit(decision, {})
|
|
177
|
-
throw new Error(blockedMessage(decision, tool, args))
|
|
178
|
-
}
|
|
179
146
|
const token = mutation ? beginMutation(scope) : undefined
|
|
180
147
|
if (token && key) {
|
|
181
148
|
const entries = pending.get(key) ?? []
|
package/src/state.js
CHANGED
|
@@ -22,29 +22,6 @@ import { canonicalTool, pathsOf } from "./tools.js"
|
|
|
22
22
|
|
|
23
23
|
const URL_PATTERN = /\b(?:https?|ftps?|sftp|scp|rsync|ssh|git):\/\/[^\s"'`<>|;&]+/gi
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
* Tools that cannot, on their own, move data off the machine.
|
|
27
|
-
*
|
|
28
|
-
* These are the only tools that skip the judge without further checks.
|
|
29
|
-
* Everything else — shell, webfetch, MCP tools, any tool added in a future
|
|
30
|
-
* OpenCode release — is judged.
|
|
31
|
-
*/
|
|
32
|
-
const LOCAL_ONLY_TOOLS = new Set([
|
|
33
|
-
"read",
|
|
34
|
-
"write",
|
|
35
|
-
"edit",
|
|
36
|
-
"apply_patch",
|
|
37
|
-
"glob",
|
|
38
|
-
"grep",
|
|
39
|
-
"list",
|
|
40
|
-
"todowrite",
|
|
41
|
-
"todoread",
|
|
42
|
-
"patch",
|
|
43
|
-
"question",
|
|
44
|
-
"plan_exit",
|
|
45
|
-
"invalid",
|
|
46
|
-
])
|
|
47
|
-
|
|
48
25
|
/**
|
|
49
26
|
* Tools whose arguments can contain a payload bound for a remote host.
|
|
50
27
|
*
|
|
@@ -301,55 +278,6 @@ export function writtenContent(tool, args) {
|
|
|
301
278
|
return ""
|
|
302
279
|
}
|
|
303
280
|
|
|
304
|
-
/**
|
|
305
|
-
* Decide whether a tool call has an outbound surface worth judging.
|
|
306
|
-
*
|
|
307
|
-
* The rule is an allowlist of known-local tools. Anything else — including
|
|
308
|
-
* MCP tools, `webfetch`, and any tool added in a future OpenCode release —
|
|
309
|
-
* is judged.
|
|
310
|
-
*
|
|
311
|
-
* A shell command is judged when it uses a network primitive, runs a file
|
|
312
|
-
* the agent has written, or mentions a written file at all.
|
|
313
|
-
*/
|
|
314
|
-
export function hasOutboundSurface(tool, args, directory, scope) {
|
|
315
|
-
requireScope(scope)
|
|
316
|
-
const name = canonicalTool(tool)
|
|
317
|
-
if (LOCAL_ONLY_TOOLS.has(name)) {
|
|
318
|
-
// A file write can stage a payload that a later call executes, so the
|
|
319
|
-
// content decides.
|
|
320
|
-
if (CONTENT_TOOLS.has(name)) {
|
|
321
|
-
const content = writtenContent(name, args)
|
|
322
|
-
return hasTransferPrimitive(content)
|
|
323
|
-
}
|
|
324
|
-
return false
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (name === "shell") {
|
|
328
|
-
const command = typeof args?.command === "string" ? args.command : ""
|
|
329
|
-
if (hasTransferPrimitive(command)) return true
|
|
330
|
-
if (runsLocalScript(command)) return true
|
|
331
|
-
// The command mentions a file the agent wrote earlier. It may now hold
|
|
332
|
-
// a payload, so it is judged.
|
|
333
|
-
if (referencedWrites(command, directory, scope).length) return true
|
|
334
|
-
return false
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
if (name === "webfetch") {
|
|
338
|
-
if (args && Object.keys(args).some((key) => !["url", "format", "timeout"].includes(key))) return true
|
|
339
|
-
// A GET can still carry data in the path or the query string.
|
|
340
|
-
const url = typeof args?.url === "string" ? args.url : ""
|
|
341
|
-
if (!url) return false
|
|
342
|
-
try {
|
|
343
|
-
const parsed = new URL(url)
|
|
344
|
-
return parsed.search.length > 1 || parsed.pathname.length > 1
|
|
345
|
-
} catch {
|
|
346
|
-
return true
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
return true
|
|
351
|
-
}
|
|
352
|
-
|
|
353
281
|
/**
|
|
354
282
|
* The contents of files the agent wrote that a command references.
|
|
355
283
|
*
|
package/src/tools.js
CHANGED
|
@@ -20,11 +20,6 @@ export function isMutationTool(tool) {
|
|
|
20
20
|
return ["write", "edit", "patch"].includes(canonicalTool(tool))
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export function listsTool(list, tool) {
|
|
24
|
-
const wanted = canonicalTool(tool)
|
|
25
|
-
return list.some(entry => typeof entry === "string" && canonicalTool(entry) === wanted)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
23
|
// 1.x write, edit and read take `filePath`; 2.x takes `path`. Every listed
|
|
29
24
|
// path counts as evidence, so a call that carries both is judged on both.
|
|
30
25
|
export function pathsOf(args) {
|
package/src/blocked-hosts.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { hostMatches } from "./guard.js"
|
|
2
|
-
|
|
3
|
-
// Bound both traversal and text scanning, including object keys. Exceeding a
|
|
4
|
-
// limit is a denial: an unseen suffix could contain a blocked destination.
|
|
5
|
-
const MAX_NODES = 10000
|
|
6
|
-
const MAX_DEPTH = 100
|
|
7
|
-
const MAX_CHARS = 1000000
|
|
8
|
-
// Stop at the authority, leaving paths and queries available for independent
|
|
9
|
-
// URL matches. Shell punctuation can also be valid URL credentials, so it
|
|
10
|
-
// must not truncate the authority before the URL parser sees it.
|
|
11
|
-
const URLS = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s/?#]+)/gi
|
|
12
|
-
const SHELL_PUNCTUATION = /["'`<>|;&()]/
|
|
13
|
-
const TRAILING_SHELL_PUNCTUATION = /["'`<>|;&()]+$/
|
|
14
|
-
|
|
15
|
-
export function scanBlockedHosts(args, patterns) {
|
|
16
|
-
const seen = new WeakSet()
|
|
17
|
-
const pending = [{ value: args, depth: 0 }]
|
|
18
|
-
let nodes = 0
|
|
19
|
-
let chars = 0
|
|
20
|
-
try {
|
|
21
|
-
while (pending.length) {
|
|
22
|
-
const { value, depth } = pending.pop()
|
|
23
|
-
if (++nodes > MAX_NODES || depth > MAX_DEPTH) return { limited: true }
|
|
24
|
-
if (typeof value === "string") {
|
|
25
|
-
chars += value.length
|
|
26
|
-
if (chars > MAX_CHARS) return { limited: true }
|
|
27
|
-
for (const match of value.matchAll(URLS)) {
|
|
28
|
-
// Check at most two interpretations: the complete authority (minus
|
|
29
|
-
// closing shell punctuation), and the prefix a shell could use.
|
|
30
|
-
// Never enumerate every punctuation prefix: work stays bounded.
|
|
31
|
-
const authority = match[2]
|
|
32
|
-
const candidates = [authority.replace(TRAILING_SHELL_PUNCTUATION, "")]
|
|
33
|
-
const delimiter = authority.search(SHELL_PUNCTUATION)
|
|
34
|
-
if (delimiter >= 0) candidates.push(authority.slice(0, delimiter))
|
|
35
|
-
for (const candidate of candidates) {
|
|
36
|
-
try {
|
|
37
|
-
const host = new URL(match[1] + candidate).hostname.toLowerCase().replace(/\.$/, "")
|
|
38
|
-
if (!host) return { limited: true }
|
|
39
|
-
if (hostMatches(host, patterns)) return { host }
|
|
40
|
-
} catch {
|
|
41
|
-
// An ambiguous/unparseable authority is not evidence of safety.
|
|
42
|
-
return { limited: true }
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
} else if (value && typeof value === "object" && !seen.has(value)) {
|
|
47
|
-
seen.add(value)
|
|
48
|
-
const keys = Object.keys(value)
|
|
49
|
-
if (nodes + pending.length + keys.length * 2 > MAX_NODES) return { limited: true }
|
|
50
|
-
for (const key of keys) {
|
|
51
|
-
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
52
|
-
if (!descriptor || !("value" in descriptor)) return { limited: true }
|
|
53
|
-
pending.push({ value: key, depth: depth + 1 }, { value: descriptor.value, depth: depth + 1 })
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return {}
|
|
58
|
-
} catch {
|
|
59
|
-
return { limited: true }
|
|
60
|
-
}
|
|
61
|
-
}
|