opencode-v2-guard 0.0.0-stage → 1.0.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/LICENSE +21 -0
- package/README.md +227 -3
- package/docs/deps-spec.md +104 -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 +223 -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/deps.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
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
|
+
const manager = tokens[i]
|
|
99
|
+
if (!manager || !MANAGERS.has(manager)) return null
|
|
100
|
+
const sub = tokens[i + 1]
|
|
101
|
+
if (!SUBCOMMANDS[manager]?.includes(sub)) return null
|
|
102
|
+
if (manager === "yarn" && tokens[i + 2] === "global") return null
|
|
103
|
+
|
|
104
|
+
const takesArg = FLAG_TAKES_ARG[manager]
|
|
105
|
+
const packages: InstallPackage[] = []
|
|
106
|
+
for (let j = i + 2; j < tokens.length; j++) {
|
|
107
|
+
const t = tokens[j]
|
|
108
|
+
if (t.startsWith("-")) {
|
|
109
|
+
if (t === "-g" || t === "--global") return null
|
|
110
|
+
if (takesArg?.has(t) && j + 1 < tokens.length) j++ // consume the flag's operand
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
let name = t
|
|
114
|
+
let version: string | undefined
|
|
115
|
+
if (manager === "pip" || manager === "pip3") {
|
|
116
|
+
const m = /^([^=<>~!]+?)([=<>~!].*)?$/.exec(t)
|
|
117
|
+
if (!m || !m[1]) continue
|
|
118
|
+
name = m[1]
|
|
119
|
+
version = m[2] ? m[2].replace(/^[=<>~!]+/, "") : undefined
|
|
120
|
+
} else {
|
|
121
|
+
const at = t.lastIndexOf("@")
|
|
122
|
+
if (at > 0) {
|
|
123
|
+
name = t.slice(0, at)
|
|
124
|
+
version = t.slice(at + 1)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (!name) continue
|
|
128
|
+
packages.push({ name, version })
|
|
129
|
+
}
|
|
130
|
+
if (packages.length === 0) return null
|
|
131
|
+
return { manager: manager === "pip3" ? "pip" : (manager as InstallTarget["manager"]), packages }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* All install targets found in a (possibly compound) command — one per segment
|
|
136
|
+
* (segments split by && || ; | or newline). Empty array if none.
|
|
137
|
+
*/
|
|
138
|
+
export function parseSegments(command: string): InstallTarget[] {
|
|
139
|
+
const out: InstallTarget[] = []
|
|
140
|
+
for (const segment of command.split(/&&|\|\||[;|]|\n/)) {
|
|
141
|
+
const hit = parseSegment(segment)
|
|
142
|
+
if (hit) out.push(hit)
|
|
143
|
+
}
|
|
144
|
+
return out
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** first install target in the command, or null */
|
|
148
|
+
export function parseInstallCommand(command: string): InstallTarget | null {
|
|
149
|
+
return parseSegments(command)[0] ?? null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function levenshtein(a: string, b: string): number {
|
|
153
|
+
if (a === b) return 0
|
|
154
|
+
const m = a.length
|
|
155
|
+
const n = b.length
|
|
156
|
+
if (m === 0) return n
|
|
157
|
+
if (n === 0) return m
|
|
158
|
+
// optimal string alignment: transposition (ab->ba) costs 1 — catches typosquats like lodahs/lodash
|
|
159
|
+
const d: number[][] = Array.from({ length: m + 1 }, (_, i) => {
|
|
160
|
+
const row = new Array<number>(n + 1).fill(0)
|
|
161
|
+
row[0] = i
|
|
162
|
+
return row
|
|
163
|
+
})
|
|
164
|
+
for (let j = 1; j <= n; j++) d[0][j] = j
|
|
165
|
+
for (let i = 1; i <= m; i++) {
|
|
166
|
+
for (let j = 1; j <= n; j++) {
|
|
167
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
168
|
+
let best = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
|
|
169
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
170
|
+
best = Math.min(best, d[i - 2][j - 2] + 1)
|
|
171
|
+
}
|
|
172
|
+
d[i][j] = best
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return d[m][n]
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** returns the trusted name this package is squatting, or null (case-insensitive) */
|
|
179
|
+
export function isTyposquat(name: string): string | null {
|
|
180
|
+
const n = name.toLowerCase()
|
|
181
|
+
if (n.startsWith("@") || n.length < 4) return null
|
|
182
|
+
if (TRUSTED_TOP.has(n)) return null
|
|
183
|
+
for (const trusted of TRUSTED_TOP) {
|
|
184
|
+
if (Math.abs(trusted.length - n.length) > 1) continue
|
|
185
|
+
if (levenshtein(n, trusted) === 1) return trusted
|
|
186
|
+
}
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export interface DepsVerdict {
|
|
191
|
+
name: string
|
|
192
|
+
reason: "blocklist" | "typosquat"
|
|
193
|
+
target?: string
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Check a raw shell command; returns the first violation or null.
|
|
198
|
+
* extraBlocked: user names added to the blocklist; allowed: user names exempted from it.
|
|
199
|
+
*/
|
|
200
|
+
export function checkInstall(command: string, extraBlocked: readonly string[] = [], allowed: readonly string[] = []): DepsVerdict | null {
|
|
201
|
+
const targets = parseSegments(command)
|
|
202
|
+
if (targets.length === 0) return null
|
|
203
|
+
const ok = new Set(allowed.map((p) => p.toLowerCase()))
|
|
204
|
+
const blocked = new Set([...BLOCKED_PACKAGES, ...extraBlocked].map((p) => p.toLowerCase()))
|
|
205
|
+
for (const target of targets) {
|
|
206
|
+
const npmish = target.manager === "npm" || target.manager === "yarn" || target.manager === "pnpm"
|
|
207
|
+
for (const pkg of target.packages) {
|
|
208
|
+
const lower = pkg.name.toLowerCase()
|
|
209
|
+
if (ok.has(lower)) continue
|
|
210
|
+
if (blocked.has(lower)) {
|
|
211
|
+
return { name: pkg.name, reason: "blocklist" }
|
|
212
|
+
}
|
|
213
|
+
// squat check is npm-only: other ecosystems reuse the same names legitimately (pip "requests" vs npm "request")
|
|
214
|
+
if (npmish) {
|
|
215
|
+
const squat = isTyposquat(pkg.name)
|
|
216
|
+
if (squat) {
|
|
217
|
+
return { name: pkg.name, reason: "typosquat", target: squat }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return null
|
|
223
|
+
}
|
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
|
+
}
|