@sriinnu/omit 0.3.0 → 0.4.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/.clinerules/omit.md +1 -1
- package/.cursor/rules/omit.mdc +1 -1
- package/.github/copilot-instructions.md +15 -0
- package/.github/workflows/publish.yml +69 -0
- package/.github/workflows/test.yml +40 -0
- package/.windsurf/rules/omit.md +1 -1
- package/AGENTS.md +11 -2
- package/README.md +72 -15
- package/action.yml +18 -1
- package/bench/run.mjs +25 -4
- package/bin/omit.mjs +518 -58
- package/hooks/command-sentinel.mjs +39 -11
- package/hooks/dep-sentinel.mjs +184 -30
- package/hooks/final-draft-gate.mjs +231 -37
- package/hooks/hazard-sentinel.mjs +157 -39
- package/hooks/hooks.json +18 -1
- package/hooks/leak-sentinel.mjs +55 -0
- package/hooks/lint-sentinel.mjs +34 -11
- package/lib/danger.mjs +9 -1
- package/lib/deps.mjs +341 -45
- package/lib/exec.mjs +18 -0
- package/lib/git.mjs +107 -0
- package/lib/hazards.mjs +29 -4
- package/lib/leaks.mjs +313 -0
- package/lib/lint.mjs +85 -5
- package/lib/receipts.mjs +356 -0
- package/package.json +5 -1
- package/skills/omit/SKILL.md +25 -13
package/lib/deps.mjs
CHANGED
|
@@ -1,56 +1,352 @@
|
|
|
1
|
-
// Shared manifest/dependency detection for hooks
|
|
1
|
+
// Shared manifest/dependency detection for the hooks, `omit audit`, and the bench.
|
|
2
|
+
//
|
|
3
|
+
// Dependencies are read by PARSING each manifest's declared dependency tables,
|
|
4
|
+
// never by matching diff lines. Line matching fails in both directions: a
|
|
5
|
+
// minified one-line package.json matches no line pattern (a false pass on a real
|
|
6
|
+
// dependency), while `"version": "1.0.0"` matches the version-range pattern (a
|
|
7
|
+
// false objection to metadata). Formatting is not a dependency, so neither
|
|
8
|
+
// spelling should change the verdict.
|
|
9
|
+
//
|
|
10
|
+
// Pure functions only — content in, dependency names out. Callers get the two
|
|
11
|
+
// revisions' contents from lib/git.mjs.
|
|
2
12
|
import { basename } from 'node:path'
|
|
3
13
|
|
|
4
|
-
const MANIFESTS = new Set([
|
|
5
|
-
'package.json', 'requirements.txt', '
|
|
14
|
+
export const MANIFESTS = new Set([
|
|
15
|
+
'package.json', 'requirements.txt', 'requirements-dev.txt', 'pyproject.toml',
|
|
16
|
+
'setup.cfg', 'Pipfile', 'environment.yml', 'environment.yaml', 'go.mod',
|
|
6
17
|
'Cargo.toml', 'Gemfile', 'composer.json',
|
|
7
18
|
])
|
|
8
19
|
|
|
9
20
|
export const isManifest = (path) => MANIFESTS.has(basename(path))
|
|
10
21
|
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
// Dependency-shaped files we still cannot read. A file that declares
|
|
23
|
+
// dependencies and is not parsed has to be reported by name, because `new deps:
|
|
24
|
+
// 0 ✅` for a manifest we never opened is the same silent pass as a missed
|
|
25
|
+
// dependency. Lockfiles are generated, not authored, so they are no gap.
|
|
26
|
+
// omitted: setup.py / build.gradle / *.csproj parsing; the predicate reports
|
|
27
|
+
// them instead. Add a parser the day one of them is actually depended on.
|
|
28
|
+
const UNPARSED_DEPENDENCY_FILES = new Set(['setup.py', 'build.gradle', 'build.gradle.kts', 'Package.swift'])
|
|
29
|
+
const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lockb', 'Cargo.lock', 'poetry.lock', 'Pipfile.lock', 'uv.lock', 'Gemfile.lock', 'composer.lock', 'go.sum', 'paket.lock', 'packages.lock.json'])
|
|
30
|
+
|
|
31
|
+
export const unparsedDependencyFile = (path) => {
|
|
32
|
+
const name = basename(path)
|
|
33
|
+
if (LOCKFILES.has(name) || name.endsWith('.lock')) return false
|
|
34
|
+
return UNPARSED_DEPENDENCY_FILES.has(name) || name.endsWith('.csproj')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Dependency names declared in `after` that were not declared in `before`.
|
|
38
|
+
// A manifest that did not exist before declares everything as new — pass ''.
|
|
39
|
+
export function addedDeps(manifestName, before, after) {
|
|
40
|
+
const had = new Set(parseDeps(manifestName, before))
|
|
41
|
+
return parseDeps(manifestName, after).filter((d) => !had.has(d))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseDeps(manifestName, content) {
|
|
45
|
+
if (!content) return []
|
|
46
|
+
// npm and conda read a BOM-terminated manifest fine, JSON.parse does not, and
|
|
47
|
+
// a parser that rejects the file falls back to guessing. One strip here keeps
|
|
48
|
+
// every format honest about what it declares.
|
|
49
|
+
const text = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content
|
|
50
|
+
switch (manifestName) {
|
|
51
|
+
case 'package.json':
|
|
52
|
+
case 'composer.json':
|
|
53
|
+
return jsonDeps(text)
|
|
54
|
+
case 'requirements.txt':
|
|
55
|
+
case 'requirements-dev.txt':
|
|
56
|
+
return requirementDeps(text)
|
|
57
|
+
case 'go.mod':
|
|
58
|
+
return goModDeps(text)
|
|
59
|
+
case 'Cargo.toml':
|
|
60
|
+
return tomlDeps(text, cargoTables)
|
|
61
|
+
case 'pyproject.toml':
|
|
62
|
+
return tomlDeps(text, pyprojectTables)
|
|
63
|
+
case 'Pipfile':
|
|
64
|
+
return tomlDeps(text, pipfileTables)
|
|
65
|
+
case 'environment.yml':
|
|
66
|
+
case 'environment.yaml':
|
|
67
|
+
return condaDeps(text)
|
|
68
|
+
case 'setup.cfg':
|
|
69
|
+
return setupCfgDeps(text)
|
|
70
|
+
case 'Gemfile':
|
|
71
|
+
return gemfileDeps(text)
|
|
72
|
+
default:
|
|
73
|
+
return []
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- package.json / composer.json ----
|
|
78
|
+
// Both declare dependencies as named objects at fixed top-level keys; composer
|
|
79
|
+
// spells them `require`/`require-dev`. Parsing the JSON is what makes a minified
|
|
80
|
+
// manifest and a pretty-printed one declare the same set.
|
|
81
|
+
const JSON_DEP_KEYS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies', 'require', 'require-dev']
|
|
82
|
+
const JSON_BUNDLE_KEYS = ['bundledDependencies', 'bundleDependencies']
|
|
83
|
+
const JSON_TABLE_KEYS = new Set([...JSON_DEP_KEYS, ...JSON_BUNDLE_KEYS])
|
|
84
|
+
|
|
85
|
+
function jsonDeps(content) {
|
|
86
|
+
let doc
|
|
87
|
+
try {
|
|
88
|
+
doc = JSON.parse(content)
|
|
89
|
+
} catch {
|
|
90
|
+
return jsonFallbackDeps(content)
|
|
91
|
+
}
|
|
92
|
+
const names = new Set()
|
|
93
|
+
for (const key of JSON_DEP_KEYS) {
|
|
94
|
+
const table = doc?.[key]
|
|
95
|
+
if (table && typeof table === 'object') for (const name of Object.keys(table)) names.add(name)
|
|
96
|
+
}
|
|
97
|
+
for (const key of JSON_BUNDLE_KEYS) {
|
|
98
|
+
if (Array.isArray(doc?.[key])) for (const name of doc[key]) names.add(name)
|
|
99
|
+
}
|
|
100
|
+
return [...names]
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The fallback covers a partial write or JSON5-ish syntax npm tolerates. It has
|
|
104
|
+
// to stay table-scoped: scanning every quoted key/version pair reports the
|
|
105
|
+
// manifest's own metadata as a dependency, and narrowing that scan to
|
|
106
|
+
// version-shaped values instead loses `"left-pad": "latest"`. So walk the
|
|
107
|
+
// text's strings and brackets and read only the dependency tables out of them.
|
|
108
|
+
function jsonFallbackDeps(content) {
|
|
109
|
+
const tokens = jsonTokens(content)
|
|
110
|
+
const names = new Set()
|
|
111
|
+
let depth = 0
|
|
112
|
+
let root = -1 // a truncated manifest can be missing its root brace
|
|
113
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
114
|
+
const token = tokens[i].t
|
|
115
|
+
if (token === '{' || token === '[') { depth++; continue }
|
|
116
|
+
if (token === '}' || token === ']') { depth--; continue }
|
|
117
|
+
if (token !== 's' || tokens[i + 1]?.t !== ':') continue
|
|
118
|
+
if (root < 0) root = depth
|
|
119
|
+
if (depth !== root || !JSON_TABLE_KEYS.has(tokens[i].v)) continue
|
|
120
|
+
const value = tokens[i + 2]
|
|
121
|
+
if (value?.t === '{' || value?.t === '[') for (const n of spanNames(tokens, i + 2, value.t === '{')) names.add(n)
|
|
122
|
+
}
|
|
123
|
+
return [...names]
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Strings and structural characters, in order — enough structure to scope a
|
|
127
|
+
// lookup to a table without a JSON parser.
|
|
128
|
+
function jsonTokens(content) {
|
|
129
|
+
const tokens = []
|
|
130
|
+
for (let i = 0; i < content.length; i++) {
|
|
131
|
+
const c = content[i]
|
|
132
|
+
if (c !== '"' && c !== "'") {
|
|
133
|
+
if (c === '{' || c === '}' || c === '[' || c === ']' || c === ':') tokens.push({ t: c })
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
let v = ''
|
|
137
|
+
for (i++; i < content.length && content[i] !== c; i++) v += content[i] === '\\' ? content[++i] ?? '' : content[i]
|
|
138
|
+
tokens.push({ t: 's', v })
|
|
139
|
+
}
|
|
140
|
+
return tokens
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// The strings of one value span: an object's keys (`object`, when the value is
|
|
144
|
+
// a table), or every string in an array (`bundledDependencies`).
|
|
145
|
+
function spanNames(tokens, start, object) {
|
|
30
146
|
const out = []
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
147
|
+
let depth = 0
|
|
148
|
+
for (let i = start; i < tokens.length; i++) {
|
|
149
|
+
const t = tokens[i].t
|
|
150
|
+
if (t === '{' || t === '[') depth++
|
|
151
|
+
else if (t === '}' || t === ']') { if (--depth === 0) break }
|
|
152
|
+
else if (depth === 1 && t === 's' && (!object || tokens[i + 1]?.t === ':')) out.push(tokens[i].v)
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ---- requirements.txt ----
|
|
158
|
+
// Line-oriented: a name, then optionally extras, a version specifier, or an
|
|
159
|
+
// environment marker. Options (-r, -e, --hash) and comments declare nothing.
|
|
160
|
+
// omitted: PEP 503 name folding (foo_bar == foo-bar): lowercasing covers the
|
|
161
|
+
// installed spelling; add folding if a repo is ever bitten by the other.
|
|
162
|
+
function requirementDeps(content) {
|
|
163
|
+
const names = []
|
|
164
|
+
for (const raw of content.split('\n')) {
|
|
165
|
+
const line = raw.split('#')[0].trim()
|
|
166
|
+
if (!line || line.startsWith('-')) continue
|
|
167
|
+
const m = line.match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/)
|
|
168
|
+
if (m) names.push(m[1].toLowerCase())
|
|
169
|
+
}
|
|
170
|
+
return [...new Set(names)]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ---- go.mod ----
|
|
174
|
+
// Modules live in a `require ( ... )` block or a single-line `require x v1.2.3`.
|
|
175
|
+
// The `go`/`toolchain` directives name no module.
|
|
176
|
+
function goModDeps(content) {
|
|
177
|
+
const names = []
|
|
178
|
+
let block = false
|
|
179
|
+
for (const raw of content.split('\n')) {
|
|
180
|
+
const line = raw.trim()
|
|
181
|
+
if (!line || line.startsWith('//')) continue
|
|
182
|
+
if (block) {
|
|
183
|
+
if (line === ')') { block = false; continue }
|
|
184
|
+
const m = line.match(/^([^\s]+)\s+v\d/)
|
|
185
|
+
if (m) names.push(m[1])
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
if (line === 'require (') { block = true; continue }
|
|
189
|
+
const m = line.match(/^require\s+([^\s]+)\s+v\d/)
|
|
190
|
+
if (m) names.push(m[1])
|
|
191
|
+
}
|
|
192
|
+
return [...new Set(names)]
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ---- Cargo.toml / pyproject.toml / Pipfile ----
|
|
196
|
+
// TOML is table-scoped, and table membership is what decides whether a key names
|
|
197
|
+
// a dependency: `version = "1.0.0"` under [package] is the crate's own version,
|
|
198
|
+
// while the same line under [dependencies] would declare a package called
|
|
199
|
+
// "version". `names(table, key, value)` returns the dependencies a key declares;
|
|
200
|
+
// key is null on a table header, which is how [dependencies.serde] is read.
|
|
201
|
+
function tomlDeps(content, names) {
|
|
202
|
+
const out = new Set()
|
|
203
|
+
const lines = content.split('\n')
|
|
204
|
+
let table = ''
|
|
205
|
+
for (let i = 0; i < lines.length; i++) {
|
|
206
|
+
const line = stripComment(lines[i]).trim()
|
|
207
|
+
if (!line) continue
|
|
208
|
+
const header = line.match(/^\[\[?([^\]]+)\]\]?$/)
|
|
209
|
+
if (header) {
|
|
210
|
+
table = header[1].trim()
|
|
211
|
+
for (const n of names(table, null, '')) out.add(n)
|
|
212
|
+
continue
|
|
52
213
|
}
|
|
53
|
-
|
|
214
|
+
const kv = line.match(/^([A-Za-z0-9_"'.-]+)\s*=\s*(.*)$/)
|
|
215
|
+
if (!kv) continue
|
|
216
|
+
let value = kv[2]
|
|
217
|
+
// An array may span lines (`dependencies = [` … `]` in pyproject). Depth is
|
|
218
|
+
// tracked as each line is appended: re-scanning the whole accumulator per
|
|
219
|
+
// line is quadratic (40k lines cost >1s), and a line that opens a table or
|
|
220
|
+
// assigns a key ends the array — otherwise an unclosed `[` swallows the rest
|
|
221
|
+
// of the file and hands its `version = "1.0.0"` back as a dependency name.
|
|
222
|
+
if (value.startsWith('[')) {
|
|
223
|
+
let depth = bracketDepth(value)
|
|
224
|
+
while (depth > 0 && i + 1 < lines.length) {
|
|
225
|
+
const next = lines[i + 1]
|
|
226
|
+
if (tomlRow(next) || value.length > TOML_VALUE_MAX) break
|
|
227
|
+
i++
|
|
228
|
+
depth += bracketDepth(next)
|
|
229
|
+
value += '\n' + next
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
for (const n of names(table, kv[1].replace(/^["']|["']$/g, ''), value)) out.add(n)
|
|
233
|
+
}
|
|
234
|
+
return [...out]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// `workspace.` is the shared table every member crate resolves through, so a
|
|
238
|
+
// dependency declared there is a dependency of the repo. TOML also allows any
|
|
239
|
+
// key to be quoted, nested table names included.
|
|
240
|
+
const CARGO_DEP_TABLE = /^(?:workspace\.)?(?:target\..+\.)?(?:dev-|build-)?dependencies$/
|
|
241
|
+
const CARGO_NESTED = /^(?:workspace\.)?(?:target\..+\.)?(?:dev-|build-)?dependencies\.(["']?)([A-Za-z0-9_-]+)\1$/
|
|
242
|
+
|
|
243
|
+
function cargoTables(table, key) {
|
|
244
|
+
const nested = table.match(CARGO_NESTED)
|
|
245
|
+
if (nested) return key === null ? [nested[2]] : []
|
|
246
|
+
return CARGO_DEP_TABLE.test(table) && key !== null ? [key] : []
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function pyprojectTables(table, key, value) {
|
|
250
|
+
if (key === null) return []
|
|
251
|
+
if (table === 'project' && key === 'dependencies') return specNames(value)
|
|
252
|
+
if (table === 'project.optional-dependencies' || table === 'dependency-groups') return specNames(value)
|
|
253
|
+
if (table === 'build-system' && key === 'requires') return specNames(value)
|
|
254
|
+
if (table === 'tool.poetry.dependencies' || /^tool\.poetry\.group\..+\.dependencies$/.test(table)) {
|
|
255
|
+
return key === 'python' ? [] : [key] // poetry's `python` key is an interpreter constraint
|
|
256
|
+
}
|
|
257
|
+
return []
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Names out of an array of PEP 508 specifiers: "requests[security]>=2.31".
|
|
261
|
+
const specNames = (value) => [...value.matchAll(/["']([^"']+)["']/g)].flatMap((m) => m[1].match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/)?.[1].toLowerCase() ?? [])
|
|
262
|
+
|
|
263
|
+
// ---- Pipfile ----
|
|
264
|
+
// TOML, but only two of its tables declare packages: [requires] pins the
|
|
265
|
+
// interpreter and [[source]] describes an index.
|
|
266
|
+
const pipfileTables = (table, key) =>
|
|
267
|
+
key !== null && (table === 'packages' || table === 'dev-packages') ? [key] : []
|
|
268
|
+
|
|
269
|
+
// ---- environment.yml / environment.yaml ----
|
|
270
|
+
// A conda environment lists its packages under the top-level `dependencies:`,
|
|
271
|
+
// as `name`, `name=1.2` or `name>=1.2`; a pip list nests one level deeper. An
|
|
272
|
+
// item that ends in `:` is a nested mapping key, not a package.
|
|
273
|
+
function condaDeps(content) {
|
|
274
|
+
const names = []
|
|
275
|
+
let list = false
|
|
276
|
+
for (const raw of content.split('\n')) {
|
|
277
|
+
const line = raw.split('#')[0]
|
|
278
|
+
if (!line.trim()) continue
|
|
279
|
+
if (line.trimStart() === line) { list = /^dependencies\s*:/.test(line); continue }
|
|
280
|
+
if (!list || /:\s*$/.test(line)) continue
|
|
281
|
+
const m = line.trim().replace(/^-\s*/, '').match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/)
|
|
282
|
+
if (m) names.push(m[1].toLowerCase())
|
|
283
|
+
}
|
|
284
|
+
return [...new Set(names)]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- setup.cfg ----
|
|
288
|
+
// INI, not TOML. Requirement specs are the continuation lines of
|
|
289
|
+
// `install_requires` under [options], and of every key under
|
|
290
|
+
// [options.extras_require]; a key sits at column 0, its value below it.
|
|
291
|
+
function setupCfgDeps(content) {
|
|
292
|
+
const names = []
|
|
293
|
+
let table = ''
|
|
294
|
+
let list = false
|
|
295
|
+
for (const raw of content.split('\n')) {
|
|
296
|
+
const line = raw.split('#')[0].trim()
|
|
297
|
+
if (!line) continue
|
|
298
|
+
const header = line.match(/^\[([^\]]+)\]$/)
|
|
299
|
+
if (header) { table = header[1].trim(); list = false; continue }
|
|
300
|
+
const kv = raw.trimStart() === raw ? line.match(/^([^=]+?)\s*=\s*(.*)$/) : null
|
|
301
|
+
if (kv) {
|
|
302
|
+
list = (table === 'options' && kv[1].trim() === 'install_requires') || table === 'options.extras_require'
|
|
303
|
+
if (list) names.push(...requirementDeps(kv[2]))
|
|
304
|
+
continue
|
|
305
|
+
}
|
|
306
|
+
if (list) names.push(...requirementDeps(line))
|
|
307
|
+
}
|
|
308
|
+
return [...new Set(names)]
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---- Gemfile ----
|
|
312
|
+
// `gem 'name', '~> 7.0'` — every further argument is a version constraint.
|
|
313
|
+
function gemfileDeps(content) {
|
|
314
|
+
const names = []
|
|
315
|
+
for (const raw of content.split('\n')) {
|
|
316
|
+
const m = stripComment(raw).trim().match(/^gem\s+['"]([^'"]+)['"]/)
|
|
317
|
+
if (m) names.push(m[1])
|
|
318
|
+
}
|
|
319
|
+
return [...new Set(names)]
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---- helpers ----
|
|
323
|
+
// Bounds a multi-line array. Real manifests are orders of magnitude smaller;
|
|
324
|
+
// anything past this is malformed or hostile, and swallowing it has no value.
|
|
325
|
+
const TOML_VALUE_MAX = 1 << 20
|
|
326
|
+
|
|
327
|
+
// How far a value string is from closing its brackets, counted once per line.
|
|
328
|
+
const bracketDepth = (s) => {
|
|
329
|
+
let d = 0
|
|
330
|
+
for (let i = 0; i < s.length; i++) {
|
|
331
|
+
if (s[i] === '[') d++
|
|
332
|
+
else if (s[i] === ']') d--
|
|
333
|
+
}
|
|
334
|
+
return d
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// A line that opens a table or assigns a key cannot be an array element, so it
|
|
338
|
+
// closes an array that is still looking for its bracket. Tables are end-anchored
|
|
339
|
+
// because a quoted spec may itself start with `[` — `"requests[security]>=2.31"`.
|
|
340
|
+
const tomlRow = (line) => /^\s*(?:\[[^\]]*\]\s*$|(?:"[^"]*"|'[^']*'|[A-Za-z0-9_-]+)\s*=)/.test(stripComment(line))
|
|
341
|
+
|
|
342
|
+
// A `#` inside a quoted string is not a comment — poetry and uv pin git deps as
|
|
343
|
+
// `"pkg @ git+https://host/repo#subdirectory=lib"`.
|
|
344
|
+
function stripComment(line) {
|
|
345
|
+
let quote = ''
|
|
346
|
+
for (let i = 0; i < line.length; i++) {
|
|
347
|
+
const c = line[i]
|
|
348
|
+
if (quote) { if (c === quote) quote = '' } else if (c === '"' || c === "'") quote = c
|
|
349
|
+
else if (c === '#') return line.slice(0, i)
|
|
54
350
|
}
|
|
55
|
-
return
|
|
351
|
+
return line
|
|
56
352
|
}
|
package/lib/exec.mjs
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// One definition of "may this process run code it did not ship".
|
|
2
|
+
//
|
|
3
|
+
// Four places need this answer — the receipt verifier, the lint bridge, the
|
|
4
|
+
// dependency hook, and the GitHub Action's environment — and three of them
|
|
5
|
+
// previously carried their own copy of the predicate. Copies of a safety rule
|
|
6
|
+
// drift: one of them accepted only the literal `1`, so `OMIT_NO_EXEC=true`, the
|
|
7
|
+
// spelling a user is most likely to reach for, silently left execution enabled.
|
|
8
|
+
//
|
|
9
|
+
// The rule is "explicitly off wins, and only these three spellings mean off",
|
|
10
|
+
// so an unset variable, an empty one, and `0` all leave the default behaviour
|
|
11
|
+
// alone while any deliberate value disarms.
|
|
12
|
+
|
|
13
|
+
// Off-values, kept identical across every flag that gates execution.
|
|
14
|
+
const OFF = ['', '0', 'false']
|
|
15
|
+
|
|
16
|
+
export const flagOn = (value) => value !== undefined && !OFF.includes(value)
|
|
17
|
+
|
|
18
|
+
export const execDisabled = () => flagOn(process.env.OMIT_NO_EXEC)
|
package/lib/git.mjs
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Git access shared by the CLI and the hooks.
|
|
2
|
+
//
|
|
3
|
+
// This contract exists because the previous one was fail-open. `git()` caught
|
|
4
|
+
// everything and returned null, and callers coalesced that to '' — so "git
|
|
5
|
+
// could not answer" (a diff past the buffer limit, a ref that does not exist)
|
|
6
|
+
// was indistinguishable from "there is nothing here". A gate reporting
|
|
7
|
+
// "hazards: 0" because it never read the diff is worse than one that crashes:
|
|
8
|
+
// the verdict looks healthy while nothing was examined.
|
|
9
|
+
//
|
|
10
|
+
// So: absence is a legitimate answer only where it is returned explicitly
|
|
11
|
+
// (`fileAtRevision` says `present: false`), and every other failure throws.
|
|
12
|
+
// Callers that cannot tolerate a throw use `probe` and must surface `ok:false`
|
|
13
|
+
// as a hard error of their own — never as an empty result.
|
|
14
|
+
import { execFileSync } from 'node:child_process'
|
|
15
|
+
import { relative, resolve } from 'node:path'
|
|
16
|
+
import { realpathSync } from 'node:fs'
|
|
17
|
+
|
|
18
|
+
// Real diffs exceed Node's 1 MiB default routinely — a lockfile, a vendored
|
|
19
|
+
// asset, a generated bundle. The old code threw ENOBUFS past that and swallowed
|
|
20
|
+
// it, which turned any large commit into a clean verdict. The ceiling is high
|
|
21
|
+
// rather than absent so a runaway still fails loudly instead of exhausting memory.
|
|
22
|
+
const MAX_BUFFER = 1 << 30
|
|
23
|
+
|
|
24
|
+
export class GitError extends Error {
|
|
25
|
+
constructor(args, reason) {
|
|
26
|
+
super(`git ${args.join(' ')} — ${reason}`)
|
|
27
|
+
this.name = 'GitError'
|
|
28
|
+
this.args = args
|
|
29
|
+
this.reason = reason
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// { ok: true, out } | { ok: false, reason }, never throws. stderr is captured
|
|
34
|
+
// because git's message is what distinguishes "this path is not in that
|
|
35
|
+
// revision" from "that revision does not exist".
|
|
36
|
+
export function probe(cwd, args) {
|
|
37
|
+
try {
|
|
38
|
+
const out = execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], maxBuffer: MAX_BUFFER })
|
|
39
|
+
return { ok: true, out }
|
|
40
|
+
} catch (e) {
|
|
41
|
+
const reason = String(e.stderr ?? '').trim() || e.code || e.message || 'failed'
|
|
42
|
+
return { ok: false, reason }
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function git(cwd, args) {
|
|
47
|
+
const r = probe(cwd, args)
|
|
48
|
+
if (!r.ok) throw new GitError(args, r.reason)
|
|
49
|
+
return r.out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The absolute repo root, or null when cwd is not inside a repository.
|
|
53
|
+
// Every path handed to a `rev:path` argument must be resolved against this,
|
|
54
|
+
// never against the caller's cwd: git reports and resolves those root-relative.
|
|
55
|
+
export function repoRoot(cwd) {
|
|
56
|
+
const r = probe(cwd, ['rev-parse', '--show-toplevel'])
|
|
57
|
+
return r.ok ? r.out.trim() : null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// A path relative to the repo ROOT, or null when the file is outside the repo.
|
|
61
|
+
// Resolved, so a `..` cannot smuggle something past the caller's containment
|
|
62
|
+
// check by being lexically inside.
|
|
63
|
+
export function repoRelPath(root, file) {
|
|
64
|
+
const abs = resolve(root, file)
|
|
65
|
+
const rel = relative(root, abs)
|
|
66
|
+
if (rel !== '' && !rel.startsWith('..')) return rel
|
|
67
|
+
// The harness's file_path and git's `--show-toplevel` can disagree on a
|
|
68
|
+
// symlinked prefix — macOS resolves /var to /private/var, so a repo under a
|
|
69
|
+
// tmpdir yields a path that reads as outside itself. Retry once through
|
|
70
|
+
// realpath before concluding the file is genuinely elsewhere; without this a
|
|
71
|
+
// real manifest edit is waved through as "outside the repo".
|
|
72
|
+
try {
|
|
73
|
+
const real = relative(realpathSync(root), realpathSync(abs))
|
|
74
|
+
if (real !== '' && !real.startsWith('..')) return real
|
|
75
|
+
} catch {}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Content of `file` at `rev`, where `rev` is any tree-ish: 'HEAD', an empty
|
|
80
|
+
// string for the staged (index) blob, or a branch/tag. Three outcomes:
|
|
81
|
+
//
|
|
82
|
+
// { present: true, text } it exists there
|
|
83
|
+
// { present: false } it legitimately does not — a new file has no
|
|
84
|
+
// earlier revision, and neither does anything in a
|
|
85
|
+
// repo whose HEAD has no commits yet
|
|
86
|
+
// { failed: true, reason } git could not answer, which is not the same as
|
|
87
|
+
// empty and must not be rendered as one
|
|
88
|
+
export function fileAtRevision(cwd, rev, path) {
|
|
89
|
+
if (!path) return { present: false }
|
|
90
|
+
// An unborn HEAD has no revision to look in — absence, not failure. Checked
|
|
91
|
+
// separately because `git show HEAD:x` reports it as an invalid object name,
|
|
92
|
+
// which is otherwise indistinguishable from a genuinely broken revision.
|
|
93
|
+
if (rev === 'HEAD' && !probe(cwd, ['rev-parse', '--verify', 'HEAD']).ok) return { present: false }
|
|
94
|
+
|
|
95
|
+
// An empty `rev` means the staged blob. Spelled `:0:<path>` rather than a bare
|
|
96
|
+
// `:<path>` because git parses the latter as a malformed revision and refuses
|
|
97
|
+
// it — an error indistinguishable from a refusal to answer, which made the
|
|
98
|
+
// gate fail in every repo that had never used omit.
|
|
99
|
+
const r = probe(cwd, ['show', rev === '' ? `:0:${path}` : `${rev}:${path}`])
|
|
100
|
+
if (r.ok) return { present: true, text: r.out }
|
|
101
|
+
// git words this differently per revision: a tree says "does not exist in
|
|
102
|
+
// 'HEAD'", the index says "does not exist (neither on disk nor in the index)".
|
|
103
|
+
// Matching only the first spelling turned an absent staged file into a hard
|
|
104
|
+
// failure, which refuses a commit over a file that was never there.
|
|
105
|
+
if (/does not exist|exists on disk, but not in/i.test(r.reason)) return { present: false }
|
|
106
|
+
return { failed: true, reason: r.reason }
|
|
107
|
+
}
|
package/lib/hazards.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Hazard scanning: hardcoded secrets and injection-prone patterns in added lines.
|
|
2
|
-
// Heuristics raise objections, not verdicts:
|
|
2
|
+
// Heuristics raise objections, not verdicts: a reviewed *injection* line is
|
|
3
|
+
// suppressed with `omit-allow: <reason>`. Secrets have no override at all.
|
|
3
4
|
|
|
4
|
-
const SECRET_RULES = [
|
|
5
|
+
export const SECRET_RULES = [
|
|
5
6
|
['aws-access-key', /AKIA[0-9A-Z]{16}/],
|
|
6
7
|
['private-key-block', /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/],
|
|
7
8
|
['github-token', /gh[pousr]_[A-Za-z0-9]{36,}/],
|
|
@@ -10,6 +11,7 @@ const SECRET_RULES = [
|
|
|
10
11
|
['stripe-live-key', /sk_live_[A-Za-z0-9]{20,}/],
|
|
11
12
|
['openai-style-key', /\bsk-[A-Za-z0-9_-]{32,}/],
|
|
12
13
|
['google-api-key', /AIza[0-9A-Za-z_-]{35}/],
|
|
14
|
+
['chitragupta-key', /chg_[A-Za-z0-9_-]{20,}/],
|
|
13
15
|
['hardcoded-credential', /(?:api[_-]?key|secret|token|password|passwd)["']?\s*[:=]\s*["'][A-Za-z0-9+/_-]{16,}["']/i],
|
|
14
16
|
]
|
|
15
17
|
|
|
@@ -19,7 +21,9 @@ const INJECTION_RULES = [
|
|
|
19
21
|
['shell-concat', /\bexec(?:Sync)?\s*\(\s*(?:`[^`]*\$\{|[^)"'`]*\+)/],
|
|
20
22
|
['subprocess-shell-true', /subprocess\.(?:run|call|Popen)\s*\(.*shell\s*=\s*True/],
|
|
21
23
|
['os-system-dynamic', /os\.system\s*\(\s*(?:f["']|[^)"']*\+)/],
|
|
22
|
-
|
|
24
|
+
// The character class is not decoration: spelled literally, this pattern
|
|
25
|
+
// matches its own source text and the scanner reports the rule table.
|
|
26
|
+
['inner-html', /\.innerHTML\s*=|dangerouslySet[A-Za-z]*HTML/],
|
|
23
27
|
['pickle-load', /pickle\.loads?\s*\(/],
|
|
24
28
|
['yaml-unsafe-load', /yaml\.load\s*\((?![^)]*SafeLoader)/],
|
|
25
29
|
]
|
|
@@ -29,13 +33,34 @@ const sqlInjection = (line) =>
|
|
|
29
33
|
/\b(?:SELECT|INSERT|UPDATE|DELETE)\b/i.test(line) &&
|
|
30
34
|
(/\$\{/.test(line) || /["'`]\s*\+/.test(line) || /f["']/.test(line) || /%\s*\(/.test(line))
|
|
31
35
|
|
|
36
|
+
// The reviewed-line marker, shared with danger.mjs and leaks.mjs so one form
|
|
37
|
+
// means one thing everywhere. Three properties turn it from free text into a
|
|
38
|
+
// review:
|
|
39
|
+
// - it must be a comment, not a substring: quoted spans are blanked first, so
|
|
40
|
+
// `echo "omit-allow:"` and `const s = "// omit-allow: nope"` don't count;
|
|
41
|
+
// - it must carry a reason — the docs have always said `omit-allow: <reason>`,
|
|
42
|
+
// and a bare token costs a reviewer nothing;
|
|
43
|
+
// - it must be the trailing comment, so what it covers is what precedes it;
|
|
44
|
+
// a marker buried mid-command (or on an earlier line of a multi-line
|
|
45
|
+
// command) reads as text, not as the documented `append: # omit-allow: …`.
|
|
46
|
+
// It is deliberately NOT wired to SECRET_RULES — see findHazards.
|
|
47
|
+
const QUOTED_SPAN_RE = /"(?:[^"\\]|\\.)*"|'[^']*'/g
|
|
48
|
+
const ALLOW_MARKER_RE = /(?:^|[\s;&|])(?:\/\/|#)[ \t]*omit-allow:[ \t]*\S[^\n]*$/
|
|
49
|
+
|
|
50
|
+
export const isAllowSuppressed = (text) =>
|
|
51
|
+
ALLOW_MARKER_RE.test(text.trimEnd().replace(QUOTED_SPAN_RE, (m) => ' '.repeat(m.length)))
|
|
52
|
+
|
|
32
53
|
export function findHazards(lines) {
|
|
33
54
|
const findings = []
|
|
34
55
|
lines.forEach((text, i) => {
|
|
35
|
-
if (text.includes('omit-allow:')) return
|
|
36
56
|
for (const [rule, re] of SECRET_RULES) {
|
|
37
57
|
if (re.test(text)) findings.push({ type: 'secret', rule, line: i + 1, text: text.trim().slice(0, 120) })
|
|
38
58
|
}
|
|
59
|
+
// Secrets are scanned before the marker runs, on purpose: `omit-allow:`
|
|
60
|
+
// restores a reviewed judgement call, and a hardcoded key is not one. The
|
|
61
|
+
// README's "Secrets have no override" is true only while this line stays
|
|
62
|
+
// below that loop.
|
|
63
|
+
if (isAllowSuppressed(text)) return
|
|
39
64
|
for (const [rule, re] of INJECTION_RULES) {
|
|
40
65
|
if (re.test(text)) findings.push({ type: 'injection', rule, line: i + 1, text: text.trim().slice(0, 120) })
|
|
41
66
|
}
|