opencode-v2-guard 1.0.2 → 1.1.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 +22 -2
- package/docs/deps25-spec.md +106 -0
- package/package.json +1 -1
- package/src/audit.ts +2 -0
- package/src/config.ts +3 -0
- package/src/deps.ts +214 -20
- package/src/guard.ts +80 -1
package/README.md
CHANGED
|
@@ -18,7 +18,16 @@ Audit: JSONL at `~/.local/share/opencode/guard/guard-audit.jsonl` — categories
|
|
|
18
18
|
|
|
19
19
|
## Dependency installation guard
|
|
20
20
|
|
|
21
|
-
`npm/pnpm/yarn add`, `pip install`, `cargo add` are parsed before execution (phase 2): a built-in blocklist of known-malicious packages (event-stream, node-ipc, ua-parser-js, coa, rc, flatmap-stream) vetoes the install, and names at Levenshtein distance 1 from a curated top-120 npm list are flagged as typosquats (npm only — other ecosystems reuse the same names legitimately). Global installs (`-g`) are out of scope.
|
|
21
|
+
`npm/pnpm/yarn add`, `pip install`, `cargo add` are parsed before execution (phase 2): a built-in blocklist of known-malicious packages (event-stream, node-ipc, ua-parser-js, coa, rc, flatmap-stream) vetoes the install, and names at Levenshtein distance 1 from a curated top-120 npm list are flagged as typosquats (npm only — other ecosystems reuse the same names legitimately). Global installs (`-g`) are out of scope.
|
|
22
|
+
|
|
23
|
+
Phase 2.5 additions:
|
|
24
|
+
|
|
25
|
+
- `npx pkg`, `pnpm dlx`, `yarn dlx` are checked the same way (execution without install)
|
|
26
|
+
- `@types/*` scoped squatting (`@types/lodahs` → veto), affix squatting (`lodashjs`, `node-lodash`, `react-cli`)
|
|
27
|
+
- `blockedPackages`/`allowedPackages` accept `name@version` (blocks only that version; plain `name` covers all versions)
|
|
28
|
+
- post-audit: ~60s after an allowed npm install, `npm audit --json` runs in the project directory; findings are warn-only audit entries (`postAudit`, default on)
|
|
29
|
+
|
|
30
|
+
Known gaps: `npm exec` is not parsed; `npx -c "<string>"` contents are not scanned; versioned entries match literally (`rc@1.2.0` does not catch `rc@^1.2.0` or `rc@latest` unless written exactly); post-audit is npm-only (no yarn/pnpm audit) and its 120s timeout is enforced via `taskkill` on Windows (best-effort elsewhere); a forced-kill partial report is dropped; `corepack <pm> ...` indirection and command substitution (`$(npm i x)`) are not expanded.
|
|
22
31
|
|
|
23
32
|
## Detection (29 patterns)
|
|
24
33
|
|
|
@@ -67,6 +76,7 @@ OpenCode installs the npm package automatically on startup. Then `opencode servi
|
|
|
67
76
|
| `scanOutbound` | `true` | scrub outbound context |
|
|
68
77
|
| `scanSkillsOnLoad` | `true` | scan installed skills at startup (warn-only audit) |
|
|
69
78
|
| `depsCheck` | `true` | intercept package installs (blocklist + typosquat) |
|
|
79
|
+
| `postAudit` | `true` | npm audit ~60s after an allowed npm install (warn-only) |
|
|
70
80
|
| `blockedPackages` | `[]` | extra package names blocked from install (union) |
|
|
71
81
|
| `allowedPackages` | `[]` | names exempted from the blocklist (escape hatch) |
|
|
72
82
|
| `blockPaths` | built-in list | **added** to defaults (union) |
|
|
@@ -140,7 +150,16 @@ opencode service restart
|
|
|
140
150
|
|
|
141
151
|
## Защита установки зависимостей
|
|
142
152
|
|
|
143
|
-
`npm/pnpm/yarn add`, `pip install`, `cargo add` парсятся до исполнения (фаза 2): встроенный blocklist известных вредоносных пакетов (event-stream, node-ipc, ua-parser-js, coa, rc, flatmap-stream) блокирует установку, а имена на расстоянии Левенштейна 1 от топ-120 npm ловятся как typosquat (только npm — в других экосистемах те же имена легитимны). Глобальные установки (`-g`) вне зоны охвата.
|
|
153
|
+
`npm/pnpm/yarn add`, `pip install`, `cargo add` парсятся до исполнения (фаза 2): встроенный blocklist известных вредоносных пакетов (event-stream, node-ipc, ua-parser-js, coa, rc, flatmap-stream) блокирует установку, а имена на расстоянии Левенштейна 1 от топ-120 npm ловятся как typosquat (только npm — в других экосистемах те же имена легитимны). Глобальные установки (`-g`) вне зоны охвата.
|
|
154
|
+
|
|
155
|
+
Добавления фазы 2.5:
|
|
156
|
+
|
|
157
|
+
- `npx pkg`, `pnpm dlx`, `yarn dlx` проверяются так же (исполнение без install)
|
|
158
|
+
- scoped-сквоттинг `@types/*` (`@types/lodahs` → вето), affix-сквоттинг (`lodashjs`, `node-lodash`, `react-cli`)
|
|
159
|
+
- `blockedPackages`/`allowedPackages` принимают `name@version` (блокирует только эту версию; голое `name` — все версии)
|
|
160
|
+
- пост-аудит: через ~60 секунд после пропущенной npm-установки в каталоге проекта запускается `npm audit --json`; находки пишутся в аудит как warn (`postAudit`, по умолчанию включён)
|
|
161
|
+
|
|
162
|
+
Известные пробелы: `npm exec` не парсится; содержимое `npx -c "<строка>"` не сканируется; версионные записи сопоставляются литерально (`rc@1.2.0` не поймает `rc@^1.2.0` или `rc@latest`, если записано иначе); пост-аудит только для npm (yarn/pnpm — нет), таймаут 120s на Windows исполняется через `taskkill` (best-effort), оборванный принудительным kill отчёт отбрасывается; косвенность `corepack <pm> ...` и подстановка команд (`$(npm i x)`) не раскрываются.
|
|
144
163
|
|
|
145
164
|
## Детекция (29 паттернов)
|
|
146
165
|
|
|
@@ -190,6 +209,7 @@ OpenCode сам установит npm-пакет при старте. Зате
|
|
|
190
209
|
| `scanOutbound` | `true` | скраб исходящего контекста |
|
|
191
210
|
| `scanSkillsOnLoad` | `true` | стартовый скан установленных скиллов (только warn-аудит) |
|
|
192
211
|
| `depsCheck` | `true` | перехват установки пакетов (blocklist + typosquat) |
|
|
212
|
+
| `postAudit` | `true` | npm audit через ~60с после пропущенной npm-установки (только warn) |
|
|
193
213
|
| `blockedPackages` | `[]` | дополнительные запрещённые имена пакетов (union) |
|
|
194
214
|
| `allowedPackages` | `[]` | имена, снятые с блоклиста (обход ложных срабатываний) |
|
|
195
215
|
| `blockPaths` | дефолтный список | **добавляется** к дефолтам (union) |
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Спека: deps-guard фаза 2.5 — доводка перехвата зависимостей
|
|
2
|
+
|
|
3
|
+
Статус: принята к реализации. Процесс: спека → тесты (красный) → реализация (зелёный) → ревью субагентами → живая проверка → v1.1.0.
|
|
4
|
+
|
|
5
|
+
Закрывает §9 deps-spec.md. Исследование API (шаг 0): `shell.create.before` несёт `cwd` —
|
|
6
|
+
пост-аудит реализуем на нём; `tool.execute.before` cwd не несёт (sessionID/agent/messageID/input).
|
|
7
|
+
|
|
8
|
+
## 1. npx / dlx-перехват (загрузка без install)
|
|
9
|
+
|
|
10
|
+
`npx pkg[@ver]`, `pnpm dlx pkg`, `yarn dlx pkg` скачивают и исполняют пакет без install — тот же
|
|
11
|
+
риск, что у install. Детерминированный парсинг:
|
|
12
|
+
|
|
13
|
+
- менеджер: `npx` (+Windows `npx.cmd`/`.ps1`/`.exe`), подкоманды `dlx` у `pnpm`/`yarn`.
|
|
14
|
+
- флаги пропускаются; `-p`/`--package` (npx) — операнд(ы) ЭТО пакеты (проверяются).
|
|
15
|
+
- первый позиционный токен после флагов — пакет для исполнения (имя до `@`, версия после).
|
|
16
|
+
Токены после `--` — аргументы исполняемой команды, НЕ пакеты (стоп).
|
|
17
|
+
- `-c`/`--call` со строкой-командой — не парсим (содержимое строки не трогаем).
|
|
18
|
+
- git/url-спеки (`npx github:user/repo`) → null.
|
|
19
|
+
- применяются те же проверки: blocklist (case-insens) + squat npm-only + affix (п.3).
|
|
20
|
+
- `npm exec` НЕ покрывается (семантика позиционных аргументов неоднозначна) — documented gap.
|
|
21
|
+
|
|
22
|
+
## 2. Scoped-сквоттинг (@types)
|
|
23
|
+
|
|
24
|
+
`@types/<base>`: если `<base>` ∈ TRUSTED_TOP → ок; иначе levenshtein(base, trusted) == 1 → typosquat.
|
|
25
|
+
Примеры: `@types/lodahs` → lodash (вето); `@types/lodash` → ок; `@scope/lodahs` → ок (фаза 3:
|
|
26
|
+
scoped-сквоттинг произвольных скоупов требует списка официальных скоупов).
|
|
27
|
+
|
|
28
|
+
## 3. Affix-сквоттинг (префикс/суффикс)
|
|
29
|
+
|
|
30
|
+
Имя = trusted + словарный аффикс (склейка или через дефис):
|
|
31
|
+
- суффиксы: `js`, `ts`, `node`, `cli`, `dev` (+ дефисные формы `-js`, `-ts`, `-node`, `-cli`, `-dev`)
|
|
32
|
+
- префиксы: `node-`, `nodejs-`
|
|
33
|
+
Ловит `lodashjs`, `node-lodash`, `react-cli`, `lodash-js`. Не ловит `lodash-es` (сам в TRUSTED_TOP).
|
|
34
|
+
Allowlist легитимных исторических имён: `angularjs`, `reactjs`, `vuejs`, `expressjs`, `nodejs`,
|
|
35
|
+
`vue-cli`, `angular-cli` (официальные legacy CLI). Только npm-экосистема.
|
|
36
|
+
Причина в аудите: `typosquat` (target = trusted).
|
|
37
|
+
|
|
38
|
+
## 4. Версионный блоклист
|
|
39
|
+
|
|
40
|
+
Записи `blockedPackages` вида `name@version` блокируют ТОЛЬКО эту версию; голые `name` — как раньше,
|
|
41
|
+
всё имя. `rc@1.2.9` в блоклисте: `npm i rc@1.2.9` → вето, `npm i rc` → ок, `npm i rc@2.0.0` → ок.
|
|
42
|
+
Scoped: `@scope/pkg@1.0.0` парсится корректно (последний `@`).
|
|
43
|
+
`allowedPackages` поддерживает обе формы; голое имя exempt-ит все версии.
|
|
44
|
+
Версионный allow НЕ снимает имённый блок (только версионную запись того же имени).
|
|
45
|
+
Встроенный BLOCKED_PACKAGES остаётся имённым (инцидентные имена).
|
|
46
|
+
|
|
47
|
+
## 5. Пост-аудит (npm audit, warn-only)
|
|
48
|
+
|
|
49
|
+
В `shell.create.before`: если команда — пропущенный npm-install (npm/yarn/pnpm? — только npm:
|
|
50
|
+
npm audit работает по npm-lockfile; yarn/pnpm — gap) и `postAudit` (default true) —
|
|
51
|
+
через 60 сек (эвристика: install успеет завершиться) отсоединённо запускаем
|
|
52
|
+
`npm audit --json` в `event.cwd` (timeout 120s, stdio pipe, ошибки глотаем).
|
|
53
|
+
Результат: `parseAuditReport(json)` агрегирует по severity (v2: `auditReport.vulnerabilities`,
|
|
54
|
+
v1: `advisories`; неизвестные severity → `unknown` — блокирует `__proto__`-строки). Находки → аудит
|
|
55
|
+
`{"kind":"deps","categories":["audit-vulnerability"],"count":N,"action":"warn",severities:{...}}`
|
|
56
|
+
+ console.warn. Блокировать постфактум нельзя — это детекция уже установленного.
|
|
57
|
+
Планируется и для пропущенных команд в warn-режиме (install выполнился — самый опасный класс).
|
|
58
|
+
Дедуп по cwd (5 мин). Таймаут: на win32 свой `taskkill /PID /T /F` (встроенный spawn-timeout
|
|
59
|
+
убивает только cmd.exe-обёртку), иначе SIGTERM; оборванный отчёт отбрасывается.
|
|
60
|
+
|
|
61
|
+
## 6. Конфиг
|
|
62
|
+
|
|
63
|
+
Новый ключ: `postAudit: boolean` (default `true`). Остальное — через существующие
|
|
64
|
+
blockedPackages/allowedPackages (новые формы записи, п.4).
|
|
65
|
+
|
|
66
|
+
## 7. Файлы
|
|
67
|
+
|
|
68
|
+
| Файл | Изменение |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `src/deps.ts` | npx/dlx парсинг, @types-scoped, affix, версионный блоклист, parseAuditReport |
|
|
71
|
+
| `src/guard.ts` | post-audit планировщик в shell.create.before |
|
|
72
|
+
| `src/config.ts` | `postAudit` |
|
|
73
|
+
| `src/audit.ts` | AuditEvent: опциональный `severities?: Record<string, number>` |
|
|
74
|
+
| `test/deps.test.ts` | +~25 тестов |
|
|
75
|
+
| `README.md` | секция deps: npx/dlx, @types, affix, версионный блоклист, пост-аудит (EN+RU) |
|
|
76
|
+
|
|
77
|
+
## 8. План тестов (TDD)
|
|
78
|
+
|
|
79
|
+
1. npx: `npx lodahs`→squat; `npx -y lodahs@1.2.3`→squat; `npx -p event-stream`→blocklist;
|
|
80
|
+
`npx -p lodash node x.js`→первый позиционный `node`… подождать: `-p` пакеты проверяются,
|
|
81
|
+
позиционные тоже; `npx lodash`→null; `npx node script.js`→null; `npx -- lodahs`→null
|
|
82
|
+
(после `--` аргументы); `npx.cmd lodahs`→squat (Windows); `npx github:x/y`→null.
|
|
83
|
+
2. dlx: `pnpm dlx lodahs`→squat; `yarn dlx event-stream`→blocklist; `pnpm dlx lodash`→null.
|
|
84
|
+
3. @types: `@types/lodahs`→squat(lodash); `@types/lodash`→null; `@types/reactt`→squat(react);
|
|
85
|
+
`@scope/lodahs`→null.
|
|
86
|
+
4. affix: `lodashjs`/`node-lodash`/`react-cli`→squat; `angularjs`/`expressjs`/`vuejs`/
|
|
87
|
+
`nodejs`/`reactjs`→null (allowlist); `lodash-es`→null (trusted); `nodemailer`→null.
|
|
88
|
+
5. версионный блоклист: `rc@1.2.9` блокирует только эту версию; `@scope/pkg@1.0.0` scoped-парс;
|
|
89
|
+
allowed `rc` снимает версионный тоже; allowed `rc@1.2.9` снимает только её.
|
|
90
|
+
6. parseAuditReport: v2-фикстура (2 critical, 1 high) → severities агрегированы; v1-фикстура;
|
|
91
|
+
мусор → null; пустой отчёт → нули.
|
|
92
|
+
7. регрессии: все существующие 111 тестов зелёные.
|
|
93
|
+
|
|
94
|
+
## 9. Критерии готовности
|
|
95
|
+
|
|
96
|
+
- 111 + новые тесты зелёные, tsc чисто.
|
|
97
|
+
- Ревью субагентом: critical/major исправлены.
|
|
98
|
+
- Живая проверка: `npx lodahs` → вето; `npm install lodash` → ок + через ~60с в аудите
|
|
99
|
+
kind:"deps" с audit-vulnerability (если в проекте есть уязвимости) либо тишина.
|
|
100
|
+
- Публикация v1.1.0.
|
|
101
|
+
|
|
102
|
+
## 10. Known gaps (после 2.5)
|
|
103
|
+
|
|
104
|
+
- `npm exec`, произвольные scoped-скоупы, yarn/pnpm audit, affix-словарь фиксирован.
|
|
105
|
+
- пост-аудит: эвристическая задержка 60s, npm-only, детекция без блокировки.
|
|
106
|
+
- OSV-API как альтернатива npm audit — фаза 3 (нужен выбор источника без ключа).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-v2-guard",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Security plugin for OpenCode V2 — deterministic secret/PII redaction, sensitive-path blocking, prompt-injection detection in skills, package-install interception (blocklist + typosquat)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/audit.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface GuardConfig {
|
|
|
13
13
|
exemptSkills: string[]
|
|
14
14
|
/** intercept package installation commands (blocklist + typosquat) */
|
|
15
15
|
depsCheck: boolean
|
|
16
|
+
/** run `npm audit --json` ~60s after an allowed npm install (warn-only detection) */
|
|
17
|
+
postAudit: boolean
|
|
16
18
|
/** extra package names blocked from installation (union with built-in list) */
|
|
17
19
|
blockedPackages: string[]
|
|
18
20
|
/** package names exempted from the blocklist (escape hatch for legit installs) */
|
|
@@ -67,6 +69,7 @@ export function resolveConfig(options: unknown): GuardConfig {
|
|
|
67
69
|
scanSkillsOnLoad: o.scanSkillsOnLoad !== false,
|
|
68
70
|
exemptSkills: strArray(o.exemptSkills) ? [...o.exemptSkills] : [],
|
|
69
71
|
depsCheck: o.depsCheck !== false,
|
|
72
|
+
postAudit: o.postAudit !== false,
|
|
70
73
|
blockedPackages: strArray(o.blockedPackages) ? [...o.blockedPackages] : [],
|
|
71
74
|
allowedPackages: strArray(o.allowedPackages) ? [...o.allowedPackages] : [],
|
|
72
75
|
blockPaths: strArray(o.blockPaths) ? [...new Set([...DEFAULT_BLOCK_PATHS, ...o.blockPaths])] : [...DEFAULT_BLOCK_PATHS],
|
package/src/deps.ts
CHANGED
|
@@ -12,19 +12,20 @@ export interface InstallPackage {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export interface InstallTarget {
|
|
15
|
-
manager: "npm" | "yarn" | "pnpm" | "pip" | "cargo"
|
|
15
|
+
manager: "npm" | "yarn" | "pnpm" | "pip" | "cargo" | "npx" | "bun"
|
|
16
16
|
packages: InstallPackage[]
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
const MANAGERS = new Set(["npm", "yarn", "pnpm", "pip", "pip3", "cargo"])
|
|
19
|
+
const MANAGERS = new Set(["npm", "yarn", "pnpm", "pip", "pip3", "cargo", "npx", "pnpx", "bun", "bunx"])
|
|
20
20
|
|
|
21
21
|
const SUBCOMMANDS: Record<string, readonly string[]> = {
|
|
22
22
|
npm: ["install", "i"],
|
|
23
|
-
yarn: ["add"],
|
|
24
|
-
pnpm: ["add", "install"],
|
|
23
|
+
yarn: ["add", "dlx"],
|
|
24
|
+
pnpm: ["add", "install", "dlx"],
|
|
25
25
|
pip: ["install"],
|
|
26
26
|
pip3: ["install"],
|
|
27
27
|
cargo: ["add"],
|
|
28
|
+
bun: ["add", "install", "x"],
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
/** flags that consume the NEXT token as their operand (never a package name) */
|
|
@@ -46,6 +47,71 @@ export const BLOCKED_PACKAGES: ReadonlySet<string> = new Set([
|
|
|
46
47
|
"rc", // 2021: hijack, same campaign as coa
|
|
47
48
|
])
|
|
48
49
|
|
|
50
|
+
/** affix squatting: trusted + short dictionary affix, with or without a dash (npm-only check) */
|
|
51
|
+
const AFFIX_SUFFIXES = ["js", "ts", "node", "cli", "dev", "-js", "-ts", "-node", "-cli", "-dev"] as const
|
|
52
|
+
const AFFIX_PREFIXES = ["node-", "nodejs-"] as const
|
|
53
|
+
/** historical legit names that would otherwise match the affix rule */
|
|
54
|
+
const AFFIX_ALLOWLIST: ReadonlySet<string> = new Set(["angularjs", "reactjs", "vuejs", "expressjs", "nodejs", "vue-cli", "angular-cli"])
|
|
55
|
+
|
|
56
|
+
/** git/url install specs are not registry names — never flag them */
|
|
57
|
+
function isGitOrUrlSpec(token: string): boolean {
|
|
58
|
+
return (
|
|
59
|
+
/:\/\//.test(token) ||
|
|
60
|
+
/^(?:git@|github:|gitlab:|bitbucket:|gist:)/.test(token) ||
|
|
61
|
+
/\.(?:tgz|tar\.gz|zip)$/i.test(token)
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** split name@version on the LAST '@' (respects scoped names: @scope/pkg@1.0.0) */
|
|
66
|
+
function splitNameVersion(entry: string): { name: string; version?: string } {
|
|
67
|
+
const at = entry.lastIndexOf("@")
|
|
68
|
+
if (at > 0) return { name: entry.slice(0, at), version: entry.slice(at + 1) || undefined }
|
|
69
|
+
return { name: entry }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* npx / pnpm dlx / yarn dlx: the executed package is a positional token, not an install operand.
|
|
74
|
+
* Flags skipped; -p/--package operands ARE packages; everything after `--` is the command's args.
|
|
75
|
+
*/
|
|
76
|
+
function parseExecTokens(tokens: string[], start: number): InstallTarget | null {
|
|
77
|
+
const packages: InstallPackage[] = []
|
|
78
|
+
let positionalTaken = false
|
|
79
|
+
let ignorePositional = false
|
|
80
|
+
for (let j = start; j < tokens.length; j++) {
|
|
81
|
+
const t = tokens[j]
|
|
82
|
+
if (t === "--") break // rest belongs to the executed command
|
|
83
|
+
if (t.startsWith("-")) {
|
|
84
|
+
if (t === "-p" || t === "--package") {
|
|
85
|
+
const next = tokens[++j]
|
|
86
|
+
if (next && !isGitOrUrlSpec(next)) packages.push(splitNameVersion(next))
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
if (t.startsWith("-p=")) {
|
|
90
|
+
const v = t.slice(3)
|
|
91
|
+
if (v && !isGitOrUrlSpec(v)) packages.push(splitNameVersion(v))
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (t.startsWith("--package=")) {
|
|
95
|
+
const v = t.slice("--package=".length)
|
|
96
|
+
if (v && !isGitOrUrlSpec(v)) packages.push(splitNameVersion(v))
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
if (t === "-c" || t === "--call") {
|
|
100
|
+
j++ // consume the shell string
|
|
101
|
+
ignorePositional = true
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (!positionalTaken && !ignorePositional && !isGitOrUrlSpec(t)) {
|
|
107
|
+
packages.push(splitNameVersion(t))
|
|
108
|
+
positionalTaken = true
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (packages.length === 0) return null
|
|
112
|
+
return { manager: "npx", packages }
|
|
113
|
+
}
|
|
114
|
+
|
|
49
115
|
/** curated popular npm names for typosquat distance-1 checks (119 unique) */
|
|
50
116
|
export const TRUSTED_TOP: ReadonlySet<string> = new Set([
|
|
51
117
|
"lodash", "react", "react-dom", "vue", "angular", "express", "axios", "moment",
|
|
@@ -98,15 +164,34 @@ function parseSegment(segment: string): InstallTarget | null {
|
|
|
98
164
|
// Windows runners: npm.cmd, pnpm.cmd, pip.exe, yarn.ps1, ... — strip the script/extension suffix
|
|
99
165
|
const raw = tokens[i]
|
|
100
166
|
if (!raw) return null
|
|
101
|
-
const manager = raw.replace(/\.(?:cmd|ps1|exe|bat)$/i, "")
|
|
167
|
+
const manager = raw.replace(/\.(?:cmd|ps1|exe|bat)$/i, "").toLowerCase()
|
|
102
168
|
if (!MANAGERS.has(manager)) return null
|
|
103
|
-
|
|
169
|
+
// execution-without-install forms: npx/pnpx/bunx pkg / pnpm|yarn dlx / bun x
|
|
170
|
+
if (manager === "npx" || manager === "pnpx" || manager === "bunx") return parseExecTokens(tokens, i + 1)
|
|
171
|
+
if ((manager === "pnpm" || manager === "yarn") && tokens[i + 1]?.toLowerCase() === "dlx") {
|
|
172
|
+
return parseExecTokens(tokens, i + 2)
|
|
173
|
+
}
|
|
174
|
+
if (manager === "bun" && tokens[i + 1]?.toLowerCase() === "x") {
|
|
175
|
+
return parseExecTokens(tokens, i + 2)
|
|
176
|
+
}
|
|
177
|
+
// global flags may precede the subcommand: npm --prefix . install pkg / yarn --cwd p add pkg
|
|
178
|
+
const takesArgEarly = FLAG_TAKES_ARG[manager]
|
|
179
|
+
let k = i + 1
|
|
180
|
+
let globalFlag = false
|
|
181
|
+
while (k < tokens.length && tokens[k].startsWith("-")) {
|
|
182
|
+
const flag = tokens[k]
|
|
183
|
+
if (flag === "-g" || flag === "--global") globalFlag = true
|
|
184
|
+
k++
|
|
185
|
+
if (takesArgEarly?.has(flag) && k < tokens.length) k++ // consume the flag's operand
|
|
186
|
+
}
|
|
187
|
+
if (globalFlag) return null
|
|
188
|
+
const sub = tokens[k]?.toLowerCase()
|
|
104
189
|
if (!SUBCOMMANDS[manager]?.includes(sub)) return null
|
|
105
|
-
if (manager === "yarn" && tokens[
|
|
190
|
+
if (manager === "yarn" && tokens[k + 1]?.toLowerCase() === "global") return null
|
|
106
191
|
|
|
107
192
|
const takesArg = FLAG_TAKES_ARG[manager]
|
|
108
193
|
const packages: InstallPackage[] = []
|
|
109
|
-
for (let j =
|
|
194
|
+
for (let j = k + 1; j < tokens.length; j++) {
|
|
110
195
|
const t = tokens[j]
|
|
111
196
|
if (t.startsWith("-")) {
|
|
112
197
|
if (t === "-g" || t === "--global") return null
|
|
@@ -118,13 +203,18 @@ function parseSegment(segment: string): InstallTarget | null {
|
|
|
118
203
|
if (manager === "pip" || manager === "pip3") {
|
|
119
204
|
const m = /^([^=<>~!]+?)([=<>~!].*)?$/.exec(t)
|
|
120
205
|
if (!m || !m[1]) continue
|
|
121
|
-
name = m[1]
|
|
206
|
+
name = m[1].replace(/\[[^\]]*\]$/, "") // extras: event-stream[socks] -> event-stream
|
|
122
207
|
version = m[2] ? m[2].replace(/^[=<>~!]+/, "") : undefined
|
|
123
208
|
} else {
|
|
124
209
|
const at = t.lastIndexOf("@")
|
|
125
210
|
if (at > 0) {
|
|
126
211
|
name = t.slice(0, at)
|
|
127
212
|
version = t.slice(at + 1)
|
|
213
|
+
// npm alias: alias@npm:event-stream actually installs event-stream
|
|
214
|
+
if (version.startsWith("npm:") && version.length > 4) {
|
|
215
|
+
name = version.slice(4)
|
|
216
|
+
version = undefined
|
|
217
|
+
}
|
|
128
218
|
}
|
|
129
219
|
}
|
|
130
220
|
if (!name) continue
|
|
@@ -140,7 +230,7 @@ function parseSegment(segment: string): InstallTarget | null {
|
|
|
140
230
|
*/
|
|
141
231
|
export function parseSegments(command: string): InstallTarget[] {
|
|
142
232
|
const out: InstallTarget[] = []
|
|
143
|
-
for (const segment of command.split(/&&|\|\||[
|
|
233
|
+
for (const segment of command.split(/&&|\|\||[;|&]|\n/)) {
|
|
144
234
|
const hit = parseSegment(segment)
|
|
145
235
|
if (hit) out.push(hit)
|
|
146
236
|
}
|
|
@@ -181,7 +271,18 @@ export function levenshtein(a: string, b: string): number {
|
|
|
181
271
|
/** returns the trusted name this package is squatting, or null (case-insensitive) */
|
|
182
272
|
export function isTyposquat(name: string): string | null {
|
|
183
273
|
const n = name.toLowerCase()
|
|
184
|
-
if (n.
|
|
274
|
+
if (n.length < 4) return null
|
|
275
|
+
// scoped: only @types/* is checked (arbitrary scopes need an official-scope list — phase 3)
|
|
276
|
+
if (n.startsWith("@")) {
|
|
277
|
+
if (!n.startsWith("@types/")) return null
|
|
278
|
+
const base = n.slice("@types/".length)
|
|
279
|
+
if (TRUSTED_TOP.has(base)) return null
|
|
280
|
+
for (const trusted of TRUSTED_TOP) {
|
|
281
|
+
if (Math.abs(trusted.length - base.length) > 1) continue
|
|
282
|
+
if (levenshtein(base, trusted) === 1) return trusted
|
|
283
|
+
}
|
|
284
|
+
return null
|
|
285
|
+
}
|
|
185
286
|
if (TRUSTED_TOP.has(n)) return null
|
|
186
287
|
for (const trusted of TRUSTED_TOP) {
|
|
187
288
|
if (Math.abs(trusted.length - n.length) > 1) continue
|
|
@@ -190,6 +291,21 @@ export function isTyposquat(name: string): string | null {
|
|
|
190
291
|
return null
|
|
191
292
|
}
|
|
192
293
|
|
|
294
|
+
/** affix squatting: lodashjs / node-lodash / react-cli — trusted + dictionary affix */
|
|
295
|
+
export function isAffixSquat(name: string): string | null {
|
|
296
|
+
const n = name.toLowerCase()
|
|
297
|
+
if (TRUSTED_TOP.has(n) || AFFIX_ALLOWLIST.has(n)) return null
|
|
298
|
+
for (const trusted of TRUSTED_TOP) {
|
|
299
|
+
for (const suffix of AFFIX_SUFFIXES) {
|
|
300
|
+
if (n === trusted + suffix) return trusted
|
|
301
|
+
}
|
|
302
|
+
for (const prefix of AFFIX_PREFIXES) {
|
|
303
|
+
if (n === prefix + trusted) return trusted
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return null
|
|
307
|
+
}
|
|
308
|
+
|
|
193
309
|
export interface DepsVerdict {
|
|
194
310
|
name: string
|
|
195
311
|
reason: "blocklist" | "typosquat"
|
|
@@ -198,24 +314,56 @@ export interface DepsVerdict {
|
|
|
198
314
|
|
|
199
315
|
/**
|
|
200
316
|
* Check a raw shell command; returns the first violation or null.
|
|
201
|
-
* extraBlocked:
|
|
317
|
+
* extraBlocked/allowed entries: plain "name" (all versions) or "name@version" (that version only).
|
|
202
318
|
*/
|
|
203
319
|
export function checkInstall(command: string, extraBlocked: readonly string[] = [], allowed: readonly string[] = []): DepsVerdict | null {
|
|
204
320
|
const targets = parseSegments(command)
|
|
205
321
|
if (targets.length === 0) return null
|
|
206
|
-
|
|
207
|
-
const
|
|
322
|
+
|
|
323
|
+
const allowedWide = new Set<string>()
|
|
324
|
+
const allowedVer = new Set<string>() // "name@version", lowercased
|
|
325
|
+
for (const e of allowed) {
|
|
326
|
+
const { name, version } = splitNameVersion(e)
|
|
327
|
+
const l = name.toLowerCase()
|
|
328
|
+
if (version) allowedVer.add(l + "@" + version.toLowerCase())
|
|
329
|
+
else allowedWide.add(l)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const blockedWide = new Set<string>()
|
|
333
|
+
const blockedVer = new Map<string, Set<string>>()
|
|
334
|
+
for (const e of [...BLOCKED_PACKAGES, ...extraBlocked]) {
|
|
335
|
+
const { name, version } = splitNameVersion(e)
|
|
336
|
+
const l = name.toLowerCase()
|
|
337
|
+
if (version) {
|
|
338
|
+
const s = blockedVer.get(l) ?? new Set<string>()
|
|
339
|
+
s.add(version.toLowerCase())
|
|
340
|
+
blockedVer.set(l, s)
|
|
341
|
+
} else {
|
|
342
|
+
blockedWide.add(l)
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const npmish = new Set(["npm", "yarn", "pnpm", "npx", "bun"])
|
|
208
347
|
for (const target of targets) {
|
|
209
|
-
const npmish = target.manager === "npm" || target.manager === "yarn" || target.manager === "pnpm"
|
|
210
348
|
for (const pkg of target.packages) {
|
|
211
349
|
const lower = pkg.name.toLowerCase()
|
|
212
|
-
|
|
213
|
-
if (
|
|
350
|
+
const ver = pkg.version ? pkg.version.toLowerCase() : undefined
|
|
351
|
+
if (allowedWide.has(lower)) continue
|
|
352
|
+
// a versioned allow entry must NOT lift a name-wide block (spec: it lifts only versioned blocks)
|
|
353
|
+
if (blockedWide.has(lower)) {
|
|
214
354
|
return { name: pkg.name, reason: "blocklist" }
|
|
215
355
|
}
|
|
216
|
-
|
|
217
|
-
if (
|
|
218
|
-
const
|
|
356
|
+
if (ver && allowedVer.has(lower + "@" + ver)) continue
|
|
357
|
+
if (ver) {
|
|
358
|
+
const s = blockedVer.get(lower)
|
|
359
|
+
if (s?.has(ver)) {
|
|
360
|
+
return { name: pkg.name, reason: "blocklist" }
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// squat checks are npm-ecosystem-only: other ecosystems reuse the same names legitimately (pip "requests" vs npm "request")
|
|
364
|
+
if (npmish.has(target.manager)) {
|
|
365
|
+
// affix first: "lodash-js" is affix of lodash (exact form), not a dist-1 edit of lodash-es
|
|
366
|
+
const squat = isAffixSquat(pkg.name) ?? isTyposquat(pkg.name)
|
|
219
367
|
if (squat) {
|
|
220
368
|
return { name: pkg.name, reason: "typosquat", target: squat }
|
|
221
369
|
}
|
|
@@ -224,3 +372,49 @@ export function checkInstall(command: string, extraBlocked: readonly string[] =
|
|
|
224
372
|
}
|
|
225
373
|
return null
|
|
226
374
|
}
|
|
375
|
+
|
|
376
|
+
/** true if any segment is an npm install (drives the post-audit scheduler; npx/dlx don't count) */
|
|
377
|
+
export function isNpmInstall(command: string): boolean {
|
|
378
|
+
return parseSegments(command).some((t) => t.manager === "npm")
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface AuditSummary {
|
|
382
|
+
total: number
|
|
383
|
+
severities: Record<string, number>
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** npm audit severity vocabulary; anything else folds into "unknown" (blocks __proto__-style key damage) */
|
|
387
|
+
const SEVERITY_NAMES = new Set(["info", "low", "moderate", "high", "critical"])
|
|
388
|
+
|
|
389
|
+
/** aggregate `npm audit --json` output (v2 auditReport.vulnerabilities or v1 advisories); null if unusable */
|
|
390
|
+
export function parseAuditReport(text: string): AuditSummary | null {
|
|
391
|
+
let json: unknown
|
|
392
|
+
try {
|
|
393
|
+
json = JSON.parse(text)
|
|
394
|
+
} catch {
|
|
395
|
+
return null
|
|
396
|
+
}
|
|
397
|
+
if (typeof json !== "object" || json === null) return null
|
|
398
|
+
const obj = json as Record<string, unknown>
|
|
399
|
+
const sources: unknown[] = []
|
|
400
|
+
if (typeof obj.auditReport === "object" && obj.auditReport !== null) {
|
|
401
|
+
sources.push((obj.auditReport as Record<string, unknown>).vulnerabilities)
|
|
402
|
+
}
|
|
403
|
+
if (typeof obj.advisories === "object" && obj.advisories !== null) {
|
|
404
|
+
sources.push(obj.advisories)
|
|
405
|
+
}
|
|
406
|
+
const found = sources.filter((s) => typeof s === "object" && s !== null && !Array.isArray(s))
|
|
407
|
+
if (found.length === 0) return null
|
|
408
|
+
const severities: Record<string, number> = {}
|
|
409
|
+
let total = 0
|
|
410
|
+
for (const s of found) {
|
|
411
|
+
for (const entry of Object.values(s as Record<string, unknown>)) {
|
|
412
|
+
if (typeof entry !== "object" || entry === null) continue
|
|
413
|
+
const sev = (entry as Record<string, unknown>).severity
|
|
414
|
+
const key = typeof sev === "string" && SEVERITY_NAMES.has(sev) ? sev : "unknown"
|
|
415
|
+
severities[key] = (severities[key] ?? 0) + 1
|
|
416
|
+
total++
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return { total, severities }
|
|
420
|
+
}
|
package/src/guard.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Error as ToolError } from "@opencode/plugin/promise/tool"
|
|
2
2
|
import type { Plugin } from "@opencode/plugin"
|
|
3
3
|
import { createHash } from "node:crypto"
|
|
4
|
+
import { spawn } from "node:child_process"
|
|
4
5
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
5
6
|
import { homedir } from "node:os"
|
|
6
7
|
import { join } from "node:path"
|
|
@@ -10,7 +11,7 @@ import { isPathBlocked, isPathExempt, isPathWriteProtected } from "./glob.ts"
|
|
|
10
11
|
import type { GuardConfig } from "./config.ts"
|
|
11
12
|
import { appendAudit, type AuditEvent } from "./audit.ts"
|
|
12
13
|
import { scanSkillDir } from "./skillcheck.ts"
|
|
13
|
-
import { checkInstall } from "./deps.ts"
|
|
14
|
+
import { checkInstall, isNpmInstall, parseAuditReport } from "./deps.ts"
|
|
14
15
|
|
|
15
16
|
type Ctx = Plugin.Context
|
|
16
17
|
|
|
@@ -140,6 +141,81 @@ const tracked: { dispose(): Promise<void> }[] = []
|
|
|
140
141
|
const DEPS_AUDIT_TTL_MS = 2000
|
|
141
142
|
const depsAuditRecent = new Map<string, number>()
|
|
142
143
|
|
|
144
|
+
/** post-audit: give the install ~60s to finish, then npm audit in the project dir (warn-only) */
|
|
145
|
+
const POST_AUDIT_DELAY_MS = 60_000
|
|
146
|
+
const POST_AUDIT_TIMEOUT_MS = 120_000
|
|
147
|
+
/** don't stack audits for the same project dir (a burst of installs schedules one audit) */
|
|
148
|
+
const postAuditRecent = new Map<string, number>()
|
|
149
|
+
const POST_AUDIT_DEDUP_MS = 5 * 60_000
|
|
150
|
+
|
|
151
|
+
function schedulePostAudit(command: string, cwd: string, cfg: GuardConfig): void {
|
|
152
|
+
try {
|
|
153
|
+
if (!isNpmInstall(command)) return
|
|
154
|
+
const last = postAuditRecent.get(cwd)
|
|
155
|
+
if (last && Date.now() - last < POST_AUDIT_DEDUP_MS) return
|
|
156
|
+
postAuditRecent.set(cwd, Date.now())
|
|
157
|
+
if (postAuditRecent.size > 100) postAuditRecent.clear()
|
|
158
|
+
const timer = setTimeout(() => {
|
|
159
|
+
try {
|
|
160
|
+
const child = spawn("npm", ["audit", "--json"], {
|
|
161
|
+
cwd,
|
|
162
|
+
shell: process.platform === "win32",
|
|
163
|
+
detached: true,
|
|
164
|
+
windowsHide: true,
|
|
165
|
+
})
|
|
166
|
+
let stdout = ""
|
|
167
|
+
child.stdout?.on("data", (d: Buffer) => {
|
|
168
|
+
stdout += d.toString()
|
|
169
|
+
})
|
|
170
|
+
child.on("error", () => {})
|
|
171
|
+
// built-in spawn timeout only kills the win32 cmd.exe wrapper — enforce our own
|
|
172
|
+
let closed = false
|
|
173
|
+
const killer = setTimeout(() => {
|
|
174
|
+
try {
|
|
175
|
+
if (closed) return
|
|
176
|
+
if (process.platform === "win32") {
|
|
177
|
+
if (child.pid === undefined) return
|
|
178
|
+
spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], { windowsHide: true }).on("error", () => {})
|
|
179
|
+
} else {
|
|
180
|
+
child.kill("SIGTERM")
|
|
181
|
+
}
|
|
182
|
+
} catch {}
|
|
183
|
+
}, POST_AUDIT_TIMEOUT_MS)
|
|
184
|
+
killer.unref?.()
|
|
185
|
+
child.on("close", () => {
|
|
186
|
+
try {
|
|
187
|
+
closed = true
|
|
188
|
+
clearTimeout(killer)
|
|
189
|
+
// partial JSON after a forced kill simply fails to parse — dropped, by design
|
|
190
|
+
const report = parseAuditReport(stdout)
|
|
191
|
+
if (!report || report.total === 0) return
|
|
192
|
+
const sev = Object.entries(report.severities)
|
|
193
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
194
|
+
.join(" ")
|
|
195
|
+
audit(cfg, {
|
|
196
|
+
ts: new Date().toISOString(),
|
|
197
|
+
kind: "deps",
|
|
198
|
+
categories: ["audit-vulnerability"],
|
|
199
|
+
count: report.total,
|
|
200
|
+
action: "warn",
|
|
201
|
+
severities: report.severities,
|
|
202
|
+
})
|
|
203
|
+
console.warn(`guard: npm audit в ${cwd} — ${report.total} уязвимостей (${sev}), детали в аудите`)
|
|
204
|
+
} catch {
|
|
205
|
+
// never crash the plugin over a post-audit result
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
child.unref?.()
|
|
209
|
+
} catch {
|
|
210
|
+
// never crash the plugin over scheduling
|
|
211
|
+
}
|
|
212
|
+
}, POST_AUDIT_DELAY_MS)
|
|
213
|
+
timer.unref?.()
|
|
214
|
+
} catch {
|
|
215
|
+
// never crash the hook
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
143
219
|
export function registerGuard(ctx: Ctx, cfg: GuardConfig): () => Promise<void> {
|
|
144
220
|
const patterns = activePatterns(cfg.disabledPatterns)
|
|
145
221
|
const now = () => new Date().toISOString()
|
|
@@ -368,9 +444,12 @@ export function registerGuard(ctx: Ctx, cfg: GuardConfig): () => Promise<void> {
|
|
|
368
444
|
if (!last || Date.now() - last > DEPS_AUDIT_TTL_MS) {
|
|
369
445
|
audit(cfg, { ts: now(), kind: "deps", categories: [cat], count: 1, action: mutate ? "blocked" : "warn" })
|
|
370
446
|
}
|
|
447
|
+
// warn mode lets the install through — it still qualifies for post-audit
|
|
448
|
+
if (!mutate && cfg.postAudit) schedulePostAudit(event.command, event.cwd, cfg)
|
|
371
449
|
return
|
|
372
450
|
}
|
|
373
451
|
}
|
|
452
|
+
if (cfg.postAudit) schedulePostAudit(event.command, event.cwd, cfg)
|
|
374
453
|
if (!cfg.redactOnWrite) return
|
|
375
454
|
const r = redact(event.command, patterns)
|
|
376
455
|
if (r.matches.length > 0) {
|