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/deps.ts ADDED
@@ -0,0 +1,226 @@
1
+ /**
2
+ * deps-guard (phase 2): intercept package installation commands.
3
+ * Deterministic pre-install checks only: known-malicious blocklist and
4
+ * typosquat detection (Levenshtein distance 1 to a curated top-name list).
5
+ * Post-install auditing (npm audit / OSV) is a later phase — it needs the
6
+ * project cwd, which the hook API does not guarantee.
7
+ */
8
+
9
+ export interface InstallPackage {
10
+ name: string
11
+ version?: string
12
+ }
13
+
14
+ export interface InstallTarget {
15
+ manager: "npm" | "yarn" | "pnpm" | "pip" | "cargo"
16
+ packages: InstallPackage[]
17
+ }
18
+
19
+ const MANAGERS = new Set(["npm", "yarn", "pnpm", "pip", "pip3", "cargo"])
20
+
21
+ const SUBCOMMANDS: Record<string, readonly string[]> = {
22
+ npm: ["install", "i"],
23
+ yarn: ["add"],
24
+ pnpm: ["add", "install"],
25
+ pip: ["install"],
26
+ pip3: ["install"],
27
+ cargo: ["add"],
28
+ }
29
+
30
+ /** flags that consume the NEXT token as their operand (never a package name) */
31
+ const FLAG_TAKES_ARG: Record<string, ReadonlySet<string>> = {
32
+ npm: new Set(["-w", "--workspace", "--prefix", "--include", "--cache", "--userconfig", "--registry"]),
33
+ yarn: new Set(["--cwd"]),
34
+ pnpm: new Set(["--filter", "-F", "--dir", "-C"]),
35
+ pip: new Set(["-r", "--requirement", "-e", "--editable", "-t", "--target", "-f", "--find-links", "-i", "--index-url", "--extra-index-url", "-c", "--constraint"]),
36
+ cargo: new Set(["--path", "--git", "--rev", "--branch", "--tag", "--manifest-path", "--registry"]),
37
+ }
38
+
39
+ /** incident names from supply-chain history; installs need explicit human approval */
40
+ export const BLOCKED_PACKAGES: ReadonlySet<string> = new Set([
41
+ "event-stream", // 2018: flatmap-stream payload (coinminer) via maintainer handover
42
+ "flatmap-stream", // the payload package itself
43
+ "node-ipc", // 2022: protestware (peacenotwar) in 9.2.x-11.0.0
44
+ "ua-parser-js", // 2021: account hijack, coinminer/password stealer versions
45
+ "coa", // 2021: hijack, script-based payload
46
+ "rc", // 2021: hijack, same campaign as coa
47
+ ])
48
+
49
+ /** curated popular npm names for typosquat distance-1 checks (119 unique) */
50
+ export const TRUSTED_TOP: ReadonlySet<string> = new Set([
51
+ "lodash", "react", "react-dom", "vue", "angular", "express", "axios", "moment",
52
+ "chalk", "commander", "debug", "request", "async", "underscore", "bluebird",
53
+ "ramda", "dayjs", "date-fns", "jquery", "bootstrap", "webpack", "babel-core",
54
+ "typescript", "eslint", "prettier", "jest", "mocha", "chai", "sinon", "supertest",
55
+ "mongoose", "sequelize", "knex", "typeorm", "prisma", "mysql", "pg", "redis",
56
+ "socket.io", "koa", "fastify", "hapi", "nestjs", "next", "nuxt", "gatsby",
57
+ "redux", "mobx", "zustand", "react-router", "react-query", "swr", "axios-retry",
58
+ "tailwindcss", "sass", "less", "postcss", "autoprefixer", "styled-components",
59
+ "graphql", "apollo-server", "urql", "dataloader", "jsonwebtoken", "bcrypt",
60
+ "crypto-js", "uuid", "nanoid", "semver", "dotenv", "cross-env", "rimraf",
61
+ "glob", "minimatch", "fs-extra", "path-to-regexp", "mime-types", "qs",
62
+ "body-parser", "cookie-parser", "cors", "helmet", "compression", "morgan",
63
+ "winston", "pino", "bunyan", "loglevel", "inquirer", "enquirer", "yargs",
64
+ "minimist", "yup", "joi", "zod", "ajv", "validator", "class-validator",
65
+ "nodemailer", "multer", "sharp", "jimp", "pdfkit", "puppeteer", "playwright",
66
+ "cheerio", "jsdom", "lodash-es", "core-js", "regenerator-runtime", "whatwg-fetch",
67
+ "node-fetch", "undici", "got", "ky", "phin", "superagent", "xml2js", "js-yaml",
68
+ "toml", "ini", "csv-parse", "xlsx", "exceljs", "archiver", "adm-zip", "tar",
69
+ "yauzl", "decompress", "ffmpeg-static", "canvas", "three", "d3",
70
+ "chart.js", "echarts", "pixi.js", "matter-js", "tone", "howler",
71
+ ])
72
+
73
+ function parseSegment(segment: string): InstallTarget | null {
74
+ const tokens = segment
75
+ .split(/[\s;&|()"'`]+/)
76
+ .filter((t) => t.length > 0)
77
+ let i = 0
78
+ // tolerate leading env assignments, `env`, and `sudo` with its flags
79
+ while (i < tokens.length) {
80
+ const t = tokens[i]
81
+ if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(t) || t === "env") {
82
+ i++
83
+ continue
84
+ }
85
+ if (t === "sudo") {
86
+ i++
87
+ while (i < tokens.length && tokens[i].startsWith("-")) {
88
+ const flag = tokens[i]
89
+ i++
90
+ if (flag === "-u" || flag === "-g" || flag === "-h" || flag === "-p") {
91
+ if (i < tokens.length) i++ // consume the flag's operand
92
+ }
93
+ }
94
+ continue
95
+ }
96
+ break
97
+ }
98
+ // Windows runners: npm.cmd, pnpm.cmd, pip.exe, yarn.ps1, ... — strip the script/extension suffix
99
+ const raw = tokens[i]
100
+ if (!raw) return null
101
+ const manager = raw.replace(/\.(?:cmd|ps1|exe|bat)$/i, "")
102
+ if (!MANAGERS.has(manager)) return null
103
+ const sub = tokens[i + 1]
104
+ if (!SUBCOMMANDS[manager]?.includes(sub)) return null
105
+ if (manager === "yarn" && tokens[i + 2] === "global") return null
106
+
107
+ const takesArg = FLAG_TAKES_ARG[manager]
108
+ const packages: InstallPackage[] = []
109
+ for (let j = i + 2; j < tokens.length; j++) {
110
+ const t = tokens[j]
111
+ if (t.startsWith("-")) {
112
+ if (t === "-g" || t === "--global") return null
113
+ if (takesArg?.has(t) && j + 1 < tokens.length) j++ // consume the flag's operand
114
+ continue
115
+ }
116
+ let name = t
117
+ let version: string | undefined
118
+ if (manager === "pip" || manager === "pip3") {
119
+ const m = /^([^=<>~!]+?)([=<>~!].*)?$/.exec(t)
120
+ if (!m || !m[1]) continue
121
+ name = m[1]
122
+ version = m[2] ? m[2].replace(/^[=<>~!]+/, "") : undefined
123
+ } else {
124
+ const at = t.lastIndexOf("@")
125
+ if (at > 0) {
126
+ name = t.slice(0, at)
127
+ version = t.slice(at + 1)
128
+ }
129
+ }
130
+ if (!name) continue
131
+ packages.push({ name, version })
132
+ }
133
+ if (packages.length === 0) return null
134
+ return { manager: manager === "pip3" ? "pip" : (manager as InstallTarget["manager"]), packages }
135
+ }
136
+
137
+ /**
138
+ * All install targets found in a (possibly compound) command — one per segment
139
+ * (segments split by && || ; | or newline). Empty array if none.
140
+ */
141
+ export function parseSegments(command: string): InstallTarget[] {
142
+ const out: InstallTarget[] = []
143
+ for (const segment of command.split(/&&|\|\||[;|]|\n/)) {
144
+ const hit = parseSegment(segment)
145
+ if (hit) out.push(hit)
146
+ }
147
+ return out
148
+ }
149
+
150
+ /** first install target in the command, or null */
151
+ export function parseInstallCommand(command: string): InstallTarget | null {
152
+ return parseSegments(command)[0] ?? null
153
+ }
154
+
155
+ export function levenshtein(a: string, b: string): number {
156
+ if (a === b) return 0
157
+ const m = a.length
158
+ const n = b.length
159
+ if (m === 0) return n
160
+ if (n === 0) return m
161
+ // optimal string alignment: transposition (ab->ba) costs 1 — catches typosquats like lodahs/lodash
162
+ const d: number[][] = Array.from({ length: m + 1 }, (_, i) => {
163
+ const row = new Array<number>(n + 1).fill(0)
164
+ row[0] = i
165
+ return row
166
+ })
167
+ for (let j = 1; j <= n; j++) d[0][j] = j
168
+ for (let i = 1; i <= m; i++) {
169
+ for (let j = 1; j <= n; j++) {
170
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
171
+ let best = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
172
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
173
+ best = Math.min(best, d[i - 2][j - 2] + 1)
174
+ }
175
+ d[i][j] = best
176
+ }
177
+ }
178
+ return d[m][n]
179
+ }
180
+
181
+ /** returns the trusted name this package is squatting, or null (case-insensitive) */
182
+ export function isTyposquat(name: string): string | null {
183
+ const n = name.toLowerCase()
184
+ if (n.startsWith("@") || n.length < 4) return null
185
+ if (TRUSTED_TOP.has(n)) return null
186
+ for (const trusted of TRUSTED_TOP) {
187
+ if (Math.abs(trusted.length - n.length) > 1) continue
188
+ if (levenshtein(n, trusted) === 1) return trusted
189
+ }
190
+ return null
191
+ }
192
+
193
+ export interface DepsVerdict {
194
+ name: string
195
+ reason: "blocklist" | "typosquat"
196
+ target?: string
197
+ }
198
+
199
+ /**
200
+ * Check a raw shell command; returns the first violation or null.
201
+ * extraBlocked: user names added to the blocklist; allowed: user names exempted from it.
202
+ */
203
+ export function checkInstall(command: string, extraBlocked: readonly string[] = [], allowed: readonly string[] = []): DepsVerdict | null {
204
+ const targets = parseSegments(command)
205
+ if (targets.length === 0) return null
206
+ const ok = new Set(allowed.map((p) => p.toLowerCase()))
207
+ const blocked = new Set([...BLOCKED_PACKAGES, ...extraBlocked].map((p) => p.toLowerCase()))
208
+ for (const target of targets) {
209
+ const npmish = target.manager === "npm" || target.manager === "yarn" || target.manager === "pnpm"
210
+ for (const pkg of target.packages) {
211
+ const lower = pkg.name.toLowerCase()
212
+ if (ok.has(lower)) continue
213
+ if (blocked.has(lower)) {
214
+ return { name: pkg.name, reason: "blocklist" }
215
+ }
216
+ // squat check is npm-only: other ecosystems reuse the same names legitimately (pip "requests" vs npm "request")
217
+ if (npmish) {
218
+ const squat = isTyposquat(pkg.name)
219
+ if (squat) {
220
+ return { name: pkg.name, reason: "typosquat", target: squat }
221
+ }
222
+ }
223
+ }
224
+ }
225
+ return null
226
+ }
package/src/glob.ts ADDED
@@ -0,0 +1,84 @@
1
+ export function normalizePath(p: string): string {
2
+ const collapsed = p.replace(/\\/g, "/").replace(/([^:/])\/{2,}/g, "$1/")
3
+ if (collapsed.length > 1 && collapsed.endsWith("/") && !/^[A-Za-z]:\/$/.test(collapsed)) {
4
+ return collapsed.slice(0, -1)
5
+ }
6
+ return collapsed
7
+ }
8
+
9
+ function escapeSeg(seg: string): string {
10
+ return seg
11
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
12
+ .replace(/\*\*/g, "\0") // placeholder so * → [^/]* doesn't touch it
13
+ .replace(/\*/g, "[^/]*")
14
+ .replace(/\?/g, "[^/]")
15
+ .replace(/\0/g, "[^/]*") // "**" inside a segment behaves like "*"
16
+ }
17
+
18
+ /** ".env" should also match ".env.local": allow extra dotted suffix for dotfiles without wildcards */
19
+ function dotFileAware(escapedSeg: string): string {
20
+ return /^\\\.[^.[\]*?]+$/.test(escapedSeg) ? `${escapedSeg}(\\..+)?` : escapedSeg
21
+ }
22
+
23
+ /** Convert a glob to an anchored RegExp. No-slash globs match at any depth. */
24
+ export function globToRegExp(glob: string): RegExp {
25
+ const segments = glob.split("/")
26
+ let src = ""
27
+ segments.forEach((seg, i) => {
28
+ const piece = seg === "**" ? (i === segments.length - 1 ? ".*" : "(?:[^/]+/)*") : escapeSeg(seg)
29
+ if (i === 0) {
30
+ src = seg === "**" ? "" : piece
31
+ if (seg === "**") src = "(?:.*/)?"
32
+ return
33
+ }
34
+ // a previous "**" piece already ends with "/" inside its group
35
+ src += segments[i - 1] === "**" ? piece : "/" + piece
36
+ })
37
+ if (!glob.includes("/")) src = `(?:^|.*/)${dotFileAware(escapeSeg(glob))}`
38
+ return new RegExp(`^${src}$`)
39
+ }
40
+
41
+ /**
42
+ * "**" as a whole segment matches any depth, including zero directories
43
+ * (trailing "**" keeps a greedy ".*" so ".ssh/**" still requires the ".ssh/" prefix).
44
+ */
45
+ function segToRegExp(seg: string, isLast: boolean): string {
46
+ if (seg === "**") return isLast ? ".*" : "(?:[^/]+/)*"
47
+ return escapeSeg(seg)
48
+ }
49
+
50
+ function suffixes(path: string): string[] {
51
+ const parts = path.split("/")
52
+ const out: string[] = [path]
53
+ for (let i = 1; i < parts.length; i++) out.push(parts.slice(i).join("/"))
54
+ return out
55
+ }
56
+
57
+ function globMatches(path: string, glob: string): boolean {
58
+ const re = globToRegExp(glob)
59
+ const candidates = glob.includes("/") ? suffixes(path) : [path]
60
+ return candidates.some((c) => re.test(c))
61
+ }
62
+
63
+ export interface PathLists {
64
+ blockPaths: readonly string[]
65
+ writeProtectPaths: readonly string[]
66
+ exemptPaths: readonly string[]
67
+ }
68
+
69
+ export function isPathExempt(path: string, cfg: PathLists): boolean {
70
+ const p = normalizePath(path)
71
+ return cfg.exemptPaths.some((g) => globMatches(p, g))
72
+ }
73
+
74
+ export function isPathBlocked(path: string, cfg: PathLists): boolean {
75
+ const p = normalizePath(path)
76
+ if (isPathExempt(p, cfg)) return false
77
+ return cfg.blockPaths.some((g) => globMatches(p, g))
78
+ }
79
+
80
+ /** Write-protected: read allowed, write blocked. Exemptions do not apply. */
81
+ export function isPathWriteProtected(path: string, cfg: PathLists): boolean {
82
+ const p = normalizePath(path)
83
+ return cfg.writeProtectPaths.some((g) => globMatches(p, g))
84
+ }