opencode-v2-guard 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +227 -3
- package/docs/deps-spec.md +106 -0
- package/docs/skill-guard-spec.md +120 -0
- package/package.json +45 -4
- package/src/audit.ts +26 -0
- package/src/config.ts +81 -0
- package/src/deps.ts +226 -0
- package/src/glob.ts +84 -0
- package/src/guard.ts +431 -0
- package/src/index.ts +11 -0
- package/src/patterns.ts +120 -0
- package/src/scan.ts +252 -0
- package/src/skillcheck.ts +44 -0
package/src/scan.ts
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { DEFAULT_PATTERNS, type Pattern } from "./patterns.ts"
|
|
2
|
+
|
|
3
|
+
export interface Match {
|
|
4
|
+
start: number
|
|
5
|
+
end: number
|
|
6
|
+
category: string
|
|
7
|
+
/** id of the pattern that produced this match */
|
|
8
|
+
matchedBy: string
|
|
9
|
+
/** the raw matched text */
|
|
10
|
+
raw: string
|
|
11
|
+
/** replacement text */
|
|
12
|
+
mask: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const compiled = new Map<string, RegExp>()
|
|
16
|
+
|
|
17
|
+
function compile(p: Pattern): RegExp {
|
|
18
|
+
let re = compiled.get(p.id)
|
|
19
|
+
if (!re) {
|
|
20
|
+
const flags = [...new Set((p.flags ?? "") + "gd")].join("")
|
|
21
|
+
re = new RegExp(p.regex, flags)
|
|
22
|
+
compiled.set(p.id, re)
|
|
23
|
+
}
|
|
24
|
+
return re
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function isLuhnValid(text: string): boolean {
|
|
28
|
+
const digits = text.replace(/\D/g, "")
|
|
29
|
+
if (digits.length < 13) return false
|
|
30
|
+
let sum = 0
|
|
31
|
+
let dbl = false
|
|
32
|
+
for (let i = digits.length - 1; i >= 0; i--) {
|
|
33
|
+
let d = digits.charCodeAt(i) - 48
|
|
34
|
+
if (dbl) {
|
|
35
|
+
d *= 2
|
|
36
|
+
if (d > 9) d -= 9
|
|
37
|
+
}
|
|
38
|
+
sum += d
|
|
39
|
+
dbl = !dbl
|
|
40
|
+
}
|
|
41
|
+
return sum % 10 === 0
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function maskFor(p: Pattern, raw: string): string {
|
|
45
|
+
const label = `<REDACTED:${p.category}>`
|
|
46
|
+
if (p.keepLast && raw.length > p.keepLast + 4) {
|
|
47
|
+
return `<REDACTED:${p.category}:${raw.slice(0, 3)}…${raw.slice(-p.keepLast)}>`
|
|
48
|
+
}
|
|
49
|
+
return label
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function collect(p: Pattern, text: string): Match[] {
|
|
53
|
+
const re = compile(p)
|
|
54
|
+
re.lastIndex = 0
|
|
55
|
+
const out: Match[] = []
|
|
56
|
+
let m: RegExpExecArray | null
|
|
57
|
+
while ((m = re.exec(text)) !== null) {
|
|
58
|
+
const group = p.maskGroup ?? 0
|
|
59
|
+
const idx = m.indices?.[group]
|
|
60
|
+
const raw = m[group] ?? m[0]
|
|
61
|
+
if (!idx || raw === undefined || raw === "") {
|
|
62
|
+
if (m[0] === "") re.lastIndex++
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
if (p.luhn && !isLuhnValid(raw)) {
|
|
66
|
+
if (m[0] === "") re.lastIndex++
|
|
67
|
+
continue
|
|
68
|
+
}
|
|
69
|
+
if (p.validate && !p.validate(raw, text, idx[0], idx[1])) {
|
|
70
|
+
if (m[0] === "") re.lastIndex++
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
out.push({ start: idx[0], end: idx[1], category: p.category, matchedBy: p.id, raw, mask: maskFor(p, raw) })
|
|
74
|
+
if (m[0] === "") re.lastIndex++
|
|
75
|
+
}
|
|
76
|
+
return out
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- encoded-blob detection (base64/hex evasion) ---
|
|
80
|
+
// A blob that decodes into printable text matching a known pattern is masked
|
|
81
|
+
// as a whole. Depth is 1 (no nested decoding) and hard limits bound the cost.
|
|
82
|
+
const ENCODED_MAX_BLOBS = 24
|
|
83
|
+
const ENCODED_MAX_BLOB_CHARS = 8192
|
|
84
|
+
const ENCODED_MIN_DECODED_BYTES = 8
|
|
85
|
+
|
|
86
|
+
/** printable ratio threshold for treating decoded bytes as text (UTF-8 high bytes allowed) */
|
|
87
|
+
const ENCODED_PRINTABLE_RATIO = 0.9
|
|
88
|
+
|
|
89
|
+
function isMostlyPrintable(bytes: Uint8Array): boolean {
|
|
90
|
+
if (bytes.length < ENCODED_MIN_DECODED_BYTES) return false
|
|
91
|
+
let ok = 0
|
|
92
|
+
for (const b of bytes) {
|
|
93
|
+
if (b === 9 || b === 10 || b === 13 || (b >= 32 && b <= 126) || b >= 128) ok++
|
|
94
|
+
}
|
|
95
|
+
return ok / bytes.length >= ENCODED_PRINTABLE_RATIO
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** strict decode: returns bytes only if re-encoding reproduces the input exactly */
|
|
99
|
+
function decodeBase64Strict(raw: string): Uint8Array | null {
|
|
100
|
+
const norm = raw.replace(/=+$/, "")
|
|
101
|
+
if (norm.length % 4 === 1) return null
|
|
102
|
+
let bin: string
|
|
103
|
+
try {
|
|
104
|
+
bin = atob(norm)
|
|
105
|
+
} catch {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
let re: string
|
|
109
|
+
try {
|
|
110
|
+
re = btoa(bin).replace(/=+$/, "")
|
|
111
|
+
} catch {
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
return re === norm ? new Uint8Array([...bin].map((c) => c.charCodeAt(0))) : null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function decodeHexStrict(raw: string): Uint8Array | null {
|
|
118
|
+
const h = raw.startsWith("0x") || raw.startsWith("0X") ? raw.slice(2) : raw
|
|
119
|
+
if (h.length % 2 !== 0 || h.length < 16 || !/^[0-9a-fA-F]+$/.test(h)) return null
|
|
120
|
+
const bytes = new Uint8Array(h.length / 2)
|
|
121
|
+
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16)
|
|
122
|
+
let re = ""
|
|
123
|
+
for (const b of bytes) re += b.toString(16).padStart(2, "0")
|
|
124
|
+
return re === h.toLowerCase() ? bytes : null
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface EncodedBlob {
|
|
128
|
+
start: number
|
|
129
|
+
end: number
|
|
130
|
+
decoded: string
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function findEncodedBlobs(text: string, isProtected: (s: number, e: number) => boolean): EncodedBlob[] {
|
|
134
|
+
const out: EncodedBlob[] = []
|
|
135
|
+
// same span may match several encodings (a hex string is also valid base64);
|
|
136
|
+
// keep every distinct interpretation and let the caller pick by decoded match
|
|
137
|
+
const seen = new Set<string>()
|
|
138
|
+
const consider = (m: RegExpExecArray, decode: (raw: string) => Uint8Array | null): boolean => {
|
|
139
|
+
if (out.length >= ENCODED_MAX_BLOBS) return false
|
|
140
|
+
const raw = m[0]
|
|
141
|
+
if (raw.length > ENCODED_MAX_BLOB_CHARS) return true
|
|
142
|
+
const start = m.index
|
|
143
|
+
const end = start + raw.length
|
|
144
|
+
if (isProtected(start, end)) return true
|
|
145
|
+
const bytes = decode(raw)
|
|
146
|
+
if (!bytes || !isMostlyPrintable(bytes)) return true
|
|
147
|
+
const decoded = new TextDecoder().decode(bytes)
|
|
148
|
+
const key = `${start}:${end}:${decoded}`
|
|
149
|
+
if (seen.has(key)) return true
|
|
150
|
+
seen.add(key)
|
|
151
|
+
out.push({ start, end, decoded })
|
|
152
|
+
return true
|
|
153
|
+
}
|
|
154
|
+
// standard and url-safe base64
|
|
155
|
+
let m: RegExpExecArray | null
|
|
156
|
+
const b64 = /[A-Za-z0-9+/]{16,}={0,2}/g
|
|
157
|
+
while ((m = b64.exec(text)) !== null) {
|
|
158
|
+
if (!consider(m, decodeBase64Strict)) break
|
|
159
|
+
if (m[0] === "") b64.lastIndex++
|
|
160
|
+
}
|
|
161
|
+
const b64url = /[A-Za-z0-9_-]{16,}={0,2}/g
|
|
162
|
+
while ((m = b64url.exec(text)) !== null) {
|
|
163
|
+
if (!consider(m, (raw) => decodeBase64Strict(raw.replace(/-/g, "+").replace(/_/g, "/")))) break
|
|
164
|
+
if (m[0] === "") b64url.lastIndex++
|
|
165
|
+
}
|
|
166
|
+
const hex = /(?:0[xX])?[0-9a-fA-F]{16,}/g
|
|
167
|
+
while ((m = hex.exec(text)) !== null) {
|
|
168
|
+
if (!consider(m, decodeHexStrict)) break
|
|
169
|
+
if (m[0] === "") hex.lastIndex++
|
|
170
|
+
}
|
|
171
|
+
return out
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Find sensitive data in text. Earlier patterns in the list win overlaps
|
|
176
|
+
* (specific beats generic: jwt before credential-assignment, etc.).
|
|
177
|
+
*/
|
|
178
|
+
export function scan(text: string, patterns: readonly Pattern[] = DEFAULT_PATTERNS): Match[] {
|
|
179
|
+
return scanInner(text, patterns, true)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function scanInner(text: string, patterns: readonly Pattern[], allowEncoded: boolean): Match[] {
|
|
183
|
+
if (!text) return []
|
|
184
|
+
// already-masked regions are untouchable: no pattern may fire inside a placeholder
|
|
185
|
+
const protectedSpans: Array<[number, number]> = []
|
|
186
|
+
const ph = /<REDACTED:[a-z-]+(:[^<>]*)?>/g
|
|
187
|
+
let pm: RegExpExecArray | null
|
|
188
|
+
while ((pm = ph.exec(text)) !== null) protectedSpans.push([pm.index, pm.index + pm[0].length])
|
|
189
|
+
const isProtected = (start: number, end: number) =>
|
|
190
|
+
protectedSpans.some(([s, e]) => start < e && end > s)
|
|
191
|
+
|
|
192
|
+
// encoded blobs (base64/hex) are decoded and re-scanned; a hit masks the whole blob.
|
|
193
|
+
// The recursive scan runs with allowEncoded=false so decoding depth stays at 1.
|
|
194
|
+
// A span is claimed only when the decoded content actually matches a pattern —
|
|
195
|
+
// otherwise another interpretation of the same span (hex string read as base64)
|
|
196
|
+
// must get its chance.
|
|
197
|
+
const encoded: Match[] = []
|
|
198
|
+
const claimed: Array<[number, number]> = []
|
|
199
|
+
if (allowEncoded) {
|
|
200
|
+
for (const blob of findEncodedBlobs(text, isProtected)) {
|
|
201
|
+
if (claimed.some(([s, e]) => blob.start < e && blob.end > s)) continue
|
|
202
|
+
const decodedMatches = scanInner(blob.decoded, patterns, false)
|
|
203
|
+
if (decodedMatches.length === 0) continue
|
|
204
|
+
claimed.push([blob.start, blob.end])
|
|
205
|
+
const first = decodedMatches[0]
|
|
206
|
+
encoded.push({
|
|
207
|
+
start: blob.start,
|
|
208
|
+
end: blob.end,
|
|
209
|
+
category: first.category,
|
|
210
|
+
matchedBy: `${first.matchedBy}:encoded`,
|
|
211
|
+
raw: text.slice(blob.start, blob.end),
|
|
212
|
+
mask: `<REDACTED:${first.category}:encoded>`,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const hitsEncoded = (start: number, end: number) =>
|
|
217
|
+
encoded.some((e) => start < e.end && end > e.start)
|
|
218
|
+
|
|
219
|
+
const all: { order: number; match: Match }[] = []
|
|
220
|
+
patterns.forEach((p, order) => {
|
|
221
|
+
for (const match of collect(p, text)) {
|
|
222
|
+
if (isProtected(match.start, match.end) || hitsEncoded(match.start, match.end)) continue
|
|
223
|
+
all.push({ order, match })
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
for (const e of encoded) all.push({ order: -1, match: e })
|
|
227
|
+
// walk left-to-right in position order; at the same start the earlier
|
|
228
|
+
// (more specific) pattern wins. A wider match starting earlier accepts
|
|
229
|
+
// first and swallows anything nested inside it, so containers like
|
|
230
|
+
// connection-string mask their whole span including embedded keys.
|
|
231
|
+
all.sort((a, b) => a.match.start - b.match.start || a.order - b.order || b.match.end - a.match.end)
|
|
232
|
+
const accepted: Match[] = []
|
|
233
|
+
let cursor = -1
|
|
234
|
+
for (const { match } of all) {
|
|
235
|
+
if (match.start >= cursor) {
|
|
236
|
+
accepted.push(match)
|
|
237
|
+
cursor = match.end
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return accepted
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function redact(text: string, patterns: readonly Pattern[] = DEFAULT_PATTERNS): { text: string; matches: Match[] } {
|
|
244
|
+
const matches = scan(text, patterns)
|
|
245
|
+
if (matches.length === 0) return { text, matches }
|
|
246
|
+
let out = text
|
|
247
|
+
for (let i = matches.length - 1; i >= 0; i--) {
|
|
248
|
+
const m = matches[i]
|
|
249
|
+
out = out.slice(0, m.start) + m.mask + out.slice(m.end)
|
|
250
|
+
}
|
|
251
|
+
return { text: out, matches }
|
|
252
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import { scan } from "./scan.ts"
|
|
4
|
+
import { DEFAULT_PATTERNS, type Pattern } from "./patterns.ts"
|
|
5
|
+
|
|
6
|
+
const SKILL_MAX_BYTES = 5_000_000
|
|
7
|
+
|
|
8
|
+
export interface SkillFinding {
|
|
9
|
+
/** directory name of the skill, e.g. "some-skill" */
|
|
10
|
+
skill: string
|
|
11
|
+
count: number
|
|
12
|
+
categories: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Scan installed skills (~/.config/opencode/skills/<name>/SKILL.md) for prompt
|
|
17
|
+
* injection and secrets. Returns counters only — never raw fragments, so the
|
|
18
|
+
* report itself cannot leak the injected content. Fails silent.
|
|
19
|
+
*/
|
|
20
|
+
export function scanSkillDir(
|
|
21
|
+
dir: string,
|
|
22
|
+
patterns: readonly Pattern[] = DEFAULT_PATTERNS,
|
|
23
|
+
exempt: readonly string[] = [],
|
|
24
|
+
): SkillFinding[] {
|
|
25
|
+
try {
|
|
26
|
+
const out: SkillFinding[] = []
|
|
27
|
+
for (const entry of readdirSync(dir).sort()) {
|
|
28
|
+
if (exempt.includes(entry)) continue
|
|
29
|
+
try {
|
|
30
|
+
const file = join(dir, entry, "SKILL.md")
|
|
31
|
+
if (statSync(file).size > SKILL_MAX_BYTES) continue
|
|
32
|
+
const text = readFileSync(file, "utf8")
|
|
33
|
+
const matches = scan(text, patterns)
|
|
34
|
+
if (matches.length === 0) continue
|
|
35
|
+
out.push({ skill: entry, count: matches.length, categories: [...new Set(matches.map((m) => m.category))].sort() })
|
|
36
|
+
} catch {
|
|
37
|
+
continue // not a skill dir, unreadable file, race with deletion, etc.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return out
|
|
41
|
+
} catch {
|
|
42
|
+
return []
|
|
43
|
+
}
|
|
44
|
+
}
|