pr-checkmate 1.23.23 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -15
- package/cspell.json +3 -8
- package/dist/cli.js +37 -62
- package/dist/config/constants.d.ts +21 -3
- package/dist/config/constants.js +33 -6
- package/dist/config/pr-checkmate-workflow.yml +33 -327
- package/dist/core/checks/dependencies/banned-imports.js +23 -24
- package/dist/core/checks/dependencies/circular-deps.js +1 -0
- package/dist/core/checks/dependencies/dependency-check.js +1 -0
- package/dist/core/checks/dependencies/grype-scan.js +1 -0
- package/dist/core/checks/dependencies/npm-audit.js +6 -1
- package/dist/core/checks/dependencies/vuln-scan.js +1 -0
- package/dist/core/checks/findings.d.ts +198 -19
- package/dist/core/checks/findings.js +474 -22
- package/dist/core/checks/git/custom-rules.js +21 -16
- package/dist/core/checks/git/diff-smell.d.ts +17 -0
- package/dist/core/checks/git/diff-smell.js +43 -24
- package/dist/core/checks/git/large-files.js +14 -2
- package/dist/core/checks/git/leftover-debug.js +23 -14
- package/dist/core/checks/git/lockfile-drift.js +2 -1
- package/dist/core/checks/git/merge-conflict.js +19 -8
- package/dist/core/checks/git/missing-tests.js +22 -6
- package/dist/core/checks/git/todo-fixme.js +10 -9
- package/dist/core/checks/languages/shellcheck.d.ts +8 -0
- package/dist/core/checks/languages/shellcheck.js +55 -0
- package/dist/core/checks/languages/typecheck.js +1 -0
- package/dist/core/checks/pr/pr-size-check.js +2 -1
- package/dist/core/checks/quality/config-validation.d.ts +14 -4
- package/dist/core/checks/quality/config-validation.js +119 -17
- package/dist/core/checks/quality/dead-code.js +1 -0
- package/dist/core/checks/quality/duplicate-check.js +1 -0
- package/dist/core/checks/quality/license-header.js +12 -2
- package/dist/core/checks/quality/spellcheck.d.ts +50 -0
- package/dist/core/checks/quality/spellcheck.js +141 -13
- package/dist/core/checks/quality/symlinks.d.ts +16 -0
- package/dist/core/checks/quality/symlinks.js +62 -0
- package/dist/core/checks/quality/yaml-lint.js +22 -9
- package/dist/core/checks/security/diff-security.js +111 -28
- package/dist/core/checks/security/dockerfile-security.js +23 -20
- package/dist/core/checks/security/migration-safety.js +19 -17
- package/dist/core/checks/security/sensitive-file-guard.js +37 -5
- package/dist/core/checks/security/workflow-security.d.ts +10 -0
- package/dist/core/checks/security/workflow-security.js +48 -26
- package/dist/core/commands.d.ts +27 -0
- package/dist/core/commands.js +91 -0
- package/dist/core/context.d.ts +12 -3
- package/dist/core/context.js +118 -16
- package/dist/core/install/cache.d.ts +39 -0
- package/dist/core/install/cache.js +99 -0
- package/dist/core/install/doctor.d.ts +35 -0
- package/dist/core/install/doctor.js +145 -0
- package/dist/core/install/ensure.d.ts +60 -0
- package/dist/core/install/ensure.js +169 -0
- package/dist/core/install/lock.d.ts +33 -0
- package/dist/core/install/lock.js +99 -0
- package/dist/core/install/profiles.d.ts +64 -0
- package/dist/core/install/profiles.js +170 -0
- package/dist/core/registry.js +4 -0
- package/dist/core/reporters.d.ts +13 -2
- package/dist/core/reporters.js +145 -104
- package/dist/core/run-checks.js +91 -14
- package/dist/core/targets.js +26 -4
- package/dist/core/types.d.ts +49 -1
- package/dist/init.d.ts +29 -14
- package/dist/init.js +204 -262
- package/dist/interfaces/index.d.ts +130 -0
- package/dist/scripts/generate-install-manifests.d.ts +39 -0
- package/dist/scripts/generate-install-manifests.js +121 -0
- package/dist/scripts/publish-github.js +18 -4
- package/dist/utils/detect-language.d.ts +37 -4
- package/dist/utils/detect-language.js +113 -19
- package/dist/utils/http.d.ts +59 -0
- package/dist/utils/http.js +119 -0
- package/dist/utils/logger.d.ts +52 -6
- package/dist/utils/logger.js +125 -33
- package/dist/utils/pool.d.ts +49 -0
- package/dist/utils/pool.js +124 -0
- package/dist/utils/update-notifier.d.ts +9 -0
- package/dist/utils/update-notifier.js +57 -3
- package/eslint.config.mjs +14 -2
- package/install-manifests/commitlint/package-lock.json +1028 -0
- package/install-manifests/commitlint/package.json +9 -0
- package/install-manifests/core/package-lock.json +1335 -0
- package/install-manifests/core/package.json +11 -0
- package/install-manifests/cpp/package-lock.json +29 -0
- package/install-manifests/cpp/package.json +8 -0
- package/install-manifests/duplicate/package-lock.json +113 -0
- package/install-manifests/duplicate/package.json +8 -0
- package/install-manifests/js/package-lock.json +5099 -0
- package/install-manifests/js/package.json +26 -0
- package/install-manifests/markdown/package-lock.json +1281 -0
- package/install-manifests/markdown/package.json +8 -0
- package/install-manifests/python/package-lock.json +21 -0
- package/install-manifests/python/package.json +8 -0
- package/install-manifests/spellcheck/package-lock.json +1047 -0
- package/install-manifests/spellcheck/package.json +8 -0
- package/package.json +13 -28
- package/pr-checkmate.schema.json +1070 -96
- package/src/config/cspell.json +3 -8
- package/src/config/pr-checkmate-workflow.yml +33 -327
- package/src/config/pr-checkmate.schema.json +1070 -96
|
@@ -20,8 +20,12 @@ const DEFAULT_HEADER_LINES = 10;
|
|
|
20
20
|
exports.licenseHeaderCheck = {
|
|
21
21
|
name: 'License Header',
|
|
22
22
|
phase: 'informational',
|
|
23
|
+
// No `requiresDiff`: with no range the question becomes "which source files
|
|
24
|
+
// lack the header", which is what a repo adopting the policy actually needs —
|
|
25
|
+
// the delta form only ever covers files added since the policy was turned on
|
|
26
|
+
// (ticket 067).
|
|
23
27
|
applies(ctx) {
|
|
24
|
-
return ctx.config.licenseHeader?.enabled === true
|
|
28
|
+
return ctx.config.licenseHeader?.enabled === true;
|
|
25
29
|
},
|
|
26
30
|
async run(ctx) {
|
|
27
31
|
const patternSrc = ctx.config.licenseHeader?.pattern;
|
|
@@ -38,7 +42,13 @@ exports.licenseHeaderCheck = {
|
|
|
38
42
|
const maxLines = ctx.config.licenseHeader?.lines ?? DEFAULT_HEADER_LINES;
|
|
39
43
|
let added;
|
|
40
44
|
try {
|
|
41
|
-
|
|
45
|
+
if (!ctx.baseSha && ctx.config.run?.fullScanBehaviour === 'skip')
|
|
46
|
+
return (0, types_1.pass)();
|
|
47
|
+
// Added files in delta mode; every tracked source file in full mode.
|
|
48
|
+
const args = ctx.baseSha
|
|
49
|
+
? ['diff', '--name-only', '--diff-filter=A', ctx.baseSha, ctx.headSha ?? 'HEAD']
|
|
50
|
+
: ['ls-files'];
|
|
51
|
+
const { stdout } = await (0, execa_1.execa)('git', args, { cwd: ctx.cwd });
|
|
42
52
|
added = stdout
|
|
43
53
|
.split('\n')
|
|
44
54
|
.filter(Boolean)
|
|
@@ -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
|
-
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
...
|
|
87
|
-
|
|
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" }`
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Check } from '../../types';
|
|
2
|
+
/** True when `fullPath` is a symlink whose target does not exist. */
|
|
3
|
+
export declare function isBrokenSymlink(fullPath: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Flags tracked symlinks whose target does not exist. `fs.existsSync` follows a
|
|
6
|
+
* symlink to its target, so it returns `false` for exactly the broken case while
|
|
7
|
+
* the link itself is still a real, tracked file — that's what makes a plain
|
|
8
|
+
* existence check (as used by `resolveTargetFiles`) unusable here and requires
|
|
9
|
+
* the dedicated `isBrokenSymlink` pass over `fs.lstatSync` above.
|
|
10
|
+
*
|
|
11
|
+
* Whole-repo scan (not delta), like Case Collision: a broken link fails a
|
|
12
|
+
* checkout or build regardless of which PR introduced it, so it's worth
|
|
13
|
+
* catching even when this run's diff never touched it. Informational and warns
|
|
14
|
+
* by default — promote to a gate per repo with `symlinks.severity`.
|
|
15
|
+
*/
|
|
16
|
+
export declare const symlinksCheck: Check;
|
|
@@ -0,0 +1,62 @@
|
|
|
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.symlinksCheck = void 0;
|
|
7
|
+
exports.isBrokenSymlink = isBrokenSymlink;
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const config_1 = require("../../../config");
|
|
11
|
+
const utils_1 = require("../../../utils");
|
|
12
|
+
const targets_1 = require("../../targets");
|
|
13
|
+
const types_1 = require("../../types");
|
|
14
|
+
const findings_1 = require("../findings");
|
|
15
|
+
/** True when `fullPath` is a symlink whose target does not exist. */
|
|
16
|
+
function isBrokenSymlink(fullPath) {
|
|
17
|
+
let stat;
|
|
18
|
+
try {
|
|
19
|
+
stat = node_fs_1.default.lstatSync(fullPath);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return false; // gone between listing and stat
|
|
23
|
+
}
|
|
24
|
+
return stat.isSymbolicLink() && !node_fs_1.default.existsSync(fullPath);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Flags tracked symlinks whose target does not exist. `fs.existsSync` follows a
|
|
28
|
+
* symlink to its target, so it returns `false` for exactly the broken case while
|
|
29
|
+
* the link itself is still a real, tracked file — that's what makes a plain
|
|
30
|
+
* existence check (as used by `resolveTargetFiles`) unusable here and requires
|
|
31
|
+
* the dedicated `isBrokenSymlink` pass over `fs.lstatSync` above.
|
|
32
|
+
*
|
|
33
|
+
* Whole-repo scan (not delta), like Case Collision: a broken link fails a
|
|
34
|
+
* checkout or build regardless of which PR introduced it, so it's worth
|
|
35
|
+
* catching even when this run's diff never touched it. Informational and warns
|
|
36
|
+
* by default — promote to a gate per repo with `symlinks.severity`.
|
|
37
|
+
*/
|
|
38
|
+
exports.symlinksCheck = {
|
|
39
|
+
name: 'Broken Symlinks',
|
|
40
|
+
phase: 'informational',
|
|
41
|
+
applies(ctx) {
|
|
42
|
+
return ctx.config.symlinks?.enabled !== false;
|
|
43
|
+
},
|
|
44
|
+
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');
|
|
48
|
+
const ignore = ctx.config.symlinks?.ignore ?? [];
|
|
49
|
+
const broken = [];
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
if (ignore.length > 0 && (0, utils_1.matchesAnyGlob)(file, ignore))
|
|
52
|
+
continue;
|
|
53
|
+
if (isBrokenSymlink(node_path_1.default.join(ctx.cwd, file)))
|
|
54
|
+
broken.push(file);
|
|
55
|
+
}
|
|
56
|
+
if (broken.length === 0)
|
|
57
|
+
return (0, types_1.pass)();
|
|
58
|
+
const sev = (0, findings_1.severityEmitter)(ctx.config.symlinks?.severity, 'warn');
|
|
59
|
+
broken.forEach(f => sev.log(`[Broken Symlinks]: ${sev.icon} ${f} — target does not exist`));
|
|
60
|
+
return sev.emit(`${broken.length} broken symlink(s)`);
|
|
61
|
+
},
|
|
62
|
+
};
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
};
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.diffSecurityCheck = void 0;
|
|
4
4
|
const utils_1 = require("../../../utils");
|
|
5
|
+
const targets_1 = require("../../targets");
|
|
5
6
|
const types_1 = require("../../types");
|
|
6
7
|
const findings_1 = require("../findings");
|
|
7
8
|
const MD5_LABEL = 'MD5 (weak hash — use sha256 or stronger)';
|
|
@@ -10,35 +11,48 @@ const SECURITY_PATTERNS = [
|
|
|
10
11
|
{
|
|
11
12
|
pattern: /^\+(?!\+).*dangerouslySetInnerHTML\s*=/,
|
|
12
13
|
label: 'dangerouslySetInnerHTML (XSS risk)',
|
|
14
|
+
view: 'code',
|
|
13
15
|
severity: 'error',
|
|
14
16
|
},
|
|
15
17
|
{
|
|
16
18
|
pattern: /^\+(?!\+).*\.innerHTML\s*=/,
|
|
17
19
|
label: 'innerHTML assignment (XSS risk)',
|
|
20
|
+
view: 'code',
|
|
18
21
|
severity: 'warn',
|
|
19
22
|
},
|
|
20
23
|
{
|
|
21
24
|
pattern: /^\+(?!\+).*document\.write\s*\(/,
|
|
22
25
|
label: 'document.write() (XSS risk)',
|
|
26
|
+
view: 'code',
|
|
23
27
|
severity: 'error',
|
|
24
28
|
},
|
|
25
29
|
{
|
|
26
|
-
|
|
30
|
+
// Matched against the string-blanked view, so unrelated quoting on the line
|
|
31
|
+
// cannot disable the rule. The old lookahead `(?![^'"`]*['"`])` meant "no
|
|
32
|
+
// quote anywhere ahead", which silently skipped `eval(x + "y")` and
|
|
33
|
+
// ``eval(`${p}`)`` — see ticket 058.
|
|
34
|
+
pattern: /^\+(?!\+).*\beval\s*\(/,
|
|
27
35
|
label: 'eval() (code injection risk)',
|
|
28
36
|
severity: 'error',
|
|
37
|
+
view: 'code',
|
|
29
38
|
},
|
|
30
39
|
{
|
|
31
40
|
pattern: /^\+(?!\+).*\bnew\s+Function\s*\(/,
|
|
32
41
|
label: 'new Function() (code injection risk)',
|
|
33
42
|
severity: 'error',
|
|
43
|
+
view: 'code',
|
|
34
44
|
},
|
|
35
45
|
{
|
|
36
|
-
|
|
46
|
+
// Both the property form (`o.__proto__ =`, `{ __proto__: … }`) and the
|
|
47
|
+
// computed form (`o['__proto__'] =`). The latter is the one that survives a
|
|
48
|
+
// grep for `.__proto__`, and the one an attacker-supplied key arrives through.
|
|
49
|
+
// The rule matches its own source, hence the opt-out on the line itself.
|
|
50
|
+
pattern: /^\+(?!\+).*(?:__proto__\s*[=:[]|\[\s*['"]__proto__['"]\s*\])/, // pr-checkmate-ignore
|
|
37
51
|
label: '__proto__ mutation (prototype pollution)',
|
|
38
52
|
severity: 'error',
|
|
39
53
|
},
|
|
40
54
|
{
|
|
41
|
-
pattern: /^\+(?!\+)
|
|
55
|
+
pattern: /^\+(?!\+).*(?:\.constructor\.prototype\b|\[\s*['"]constructor['"]\s*\]\s*\[\s*['"]prototype['"]\s*\])/,
|
|
42
56
|
label: 'constructor.prototype access (prototype pollution)',
|
|
43
57
|
severity: 'warn',
|
|
44
58
|
},
|
|
@@ -55,11 +69,13 @@ const SECURITY_PATTERNS = [
|
|
|
55
69
|
{
|
|
56
70
|
pattern: /^\+(?!\+).*\bcreateDecipher\s*\(/,
|
|
57
71
|
label: 'deprecated createDecipher() — use createDecipheriv()',
|
|
72
|
+
view: 'code',
|
|
58
73
|
severity: 'warn',
|
|
59
74
|
},
|
|
60
75
|
{
|
|
61
76
|
pattern: /^\+(?!\+).*\bcreateCipher\s*\(/,
|
|
62
77
|
label: 'deprecated createCipher() — use createCipheriv()',
|
|
78
|
+
view: 'code',
|
|
63
79
|
severity: 'warn',
|
|
64
80
|
},
|
|
65
81
|
{
|
|
@@ -103,8 +119,18 @@ const SECURITY_PATTERNS = [
|
|
|
103
119
|
];
|
|
104
120
|
/** Python-specific dangerous constructs, scanned in *.py diffs. */
|
|
105
121
|
const PYTHON_SECURITY_PATTERNS = [
|
|
106
|
-
{
|
|
107
|
-
|
|
122
|
+
{
|
|
123
|
+
pattern: /^\+(?!\+).*\beval\s*\(/,
|
|
124
|
+
label: 'eval() (code injection risk)',
|
|
125
|
+
severity: 'error',
|
|
126
|
+
view: 'code',
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
pattern: /^\+(?!\+).*\bexec\s*\(/,
|
|
130
|
+
label: 'exec() (code injection risk)',
|
|
131
|
+
severity: 'error',
|
|
132
|
+
view: 'code',
|
|
133
|
+
},
|
|
108
134
|
{
|
|
109
135
|
pattern: /^\+(?!\+).*\bos\.system\s*\(/,
|
|
110
136
|
label: 'os.system() (command injection risk)',
|
|
@@ -209,49 +235,106 @@ const KOTLIN_SECURITY_PATTERNS = [
|
|
|
209
235
|
},
|
|
210
236
|
];
|
|
211
237
|
const JS_GLOBS = ['*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs'];
|
|
212
|
-
/**
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
238
|
+
/**
|
|
239
|
+
* Compile the client's `extraPatterns` into the same shape as the built-ins, so
|
|
240
|
+
* a project rule gets identical treatment: comment stripping, the code/raw view,
|
|
241
|
+
* grouping, sampling and severity.
|
|
242
|
+
*
|
|
243
|
+
* A malformed regex is reported and dropped rather than thrown: one bad entry in
|
|
244
|
+
* a config file must not take down the whole security scan.
|
|
245
|
+
*/
|
|
246
|
+
function compileExtraPatterns(ctx) {
|
|
247
|
+
const extra = ctx.config.diffSecurity?.extraPatterns ?? [];
|
|
248
|
+
return extra.flatMap(rule => {
|
|
249
|
+
try {
|
|
250
|
+
// `g`/`y` are stripped: both carry lastIndex state across .test() calls,
|
|
251
|
+
// which would make a rule match every other line.
|
|
252
|
+
const flags = (rule.flags ?? '').replace(/[gy]/g, '');
|
|
253
|
+
return [
|
|
254
|
+
{
|
|
255
|
+
// Anchored on the added-line marker the same way the built-ins are, so
|
|
256
|
+
// a client writes the body of the rule, not the diff plumbing.
|
|
257
|
+
pattern: new RegExp(`^\\+(?!\\+).*(?:${rule.pattern})`, flags),
|
|
258
|
+
label: rule.label,
|
|
259
|
+
severity: rule.severity ?? 'warn',
|
|
260
|
+
view: rule.view ?? 'raw',
|
|
261
|
+
},
|
|
262
|
+
];
|
|
223
263
|
}
|
|
224
|
-
|
|
264
|
+
catch (err) {
|
|
265
|
+
utils_1.logger.warn(`[Diff Security]: ⚠️ ignoring extraPattern "${rule.label}" — invalid regex: ${err}`);
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Built-ins minus anything the client switched off by label. `disablePatterns`
|
|
272
|
+
* differs from `ignore`: the rule never runs at all, rather than running and
|
|
273
|
+
* having its findings dropped afterwards.
|
|
274
|
+
*/
|
|
275
|
+
function activePatterns(patterns, disabled) {
|
|
276
|
+
if (disabled.length === 0)
|
|
277
|
+
return patterns;
|
|
278
|
+
const lowered = disabled.map(d => d.toLowerCase());
|
|
279
|
+
return patterns.filter(p => !lowered.some(d => p.label.toLowerCase().includes(d)));
|
|
225
280
|
}
|
|
226
281
|
exports.diffSecurityCheck = {
|
|
227
282
|
name: 'Diff Security',
|
|
228
283
|
phase: 'informational',
|
|
284
|
+
// No `requiresDiff`: a dangerous call is dangerous whether it arrived in this
|
|
285
|
+
// PR or three years ago, so with no diff range the whole tree is reviewed
|
|
286
|
+
// instead of the check dropping out (ticket 067).
|
|
229
287
|
applies(ctx) {
|
|
230
|
-
return ctx.config.diffSecurity?.enabled !== false
|
|
288
|
+
return ctx.config.diffSecurity?.enabled !== false;
|
|
231
289
|
},
|
|
232
290
|
async run(ctx) {
|
|
233
291
|
try {
|
|
234
|
-
const
|
|
235
|
-
const
|
|
292
|
+
const cfg = ctx.config.diffSecurity ?? {};
|
|
293
|
+
const found = (0, findings_1.emptyBuckets)();
|
|
294
|
+
const scanOpts = {
|
|
295
|
+
comments: cfg.commentHandling ?? 'language',
|
|
296
|
+
ignorePaths: cfg.ignorePaths,
|
|
297
|
+
};
|
|
298
|
+
const extra = compileExtraPatterns(ctx);
|
|
299
|
+
const disabled = cfg.disablePatterns ?? [];
|
|
300
|
+
const scanInto = (stdout, patterns) => {
|
|
301
|
+
(0, findings_1.mergeSeverityBuckets)(found, (0, findings_1.scanDiffPatterns)(stdout, activePatterns(patterns, disabled), scanOpts));
|
|
302
|
+
};
|
|
303
|
+
/**
|
|
304
|
+
* Feed one language's sources through the patterns, from the diff when
|
|
305
|
+
* there is one and from the files themselves otherwise. Both paths reach
|
|
306
|
+
* `scanDiffPatterns` in the same shape, so the two modes cannot disagree
|
|
307
|
+
* about what counts as a finding.
|
|
308
|
+
*/
|
|
309
|
+
const review = async (globs, patterns) => {
|
|
310
|
+
if (ctx.baseSha) {
|
|
311
|
+
scanInto(await (0, findings_1.diffAddedLines)(ctx, globs), patterns);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (ctx.config.run?.fullScanBehaviour === 'skip')
|
|
315
|
+
return;
|
|
316
|
+
const files = await (0, targets_1.listTrackedFiles)(ctx, globs, ['.']);
|
|
317
|
+
await (0, findings_1.scanTrackedFiles)(ctx, files, asDiff => scanInto(asDiff, patterns));
|
|
318
|
+
};
|
|
236
319
|
// JS/TS patterns always run; language-specific sets scan their own globs
|
|
237
320
|
// only when that language is present, so a Python repo gets Python rules.
|
|
238
|
-
|
|
321
|
+
// Client `extraPatterns` run against every scanned language, since a
|
|
322
|
+
// project rule is not tied to the built-in language split.
|
|
323
|
+
await review(JS_GLOBS, [...SECURITY_PATTERNS, ...extra]);
|
|
239
324
|
if (ctx.languages.includes('python')) {
|
|
240
|
-
|
|
325
|
+
await review(['*.py'], [...PYTHON_SECURITY_PATTERNS, ...extra]);
|
|
241
326
|
}
|
|
242
327
|
if (ctx.languages.includes('go')) {
|
|
243
|
-
|
|
328
|
+
await review(['*.go'], [...GO_SECURITY_PATTERNS, ...extra]);
|
|
244
329
|
}
|
|
245
330
|
if (ctx.languages.includes('kotlin')) {
|
|
246
|
-
|
|
247
|
-
scanInto(ktOut, KOTLIN_SECURITY_PATTERNS, errors, warnings);
|
|
331
|
+
await review(['*.kt', '*.kts', '*.java'], [...KOTLIN_SECURITY_PATTERNS, ...extra]);
|
|
248
332
|
}
|
|
249
|
-
return (0, findings_1.reportSeverityBuckets)(errors, warnings, {
|
|
333
|
+
return (0, findings_1.reportSeverityBuckets)(found.errors, found.warnings, {
|
|
250
334
|
prefix: 'Diff Security',
|
|
251
335
|
errorSummary: n => `${n} issue(s)`,
|
|
252
336
|
warningSummary: n => `${n} warning(s)`,
|
|
253
|
-
|
|
254
|
-
ignore: ctx.config.diffSecurity?.ignore,
|
|
337
|
+
...(0, findings_1.clientFindingOptions)(ctx, cfg),
|
|
255
338
|
});
|
|
256
339
|
}
|
|
257
340
|
catch (err) {
|