pr-checkmate 1.23.2 → 1.23.3

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.
@@ -253,6 +253,7 @@ exports.KNOWN_CONFIG_KEYS = new Set([
253
253
  'outdatedDeps',
254
254
  'licenseCheck',
255
255
  'vulnScan',
256
+ 'grypeScan',
256
257
  'bannedImports',
257
258
  // git-diff / PR
258
259
  'mergeConflict',
@@ -3,4 +3,4 @@ import { PRCheckMateConfig } from '../interfaces';
3
3
  * Load project configuration from pr-checkmate.json
4
4
  * Falls back to defaults if not found
5
5
  */
6
- export declare function loadProjectConfig(): PRCheckMateConfig;
6
+ export declare function loadProjectConfig(cwd?: string): PRCheckMateConfig;
@@ -12,9 +12,8 @@ const constants_1 = require("./constants");
12
12
  * Load project configuration from pr-checkmate.json
13
13
  * Falls back to defaults if not found
14
14
  */
15
- function loadProjectConfig() {
16
- const projectRoot = process.cwd();
17
- const configPath = node_path_1.default.join(projectRoot, 'pr-checkmate.json');
15
+ function loadProjectConfig(cwd = process.cwd()) {
16
+ const configPath = node_path_1.default.join(cwd, 'pr-checkmate.json');
18
17
  if (node_fs_1.default.existsSync(configPath)) {
19
18
  try {
20
19
  const content = node_fs_1.default.readFileSync(configPath, 'utf-8');
@@ -33,6 +32,11 @@ function loadProjectConfig() {
33
32
  function mergeWithDefaults(userConfig) {
34
33
  const defaults = getDefaultConfig();
35
34
  return {
35
+ // Preserve every key the user set — including opt-in checks (vulnScan,
36
+ // grypeScan) and controls with no default entry below (severity, customRules,
37
+ // prSize, caseCollision, go/rust/ruby/php, …). The explicitly-merged keys
38
+ // that follow override this spread with their default-merged values.
39
+ ...userConfig,
36
40
  sourcePath: userConfig.sourcePath || defaults.sourcePath,
37
41
  // Replace (not merge): the client sees the full default list in their
38
42
  // generated config and edits it directly, so their list is authoritative.
@@ -0,0 +1,16 @@
1
+ import { Check } from '../../types';
2
+ /**
3
+ * Dependency vulnerability scan via Anchore's `grype`. Non-blocking and opt-in
4
+ * (`grypeScan.enabled: true`): grype must be on the runner, and the check skips
5
+ * gracefully when it is absent.
6
+ *
7
+ * grype resolves versions from lockfiles, so its raw output includes the entire
8
+ * transitive tree — and, because pr-checkmate installs into the consumer's
9
+ * node_modules, its own bundled dependencies too. Neither is something the
10
+ * consumer can fix. We therefore report ONLY vulnerabilities in DIRECT
11
+ * dependencies (those declared in the project's manifests, see
12
+ * `collectDirectDeps`): the set the maintainer actually controls. Findings whose
13
+ * ecosystem has no manifest we can parse are suppressed too, and the count of
14
+ * everything dropped is logged so nothing is hidden silently.
15
+ */
16
+ export declare const grypeScanCheck: Check;
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.grypeScanCheck = void 0;
4
+ const utils_1 = require("../../../utils");
5
+ const execa_1 = require("../../../utils/execa");
6
+ const types_1 = require("../../types");
7
+ const SEVERITY_ORDER = ['negligible', 'low', 'medium', 'high', 'critical'];
8
+ /** grype artifact `type` → the ecosystem we can resolve direct deps for. */
9
+ const TYPE_TO_ECOSYSTEM = {
10
+ npm: 'npm',
11
+ python: 'python',
12
+ 'go-module': 'go',
13
+ 'rust-crate': 'rust',
14
+ gem: 'ruby',
15
+ 'php-composer': 'php',
16
+ };
17
+ function severityRank(severity) {
18
+ return SEVERITY_ORDER.indexOf((severity ?? '').toLowerCase());
19
+ }
20
+ /**
21
+ * Dependency vulnerability scan via Anchore's `grype`. Non-blocking and opt-in
22
+ * (`grypeScan.enabled: true`): grype must be on the runner, and the check skips
23
+ * gracefully when it is absent.
24
+ *
25
+ * grype resolves versions from lockfiles, so its raw output includes the entire
26
+ * transitive tree — and, because pr-checkmate installs into the consumer's
27
+ * node_modules, its own bundled dependencies too. Neither is something the
28
+ * consumer can fix. We therefore report ONLY vulnerabilities in DIRECT
29
+ * dependencies (those declared in the project's manifests, see
30
+ * `collectDirectDeps`): the set the maintainer actually controls. Findings whose
31
+ * ecosystem has no manifest we can parse are suppressed too, and the count of
32
+ * everything dropped is logged so nothing is hidden silently.
33
+ */
34
+ exports.grypeScanCheck = {
35
+ name: 'Grype Scan',
36
+ phase: 'informational',
37
+ applies(ctx) {
38
+ return ctx.config.grypeScan?.enabled === true;
39
+ },
40
+ async run(ctx) {
41
+ const minSeverity = (ctx.config.grypeScan?.minSeverity ?? 'medium').toLowerCase();
42
+ const minRank = severityRank(minSeverity);
43
+ const ignore = (ctx.config.grypeScan?.ignore ?? []).map(s => s.toLowerCase());
44
+ const result = await (0, execa_1.execa)('grype', ['dir:.', '-o', 'json', '-q'], {
45
+ cwd: ctx.cwd,
46
+ reject: false,
47
+ });
48
+ // execa v9 with reject:false resolves (not throws) with `code: 'ENOENT'`
49
+ // when the binary is absent — detect the missing scanner on the result.
50
+ if (result.code === 'ENOENT')
51
+ return (0, types_1.skip)('grype not installed');
52
+ let report;
53
+ try {
54
+ report = JSON.parse(result.stdout);
55
+ }
56
+ catch {
57
+ return (0, types_1.warn)('grype produced no parseable output');
58
+ }
59
+ const matches = report.matches ?? [];
60
+ const direct = (0, utils_1.collectDirectDeps)(ctx.cwd);
61
+ // Keep only: severity >= threshold, a direct dependency, and not ignored.
62
+ const isDirect = (m) => {
63
+ const ecosystem = TYPE_TO_ECOSYSTEM[m.artifact?.type ?? ''];
64
+ const names = ecosystem && direct.get(ecosystem);
65
+ if (!names)
66
+ return false; // unknown/unparseable ecosystem → not our concern
67
+ return names.has((0, utils_1.normalizeName)(ecosystem, m.artifact?.name ?? ''));
68
+ };
69
+ const isIgnored = (m) => ignore.includes((m.vulnerability?.id ?? '').toLowerCase()) ||
70
+ ignore.includes((m.artifact?.name ?? '').toLowerCase());
71
+ const atSeverity = matches.filter(m => severityRank(m.vulnerability?.severity) >= minRank);
72
+ const findings = atSeverity.filter(m => isDirect(m) && !isIgnored(m));
73
+ const suppressed = atSeverity.length - findings.length;
74
+ if (suppressed > 0) {
75
+ utils_1.logger.info(`[Grype Scan]: suppressed ${suppressed} finding(s) — transitive/third-party (not directly fixable) or ignored`);
76
+ }
77
+ if (findings.length === 0) {
78
+ utils_1.logger.info('[Grype Scan]: ✅ No known vulnerabilities in direct dependencies');
79
+ return (0, types_1.pass)();
80
+ }
81
+ utils_1.logger.warn(`[Grype Scan]: ⚠️ vulnerabilities in direct dependencies (>= ${minSeverity}):`);
82
+ findings
83
+ .slice(0, 10)
84
+ .forEach(m => utils_1.logger.warn(` ${m.vulnerability?.id ?? 'unknown'} — ${m.artifact?.name ?? '?'}@${m.artifact?.version ?? '?'} (${m.vulnerability?.severity ?? '?'})`));
85
+ if (findings.length > 10)
86
+ utils_1.logger.warn(` ... and ${findings.length - 10} more`);
87
+ return (0, types_1.warn)(`${findings.length} direct-dependency vulnerability(ies) at "${minSeverity}"+ (grype)`);
88
+ },
89
+ };
@@ -4,6 +4,7 @@ exports.REGISTRY = void 0;
4
4
  const banned_imports_1 = require("./checks/dependencies/banned-imports");
5
5
  const circular_deps_1 = require("./checks/dependencies/circular-deps");
6
6
  const dependency_check_1 = require("./checks/dependencies/dependency-check");
7
+ const grype_scan_1 = require("./checks/dependencies/grype-scan");
7
8
  const license_check_1 = require("./checks/dependencies/license-check");
8
9
  const npm_audit_1 = require("./checks/dependencies/npm-audit");
9
10
  const outdated_deps_1 = require("./checks/dependencies/outdated-deps");
@@ -71,6 +72,7 @@ exports.REGISTRY = [
71
72
  dockerfile_security_1.dockerfileSecurityCheck,
72
73
  case_collision_1.caseCollisionCheck,
73
74
  vuln_scan_1.vulnScanCheck,
75
+ grype_scan_1.grypeScanCheck,
74
76
  custom_rules_1.customRulesCheck,
75
77
  banned_imports_1.bannedImportsCheck,
76
78
  license_header_1.licenseHeaderCheck,
package/dist/init.js CHANGED
@@ -134,6 +134,7 @@ const DEFAULT_CONFIG = {
134
134
  dockerfileSecurity: { enabled: true }, // Dockerfile: :latest, USER root, curl|bash, …
135
135
  caseCollision: { enabled: true }, // files differing only by case
136
136
  vulnScan: { enabled: false }, // cross-language dep vulns (osv-scanner, runner-dependency)
137
+ grypeScan: { enabled: false, minSeverity: 'medium' }, // dep vulns (grype, non-blocking, runner-dependency)
137
138
  // Test-coverage presence. Opt-in: enable once your CI produces a coverage
138
139
  // report (lcov/cobertura/jacoco/…); warns if none is found.
139
140
  coverage: { enabled: false },
@@ -199,6 +199,11 @@ export interface PRCheckMateConfig {
199
199
  vulnScan?: {
200
200
  enabled?: boolean;
201
201
  };
202
+ grypeScan?: {
203
+ enabled?: boolean;
204
+ minSeverity?: 'negligible' | 'low' | 'medium' | 'high' | 'critical';
205
+ ignore?: string[];
206
+ };
202
207
  bannedImports?: Array<{
203
208
  name: string;
204
209
  message?: string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Package ecosystems for which we can determine a project's DIRECT (declared)
3
+ * dependencies from a manifest — the set a maintainer actually controls and can
4
+ * bump. Transitive packages (resolved only in a lockfile) are deliberately not
5
+ * here: the consumer cannot fix them directly, so a vuln scan must not flag them.
6
+ */
7
+ export type Ecosystem = 'npm' | 'python' | 'go' | 'rust' | 'ruby' | 'php';
8
+ /** Canonical key for name comparison within an ecosystem. */
9
+ export declare function normalizeName(ecosystem: Ecosystem, name: string): string;
10
+ /**
11
+ * Build the set of DIRECT (declared) dependency names for `cwd`, keyed by
12
+ * ecosystem, by parsing manifests — not lockfiles. Used to filter vulnerability
13
+ * scanner output down to what the consumer actually controls: transitive
14
+ * dependencies (present only in lockfiles) and the scanning tool's own bundled
15
+ * dependencies are absent from these manifests and thus never reported.
16
+ *
17
+ * Dependency-free and best-effort: unparseable/absent manifests contribute
18
+ * nothing rather than throwing. An ecosystem with no manifest yields no entry
19
+ * (callers should treat "no entry" as "cannot classify" — see grype-scan).
20
+ */
21
+ export declare function collectDirectDeps(cwd: string): Map<Ecosystem, Set<string>>;
@@ -0,0 +1,225 @@
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.normalizeName = normalizeName;
7
+ exports.collectDirectDeps = collectDirectDeps;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ /** Directories never worth descending into when locating manifests. */
11
+ const SKIP_DIRS = new Set([
12
+ 'node_modules',
13
+ '.git',
14
+ 'dist',
15
+ 'build',
16
+ 'vendor',
17
+ '.venv',
18
+ 'venv',
19
+ 'target',
20
+ ]);
21
+ /** PEP 503 normalisation, applied to both manifest and scanner names for Python. */
22
+ function normalizePython(name) {
23
+ return name
24
+ .trim()
25
+ .toLowerCase()
26
+ .replace(/[-_.]+/g, '-');
27
+ }
28
+ /** Canonical key for name comparison within an ecosystem. */
29
+ function normalizeName(ecosystem, name) {
30
+ return ecosystem === 'python' ? normalizePython(name) : name.trim().toLowerCase();
31
+ }
32
+ function readJson(file) {
33
+ try {
34
+ return JSON.parse(node_fs_1.default.readFileSync(file, 'utf-8'));
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
40
+ function readText(file) {
41
+ try {
42
+ return node_fs_1.default.readFileSync(file, 'utf-8');
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ /** Collect direct dep names from a package.json's four dependency maps. */
49
+ function addNpm(file, out) {
50
+ const pkg = readJson(file);
51
+ if (!pkg)
52
+ return;
53
+ for (const key of [
54
+ 'dependencies',
55
+ 'devDependencies',
56
+ 'peerDependencies',
57
+ 'optionalDependencies',
58
+ ]) {
59
+ const map = pkg[key];
60
+ if (map && typeof map === 'object') {
61
+ for (const name of Object.keys(map))
62
+ out.add(normalizeName('npm', name));
63
+ }
64
+ }
65
+ }
66
+ /** Extract the bare package name from a Python requirement specifier line. */
67
+ function pythonReqName(line) {
68
+ const stripped = line.split('#')[0].trim();
69
+ if (!stripped || stripped.startsWith('-'))
70
+ return null; // skip options like -r / -e
71
+ const match = /^([A-Za-z0-9._-]+)/.exec(stripped);
72
+ return match ? normalizePython(match[1]) : null;
73
+ }
74
+ function addPython(cwd, out, manifests) {
75
+ for (const file of manifests) {
76
+ const base = node_path_1.default.basename(file);
77
+ if (base.startsWith('requirements') && base.endsWith('.txt')) {
78
+ const text = readText(file);
79
+ if (text)
80
+ text.split('\n').forEach(l => {
81
+ const n = pythonReqName(l);
82
+ if (n)
83
+ out.add(n);
84
+ });
85
+ }
86
+ else if (base === 'pyproject.toml' || base === 'Pipfile') {
87
+ const text = readText(file);
88
+ if (!text)
89
+ continue;
90
+ // Best-effort, parser-free: PEP 621 `dependencies = ["pkg>=1", ...]`,
91
+ // Poetry/Pipfile `pkg = "^1.2"` table entries. Section-agnostic on purpose.
92
+ for (const m of text.matchAll(/["']([A-Za-z0-9._-]+)\s*(?:[<>=!~;)\[]|["'])/g)) {
93
+ const n = normalizePython(m[1]);
94
+ if (n && n !== 'python')
95
+ out.add(n);
96
+ }
97
+ for (const m of text.matchAll(/^\s*([A-Za-z0-9._-]+)\s*=/gm)) {
98
+ const n = normalizePython(m[1]);
99
+ if (n && n !== 'python')
100
+ out.add(n);
101
+ }
102
+ }
103
+ }
104
+ }
105
+ function addGo(file, out) {
106
+ const text = readText(file);
107
+ if (!text)
108
+ return;
109
+ // `require ( ... )` block and single-line `require x v...`; skip `// indirect`.
110
+ for (const raw of text.split('\n')) {
111
+ const line = raw.trim();
112
+ if (line.includes('// indirect'))
113
+ continue;
114
+ const m = /^(?:require\s+)?([a-zA-Z0-9._~/-]+\.[a-zA-Z0-9._~/-]+)\s+v/.exec(line);
115
+ if (m)
116
+ out.add(m[1].toLowerCase());
117
+ }
118
+ }
119
+ function addRust(file, out) {
120
+ const text = readText(file);
121
+ if (!text)
122
+ return;
123
+ // Keys under [dependencies] / [dev-dependencies] / [build-dependencies].
124
+ let inDeps = false;
125
+ for (const raw of text.split('\n')) {
126
+ const line = raw.trim();
127
+ if (line.startsWith('[')) {
128
+ inDeps = /^\[(?:.*\.)?(?:dev-|build-)?dependencies\]$/.test(line);
129
+ continue;
130
+ }
131
+ if (!inDeps)
132
+ continue;
133
+ const m = /^([A-Za-z0-9._-]+)\s*=/.exec(line);
134
+ if (m)
135
+ out.add(m[1].toLowerCase());
136
+ }
137
+ }
138
+ function addRuby(file, out) {
139
+ const text = readText(file);
140
+ if (!text)
141
+ return;
142
+ for (const m of text.matchAll(/^\s*gem\s+["']([A-Za-z0-9._-]+)["']/gm)) {
143
+ out.add(m[1].toLowerCase());
144
+ }
145
+ }
146
+ function addPhp(file, out) {
147
+ const pkg = readJson(file);
148
+ if (!pkg)
149
+ return;
150
+ for (const key of ['require', 'require-dev']) {
151
+ const map = pkg[key];
152
+ if (map && typeof map === 'object') {
153
+ for (const name of Object.keys(map)) {
154
+ if (name === 'php' || name.startsWith('ext-'))
155
+ continue;
156
+ out.add(name.toLowerCase());
157
+ }
158
+ }
159
+ }
160
+ }
161
+ /** Recursively list files matching any of `names`, skipping vendored dirs. */
162
+ function findManifests(cwd, names, matchPrefix) {
163
+ const found = [];
164
+ const walk = (dir) => {
165
+ let entries;
166
+ try {
167
+ entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
168
+ }
169
+ catch {
170
+ return;
171
+ }
172
+ for (const entry of entries) {
173
+ if (entry.isDirectory()) {
174
+ if (!SKIP_DIRS.has(entry.name) && !entry.name.startsWith('.')) {
175
+ walk(node_path_1.default.join(dir, entry.name));
176
+ }
177
+ }
178
+ else if (names.has(entry.name) || (matchPrefix && matchPrefix.test(entry.name))) {
179
+ found.push(node_path_1.default.join(dir, entry.name));
180
+ }
181
+ }
182
+ };
183
+ walk(cwd);
184
+ return found;
185
+ }
186
+ /**
187
+ * Build the set of DIRECT (declared) dependency names for `cwd`, keyed by
188
+ * ecosystem, by parsing manifests — not lockfiles. Used to filter vulnerability
189
+ * scanner output down to what the consumer actually controls: transitive
190
+ * dependencies (present only in lockfiles) and the scanning tool's own bundled
191
+ * dependencies are absent from these manifests and thus never reported.
192
+ *
193
+ * Dependency-free and best-effort: unparseable/absent manifests contribute
194
+ * nothing rather than throwing. An ecosystem with no manifest yields no entry
195
+ * (callers should treat "no entry" as "cannot classify" — see grype-scan).
196
+ */
197
+ function collectDirectDeps(cwd) {
198
+ const result = new Map();
199
+ const npm = new Set();
200
+ findManifests(cwd, new Set(['package.json'])).forEach(f => addNpm(f, npm));
201
+ if (npm.size)
202
+ result.set('npm', npm);
203
+ const python = new Set();
204
+ const pyManifests = findManifests(cwd, new Set(['pyproject.toml', 'Pipfile']), /^requirements.*\.txt$/);
205
+ addPython(cwd, python, pyManifests);
206
+ if (python.size)
207
+ result.set('python', python);
208
+ const go = new Set();
209
+ findManifests(cwd, new Set(['go.mod'])).forEach(f => addGo(f, go));
210
+ if (go.size)
211
+ result.set('go', go);
212
+ const rust = new Set();
213
+ findManifests(cwd, new Set(['Cargo.toml'])).forEach(f => addRust(f, rust));
214
+ if (rust.size)
215
+ result.set('rust', rust);
216
+ const ruby = new Set();
217
+ findManifests(cwd, new Set(['Gemfile'])).forEach(f => addRuby(f, ruby));
218
+ if (ruby.size)
219
+ result.set('ruby', ruby);
220
+ const php = new Set();
221
+ findManifests(cwd, new Set(['composer.json'])).forEach(f => addPhp(f, php));
222
+ if (php.size)
223
+ result.set('php', php);
224
+ return result;
225
+ }
@@ -2,6 +2,7 @@ export * from './logger';
2
2
  export * from './result-collector';
3
3
  export * from './resolve-bin';
4
4
  export * from './own-deps';
5
+ export * from './direct-deps';
5
6
  export * from './detect-language';
6
7
  export * from './glob';
7
8
  export * from './update-notifier';
@@ -24,6 +24,7 @@ __exportStar(require("./logger"), exports);
24
24
  __exportStar(require("./result-collector"), exports);
25
25
  __exportStar(require("./resolve-bin"), exports);
26
26
  __exportStar(require("./own-deps"), exports);
27
+ __exportStar(require("./direct-deps"), exports);
27
28
  __exportStar(require("./detect-language"), exports);
28
29
  __exportStar(require("./glob"), exports);
29
30
  __exportStar(require("./update-notifier"), exports);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pr-checkmate",
3
- "version": "1.23.2",
3
+ "version": "1.23.3",
4
4
  "description": "Automated PR quality checks: linting, formatting, dependency analysis, and spellcheck",
5
5
  "keywords": [
6
6
  "github-actions",
@@ -41,7 +41,7 @@
41
41
  ],
42
42
  "scripts": {
43
43
  "copy-configs": "ts-node src/scripts/copy-configs.ts",
44
- "build": "npm run clean && npm run copy-configs && tsc && npm run fix-shebang",
44
+ "build": "npm run clean && npm run copy-configs && tsc -p tsconfig.build.json && npm run fix-shebang",
45
45
  "fix-shebang": "node -e \"const fs=require('fs'); const path='dist/cli.js'; let content=fs.readFileSync(path,'utf8'); if(!content.startsWith('#!')){content='#!/usr/bin/env node\\n'+content; fs.writeFileSync(path,content);} fs.chmodSync(path,'755');\"",
46
46
  "check": "npm run build && npm run lint && npm run prettier && npm run spellcheck",
47
47
  "prepublishOnly": "npm run build",
@@ -50,7 +50,7 @@
50
50
  "spellcheck": "cspell '**/*.{ts,tsx,js,jsx,md,json}' --no-progress",
51
51
  "docs": "typedoc",
52
52
  "scan:secrets": "gitleaks-secret-scanner --diff-mode ci --html-report gitleaks-report.html",
53
- "test": "echo 'Tests coming soon'",
53
+ "test": "node --require ts-node/register --test \"src/**/*.test.ts\"",
54
54
  "security-scan": "npx pr-checkmate security",
55
55
  "clean": "rm -rf dist",
56
56
  "release": "standard-version"
@@ -89,6 +89,17 @@
89
89
  "type": "object",
90
90
  "properties": { "enabled": { "type": "boolean" } }
91
91
  },
92
+ "grypeScan": {
93
+ "type": "object",
94
+ "properties": {
95
+ "enabled": { "type": "boolean" },
96
+ "minSeverity": {
97
+ "type": "string",
98
+ "enum": ["negligible", "low", "medium", "high", "critical"]
99
+ },
100
+ "ignore": { "type": "array", "items": { "type": "string" } }
101
+ }
102
+ },
92
103
  "mergeConflict": { "$ref": "#/$defs/toggleSeverity" },
93
104
  "leftoverDebug": { "$ref": "#/$defs/toggleSeverity" },
94
105
  "lockfileDrift": { "$ref": "#/$defs/toggleSeverity" },