pr-checkmate 1.23.24 → 2.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.
Files changed (105) hide show
  1. package/README.md +89 -13
  2. package/cspell.json +11 -9
  3. package/dist/cli.js +37 -64
  4. package/dist/config/constants.d.ts +51 -3
  5. package/dist/config/constants.js +73 -11
  6. package/dist/config/pr-checkmate-workflow-docker.yml +79 -0
  7. package/dist/config/pr-checkmate-workflow.yml +65 -326
  8. package/dist/core/checks/dependencies/banned-imports.js +23 -24
  9. package/dist/core/checks/dependencies/circular-deps.js +1 -0
  10. package/dist/core/checks/dependencies/dependency-check.js +1 -0
  11. package/dist/core/checks/dependencies/grype-scan.js +1 -0
  12. package/dist/core/checks/dependencies/npm-audit.js +6 -1
  13. package/dist/core/checks/dependencies/vuln-scan.js +1 -0
  14. package/dist/core/checks/findings.d.ts +198 -19
  15. package/dist/core/checks/findings.js +474 -22
  16. package/dist/core/checks/git/custom-rules.js +21 -16
  17. package/dist/core/checks/git/diff-smell.d.ts +17 -0
  18. package/dist/core/checks/git/diff-smell.js +43 -24
  19. package/dist/core/checks/git/large-files.js +14 -2
  20. package/dist/core/checks/git/leftover-debug.js +23 -14
  21. package/dist/core/checks/git/lockfile-drift.js +2 -1
  22. package/dist/core/checks/git/merge-conflict.js +19 -8
  23. package/dist/core/checks/git/missing-tests.js +22 -6
  24. package/dist/core/checks/git/todo-fixme.js +10 -9
  25. package/dist/core/checks/languages/shellcheck.d.ts +1 -1
  26. package/dist/core/checks/languages/shellcheck.js +1 -1
  27. package/dist/core/checks/languages/typecheck.js +1 -0
  28. package/dist/core/checks/pr/pr-size-check.js +2 -1
  29. package/dist/core/checks/quality/config-validation.d.ts +14 -4
  30. package/dist/core/checks/quality/config-validation.js +119 -17
  31. package/dist/core/checks/quality/dead-code.js +1 -0
  32. package/dist/core/checks/quality/duplicate-check.js +1 -0
  33. package/dist/core/checks/quality/license-header.js +12 -2
  34. package/dist/core/checks/quality/markdown-lint.js +7 -2
  35. package/dist/core/checks/quality/spellcheck.d.ts +50 -0
  36. package/dist/core/checks/quality/spellcheck.js +141 -13
  37. package/dist/core/checks/quality/yaml-lint.js +22 -9
  38. package/dist/core/checks/security/diff-security.js +115 -28
  39. package/dist/core/checks/security/dockerfile-security.js +23 -20
  40. package/dist/core/checks/security/migration-safety.js +19 -17
  41. package/dist/core/checks/security/sensitive-file-guard.js +37 -5
  42. package/dist/core/checks/security/workflow-security.d.ts +10 -0
  43. package/dist/core/checks/security/workflow-security.js +48 -26
  44. package/dist/core/commands.d.ts +27 -0
  45. package/dist/core/commands.js +91 -0
  46. package/dist/core/context.d.ts +12 -3
  47. package/dist/core/context.js +118 -16
  48. package/dist/core/install/cache.d.ts +39 -0
  49. package/dist/core/install/cache.js +99 -0
  50. package/dist/core/install/doctor.d.ts +35 -0
  51. package/dist/core/install/doctor.js +145 -0
  52. package/dist/core/install/ensure.d.ts +60 -0
  53. package/dist/core/install/ensure.js +169 -0
  54. package/dist/core/install/lock.d.ts +33 -0
  55. package/dist/core/install/lock.js +99 -0
  56. package/dist/core/install/profiles.d.ts +64 -0
  57. package/dist/core/install/profiles.js +170 -0
  58. package/dist/core/reporters.d.ts +13 -2
  59. package/dist/core/reporters.js +145 -104
  60. package/dist/core/run-checks.d.ts +25 -1
  61. package/dist/core/run-checks.js +124 -14
  62. package/dist/core/targets.js +26 -4
  63. package/dist/core/types.d.ts +49 -1
  64. package/dist/init.d.ts +29 -14
  65. package/dist/init.js +284 -263
  66. package/dist/interfaces/index.d.ts +120 -0
  67. package/dist/scripts/copy-configs.js +4 -0
  68. package/dist/scripts/generate-install-manifests.d.ts +39 -0
  69. package/dist/scripts/generate-install-manifests.js +121 -0
  70. package/dist/scripts/publish-github.js +18 -4
  71. package/dist/utils/detect-language.d.ts +37 -4
  72. package/dist/utils/detect-language.js +113 -19
  73. package/dist/utils/http.d.ts +59 -0
  74. package/dist/utils/http.js +119 -0
  75. package/dist/utils/logger.d.ts +52 -6
  76. package/dist/utils/logger.js +125 -33
  77. package/dist/utils/pool.d.ts +49 -0
  78. package/dist/utils/pool.js +124 -0
  79. package/dist/utils/update-notifier.d.ts +9 -0
  80. package/dist/utils/update-notifier.js +57 -3
  81. package/eslint-default-rules.json +1 -1
  82. package/eslint.config.mjs +14 -2
  83. package/install-manifests/commitlint/package-lock.json +1028 -0
  84. package/install-manifests/commitlint/package.json +9 -0
  85. package/install-manifests/core/package-lock.json +1335 -0
  86. package/install-manifests/core/package.json +11 -0
  87. package/install-manifests/cpp/package-lock.json +29 -0
  88. package/install-manifests/cpp/package.json +8 -0
  89. package/install-manifests/duplicate/package-lock.json +113 -0
  90. package/install-manifests/duplicate/package.json +8 -0
  91. package/install-manifests/js/package-lock.json +5099 -0
  92. package/install-manifests/js/package.json +26 -0
  93. package/install-manifests/markdown/package-lock.json +1281 -0
  94. package/install-manifests/markdown/package.json +8 -0
  95. package/install-manifests/python/package-lock.json +21 -0
  96. package/install-manifests/python/package.json +8 -0
  97. package/install-manifests/spellcheck/package-lock.json +1047 -0
  98. package/install-manifests/spellcheck/package.json +8 -0
  99. package/package.json +14 -28
  100. package/pr-checkmate.schema.json +1062 -104
  101. package/src/config/cspell.json +11 -9
  102. package/src/config/eslint-default-rules.json +1 -1
  103. package/src/config/pr-checkmate-workflow-docker.yml +79 -0
  104. package/src/config/pr-checkmate-workflow.yml +65 -326
  105. package/src/config/pr-checkmate.schema.json +1062 -104
@@ -72,15 +72,20 @@ exports.markdownLintCheck = {
72
72
  'coverage/**',
73
73
  'graphify-out/**',
74
74
  '.claude/**',
75
+ // Release-tool output, not prose anyone edits — see GENERATED_DOC_FILES.
76
+ ...config_1.GENERATED_DOC_FILES,
75
77
  ...(userConfig.ignores ?? []),
76
78
  ];
77
79
  // markdownlint-cli2 has no --ignore-path flag; exclusions are expressed as
78
80
  // negated globs (`#pattern`). A bare directory name also needs `#name/**`
79
- // to exclude its contents.
81
+ // to exclude its contents — but a filename does not, and `#CHANGELOG.md/**`
82
+ // is a glob that can never match. `path.extname` is what tells them apart,
83
+ // and it reads a leading dot as part of the name, so `.claude` is still
84
+ // treated as the directory it is.
80
85
  const ignoreGlobs = defaultIgnorePatterns.flatMap(pattern => {
81
86
  const clean = pattern.replace(/\/+$/, '');
82
87
  const globs = [`#${clean}`];
83
- if (!clean.includes('*'))
88
+ if (!clean.includes('*') && !node_path_1.default.extname(clean))
84
89
  globs.push(`#${clean}/**`);
85
90
  return globs;
86
91
  });
@@ -24,4 +24,54 @@ export declare function anchorIgnoreGlob(pattern: string): string;
24
24
  * a client writing `"en"` means exactly that and appending would be meaningless.
25
25
  */
26
26
  export declare function mergeCspellConfig(defaultConfig: CspellConfig, userConfig: Partial<CspellConfig>): CspellConfig;
27
+ /**
28
+ * The optional dictionaries this installation actually has — and what to do
29
+ * about the ones it does not.
30
+ *
31
+ * Dropping a dictionary must not turn into a flood of warnings: a repository
32
+ * with Russian comments would report every Russian word as a misspelling, which
33
+ * is worse than the ~9 MB the dictionaries cost. So for the languages written in
34
+ * a script of their own, absence suppresses that script instead: cspell cannot
35
+ * judge those words, and reporting words it cannot judge is noise, not coverage.
36
+ *
37
+ * Latin-script languages (French, Spanish) have no such escape — their words are
38
+ * indistinguishable from misspelled English — so their absence is only logged,
39
+ * with the command that restores them.
40
+ *
41
+ * @returns imports and language codes to enable, ignore patterns for scripts left
42
+ * without a dictionary, and the packages a client may want to install.
43
+ */
44
+ export declare function availableDictionaries(resolve?: (id: string) => string): {
45
+ imports: string[];
46
+ languages: string[];
47
+ ignoreRegExpList: string[];
48
+ absent: string[];
49
+ };
50
+ /**
51
+ * Turn config imports into absolute paths, dropping the ones that are not installed.
52
+ *
53
+ * An unresolvable `import` is not a soft failure in cspell: it aborts config
54
+ * loading, checks **zero files**, and exits 1 — which this check would then have
55
+ * reported as "spelling issues found", a warning about problems that do not exist
56
+ * on a run that examined nothing. Passing the raw string through and hoping cspell
57
+ * copes is therefore the one thing that must not happen.
58
+ *
59
+ * @returns the imports that resolved, plus the language codes left without a
60
+ * dictionary so the caller can drop them from `language`.
61
+ */
62
+ export declare function resolveImports(imports: string[], baseDir: string, resolve?: (id: string) => string): {
63
+ resolved: string[];
64
+ missing: Array<{
65
+ pkg: string;
66
+ lang?: string;
67
+ }>;
68
+ };
69
+ /**
70
+ * Drop language codes whose dictionary is absent.
71
+ *
72
+ * Leaving `ru` in `language` with no Russian dictionary does not spellcheck
73
+ * Russian — it reports every Russian word as a misspelling. Having no opinion is
74
+ * the honest outcome when the dictionary that would form one is not installed.
75
+ */
76
+ export declare function dropMissingLanguages(language: string, missing: string[]): string;
27
77
  export declare const spellcheckCheck: Check;
@@ -6,6 +6,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.spellcheckCheck = void 0;
7
7
  exports.anchorIgnoreGlob = anchorIgnoreGlob;
8
8
  exports.mergeCspellConfig = mergeCspellConfig;
9
+ exports.availableDictionaries = availableDictionaries;
10
+ exports.resolveImports = resolveImports;
11
+ exports.dropMissingLanguages = dropMissingLanguages;
9
12
  const node_fs_1 = __importDefault(require("node:fs"));
10
13
  const node_path_1 = __importDefault(require("node:path"));
11
14
  const config_1 = require("../../../config");
@@ -55,6 +58,10 @@ function mergeCspellConfig(defaultConfig, userConfig) {
55
58
  return {
56
59
  ...defaultConfig,
57
60
  language: userConfig.language || defaultConfig.language,
61
+ // Appended, so a client can point at a dictionary they installed themselves
62
+ // without losing the bundled ones. Anything that does not resolve is dropped
63
+ // later rather than passed to cspell, which would abort the whole run.
64
+ import: [...(defaultConfig.import ?? []), ...(userConfig.import ?? [])],
58
65
  words: [...defaultConfig.words, ...(userConfig.words || [])],
59
66
  // Client globs are anchored too — a repo's own pattern is meant to be relative
60
67
  // to that repo, never to whatever directories sit above the checkout.
@@ -66,25 +73,145 @@ function mergeCspellConfig(defaultConfig, userConfig) {
66
73
  ...(dictionaries.length > 0 ? { dictionaries } : {}),
67
74
  };
68
75
  }
76
+ /**
77
+ * Non-English dictionaries the package no longer installs, and the language code
78
+ * each one serves.
79
+ *
80
+ * They used to be hard dependencies and cost every client ~9 MB on every install,
81
+ * to spellcheck languages almost none of them write. They are still *supported*:
82
+ * {@link availableDictionaries} enables one the moment it resolves, so a client
83
+ * who wants Russian back runs `npm i -D @cspell/dict-ru_ru` and edits no config.
84
+ *
85
+ * They are added at runtime rather than left in the bundled `cspell.json`,
86
+ * because a config that references an uninstalled dictionary is not a degraded
87
+ * config — see {@link resolveImports} — it is a broken one.
88
+ */
89
+ const OPTIONAL_DICTIONARIES = [
90
+ { pkg: '@cspell/dict-ru_ru/cspell-ext.json', lang: 'ru', script: 'Cyrillic' },
91
+ { pkg: '@cspell/dict-uk-ua/cspell-ext.json', lang: 'uk', script: 'Cyrillic' },
92
+ { pkg: '@cspell/dict-he/cspell-ext.json', lang: 'he', script: 'Hebrew' },
93
+ { pkg: '@cspell/dict-fr-fr/cspell-ext.json', lang: 'fr' },
94
+ { pkg: '@cspell/dict-es-es/cspell-ext.json', lang: 'es' },
95
+ ];
96
+ /**
97
+ * The optional dictionaries this installation actually has — and what to do
98
+ * about the ones it does not.
99
+ *
100
+ * Dropping a dictionary must not turn into a flood of warnings: a repository
101
+ * with Russian comments would report every Russian word as a misspelling, which
102
+ * is worse than the ~9 MB the dictionaries cost. So for the languages written in
103
+ * a script of their own, absence suppresses that script instead: cspell cannot
104
+ * judge those words, and reporting words it cannot judge is noise, not coverage.
105
+ *
106
+ * Latin-script languages (French, Spanish) have no such escape — their words are
107
+ * indistinguishable from misspelled English — so their absence is only logged,
108
+ * with the command that restores them.
109
+ *
110
+ * @returns imports and language codes to enable, ignore patterns for scripts left
111
+ * without a dictionary, and the packages a client may want to install.
112
+ */
113
+ function availableDictionaries(resolve = require.resolve) {
114
+ const imports = [];
115
+ const languages = [];
116
+ const scripts = new Set();
117
+ const absent = [];
118
+ for (const { pkg, lang, script } of OPTIONAL_DICTIONARIES) {
119
+ try {
120
+ imports.push(resolve(pkg));
121
+ languages.push(lang);
122
+ }
123
+ catch {
124
+ absent.push(pkg.split('/cspell-ext.json')[0]);
125
+ if (script)
126
+ scripts.add(script);
127
+ }
128
+ }
129
+ // A script is only suppressed when NO dictionary covering it survived: with
130
+ // Russian installed and Ukrainian missing, Cyrillic is still checked.
131
+ const covered = new Set(OPTIONAL_DICTIONARIES.filter(d => d.script && languages.includes(d.lang)).map(d => d.script));
132
+ return {
133
+ imports,
134
+ languages,
135
+ ignoreRegExpList: [...scripts]
136
+ .filter(script => !covered.has(script))
137
+ .map(script => `/\\p{Script=${script}}+/gu`),
138
+ absent,
139
+ };
140
+ }
141
+ /**
142
+ * Turn config imports into absolute paths, dropping the ones that are not installed.
143
+ *
144
+ * An unresolvable `import` is not a soft failure in cspell: it aborts config
145
+ * loading, checks **zero files**, and exits 1 — which this check would then have
146
+ * reported as "spelling issues found", a warning about problems that do not exist
147
+ * on a run that examined nothing. Passing the raw string through and hoping cspell
148
+ * copes is therefore the one thing that must not happen.
149
+ *
150
+ * @returns the imports that resolved, plus the language codes left without a
151
+ * dictionary so the caller can drop them from `language`.
152
+ */
153
+ function resolveImports(imports, baseDir, resolve = require.resolve) {
154
+ const resolvedPaths = [];
155
+ const missing = [];
156
+ for (const entry of imports) {
157
+ // A relative path comes from our own bundled config; it ships with the
158
+ // package and is resolved against it, not against the client's cwd.
159
+ if (entry.startsWith('.')) {
160
+ resolvedPaths.push(node_path_1.default.resolve(baseDir, entry));
161
+ continue;
162
+ }
163
+ try {
164
+ resolvedPaths.push(resolve(entry));
165
+ }
166
+ catch {
167
+ missing.push({
168
+ pkg: entry,
169
+ lang: OPTIONAL_DICTIONARIES.find(d => entry.startsWith(d.pkg))?.lang,
170
+ });
171
+ }
172
+ }
173
+ return { resolved: resolvedPaths, missing };
174
+ }
175
+ /**
176
+ * Drop language codes whose dictionary is absent.
177
+ *
178
+ * Leaving `ru` in `language` with no Russian dictionary does not spellcheck
179
+ * Russian — it reports every Russian word as a misspelling. Having no opinion is
180
+ * the honest outcome when the dictionary that would form one is not installed.
181
+ */
182
+ function dropMissingLanguages(language, missing) {
183
+ const kept = language
184
+ .split(',')
185
+ .map(code => code.trim())
186
+ .filter(code => code && !missing.includes(code));
187
+ // `en` is the floor: a config with an empty `language` makes cspell fall back
188
+ // to its own default, which is not necessarily what was configured here.
189
+ return kept.length > 0 ? kept.join(',') : 'en';
190
+ }
69
191
  function createTempCspellConfig(cwd) {
70
192
  const packageConfigPath = (0, config_1.getPackageConfigPath)('cspell.json');
71
193
  const packageConfigDir = node_path_1.default.dirname(packageConfigPath);
72
194
  const defaultConfig = JSON.parse(node_fs_1.default.readFileSync(packageConfigPath, 'utf-8'));
73
195
  const userConfig = loadUserCspellConfig(cwd);
74
- // Resolve import paths to absolute so they work from any client cwd.
75
- const resolvedImports = (defaultConfig.import ?? []).map(p => {
76
- if (p.startsWith('.'))
77
- return node_path_1.default.resolve(packageConfigDir, p);
78
- try {
79
- return require.resolve(p);
80
- }
81
- catch {
82
- return p;
83
- }
84
- });
196
+ const merged = mergeCspellConfig(defaultConfig, userConfig);
197
+ const { resolved, missing } = resolveImports(merged.import ?? [], packageConfigDir);
198
+ const available = availableDictionaries();
199
+ for (const { pkg } of missing) {
200
+ utils_1.logger.info(`[Spellcheck]: dictionary ${pkg} is not installed and was skipped. Add it with \`npm i -D ${pkg}\`.`);
201
+ }
202
+ if (available.absent.length > 0) {
203
+ utils_1.logger.debug(`[Spellcheck]: optional dictionaries not installed: ${available.absent.join(', ')}. ` +
204
+ `Install any of them to spellcheck that language (\`npm i -D ${available.absent[0]}\`).`);
205
+ }
206
+ // A language the client asked for whose dictionary is absent is dropped rather
207
+ // than left in place: without the dictionary, "ru" does not spellcheck Russian,
208
+ // it reports every Russian word as a misspelling.
209
+ const requested = dropMissingLanguages(merged.language, OPTIONAL_DICTIONARIES.map(d => d.lang).filter(lang => !available.languages.includes(lang)));
85
210
  const mergedConfig = {
86
- ...mergeCspellConfig(defaultConfig, userConfig),
87
- import: resolvedImports,
211
+ ...merged,
212
+ language: [...new Set([...requested.split(','), ...available.languages])].join(','),
213
+ import: [...resolved, ...available.imports],
214
+ ignoreRegExpList: [...merged.ignoreRegExpList, ...available.ignoreRegExpList],
88
215
  };
89
216
  const tempConfigPath = node_path_1.default.join(cwd, '.cspell.temp.json');
90
217
  node_fs_1.default.writeFileSync(tempConfigPath, JSON.stringify(mergedConfig, null, 2));
@@ -93,6 +220,7 @@ function createTempCspellConfig(cwd) {
93
220
  exports.spellcheckCheck = {
94
221
  name: 'Spellcheck',
95
222
  phase: 'informational',
223
+ weight: 'heavy',
96
224
  /**
97
225
  * Clients turn the check off with `cspell.enabled: false` (or the `spellcheck`
98
226
  * alias the config interface uses). The universal `severity: { Spellcheck: "off" }`
@@ -83,10 +83,15 @@ exports.yamlLintCheck = {
83
83
  // so a single malformed file cannot abort the whole check.
84
84
  const docs = (0, yaml_1.parseAllDocuments)(source, { prettyErrors: true });
85
85
  for (const doc of docs) {
86
- for (const problem of [...doc.errors, ...doc.warnings].filter(isFileDefect)) {
86
+ const problems = [
87
+ ...doc.errors.map(p => ({ problem: p, blocking: true })),
88
+ ...doc.warnings.map(p => ({ problem: p, blocking: false })),
89
+ ].filter(({ problem }) => isFileDefect(problem));
90
+ for (const { problem, blocking } of problems) {
87
91
  const pos = problem.linePos?.[0];
88
92
  issues.push({
89
93
  file,
94
+ blocking,
90
95
  line: pos?.line,
91
96
  col: pos?.col,
92
97
  message: shortMessage(problem.message),
@@ -96,15 +101,23 @@ exports.yamlLintCheck = {
96
101
  }
97
102
  if (issues.length === 0)
98
103
  return (0, types_1.pass)();
99
- // YAML problems are a hard error by default; a repo may downgrade to `warn`
100
- // via `yamlLint.severity`.
101
- const sev = (0, findings_1.severityEmitter)(ctx.config.yamlLint?.severity, 'error');
102
- issues.slice(0, 10).forEach(i => {
103
- const where = i.line ? `:${i.line}${i.col ? `:${i.col}` : ''}` : '';
104
- sev.log(`[YAML Lint]: ${sev.icon} ${i.file}${where} — ${i.message}`);
105
- });
104
+ // A file that does not parse blocks; a file that parses and merely worries
105
+ // the parser does not. Treating both as blocking is what made this check
106
+ // feel like an obstacle rather than a gate — and a check people switch off
107
+ // protects nobody. `yamlLint.severity` still overrides both at once.
108
+ const configured = ctx.config.yamlLint?.severity;
109
+ const broken = issues.filter(i => i.blocking);
110
+ const questionable = issues.filter(i => !i.blocking);
111
+ const describe = (i) => `${i.file}${i.line ? `:${i.line}${i.col ? `:${i.col}` : ''}` : ''} — ${i.message}`;
112
+ const sev = (0, findings_1.severityEmitter)(configured, broken.length > 0 ? 'error' : 'warn');
113
+ [...broken, ...questionable]
114
+ .slice(0, 10)
115
+ .forEach(i => sev.log(`[YAML Lint]: ${sev.icon} ${describe(i)}`));
106
116
  if (issues.length > 10)
107
117
  sev.log(`[YAML Lint]: ... and ${issues.length - 10} more`);
108
- return sev.emit(`${issues.length} YAML issue(s) found`);
118
+ const summary = broken.length > 0 && questionable.length > 0
119
+ ? `${broken.length} unparseable, ${questionable.length} questionable YAML issue(s)`
120
+ : `${issues.length} YAML issue(s) found`;
121
+ return sev.emit(summary);
109
122
  },
110
123
  };
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.diffSecurityCheck = void 0;
4
+ const config_1 = require("../../../config");
4
5
  const utils_1 = require("../../../utils");
6
+ const targets_1 = require("../../targets");
5
7
  const types_1 = require("../../types");
6
8
  const findings_1 = require("../findings");
7
9
  const MD5_LABEL = 'MD5 (weak hash — use sha256 or stronger)';
@@ -10,35 +12,48 @@ const SECURITY_PATTERNS = [
10
12
  {
11
13
  pattern: /^\+(?!\+).*dangerouslySetInnerHTML\s*=/,
12
14
  label: 'dangerouslySetInnerHTML (XSS risk)',
15
+ view: 'code',
13
16
  severity: 'error',
14
17
  },
15
18
  {
16
19
  pattern: /^\+(?!\+).*\.innerHTML\s*=/,
17
20
  label: 'innerHTML assignment (XSS risk)',
21
+ view: 'code',
18
22
  severity: 'warn',
19
23
  },
20
24
  {
21
25
  pattern: /^\+(?!\+).*document\.write\s*\(/,
22
26
  label: 'document.write() (XSS risk)',
27
+ view: 'code',
23
28
  severity: 'error',
24
29
  },
25
30
  {
26
- pattern: /^\+(?!\+)(?![^'"`]*['"`]).*\beval\s*\(/,
31
+ // Matched against the string-blanked view, so unrelated quoting on the line
32
+ // cannot disable the rule. The old lookahead `(?![^'"`]*['"`])` meant "no
33
+ // quote anywhere ahead", which silently skipped `eval(x + "y")` and
34
+ // ``eval(`${p}`)`` — see ticket 058.
35
+ pattern: /^\+(?!\+).*\beval\s*\(/,
27
36
  label: 'eval() (code injection risk)',
28
37
  severity: 'error',
38
+ view: 'code',
29
39
  },
30
40
  {
31
41
  pattern: /^\+(?!\+).*\bnew\s+Function\s*\(/,
32
42
  label: 'new Function() (code injection risk)',
33
43
  severity: 'error',
44
+ view: 'code',
34
45
  },
35
46
  {
36
- pattern: /^\+(?!\+).*__proto__\s*[=:[]/,
47
+ // Both the property form (`o.__proto__ =`, `{ __proto__: … }`) and the
48
+ // computed form (`o['__proto__'] =`). The latter is the one that survives a
49
+ // grep for `.__proto__`, and the one an attacker-supplied key arrives through.
50
+ // The rule matches its own source, hence the opt-out on the line itself.
51
+ pattern: /^\+(?!\+).*(?:__proto__\s*[=:[]|\[\s*['"]__proto__['"]\s*\])/, // pr-checkmate-ignore
37
52
  label: '__proto__ mutation (prototype pollution)',
38
53
  severity: 'error',
39
54
  },
40
55
  {
41
- pattern: /^\+(?!\+).*\.constructor\.prototype\b/,
56
+ pattern: /^\+(?!\+).*(?:\.constructor\.prototype\b|\[\s*['"]constructor['"]\s*\]\s*\[\s*['"]prototype['"]\s*\])/,
42
57
  label: 'constructor.prototype access (prototype pollution)',
43
58
  severity: 'warn',
44
59
  },
@@ -55,11 +70,13 @@ const SECURITY_PATTERNS = [
55
70
  {
56
71
  pattern: /^\+(?!\+).*\bcreateDecipher\s*\(/,
57
72
  label: 'deprecated createDecipher() — use createDecipheriv()',
73
+ view: 'code',
58
74
  severity: 'warn',
59
75
  },
60
76
  {
61
77
  pattern: /^\+(?!\+).*\bcreateCipher\s*\(/,
62
78
  label: 'deprecated createCipher() — use createCipheriv()',
79
+ view: 'code',
63
80
  severity: 'warn',
64
81
  },
65
82
  {
@@ -103,8 +120,18 @@ const SECURITY_PATTERNS = [
103
120
  ];
104
121
  /** Python-specific dangerous constructs, scanned in *.py diffs. */
105
122
  const PYTHON_SECURITY_PATTERNS = [
106
- { pattern: /^\+(?!\+).*\beval\s*\(/, label: 'eval() (code injection risk)', severity: 'error' },
107
- { pattern: /^\+(?!\+).*\bexec\s*\(/, label: 'exec() (code injection risk)', severity: 'error' },
123
+ {
124
+ pattern: /^\+(?!\+).*\beval\s*\(/,
125
+ label: 'eval() (code injection risk)',
126
+ severity: 'error',
127
+ view: 'code',
128
+ },
129
+ {
130
+ pattern: /^\+(?!\+).*\bexec\s*\(/,
131
+ label: 'exec() (code injection risk)',
132
+ severity: 'error',
133
+ view: 'code',
134
+ },
108
135
  {
109
136
  pattern: /^\+(?!\+).*\bos\.system\s*\(/,
110
137
  label: 'os.system() (command injection risk)',
@@ -209,49 +236,109 @@ const KOTLIN_SECURITY_PATTERNS = [
209
236
  },
210
237
  ];
211
238
  const JS_GLOBS = ['*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs'];
212
- /** Scan a diff with a pattern set, accumulating matches into severity buckets. */
213
- function scanInto(stdout, patterns, errors, warnings) {
214
- for (const line of stdout.split('\n')) {
215
- // Match against the comment-stripped line so `eval(x)` in a comment isn't
216
- // flagged, but report the original line for context.
217
- const scanned = (0, findings_1.stripInlineComment)(line);
218
- for (const { pattern, label, severity } of patterns) {
219
- if (pattern.test(scanned)) {
220
- const bucket = severity === 'error' ? errors : warnings;
221
- (bucket[label] ?? (bucket[label] = [])).push(line.slice(1).trim().substring(0, 120));
222
- }
239
+ /**
240
+ * Compile the client's `extraPatterns` into the same shape as the built-ins, so
241
+ * a project rule gets identical treatment: comment stripping, the code/raw view,
242
+ * grouping, sampling and severity.
243
+ *
244
+ * A malformed regex is reported and dropped rather than thrown: one bad entry in
245
+ * a config file must not take down the whole security scan.
246
+ */
247
+ function compileExtraPatterns(ctx) {
248
+ const extra = ctx.config.diffSecurity?.extraPatterns ?? [];
249
+ return extra.flatMap(rule => {
250
+ try {
251
+ // `g`/`y` are stripped: both carry lastIndex state across .test() calls,
252
+ // which would make a rule match every other line.
253
+ const flags = (rule.flags ?? '').replace(/[gy]/g, '');
254
+ return [
255
+ {
256
+ // Anchored on the added-line marker the same way the built-ins are, so
257
+ // a client writes the body of the rule, not the diff plumbing.
258
+ pattern: new RegExp(`^\\+(?!\\+).*(?:${rule.pattern})`, flags),
259
+ label: rule.label,
260
+ severity: rule.severity ?? 'warn',
261
+ view: rule.view ?? 'raw',
262
+ },
263
+ ];
223
264
  }
224
- }
265
+ catch (err) {
266
+ utils_1.logger.warn(`[Diff Security]: ⚠️ ignoring extraPattern "${rule.label}" — invalid regex: ${err}`);
267
+ return [];
268
+ }
269
+ });
270
+ }
271
+ /**
272
+ * Built-ins minus anything the client switched off by label. `disablePatterns`
273
+ * differs from `ignore`: the rule never runs at all, rather than running and
274
+ * having its findings dropped afterwards.
275
+ */
276
+ function activePatterns(patterns, disabled) {
277
+ if (disabled.length === 0)
278
+ return patterns;
279
+ const lowered = disabled.map(d => d.toLowerCase());
280
+ return patterns.filter(p => !lowered.some(d => p.label.toLowerCase().includes(d)));
225
281
  }
226
282
  exports.diffSecurityCheck = {
227
283
  name: 'Diff Security',
228
284
  phase: 'informational',
285
+ // No `requiresDiff`: a dangerous call is dangerous whether it arrived in this
286
+ // PR or three years ago, so with no diff range the whole tree is reviewed
287
+ // instead of the check dropping out (ticket 067).
229
288
  applies(ctx) {
230
- return ctx.config.diffSecurity?.enabled !== false && Boolean(ctx.baseSha);
289
+ return ctx.config.diffSecurity?.enabled !== false;
231
290
  },
232
291
  async run(ctx) {
233
292
  try {
234
- const errors = {};
235
- const warnings = {};
293
+ const cfg = ctx.config.diffSecurity ?? {};
294
+ const found = (0, findings_1.emptyBuckets)();
295
+ const scanOpts = {
296
+ comments: cfg.commentHandling ?? 'language',
297
+ // Setting `ignorePaths` REPLACES the test-path defaults rather than adding
298
+ // to them, matching `duplicate.ignore` and `deadCode.ignoreFiles`. A client
299
+ // who wants their tests scanned says so by listing something else here.
300
+ ignorePaths: cfg.ignorePaths ?? config_1.DIFF_SECURITY_IGNORE_DEFAULTS,
301
+ };
302
+ const extra = compileExtraPatterns(ctx);
303
+ const disabled = cfg.disablePatterns ?? [];
304
+ const scanInto = (stdout, patterns) => {
305
+ (0, findings_1.mergeSeverityBuckets)(found, (0, findings_1.scanDiffPatterns)(stdout, activePatterns(patterns, disabled), scanOpts));
306
+ };
307
+ /**
308
+ * 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.
312
+ */
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
+ };
236
323
  // JS/TS patterns always run; language-specific sets scan their own globs
237
324
  // only when that language is present, so a Python repo gets Python rules.
238
- scanInto(await (0, findings_1.diffAddedLines)(ctx, JS_GLOBS), SECURITY_PATTERNS, errors, warnings);
325
+ // Client `extraPatterns` run against every scanned language, since a
326
+ // project rule is not tied to the built-in language split.
327
+ await review(JS_GLOBS, [...SECURITY_PATTERNS, ...extra]);
239
328
  if (ctx.languages.includes('python')) {
240
- scanInto(await (0, findings_1.diffAddedLines)(ctx, ['*.py']), PYTHON_SECURITY_PATTERNS, errors, warnings);
329
+ await review(['*.py'], [...PYTHON_SECURITY_PATTERNS, ...extra]);
241
330
  }
242
331
  if (ctx.languages.includes('go')) {
243
- scanInto(await (0, findings_1.diffAddedLines)(ctx, ['*.go']), GO_SECURITY_PATTERNS, errors, warnings);
332
+ await review(['*.go'], [...GO_SECURITY_PATTERNS, ...extra]);
244
333
  }
245
334
  if (ctx.languages.includes('kotlin')) {
246
- const ktOut = await (0, findings_1.diffAddedLines)(ctx, ['*.kt', '*.kts', '*.java']);
247
- scanInto(ktOut, KOTLIN_SECURITY_PATTERNS, errors, warnings);
335
+ await review(['*.kt', '*.kts', '*.java'], [...KOTLIN_SECURITY_PATTERNS, ...extra]);
248
336
  }
249
- return (0, findings_1.reportSeverityBuckets)(errors, warnings, {
337
+ return (0, findings_1.reportSeverityBuckets)(found.errors, found.warnings, {
250
338
  prefix: 'Diff Security',
251
339
  errorSummary: n => `${n} issue(s)`,
252
340
  warningSummary: n => `${n} warning(s)`,
253
- sampleLimit: 3,
254
- ignore: ctx.config.diffSecurity?.ignore,
341
+ ...(0, findings_1.clientFindingOptions)(ctx, cfg),
255
342
  });
256
343
  }
257
344
  catch (err) {
@@ -45,26 +45,13 @@ const DOCKER_PATTERNS = [
45
45
  severity: 'warn',
46
46
  },
47
47
  ];
48
- function scanDiff(stdout) {
49
- const errors = {};
50
- const warnings = {};
51
- for (const line of stdout.split('\n')) {
52
- if (line.includes('pr-checkmate-ignore'))
53
- continue;
54
- for (const { pattern, label, severity } of DOCKER_PATTERNS) {
55
- if (pattern.test(line)) {
56
- const bucket = severity === 'error' ? errors : warnings;
57
- (bucket[label] ?? (bucket[label] = [])).push(line.slice(1).trim().substring(0, 120));
58
- }
59
- }
60
- }
61
- return { errors, warnings };
62
- }
63
48
  exports.dockerfileSecurityCheck = {
64
49
  name: 'Dockerfile Security',
65
50
  phase: 'informational',
51
+ // No `requiresDiff`: a secret baked into an ENV line is baked in whether the
52
+ // line is new or inherited (ticket 067).
66
53
  applies(ctx) {
67
- return ctx.config.dockerfileSecurity?.enabled !== false && Boolean(ctx.baseSha);
54
+ return ctx.config.dockerfileSecurity?.enabled !== false;
68
55
  },
69
56
  async run(ctx) {
70
57
  const files = await (0, targets_1.resolveTargetFiles)(ctx, ['*']);
@@ -74,14 +61,30 @@ exports.dockerfileSecurityCheck = {
74
61
  if (dockerfiles.length === 0)
75
62
  return (0, types_1.pass)();
76
63
  try {
77
- const { stdout } = await (0, execa_1.execa)('git', ['diff', '--unified=0', ctx.baseSha, ctx.headSha ?? 'HEAD', '--', ...dockerfiles], { cwd: ctx.cwd });
78
- const { errors, warnings } = scanDiff(stdout);
64
+ const cfg = ctx.config.dockerfileSecurity ?? {};
65
+ let stdout = '';
66
+ if (ctx.baseSha) {
67
+ stdout = (await (0, execa_1.execa)('git', ['diff', '--unified=0', ctx.baseSha, ctx.headSha ?? 'HEAD', '--', ...dockerfiles], { cwd: ctx.cwd })).stdout;
68
+ }
69
+ else if (ctx.config.run?.fullScanBehaviour !== 'skip') {
70
+ await (0, findings_1.scanTrackedFiles)(ctx, dockerfiles, asDiff => {
71
+ stdout += `${asDiff}\n`;
72
+ });
73
+ }
74
+ // This check runs `git diff` itself rather than going through
75
+ // `diffAddedLines`, so the `pr-checkmate-ignore` opt-out is applied here.
76
+ const { errors, warnings } = (0, findings_1.scanDiffPatterns)(stdout, DOCKER_PATTERNS, {
77
+ skipIgnored: true,
78
+ // Dockerfiles have no extension to key off, and `#` is their comment
79
+ // marker — the `hash` reading, not the language-detected one.
80
+ comments: 'legacy',
81
+ ignorePaths: cfg.ignorePaths,
82
+ });
79
83
  return (0, findings_1.reportSeverityBuckets)(errors, warnings, {
80
84
  prefix: 'Dockerfile Security',
81
85
  errorSummary: n => `${n} issue(s)`,
82
86
  warningSummary: n => `${n} warning(s)`,
83
- sampleLimit: 3,
84
- ignore: ctx.config.dockerfileSecurity?.ignore,
87
+ ...(0, findings_1.clientFindingOptions)(ctx, cfg),
85
88
  });
86
89
  }
87
90
  catch (err) {