@codacy/verity-cli 0.34.4 → 0.35.0-experimental.16e0448

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.
@@ -1,345 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Derive and validate codacy-analysis patternIds against the pattern catalogue the
4
- * installed @codacy/analysis-cli tool adapters actually define.
5
- *
6
- * WHY THIS EXISTS
7
- * ---------------
8
- * A wrong patternId fails SILENTLY. Each adapter builds `enabledPatterns` from the
9
- * config and then either skips work entirely or filters every result away:
10
- *
11
- * runVulnScan = enabledPatterns.size === 0 || vulnPatternIds.some(id => enabledPatterns.has(id))
12
- *
13
- * So a tool with an unrecognised pattern list reports "ready", routes files, and
14
- * returns 0 issues — indistinguishable from clean code. Worse, `size === 0` means
15
- * "run ALL defaults", so an empty list works while a wrong list silently disables
16
- * the tool. Verity shipped `trivy_vuln`, bare `no-eval`, and bare `F401` for months;
17
- * ESLint, Semgrep, Trivy and Ruff were all dead and nothing surfaced it.
18
- *
19
- * The ID format is `<toolId>_<ruleId>`, with `/` replaced by `_` in namespaced
20
- * rules (`@typescript-eslint/no-explicit-any` → `ESLint9_@typescript-eslint_no-explicit-any`).
21
- *
22
- * MODES
23
- * validate [config] Check every patternId in the config resolves.
24
- * Exit 1 if any does not. Default: .codacy/codacy.config.json
25
- * --list <toolId> [regex] Print the adapter's real pattern IDs, one per line.
26
- * --emit <toolId> [regex] Print a ready-to-paste JSON "patterns" array.
27
- *
28
- * Use --emit to GENERATE config blocks so IDs are never hand-authored, e.g. Trivy:
29
- * node validate-patterns.mjs --emit Trivy '^Trivy_(secret|malicious_packages|vulnerability_(critical|high))$'
30
- */
31
-
32
- import { readFileSync, readdirSync, existsSync, realpathSync, statSync, writeSync } from 'node:fs'
33
- import { join, dirname, delimiter } from 'node:path'
34
-
35
- /**
36
- * Locate the @codacy adapter packages shipped with the installed analysis CLI.
37
- * Resolved without shelling out, so this works on Windows as well as POSIX.
38
- */
39
- function findToolsDir() {
40
- if (process.env.CODACY_TOOLS_DIR) return process.env.CODACY_TOOLS_DIR
41
-
42
- // Walk PATH ourselves rather than calling `which`/`where`, which differ per platform.
43
- const exts = process.platform === 'win32'
44
- ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';')
45
- : ['']
46
- const candidates = []
47
- for (const dir of (process.env.PATH ?? '').split(delimiter)) {
48
- if (!dir) continue
49
- for (const ext of exts) {
50
- candidates.push(join(dir, 'codacy-analysis' + ext.toLowerCase()))
51
- if (ext) candidates.push(join(dir, 'codacy-analysis' + ext))
52
- }
53
- }
54
-
55
- for (const candidate of candidates) {
56
- let resolved
57
- try {
58
- if (!statSync(candidate).isFile()) continue
59
- resolved = realpathSync(candidate)
60
- } catch {
61
- continue
62
- }
63
- // resolved is <pkg>/dist/index.js (POSIX symlink) or a shim dir (Windows).
64
- // Walk up looking for node_modules/@codacy.
65
- let dir = dirname(resolved)
66
- for (let i = 0; i < 6; i++) {
67
- const tools = join(dir, 'node_modules', '@codacy')
68
- if (existsSync(tools)) return tools
69
- const parent = dirname(dir)
70
- if (parent === dir) break
71
- dir = parent
72
- }
73
- }
74
- return null
75
- }
76
-
77
- /**
78
- * Harvest the pattern IDs an adapter defines. Adapters ship as bundled JS with the
79
- * pattern catalogue inlined, so we scan for `<toolId>_<rule>` literals. This is a
80
- * heuristic over a build artifact, not a public API — it can only ever produce false
81
- * ALARMS (an ID we fail to find), never false confidence, which is the safe direction.
82
- */
83
- /** The characters a pattern id may contain after its `<toolId>_` prefix. */
84
- const ID_CHAR = /[A-Za-z0-9_@./-]/
85
-
86
- /**
87
- * Scan for `<toolId>_<rule>` literals WITHOUT building a regex from `toolId`.
88
- *
89
- * The previous form compiled `new RegExp(`${escaped}_[...]+`)` per call. Escaping
90
- * made it correct, but a RegExp built from a runtime value is a ReDoS surface by
91
- * construction and reads as one to any reviewer or scanner. An indexOf scan has
92
- * neither problem, needs no escaping to be right, and is what the code was always
93
- * expressing: find the prefix, then take the id characters that follow it.
94
- */
95
- function harvestIds(toolsDir, toolId) {
96
- const ids = new Set()
97
- const prefix = toolId + '_'
98
- const scan = (text) => {
99
- let i = text.indexOf(prefix)
100
- while (i !== -1) {
101
- let end = i + prefix.length
102
- while (end < text.length && ID_CHAR.test(text[end])) end++
103
- // A bare prefix with nothing after it is not an id.
104
- if (end > i + prefix.length) ids.add(text.slice(i, end))
105
- i = text.indexOf(prefix, end > i ? end : i + 1)
106
- }
107
- }
108
- for (const pkg of readdirSync(toolsDir)) {
109
- if (!pkg.startsWith('tools-')) continue
110
- const distDir = join(toolsDir, pkg, 'dist')
111
- if (!existsSync(distDir)) continue
112
- for (const file of readdirSync(distDir)) {
113
- if (!file.endsWith('.js')) continue
114
- scan(readFileSync(join(distDir, file), 'utf-8'))
115
- }
116
- }
117
- return ids
118
- }
119
-
120
- const toolsDir = findToolsDir()
121
- if (!toolsDir) {
122
- console.error('SKIP: could not locate @codacy tool adapters.')
123
- console.error(' Install the CLI (npm i -g @codacy/analysis-cli) or set CODACY_TOOLS_DIR.')
124
- process.exit(1)
125
- }
126
-
127
- /**
128
- * Write to fd 1 synchronously, looping until every byte is gone.
129
- *
130
- * `console.log` / `process.stdout.write` are ASYNCHRONOUS when stdout is a pipe,
131
- * and this script exits in the same tick — so the output was cut at whatever the
132
- * pipe accepted. A write callback does not help either: this is a top-level
133
- * block, so execution falls through to the validate path and exits 1 before the
134
- * callback can run (observed: 65536 bytes, status 1).
135
- *
136
- * The loop matters. A single writeSync to a pipe may report a SHORT write, and
137
- * dropping the remainder would reintroduce the same bug in a quieter form.
138
- */
139
- function writeStdoutSync(text) {
140
- const buf = Buffer.from(text, 'utf8')
141
- let off = 0
142
- while (off < buf.length) {
143
- try {
144
- off += writeSync(1, buf, off, buf.length - off)
145
- } catch (err) {
146
- if (err.code === 'EAGAIN') continue // non-blocking pipe not ready; retry
147
- if (err.code === 'EPIPE') return // consumer went away (e.g. `| head`)
148
- throw err
149
- }
150
- }
151
- }
152
-
153
- const argv = process.argv.slice(2)
154
- const mode = argv[0] === '--list' || argv[0] === '--emit' ? argv[0] : 'validate'
155
-
156
- // ---------------------------------------------------------------------------
157
- // --list / --emit : derive IDs straight from adapter metadata
158
- // ---------------------------------------------------------------------------
159
- if (mode !== 'validate') {
160
- const toolId = argv[1]
161
- if (!toolId) {
162
- console.error(`Usage: validate-patterns.mjs ${mode} <toolId> [regex]`)
163
- process.exit(1)
164
- }
165
- // An invalid pattern here is a typo in a hand-typed argument, not an error worth a
166
- // stack trace — report it with the offending input so it can be fixed at a glance.
167
- let filter = null
168
- if (argv[2]) {
169
- try {
170
- // The filter IS a regex — that is the documented interface
171
- // (`--list Trivy '^Trivy_secret$'`) and two tests pin the behaviour, so it
172
- // cannot become a substring match. Unlike the harvest above, there is no
173
- // way to express this without compiling a runtime value.
174
- //
175
- // It is also not a security boundary: this is a local developer CLI, the
176
- // pattern comes from the operator's own shell, and the only thing a
177
- // catastrophic regex can stall is the operator's own terminal. Nothing
178
- // untrusted reaches this line — `argv[2]` is typed by the person running it.
179
- // Suppressions for both engines Codacy may raise this under. The ESLint
180
- // form alone did not clear it, so the finding comes from the Semgrep side;
181
- // `nosemgrep` is rule-agnostic, which is deliberate here — pinning a rule id
182
- // guessed from a message string is how the first attempt failed.
183
- // eslint-disable-next-line security/detect-non-literal-regexp -- operator-supplied filter, local CLI, self-inflicted at worst
184
- // nosemgrep: operator-supplied filter on a local CLI; see the reasoning above
185
- filter = new RegExp(argv[2])
186
- } catch (err) {
187
- console.error(`Invalid regex: ${argv[2]}`)
188
- console.error(` ${err.message}`)
189
- console.error(` Note: quote the argument so the shell does not expand it, e.g. '^Trivy_secret$'`)
190
- process.exit(1)
191
- }
192
- }
193
- const ids = [...harvestIds(toolsDir, toolId)].sort().filter((id) => !filter || filter.test(id))
194
- if (ids.length === 0) {
195
- console.error(`No pattern IDs found for toolId "${toolId}"${filter ? ' matching ' + argv[2] : ''}.`)
196
- console.error('Check the toolId spelling (case-sensitive: ESLint9, Semgrep, Trivy, Ruff, shellcheck).')
197
- process.exit(1)
198
- }
199
- // ⚠ NEVER `process.exit()` IN THE SAME TICK AS A WRITE TO STDOUT.
200
- //
201
- // When stdout is a PIPE, Node's writes are asynchronous. `process.exit()`
202
- // terminates before the buffer drains, truncating output at whatever the pipe
203
- // accepted — about 64 KB. Redirecting to a FILE hides it completely, because
204
- // file writes are synchronous. So it looks fine every time you check by hand,
205
- // and is broken for every caller that reads the output programmatically.
206
- //
207
- // Measured on the ESLint9 catalogue (2936 ids), five runs each:
208
- // node ... > file -> 2936, 2936, 2936, 2936, 2936 (whole)
209
- // node ... | cat -> 1812, 1812, 1812, 1812, 1812 (cut at the buffer)
210
- //
211
- // The harvest is deterministic. Only the DELIVERY was not, and it cost a real
212
- // misdiagnosis: `ESLint9_use-isnan` sorts at line 2608, past the cut, so every
213
- // consumer reading through a pipe — including the review that flagged it —
214
- // concluded a perfectly valid pattern id did not exist.
215
- //
216
- // `process.exitCode = 0` alone is NOT enough here: this is a top-level block,
217
- // so execution would fall through into the validate path below and exit there,
218
- // truncating exactly as before. The write callback fires once the data has been
219
- // handed to the OS, which is the point at which exiting is safe.
220
- const out =
221
- mode === '--list'
222
- ? ids.join('\n')
223
- : JSON.stringify(ids.map((patternId) => ({ patternId })), null, 2)
224
- writeStdoutSync(out + '\n')
225
- process.exit(0)
226
- }
227
-
228
- // ---------------------------------------------------------------------------
229
- // validate : every patternId in the config must resolve
230
- // ---------------------------------------------------------------------------
231
- const configPath = argv[0] ?? '.codacy/codacy.config.json'
232
-
233
- if (!existsSync(configPath)) {
234
- console.error(`SKIP: ${configPath} not found — write the config first.`)
235
- process.exit(1)
236
- }
237
-
238
- let config
239
- try {
240
- config = JSON.parse(readFileSync(configPath, 'utf-8'))
241
- } catch (err) {
242
- console.error(`FAIL: could not parse ${configPath}`)
243
- console.error(` ${err.message}`)
244
- process.exit(1)
245
- }
246
-
247
- const tools = config?.tools ?? []
248
- if (!Array.isArray(tools)) {
249
- console.error(`FAIL: ${configPath} has a "tools" field that is not an array.`)
250
- process.exit(1)
251
- }
252
-
253
- if (tools.length === 0) {
254
- console.log('OK: "tools": [] — every tool runs its default pattern set. Nothing to validate.')
255
- process.exit(0)
256
- }
257
-
258
- let invalidTotal = 0
259
- let emptyTotal = 0
260
- let unverifiableTotal = 0
261
-
262
- /**
263
- * Reject a malformed tools[] entry with a located, actionable message. A generator
264
- * bug or a half-written file must fail closed here rather than throw somewhere
265
- * further down, where the stack trace would say nothing about which entry is bad.
266
- */
267
- function readToolEntry(tool, index) {
268
- const at = `tools[${index}]`
269
- if (tool === null || typeof tool !== 'object' || Array.isArray(tool)) {
270
- console.error(`FAIL: ${at} is not an object.`)
271
- process.exit(1)
272
- }
273
- const { toolId, patterns } = tool
274
- if (typeof toolId !== 'string' || toolId.trim() === '') {
275
- console.error(`FAIL: ${at} has no valid "toolId" (expected a non-empty string).`)
276
- process.exit(1)
277
- }
278
- if (patterns !== undefined && !Array.isArray(patterns)) {
279
- console.error(`FAIL: ${toolId}: "patterns" must be an array, or omitted for tool defaults.`)
280
- process.exit(1)
281
- }
282
- const patternIds = []
283
- for (const [i, entry] of (patterns ?? []).entries()) {
284
- if (entry === null || typeof entry !== 'object' || typeof entry.patternId !== 'string') {
285
- console.error(
286
- `FAIL: ${toolId}: patterns[${i}] must be an object with a string "patternId".`,
287
- )
288
- process.exit(1)
289
- }
290
- patternIds.push(entry.patternId)
291
- }
292
- return { toolId, patternIds }
293
- }
294
-
295
- for (const [index, rawTool] of tools.entries()) {
296
- const { toolId, patternIds } = readToolEntry(rawTool, index)
297
- if (patternIds.length === 0) {
298
- console.log(` ${toolId}: patterns: [] → all defaults enabled (valid, but noisy)`)
299
- emptyTotal++
300
- continue
301
- }
302
- const valid = harvestIds(toolsDir, toolId)
303
- // FAIL CLOSED. "Cannot verify" is not "verified". This whole script exists because
304
- // a silently-disabled tool looks identical to clean code, so reporting PASS on an
305
- // unverifiable tool would reproduce the exact failure it is meant to catch.
306
- if (valid.size === 0) {
307
- unverifiableTotal++
308
- console.log(
309
- ` ${toolId}: UNVERIFIABLE — no pattern IDs harvested. Adapter missing, or the` +
310
- ` toolId is misspelled (case-sensitive).`,
311
- )
312
- continue
313
- }
314
- const bad = patternIds.filter((id) => !valid.has(id))
315
- if (bad.length === 0) {
316
- console.log(` ${toolId}: ${patternIds.length}/${patternIds.length} valid`)
317
- } else {
318
- invalidTotal += bad.length
319
- console.log(
320
- ` ${toolId}: ${patternIds.length - bad.length}/${patternIds.length} valid — ${bad.length} INVALID:`,
321
- )
322
- for (const id of bad) {
323
- // Suggest the conventional form so the fix is obvious.
324
- const guess = `${toolId}_${id.replace(/\//g, '_')}`
325
- const hint = valid.has(guess)
326
- ? `did you mean "${guess}"?`
327
- : `no close match — regenerate with: --emit ${toolId}`
328
- console.log(` ✗ ${id} — ${hint}`)
329
- }
330
- }
331
- }
332
-
333
- console.log('')
334
- if (invalidTotal > 0) {
335
- console.error(`FAIL: ${invalidTotal} patternId(s) do not exist. Those rules are SILENTLY DISABLED.`)
336
- console.error(' Regenerate the list with --emit <toolId>, or use "patterns": [] for tool defaults.')
337
- process.exit(1)
338
- }
339
- if (unverifiableTotal > 0) {
340
- console.error(`FAIL: ${unverifiableTotal} tool(s) could not be verified — this is NOT a pass.`)
341
- console.error(' Check the toolId spelling, reinstall @codacy/analysis-cli, or point')
342
- console.error(' CODACY_TOOLS_DIR at the directory holding the tools-* adapter packages.')
343
- process.exit(1)
344
- }
345
- console.log(`PASS: every patternId resolves${emptyTotal ? ` (${emptyTotal} tool(s) on defaults)` : ''}.`)