pr-checkmate 2.1.2 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +63 -268
  2. package/cspell.json +470 -447
  3. package/dist/cli.js +1 -1
  4. package/dist/config/constants.d.ts +6 -1
  5. package/dist/config/constants.js +9 -7
  6. package/dist/config/index.d.ts +23 -11
  7. package/dist/config/index.js +27 -33
  8. package/dist/core/checks/dependencies/banned-imports.js +4 -1
  9. package/dist/core/checks/dependencies/circular-deps.js +14 -5
  10. package/dist/core/checks/dependencies/dependency-check.js +1 -1
  11. package/dist/core/checks/dependencies/grype-scan.js +2 -1
  12. package/dist/core/checks/dependencies/vuln-scan.js +2 -1
  13. package/dist/core/checks/findings.d.ts +46 -2
  14. package/dist/core/checks/findings.js +140 -2
  15. package/dist/core/checks/git/custom-rules.js +2 -0
  16. package/dist/core/checks/git/diff-smell.js +6 -1
  17. package/dist/core/checks/git/leftover-debug.js +8 -2
  18. package/dist/core/checks/git/lockfile-drift.js +6 -1
  19. package/dist/core/checks/git/merge-conflict.js +1 -1
  20. package/dist/core/checks/git/missing-tests.js +1 -1
  21. package/dist/core/checks/git/todo-fixme.js +10 -2
  22. package/dist/core/checks/languages/cpp-format.js +19 -42
  23. package/dist/core/checks/languages/external-checks.d.ts +77 -0
  24. package/dist/core/checks/languages/external-checks.js +200 -0
  25. package/dist/core/checks/languages/external-runner.d.ts +119 -0
  26. package/dist/core/checks/languages/external-runner.js +143 -0
  27. package/dist/core/checks/languages/go-format.d.ts +4 -4
  28. package/dist/core/checks/languages/go-format.js +151 -30
  29. package/dist/core/checks/languages/lint.js +19 -19
  30. package/dist/core/checks/languages/prettier.js +2 -7
  31. package/dist/core/checks/languages/python-typecheck.d.ts +10 -5
  32. package/dist/core/checks/languages/python-typecheck.js +161 -37
  33. package/dist/core/checks/languages/ruff-shared.d.ts +3 -3
  34. package/dist/core/checks/languages/ruff-shared.js +27 -4
  35. package/dist/core/checks/languages/typecheck.js +10 -8
  36. package/dist/core/checks/pr/commitlint.js +7 -1
  37. package/dist/core/checks/quality/case-collision.js +6 -4
  38. package/dist/core/checks/quality/config-validation.d.ts +0 -7
  39. package/dist/core/checks/quality/config-validation.js +30 -0
  40. package/dist/core/checks/quality/coverage.js +6 -1
  41. package/dist/core/checks/quality/dead-code.js +12 -3
  42. package/dist/core/checks/quality/duplicate-check.js +54 -27
  43. package/dist/core/checks/quality/license-header.js +7 -4
  44. package/dist/core/checks/quality/markdown-lint.js +1 -6
  45. package/dist/core/checks/quality/package-size.d.ts +15 -0
  46. package/dist/core/checks/quality/package-size.js +130 -0
  47. package/dist/core/checks/quality/spellcheck.js +34 -5
  48. package/dist/core/checks/quality/symlinks.js +6 -4
  49. package/dist/core/checks/security/diff-security.js +26 -18
  50. package/dist/core/checks/security/dockerfile-security.js +7 -1
  51. package/dist/core/checks/security/migration-safety.js +32 -4
  52. package/dist/core/checks/security/security.d.ts +56 -0
  53. package/dist/core/checks/security/security.js +135 -11
  54. package/dist/core/checks/security/sensitive-file-guard.js +40 -6
  55. package/dist/core/checks/security/workflow-security.js +7 -1
  56. package/dist/core/checks/tool-install.d.ts +102 -0
  57. package/dist/core/checks/tool-install.js +69 -0
  58. package/dist/core/commands.js +1 -0
  59. package/dist/core/context.js +9 -1
  60. package/dist/core/install/cache.d.ts +20 -1
  61. package/dist/core/install/cache.js +65 -12
  62. package/dist/core/install/doctor.js +49 -15
  63. package/dist/core/install/ensure.d.ts +31 -14
  64. package/dist/core/install/ensure.js +165 -61
  65. package/dist/core/install/lock.d.ts +2 -1
  66. package/dist/core/install/merge.d.ts +47 -0
  67. package/dist/core/install/merge.js +91 -0
  68. package/dist/core/install/prepare.d.ts +76 -0
  69. package/dist/core/install/prepare.js +147 -0
  70. package/dist/core/install/profiles.d.ts +17 -3
  71. package/dist/core/install/profiles.js +27 -0
  72. package/dist/core/install/reuse.d.ts +41 -0
  73. package/dist/core/install/reuse.js +107 -0
  74. package/dist/core/registry.js +12 -18
  75. package/dist/core/run-checks.js +74 -3
  76. package/dist/core/targets.d.ts +116 -5
  77. package/dist/core/targets.js +101 -16
  78. package/dist/core/types.d.ts +8 -2
  79. package/dist/init.js +21 -1
  80. package/dist/interfaces/index.d.ts +6 -0
  81. package/dist/utils/resolve-bin.d.ts +56 -6
  82. package/dist/utils/resolve-bin.js +136 -26
  83. package/gitleaks.toml +14 -0
  84. package/install-manifests/commitlint/package-lock.json +128 -250
  85. package/install-manifests/commitlint/package.json +2 -2
  86. package/install-manifests/core/package-lock.json +76 -76
  87. package/install-manifests/core/package.json +1 -1
  88. package/install-manifests/duplicate/package-lock.json +45 -45
  89. package/install-manifests/duplicate/package.json +1 -1
  90. package/install-manifests/go/package-lock.json +24 -0
  91. package/install-manifests/go/package.json +8 -0
  92. package/install-manifests/js/package-lock.json +248 -283
  93. package/install-manifests/js/package.json +9 -9
  94. package/install-manifests/python/package-lock.json +4 -4
  95. package/install-manifests/python/package.json +1 -1
  96. package/install-manifests/pytypes/package-lock.json +45 -0
  97. package/install-manifests/pytypes/package.json +8 -0
  98. package/package.json +21 -19
  99. package/pr-checkmate.schema.json +41 -13
  100. package/src/config/cspell.json +470 -447
  101. package/src/config/pr-checkmate.schema.json +41 -13
  102. package/dist/core/checks/languages/csharp-format.d.ts +0 -13
  103. package/dist/core/checks/languages/csharp-format.js +0 -52
  104. package/dist/core/checks/languages/go-lint.d.ts +0 -8
  105. package/dist/core/checks/languages/go-lint.js +0 -48
  106. package/dist/core/checks/languages/kotlin-lint.d.ts +0 -2
  107. package/dist/core/checks/languages/kotlin-lint.js +0 -42
  108. package/dist/core/checks/languages/php-format.d.ts +0 -9
  109. package/dist/core/checks/languages/php-format.js +0 -49
  110. package/dist/core/checks/languages/ruby-lint.d.ts +0 -8
  111. package/dist/core/checks/languages/ruby-lint.js +0 -50
  112. package/dist/core/checks/languages/rust-format.d.ts +0 -9
  113. package/dist/core/checks/languages/rust-format.js +0 -45
  114. package/dist/core/checks/languages/rust-lint.d.ts +0 -8
  115. package/dist/core/checks/languages/rust-lint.js +0 -50
  116. package/dist/core/checks/languages/shellcheck.d.ts +0 -8
  117. package/dist/core/checks/languages/shellcheck.js +0 -55
  118. package/dist/core/checks/languages/swift-lint.d.ts +0 -2
  119. package/dist/core/checks/languages/swift-lint.js +0 -46
@@ -0,0 +1,15 @@
1
+ import { Check } from '../../types';
2
+ /**
3
+ * Reports how much the published package grew since the last release.
4
+ *
5
+ * The question this answers is "is it growing without anyone noticing", not "has
6
+ * it crossed a line" — so the comparison is against the previously published
7
+ * version rather than a threshold somebody had to invent. A number a client never
8
+ * configured is a number they will not trust, and a check that fires on every
9
+ * release teaches people to skim the whole report.
10
+ *
11
+ * Advisory by design. Growth is usually earned — a language profile was added, a
12
+ * WASM binary landed — and a red build for it would be wrong. `severity:
13
+ * { "Package Size": "error" }` makes it a gate for a repository that wants one.
14
+ */
15
+ export declare const packageSizeCheck: Check;
@@ -0,0 +1,130 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.packageSizeCheck = void 0;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const utils_1 = require("../../../utils");
10
+ const execa_1 = require("../../../utils/execa");
11
+ const types_1 = require("../../types");
12
+ const findings_1 = require("../findings");
13
+ /** Growth that is worth a row in the report, unless the client says otherwise. */
14
+ const DEFAULT_MAX_GROWTH = 20;
15
+ /** How many of the files behind the growth are named. */
16
+ const NAMED_FILES = 5;
17
+ /**
18
+ * Ask npm what a tarball would contain, without producing one.
19
+ *
20
+ * `--dry-run` writes nothing to the working tree — which matters, because a check
21
+ * whose side effect is a `.tgz` beside the source shows up in the client's next
22
+ * commit. Given a package spec instead of nothing, npm answers for the PUBLISHED
23
+ * version, with the same shape and the same per-file detail; that is what makes a
24
+ * real comparison possible rather than a bare number.
25
+ *
26
+ * `--ignore-scripts` is not negotiable. `npm pack` runs `prepare` and `prepack`,
27
+ * and in a client's repository those are arbitrary commands — frequently a full
28
+ * build. A quality check that executes them has stopped being a check and started
29
+ * being a build step with side effects nobody asked for. We are a supply-chain
30
+ * tool; the same rule already governs the install path and the Docker build.
31
+ *
32
+ * @param cwd Repository to run in.
33
+ * @param spec `undefined` for the working tree, or `name@tag` for a release.
34
+ */
35
+ async function packManifest(cwd, spec) {
36
+ const args = ['pack', ...(spec ? [spec] : []), '--dry-run', '--json', '--ignore-scripts'];
37
+ const result = await (0, execa_1.execa)('npm', args, { cwd, reject: false });
38
+ if (result.exitCode !== 0)
39
+ return null;
40
+ try {
41
+ const parsed = JSON.parse(result.stdout);
42
+ return Array.isArray(parsed) && parsed.length > 0 ? parsed[0] : null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** `1.15 MB`, at the precision a human compares two of these at. */
49
+ function mb(bytes) {
50
+ return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
51
+ }
52
+ /**
53
+ * Which files account for the growth, biggest first.
54
+ *
55
+ * A percentage alone is a finding nobody can act on — the same defect as a
56
+ * duplicate-code report with no file names (ticket 078). The published manifest
57
+ * carries per-file sizes, so the answer is a plain diff of the two lists: files
58
+ * that appeared, and files that grew.
59
+ */
60
+ function growthByFile(before, after) {
61
+ const old = new Map(before.map(f => [f.path, f.size]));
62
+ return after
63
+ .map(f => ({ path: f.path, delta: f.size - (old.get(f.path) ?? 0), isNew: !old.has(f.path) }))
64
+ .filter(f => f.delta > 0)
65
+ .sort((a, b) => b.delta - a.delta)
66
+ .slice(0, NAMED_FILES)
67
+ .map(f => `${f.path} +${mb(f.delta)}${f.isNew ? ' (new)' : ''}`);
68
+ }
69
+ /** Whether this repository publishes anything to compare. */
70
+ function publishedName(cwd) {
71
+ try {
72
+ const pkg = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(cwd, 'package.json'), 'utf-8'));
73
+ return pkg.private || !pkg.name ? null : pkg.name;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Reports how much the published package grew since the last release.
81
+ *
82
+ * The question this answers is "is it growing without anyone noticing", not "has
83
+ * it crossed a line" — so the comparison is against the previously published
84
+ * version rather than a threshold somebody had to invent. A number a client never
85
+ * configured is a number they will not trust, and a check that fires on every
86
+ * release teaches people to skim the whole report.
87
+ *
88
+ * Advisory by design. Growth is usually earned — a language profile was added, a
89
+ * WASM binary landed — and a red build for it would be wrong. `severity:
90
+ * { "Package Size": "error" }` makes it a gate for a repository that wants one.
91
+ */
92
+ exports.packageSizeCheck = {
93
+ name: 'Package Size',
94
+ phase: 'informational',
95
+ weight: 'heavy',
96
+ applies(ctx) {
97
+ if (ctx.config.packageSize?.enabled === false)
98
+ return false;
99
+ // A private package is never published, so there is nothing to compare with
100
+ // and no consumer to pay for the growth.
101
+ return publishedName(ctx.cwd) !== null;
102
+ },
103
+ async run(ctx) {
104
+ const name = publishedName(ctx.cwd);
105
+ if (!name)
106
+ return (0, types_1.skip)('no publishable package.json');
107
+ const tag = ctx.config.packageSize?.compareWith ?? 'latest';
108
+ const maxGrowth = ctx.config.packageSize?.maxGrowthPercent ?? DEFAULT_MAX_GROWTH;
109
+ const current = await packManifest(ctx.cwd);
110
+ if (!current)
111
+ return (0, types_1.skip)('npm could not describe this package');
112
+ // Registry unreachable, or nothing published yet. Both are "we could not
113
+ // look", never "nothing changed" — a pass here would be a green row over a
114
+ // comparison that never happened (ticket 072).
115
+ const published = await packManifest(ctx.cwd, `${name}@${tag}`);
116
+ if (!published) {
117
+ return (0, types_1.skip)(`no published ${name}@${tag} to compare with (or registry unreachable)`);
118
+ }
119
+ const delta = current.unpackedSize - published.unpackedSize;
120
+ const growth = published.unpackedSize > 0 ? (delta / published.unpackedSize) * 100 : 0;
121
+ const summary = `${mb(published.unpackedSize)} → ${mb(current.unpackedSize)} ` +
122
+ `(${growth >= 0 ? '+' : ''}${growth.toFixed(1)}%) vs ${published.version}`;
123
+ if (growth <= maxGrowth) {
124
+ utils_1.logger.info(`[Package Size]: ✅ ${summary}`);
125
+ return (0, types_1.pass)(summary);
126
+ }
127
+ (0, findings_1.logSample)('Package Size', `${summary}:`, growthByFile(published.files, current.files));
128
+ return (0, types_1.warn)(summary);
129
+ },
130
+ };
@@ -188,19 +188,48 @@ function dropMissingLanguages(language, missing) {
188
188
  // to its own default, which is not necessarily what was configured here.
189
189
  return kept.length > 0 ? kept.join(',') : 'en';
190
190
  }
191
+ /**
192
+ * Resolve a package id from the repository under review first, then from our own
193
+ * tree.
194
+ *
195
+ * The default `require.resolve` searches from THIS module, which is only the
196
+ * client's tree by accident. Installed as a dependency we sit in their
197
+ * `node_modules` and npm hoists `@cspell/dict-*` above us, so walking up finds
198
+ * it. Run through `npx` we sit in the npx cache and their `node_modules` is not
199
+ * on the path at all — so `npm i -D @cspell/dict-ru_ru`, which this file's own
200
+ * docstring promises is all it takes, did nothing and said nothing (ticket 081).
201
+ *
202
+ * Same policy as tool resolution for binaries: the client's copy first, ours as
203
+ * the fallback.
204
+ */
205
+ function resolveFrom(cwd) {
206
+ return id => {
207
+ try {
208
+ return require.resolve(id, { paths: [cwd] });
209
+ }
210
+ catch {
211
+ return require.resolve(id);
212
+ }
213
+ };
214
+ }
191
215
  function createTempCspellConfig(cwd) {
192
216
  const packageConfigPath = (0, config_1.getPackageConfigPath)('cspell.json');
193
217
  const packageConfigDir = node_path_1.default.dirname(packageConfigPath);
194
218
  const defaultConfig = JSON.parse(node_fs_1.default.readFileSync(packageConfigPath, 'utf-8'));
195
219
  const userConfig = loadUserCspellConfig(cwd);
196
220
  const merged = mergeCspellConfig(defaultConfig, userConfig);
197
- const { resolved, missing } = resolveImports(merged.import ?? [], packageConfigDir);
198
- const available = availableDictionaries();
221
+ const resolve = resolveFrom(cwd);
222
+ const { resolved, missing } = resolveImports(merged.import ?? [], packageConfigDir, resolve);
223
+ const available = availableDictionaries(resolve);
199
224
  for (const { pkg } of missing) {
200
225
  utils_1.logger.info(`[Spellcheck]: dictionary ${pkg} is not installed and was skipped. Add it with \`npm i -D ${pkg}\`.`);
201
226
  }
202
227
  if (available.absent.length > 0) {
203
- utils_1.logger.debug(`[Spellcheck]: optional dictionaries not installed: ${available.absent.join(', ')}. ` +
228
+ // `info`, not `debug`. A client who installed a dictionary and saw no change
229
+ // had nothing to read: the one line that would have explained it was behind a
230
+ // flag. Which dictionaries are in play decides which findings appear, so it
231
+ // belongs in the ordinary log (ticket 081).
232
+ utils_1.logger.info(`[Spellcheck]: optional dictionaries not installed: ${available.absent.join(', ')}. ` +
204
233
  `Install any of them to spellcheck that language (\`npm i -D ${available.absent[0]}\`).`);
205
234
  }
206
235
  // A language the client asked for whose dictionary is absent is dropped rather
@@ -230,7 +259,7 @@ exports.spellcheckCheck = {
230
259
  return ctx.config.cspell?.enabled !== false && ctx.config.spellcheck?.enabled !== false;
231
260
  },
232
261
  async run(ctx) {
233
- const targets = await (0, targets_1.resolveScopedTargetFiles)(ctx, ['*.ts', '*.tsx', '*.js', '*.jsx', '*.md', '*.mdx', '*.json'], (0, config_1.getSourcePaths)());
262
+ const targets = await (0, targets_1.resolveScopedTargetFiles)(ctx, ['*.ts', '*.tsx', '*.js', '*.jsx', '*.md', '*.mdx', '*.json'], (0, config_1.getSourcePaths)(ctx.cwd));
234
263
  if (targets === null)
235
264
  return (0, types_1.skip)('git unavailable');
236
265
  // Committed build output and test reports are dropped here, in JS, against
@@ -246,7 +275,7 @@ exports.spellcheckCheck = {
246
275
  const files = targets.filter(f => !(0, utils_1.matchesAnyGlob)(f, config_1.IGNORE_PATTERNS));
247
276
  if (files.length === 0)
248
277
  return (0, types_1.pass)('no files to spellcheck');
249
- const hasLocal = (0, config_1.hasLocalConfig)(['cspell.json', '.cspell.json', 'cspell.config.json']);
278
+ const hasLocal = (0, config_1.hasLocalConfig)(['cspell.json', '.cspell.json', 'cspell.config.json'], ctx.cwd);
250
279
  let tempConfigPath = null;
251
280
  const cspellBin = (0, utils_1.resolvePackageBin)('cspell', 'bin.mjs');
252
281
  // `--no-must-find-files`: cspell exits 1 when it checked nothing, which the
@@ -7,7 +7,6 @@ exports.symlinksCheck = void 0;
7
7
  exports.isBrokenSymlink = isBrokenSymlink;
8
8
  const node_fs_1 = __importDefault(require("node:fs"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
- const config_1 = require("../../../config");
11
10
  const utils_1 = require("../../../utils");
12
11
  const targets_1 = require("../../targets");
13
12
  const types_1 = require("../../types");
@@ -42,9 +41,12 @@ exports.symlinksCheck = {
42
41
  return ctx.config.symlinks?.enabled !== false;
43
42
  },
44
43
  async run(ctx) {
45
- const files = await (0, targets_1.listTrackedFiles)(ctx, ['*'], (0, config_1.getSourcePaths)());
46
- if (files.length === 0)
47
- return (0, types_1.skip)('no tracked files');
44
+ const files = await (0, targets_1.resolveTargets)(ctx, ['*'], {
45
+ wholeTree: true,
46
+ onEmpty: { skip: 'no tracked files' },
47
+ });
48
+ if (!Array.isArray(files))
49
+ return files;
48
50
  const ignore = ctx.config.symlinks?.ignore ?? [];
49
51
  const broken = [];
50
52
  for (const file of files) {
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.diffSecurityCheck = void 0;
4
4
  const config_1 = require("../../../config");
5
5
  const utils_1 = require("../../../utils");
6
- const targets_1 = require("../../targets");
7
6
  const types_1 = require("../../types");
8
7
  const findings_1 = require("../findings");
9
8
  const MD5_LABEL = 'MD5 (weak hash — use sha256 or stronger)';
@@ -298,6 +297,9 @@ exports.diffSecurityCheck = {
298
297
  // to them, matching `duplicate.ignore` and `deadCode.ignoreFiles`. A client
299
298
  // who wants their tests scanned says so by listing something else here.
300
299
  ignorePaths: cfg.ignorePaths ?? config_1.DIFF_SECURITY_IGNORE_DEFAULTS,
300
+ // This check owns the cross-line heuristic, so it is the one that turns it
301
+ // on. `0` from the client switches it off.
302
+ dataflowWindow: cfg.dataflowWindow ?? findings_1.DEFAULT_DATAFLOW_WINDOW,
301
303
  };
302
304
  const extra = compileExtraPatterns(ctx);
303
305
  const disabled = cfg.disablePatterns ?? [];
@@ -306,33 +308,39 @@ exports.diffSecurityCheck = {
306
308
  };
307
309
  /**
308
310
  * Feed one language's sources through the patterns, from the diff when
309
- * there is one and from the files themselves otherwise. Both paths reach
310
- * `scanDiffPatterns` in the same shape, so the two modes cannot disagree
311
- * about what counts as a finding.
311
+ * there is one and from the files themselves otherwise.
312
+ *
313
+ * Through {@link reviewLines}, like the four other checks that need both
314
+ * modes. This used to be its body copied inline, on the assumption that a
315
+ * closure was needed to carry `patterns` — it is not, that is what `onChunk`
316
+ * is for, and the copy meant `fullScanBehaviour` and the `null`/`[]`
317
+ * distinction lived in two places, one of them a blocking security check
318
+ * (ticket 079).
312
319
  */
313
- const review = async (globs, patterns) => {
314
- if (ctx.baseSha) {
315
- scanInto(await (0, findings_1.diffAddedLines)(ctx, globs), patterns);
316
- return;
317
- }
318
- if (ctx.config.run?.fullScanBehaviour === 'skip')
319
- return;
320
- const files = await (0, targets_1.listTrackedFiles)(ctx, globs, ['.']);
321
- await (0, findings_1.scanTrackedFiles)(ctx, files, asDiff => scanInto(asDiff, patterns));
322
- };
320
+ const review = (globs, patterns) => (0, findings_1.reviewLines)(ctx, globs, stdout => scanInto(stdout, patterns));
323
321
  // JS/TS patterns always run; language-specific sets scan their own globs
324
322
  // only when that language is present, so a Python repo gets Python rules.
325
323
  // Client `extraPatterns` run against every scanned language, since a
326
324
  // project rule is not tied to the built-in language split.
327
- await review(JS_GLOBS, [...SECURITY_PATTERNS, ...extra]);
325
+ const sets = [[JS_GLOBS, [...SECURITY_PATTERNS, ...extra]]];
328
326
  if (ctx.languages.includes('python')) {
329
- await review(['*.py'], [...PYTHON_SECURITY_PATTERNS, ...extra]);
327
+ sets.push([['*.py'], [...PYTHON_SECURITY_PATTERNS, ...extra]]);
330
328
  }
331
329
  if (ctx.languages.includes('go')) {
332
- await review(['*.go'], [...GO_SECURITY_PATTERNS, ...extra]);
330
+ sets.push([['*.go'], [...GO_SECURITY_PATTERNS, ...extra]]);
333
331
  }
334
332
  if (ctx.languages.includes('kotlin')) {
335
- await review(['*.kt', '*.kts', '*.java'], [...KOTLIN_SECURITY_PATTERNS, ...extra]);
333
+ sets.push([
334
+ ['*.kt', '*.kts', '*.java'],
335
+ [...KOTLIN_SECURITY_PATTERNS, ...extra],
336
+ ]);
337
+ }
338
+ for (const [globs, patterns] of sets) {
339
+ // One failed set fails the check. A partial answer is worse than none
340
+ // here: three of five language sets scanned, reported as a pass, reads
341
+ // as a clean repository (ticket 072).
342
+ if (!(await review(globs, patterns)))
343
+ return (0, types_1.skip)('git unavailable');
336
344
  }
337
345
  return (0, findings_1.reportSeverityBuckets)(found.errors, found.warnings, {
338
346
  prefix: 'Diff Security',
@@ -30,7 +30,13 @@ const DOCKER_PATTERNS = [
30
30
  severity: 'warn',
31
31
  },
32
32
  {
33
- pattern: /^\+(?!\+)\s*(?:ENV|ARG)\s+\w*(?:PASSWORD|SECRET|TOKEN|API_?KEY|ACCESS_KEY)\w*\s*[=\s]/i,
33
+ // The `_FILE`/`_PATH` exclusion is not a loophole, it is the thing this rule
34
+ // is trying to steer people towards: `ENV DB_PASSWORD_FILE=/run/secrets/db`
35
+ // names a location, holds no secret, and is how Docker and Kubernetes tell
36
+ // you to do it. Flagging the correct practice alongside the wrong one is how
37
+ // a rule gets switched off — and it teaches obscuring the variable name,
38
+ // which makes the next real secret harder to spot, not easier.
39
+ pattern: /^\+(?!\+)\s*(?:ENV|ARG)\s+\w*(?:PASSWORD|SECRET|TOKEN|API_?KEY|ACCESS_KEY)\w*(?<!_FILE)(?<!_PATH)(?<!_FILEPATH)\s*[=\s]/i,
34
40
  label: 'secret baked into ENV/ARG (use build secrets / runtime injection)',
35
41
  severity: 'warn',
36
42
  },
@@ -48,17 +48,45 @@ function isAddNotNullNoDefault(line) {
48
48
  /\bNOT\s+NULL\b/i.test(line) &&
49
49
  !/\bDEFAULT\b/i.test(line));
50
50
  }
51
+ /**
52
+ * Drop the comment from an added migration line, keeping the leading `+` so the
53
+ * patterns stay anchored on it.
54
+ *
55
+ * This check does its own matching rather than going through `scanDiffPatterns`,
56
+ * and it inherited none of the comment handling — so a commented-out statement
57
+ * was reported as destructive. `-- DROP TABLE users;` does nothing, and calling
58
+ * it "irreversible data loss" is noise on the most alarming label the product
59
+ * prints. Found by ticket 062's corpus.
60
+ *
61
+ * Only the two markers that are unambiguous in the files this check looks at:
62
+ * `--` in SQL and `#` in Python/Ruby migrations. `--` is required to be preceded
63
+ * by whitespace or the line start, which keeps it away from an identifier like
64
+ * `a--b` and from most string contents; `stripInlineComment` handles `#` with the
65
+ * string awareness it already has.
66
+ */
67
+ function withoutComment(line) {
68
+ // The diff marker is split off first: `+-- DROP TABLE` puts `--` immediately
69
+ // after the `+`, where a boundary rule looking for whitespace or line-start
70
+ // does not see it — which is exactly the shape a commented-out statement takes.
71
+ const marker = line.startsWith('+') ? '+' : '';
72
+ const body = (0, findings_1.stripInlineComment)(line.slice(marker.length), 'hash').replace(/(^|\s)--.*$/, '$1');
73
+ return marker + body;
74
+ }
51
75
  /** Bucket dangerous added lines by risk label, honouring the opt-out directive. */
52
76
  function scan(stdout, findings) {
53
77
  for (const line of stdout.split('\n')) {
54
78
  if (line.includes('pr-checkmate-ignore'))
55
79
  continue;
80
+ // The sample keeps the ORIGINAL line: the reader needs to see what was
81
+ // written, comment and all, even though the match ran on the code.
82
+ const code = withoutComment(line);
83
+ const sample = () => line.slice(1).trim().substring(0, 120);
56
84
  for (const { pattern, label } of DDL_PATTERNS) {
57
- if (pattern.test(line))
58
- (findings[label] ?? (findings[label] = [])).push(line.slice(1).trim().substring(0, 120));
85
+ if (pattern.test(code))
86
+ (findings[label] ?? (findings[label] = [])).push(sample());
59
87
  }
60
- if (isAddNotNullNoDefault(line)) {
61
- (findings[NOT_NULL_NO_DEFAULT] ?? (findings[NOT_NULL_NO_DEFAULT] = [])).push(line.slice(1).trim().substring(0, 120));
88
+ if (isAddNotNullNoDefault(code)) {
89
+ (findings[NOT_NULL_NO_DEFAULT] ?? (findings[NOT_NULL_NO_DEFAULT] = [])).push(sample());
62
90
  }
63
91
  }
64
92
  }
@@ -1,4 +1,60 @@
1
1
  import { Check } from '../../types';
2
+ /**
3
+ * A HOME the scanner can actually write to.
4
+ *
5
+ * The bundled wrapper caches the gitleaks binary in `os.homedir()/.gitleaks-cache`
6
+ * and offers no way to move it — the path is hardcoded. So in any environment
7
+ * where HOME is not writable it crashes before scanning a single commit. That is
8
+ * not exotic: it is what the container does under `--user "$(id -u):$(id -g)"`,
9
+ * the invocation our own entrypoint and docs recommend, where HOME is `/`.
10
+ *
11
+ * Redirecting to the temp directory costs nothing that was available anyway — if
12
+ * HOME cannot be written to, there was no `~/.gitconfig` of ours to lose either.
13
+ */
14
+ /**
15
+ * How many secrets the scan actually found.
16
+ *
17
+ * The row used to read `❌ Security Scan: potential secrets detected` whether the
18
+ * number was one or thirty-nine, and that is not a cosmetic gap. It is the reason
19
+ * a real regression nearly shipped: an allowlist entry removed six genuine
20
+ * `Generic API Key` findings from a client repository, 39 → 33, and the A/B sweep
21
+ * that compares report rows across 28 repositories saw two identical lines
22
+ * (ticket 085). A check that finds things has to say how many, or nothing can
23
+ * tell whether it started finding fewer.
24
+ *
25
+ * Read from gitleaks' JSON report rather than counted out of its console output —
26
+ * the same lesson as ticket 078, where the clone count was parsed from an English
27
+ * sentence and would have reported zero on a repository full of duplication.
28
+ *
29
+ * @returns The count, or `null` when the report could not be read — in which case
30
+ * the caller keeps its old wording rather than claiming a number it does not have.
31
+ */
32
+ export declare function countFindings(reportPath: string): number | null;
33
+ /**
34
+ * How the outcome names what was found.
35
+ *
36
+ * Its own function so both halves are reachable without running the scanner. The
37
+ * corpus for this check deliberately does not: the wrapper downloads a gitleaks
38
+ * binary on first use, and a unit suite that reaches the network is the thing the
39
+ * `installer` seam in `ensure.ts` exists to avoid. What broke here was the wording
40
+ * — a row that read the same for one secret and for thirty-nine — and the wording
41
+ * is testable on its own.
42
+ *
43
+ * `null` keeps the old sentence rather than inventing a number: a report we could
44
+ * not read is not a reason to claim a count.
45
+ */
46
+ export declare function secretsSummary(found: number | null): string;
47
+ /**
48
+ * Whether the wrapper died instead of reporting, judged from its stderr.
49
+ *
50
+ * The wrapper is a Node script, and Node exits **1** on an unhandled exception —
51
+ * the same code gitleaks itself uses for "secrets found". Left undistinguished,
52
+ * a scanner that never ran is reported as `potential secrets detected`, pointing
53
+ * at a report file that was never written. A gate accusing a clean repository is
54
+ * the same class of failure as one that stays quiet about a real leak, and this
55
+ * is the only signal available to tell them apart.
56
+ */
57
+ export declare function looksLikeCrash(stderr: string): boolean;
2
58
  /**
3
59
  * Secret scanning via the bundled gitleaks package. Language-agnostic — runs on
4
60
  * every repo (Node, Python, C++, mobile). Secrets found → fail (blocking).
@@ -1,10 +1,107 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.securityCheck = void 0;
7
+ exports.countFindings = countFindings;
8
+ exports.secretsSummary = secretsSummary;
9
+ exports.looksLikeCrash = looksLikeCrash;
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ const node_os_1 = __importDefault(require("node:os"));
12
+ const node_path_1 = __importDefault(require("node:path"));
4
13
  const config_1 = require("../../../config");
5
14
  const utils_1 = require("../../../utils");
6
15
  const execa_1 = require("../../../utils/execa");
7
16
  const types_1 = require("../../types");
17
+ /**
18
+ * A HOME the scanner can actually write to.
19
+ *
20
+ * The bundled wrapper caches the gitleaks binary in `os.homedir()/.gitleaks-cache`
21
+ * and offers no way to move it — the path is hardcoded. So in any environment
22
+ * where HOME is not writable it crashes before scanning a single commit. That is
23
+ * not exotic: it is what the container does under `--user "$(id -u):$(id -g)"`,
24
+ * the invocation our own entrypoint and docs recommend, where HOME is `/`.
25
+ *
26
+ * Redirecting to the temp directory costs nothing that was available anyway — if
27
+ * HOME cannot be written to, there was no `~/.gitconfig` of ours to lose either.
28
+ */
29
+ /**
30
+ * How many secrets the scan actually found.
31
+ *
32
+ * The row used to read `❌ Security Scan: potential secrets detected` whether the
33
+ * number was one or thirty-nine, and that is not a cosmetic gap. It is the reason
34
+ * a real regression nearly shipped: an allowlist entry removed six genuine
35
+ * `Generic API Key` findings from a client repository, 39 → 33, and the A/B sweep
36
+ * that compares report rows across 28 repositories saw two identical lines
37
+ * (ticket 085). A check that finds things has to say how many, or nothing can
38
+ * tell whether it started finding fewer.
39
+ *
40
+ * Read from gitleaks' JSON report rather than counted out of its console output —
41
+ * the same lesson as ticket 078, where the clone count was parsed from an English
42
+ * sentence and would have reported zero on a repository full of duplication.
43
+ *
44
+ * @returns The count, or `null` when the report could not be read — in which case
45
+ * the caller keeps its old wording rather than claiming a number it does not have.
46
+ */
47
+ function countFindings(reportPath) {
48
+ try {
49
+ const report = JSON.parse(node_fs_1.default.readFileSync(reportPath, 'utf-8'));
50
+ return Array.isArray(report) ? report.length : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /**
57
+ * How the outcome names what was found.
58
+ *
59
+ * Its own function so both halves are reachable without running the scanner. The
60
+ * corpus for this check deliberately does not: the wrapper downloads a gitleaks
61
+ * binary on first use, and a unit suite that reaches the network is the thing the
62
+ * `installer` seam in `ensure.ts` exists to avoid. What broke here was the wording
63
+ * — a row that read the same for one secret and for thirty-nine — and the wording
64
+ * is testable on its own.
65
+ *
66
+ * `null` keeps the old sentence rather than inventing a number: a report we could
67
+ * not read is not a reason to claim a count.
68
+ */
69
+ function secretsSummary(found) {
70
+ return found === null ? 'potential secrets detected' : `${found} potential secret(s)`;
71
+ }
72
+ function writableHome() {
73
+ const home = node_os_1.default.homedir();
74
+ try {
75
+ node_fs_1.default.accessSync(home, node_fs_1.default.constants.W_OK);
76
+ return home;
77
+ }
78
+ catch { }
79
+ const fallback = node_os_1.default.tmpdir();
80
+ try {
81
+ node_fs_1.default.accessSync(fallback, node_fs_1.default.constants.W_OK);
82
+ utils_1.logger.info(`[Security Scan]: HOME (${home}) is not writable — caching the scanner in ${fallback}`);
83
+ return fallback;
84
+ }
85
+ catch {
86
+ return null;
87
+ }
88
+ }
89
+ /**
90
+ * Whether the wrapper died instead of reporting, judged from its stderr.
91
+ *
92
+ * The wrapper is a Node script, and Node exits **1** on an unhandled exception —
93
+ * the same code gitleaks itself uses for "secrets found". Left undistinguished,
94
+ * a scanner that never ran is reported as `potential secrets detected`, pointing
95
+ * at a report file that was never written. A gate accusing a clean repository is
96
+ * the same class of failure as one that stays quiet about a real leak, and this
97
+ * is the only signal available to tell them apart.
98
+ */
99
+ function looksLikeCrash(stderr) {
100
+ // The errno names are written out rather than factored to a shared `E` prefix:
101
+ // `E(?:ACCES|PERM|…)` saves four characters and costs the reader the ability to
102
+ // grep for the thing they are debugging.
103
+ return /^\s*(?:[A-Za-z]*Error|Uncaught|node:internal)|\b(?:EACCES|EPERM|ENOENT|EROFS)\b/m.test(stderr);
104
+ }
8
105
  /**
9
106
  * Secret scanning via the bundled gitleaks package. Language-agnostic — runs on
10
107
  * every repo (Node, Python, C++, mobile). Secrets found → fail (blocking).
@@ -29,11 +126,7 @@ exports.securityCheck = {
29
126
  utils_1.logger.warn('[Security Scan]: ⚠️ bundled gitleaks scanner could not be resolved — skipping');
30
127
  return (0, types_1.skip)('gitleaks unavailable');
31
128
  }
32
- const hasClientConfig = (0, config_1.hasLocalConfig)([
33
- '.gitleaks.toml',
34
- 'gitleaks.toml',
35
- '.gitleaks/config.toml',
36
- ]);
129
+ const hasClientConfig = (0, config_1.hasLocalConfig)(['.gitleaks.toml', 'gitleaks.toml', '.gitleaks/config.toml'], ctx.cwd);
37
130
  // Pin the gitleaks binary version so the scanner never reaches out to the
38
131
  // GitHub API for the "latest" version — that network call adds latency and
39
132
  // times out in locked-down/offline CI (the scanner already falls back to
@@ -62,25 +155,56 @@ exports.securityCheck = {
62
155
  if (!hasClientConfig) {
63
156
  args.push('--config', (0, config_1.getPackageConfigPath)('gitleaks.toml'));
64
157
  }
158
+ // A machine-readable copy of the findings, purely so the outcome can carry a
159
+ // number. Written to a temp directory, never into the repository under review:
160
+ // a check whose side effect is a new untracked file shows up in the client's
161
+ // next commit. The console output is untouched — `stdio: inherit` below still
162
+ // streams the scanner's own report, which is what a reader actually looks at.
163
+ const reportDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pr-checkmate-gitleaks-'));
164
+ const reportPath = node_path_1.default.join(reportDir, 'findings.json');
165
+ args.push('--report-format', 'json', '--report-path', reportPath);
65
166
  utils_1.logger.info(`[Security Scan]: scanning ${isDelta ? 'the diff range' : `full history${depth ? ` (last ${depth} commits)` : ''}`}`);
167
+ const home = writableHome();
168
+ if (!home)
169
+ return (0, types_1.skip)('no writable directory for the scanner cache');
66
170
  const env = {
67
171
  ...process.env,
172
+ HOME: home,
68
173
  ...(ctx.headSha ? { HEAD_SHA: ctx.headSha } : {}),
69
174
  ...(ctx.baseSha ? { BASE_SHA: ctx.baseSha } : {}),
70
175
  };
176
+ // stdout stays inherited so the scanner's own report streams to the log as it
177
+ // always has; stderr is captured because it is the only thing that separates
178
+ // a crash from a finding — see `crashed`.
71
179
  const result = await (0, execa_1.execa)(process.execPath, [gitleaksBin, ...args], {
72
180
  cwd: ctx.cwd,
73
181
  env,
74
- stdio: 'inherit',
182
+ stdio: ['ignore', 'inherit', 'pipe'],
75
183
  reject: false,
76
184
  });
185
+ const stderr = String(result.stderr ?? '');
186
+ if (stderr.trim())
187
+ utils_1.logger.warn(stderr.trimEnd());
188
+ const found = countFindings(reportPath);
189
+ node_fs_1.default.rmSync(reportDir, { recursive: true, force: true });
77
190
  if (result.exitCode === 0)
78
191
  return (0, types_1.pass)();
79
- // gitleaks exits 1 specifically when it finds secrets — that is a real violation.
80
- if (result.exitCode === 1) {
81
- utils_1.logger.warn('⚠️ gitleaks found potential secrets — review gitleaks-report.html');
82
- return (0, types_1.fail)('potential secrets detected');
192
+ // gitleaks exits 1 specifically when it finds secrets — but so does Node when
193
+ // the wrapper around it throws. Only the second one leaves a stack trace.
194
+ if (result.exitCode === 1 && !looksLikeCrash(stderr)) {
195
+ // The findings themselves are already above, streamed from the scanner. This
196
+ // used to say "review gitleaks-report.html" — a file the check never wrote:
197
+ // `--html-report` is passed by our own `scan:secrets` npm script and by
198
+ // nothing else, so a blocking check told the client to open something that
199
+ // does not exist (ticket 085).
200
+ const summary = secretsSummary(found);
201
+ utils_1.logger.warn(`⚠️ gitleaks found ${summary} — see the findings above`);
202
+ return (0, types_1.fail)(summary);
83
203
  }
84
- return (0, types_1.warn)(`scanner error (exit ${result.exitCode})`);
204
+ const detail = looksLikeCrash(stderr)
205
+ ? 'scanner failed to start — nothing was scanned'
206
+ : `scanner error (exit ${result.exitCode})`;
207
+ utils_1.logger.warn(`[Security Scan]: ⚠️ ${detail}`);
208
+ return (0, types_1.warn)(detail);
85
209
  },
86
210
  };