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/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
+ }