pr-checkmate 2.1.2 → 3.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 +63 -268
- package/cspell.json +155 -133
- package/dist/config/constants.d.ts +6 -1
- package/dist/config/constants.js +8 -7
- package/dist/config/index.d.ts +23 -11
- package/dist/config/index.js +27 -33
- package/dist/core/checks/dependencies/banned-imports.js +4 -1
- package/dist/core/checks/dependencies/circular-deps.js +14 -5
- package/dist/core/checks/dependencies/dependency-check.js +1 -1
- package/dist/core/checks/dependencies/grype-scan.js +2 -1
- package/dist/core/checks/dependencies/vuln-scan.js +2 -1
- package/dist/core/checks/findings.d.ts +46 -2
- package/dist/core/checks/findings.js +140 -2
- package/dist/core/checks/git/custom-rules.js +2 -0
- package/dist/core/checks/git/diff-smell.js +6 -1
- package/dist/core/checks/git/leftover-debug.js +8 -2
- package/dist/core/checks/git/lockfile-drift.js +6 -1
- package/dist/core/checks/git/merge-conflict.js +1 -1
- package/dist/core/checks/git/missing-tests.js +1 -1
- package/dist/core/checks/git/todo-fixme.js +10 -2
- package/dist/core/checks/languages/cpp-format.js +19 -42
- package/dist/core/checks/languages/external-checks.d.ts +77 -0
- package/dist/core/checks/languages/external-checks.js +200 -0
- package/dist/core/checks/languages/external-runner.d.ts +119 -0
- package/dist/core/checks/languages/external-runner.js +143 -0
- package/dist/core/checks/languages/go-format.d.ts +4 -4
- package/dist/core/checks/languages/go-format.js +151 -30
- package/dist/core/checks/languages/lint.js +19 -19
- package/dist/core/checks/languages/prettier.js +2 -7
- package/dist/core/checks/languages/python-typecheck.d.ts +10 -5
- package/dist/core/checks/languages/python-typecheck.js +161 -37
- package/dist/core/checks/languages/typecheck.js +10 -8
- package/dist/core/checks/pr/commitlint.js +7 -1
- package/dist/core/checks/quality/case-collision.js +6 -4
- package/dist/core/checks/quality/config-validation.d.ts +0 -7
- package/dist/core/checks/quality/config-validation.js +30 -0
- package/dist/core/checks/quality/duplicate-check.js +54 -27
- package/dist/core/checks/quality/license-header.js +7 -4
- package/dist/core/checks/quality/markdown-lint.js +1 -6
- package/dist/core/checks/quality/spellcheck.js +34 -5
- package/dist/core/checks/quality/symlinks.js +6 -4
- package/dist/core/checks/security/diff-security.js +26 -18
- package/dist/core/checks/security/dockerfile-security.js +7 -1
- package/dist/core/checks/security/migration-safety.js +32 -4
- package/dist/core/checks/security/security.d.ts +56 -0
- package/dist/core/checks/security/security.js +135 -11
- package/dist/core/checks/security/sensitive-file-guard.js +40 -6
- package/dist/core/checks/security/workflow-security.js +7 -1
- package/dist/core/checks/tool-install.d.ts +102 -0
- package/dist/core/checks/tool-install.js +69 -0
- package/dist/core/context.js +9 -1
- package/dist/core/install/cache.js +42 -11
- package/dist/core/install/doctor.js +9 -0
- package/dist/core/install/ensure.js +22 -3
- package/dist/core/install/lock.d.ts +2 -1
- package/dist/core/install/prepare.d.ts +75 -0
- package/dist/core/install/prepare.js +151 -0
- package/dist/core/install/profiles.d.ts +17 -3
- package/dist/core/install/profiles.js +27 -0
- package/dist/core/install/reuse.d.ts +41 -0
- package/dist/core/install/reuse.js +107 -0
- package/dist/core/registry.js +10 -18
- package/dist/core/run-checks.js +74 -3
- package/dist/core/targets.d.ts +116 -5
- package/dist/core/targets.js +101 -16
- package/dist/core/types.d.ts +8 -2
- package/dist/init.js +21 -1
- package/dist/interfaces/index.d.ts +1 -0
- package/dist/utils/resolve-bin.d.ts +56 -6
- package/dist/utils/resolve-bin.js +136 -26
- package/gitleaks.toml +14 -0
- package/install-manifests/go/package-lock.json +24 -0
- package/install-manifests/go/package.json +8 -0
- package/install-manifests/pytypes/package-lock.json +45 -0
- package/install-manifests/pytypes/package.json +8 -0
- package/package.json +4 -2
- package/pr-checkmate.schema.json +8 -2
- package/src/config/cspell.json +155 -133
- package/src/config/pr-checkmate.schema.json +8 -2
- package/dist/core/checks/languages/csharp-format.d.ts +0 -13
- package/dist/core/checks/languages/csharp-format.js +0 -52
- package/dist/core/checks/languages/go-lint.d.ts +0 -8
- package/dist/core/checks/languages/go-lint.js +0 -48
- package/dist/core/checks/languages/kotlin-lint.d.ts +0 -2
- package/dist/core/checks/languages/kotlin-lint.js +0 -42
- package/dist/core/checks/languages/php-format.d.ts +0 -9
- package/dist/core/checks/languages/php-format.js +0 -49
- package/dist/core/checks/languages/ruby-lint.d.ts +0 -8
- package/dist/core/checks/languages/ruby-lint.js +0 -50
- package/dist/core/checks/languages/rust-format.d.ts +0 -9
- package/dist/core/checks/languages/rust-format.js +0 -45
- package/dist/core/checks/languages/rust-lint.d.ts +0 -8
- package/dist/core/checks/languages/rust-lint.js +0 -50
- package/dist/core/checks/languages/shellcheck.d.ts +0 -8
- package/dist/core/checks/languages/shellcheck.js +0 -55
- package/dist/core/checks/languages/swift-lint.d.ts +0 -2
- package/dist/core/checks/languages/swift-lint.js +0 -46
|
@@ -3,7 +3,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.diffSecurityCheck = void 0;
|
|
4
4
|
const config_1 = require("../../../config");
|
|
5
5
|
const utils_1 = require("../../../utils");
|
|
6
|
-
const targets_1 = require("../../targets");
|
|
7
6
|
const types_1 = require("../../types");
|
|
8
7
|
const findings_1 = require("../findings");
|
|
9
8
|
const MD5_LABEL = 'MD5 (weak hash — use sha256 or stronger)';
|
|
@@ -298,6 +297,9 @@ exports.diffSecurityCheck = {
|
|
|
298
297
|
// to them, matching `duplicate.ignore` and `deadCode.ignoreFiles`. A client
|
|
299
298
|
// who wants their tests scanned says so by listing something else here.
|
|
300
299
|
ignorePaths: cfg.ignorePaths ?? config_1.DIFF_SECURITY_IGNORE_DEFAULTS,
|
|
300
|
+
// This check owns the cross-line heuristic, so it is the one that turns it
|
|
301
|
+
// on. `0` from the client switches it off.
|
|
302
|
+
dataflowWindow: cfg.dataflowWindow ?? findings_1.DEFAULT_DATAFLOW_WINDOW,
|
|
301
303
|
};
|
|
302
304
|
const extra = compileExtraPatterns(ctx);
|
|
303
305
|
const disabled = cfg.disablePatterns ?? [];
|
|
@@ -306,33 +308,39 @@ exports.diffSecurityCheck = {
|
|
|
306
308
|
};
|
|
307
309
|
/**
|
|
308
310
|
* Feed one language's sources through the patterns, from the diff when
|
|
309
|
-
* there is one and from the files themselves otherwise.
|
|
310
|
-
*
|
|
311
|
-
*
|
|
311
|
+
* there is one and from the files themselves otherwise.
|
|
312
|
+
*
|
|
313
|
+
* Through {@link reviewLines}, like the four other checks that need both
|
|
314
|
+
* modes. This used to be its body copied inline, on the assumption that a
|
|
315
|
+
* closure was needed to carry `patterns` — it is not, that is what `onChunk`
|
|
316
|
+
* is for, and the copy meant `fullScanBehaviour` and the `null`/`[]`
|
|
317
|
+
* distinction lived in two places, one of them a blocking security check
|
|
318
|
+
* (ticket 079).
|
|
312
319
|
*/
|
|
313
|
-
const review =
|
|
314
|
-
if (ctx.baseSha) {
|
|
315
|
-
scanInto(await (0, findings_1.diffAddedLines)(ctx, globs), patterns);
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
if (ctx.config.run?.fullScanBehaviour === 'skip')
|
|
319
|
-
return;
|
|
320
|
-
const files = await (0, targets_1.listTrackedFiles)(ctx, globs, ['.']);
|
|
321
|
-
await (0, findings_1.scanTrackedFiles)(ctx, files, asDiff => scanInto(asDiff, patterns));
|
|
322
|
-
};
|
|
320
|
+
const review = (globs, patterns) => (0, findings_1.reviewLines)(ctx, globs, stdout => scanInto(stdout, patterns));
|
|
323
321
|
// JS/TS patterns always run; language-specific sets scan their own globs
|
|
324
322
|
// only when that language is present, so a Python repo gets Python rules.
|
|
325
323
|
// Client `extraPatterns` run against every scanned language, since a
|
|
326
324
|
// project rule is not tied to the built-in language split.
|
|
327
|
-
|
|
325
|
+
const sets = [[JS_GLOBS, [...SECURITY_PATTERNS, ...extra]]];
|
|
328
326
|
if (ctx.languages.includes('python')) {
|
|
329
|
-
|
|
327
|
+
sets.push([['*.py'], [...PYTHON_SECURITY_PATTERNS, ...extra]]);
|
|
330
328
|
}
|
|
331
329
|
if (ctx.languages.includes('go')) {
|
|
332
|
-
|
|
330
|
+
sets.push([['*.go'], [...GO_SECURITY_PATTERNS, ...extra]]);
|
|
333
331
|
}
|
|
334
332
|
if (ctx.languages.includes('kotlin')) {
|
|
335
|
-
|
|
333
|
+
sets.push([
|
|
334
|
+
['*.kt', '*.kts', '*.java'],
|
|
335
|
+
[...KOTLIN_SECURITY_PATTERNS, ...extra],
|
|
336
|
+
]);
|
|
337
|
+
}
|
|
338
|
+
for (const [globs, patterns] of sets) {
|
|
339
|
+
// One failed set fails the check. A partial answer is worse than none
|
|
340
|
+
// here: three of five language sets scanned, reported as a pass, reads
|
|
341
|
+
// as a clean repository (ticket 072).
|
|
342
|
+
if (!(await review(globs, patterns)))
|
|
343
|
+
return (0, types_1.skip)('git unavailable');
|
|
336
344
|
}
|
|
337
345
|
return (0, findings_1.reportSeverityBuckets)(found.errors, found.warnings, {
|
|
338
346
|
prefix: 'Diff Security',
|
|
@@ -30,7 +30,13 @@ const DOCKER_PATTERNS = [
|
|
|
30
30
|
severity: 'warn',
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
|
-
|
|
33
|
+
// The `_FILE`/`_PATH` exclusion is not a loophole, it is the thing this rule
|
|
34
|
+
// is trying to steer people towards: `ENV DB_PASSWORD_FILE=/run/secrets/db`
|
|
35
|
+
// names a location, holds no secret, and is how Docker and Kubernetes tell
|
|
36
|
+
// you to do it. Flagging the correct practice alongside the wrong one is how
|
|
37
|
+
// a rule gets switched off — and it teaches obscuring the variable name,
|
|
38
|
+
// which makes the next real secret harder to spot, not easier.
|
|
39
|
+
pattern: /^\+(?!\+)\s*(?:ENV|ARG)\s+\w*(?:PASSWORD|SECRET|TOKEN|API_?KEY|ACCESS_KEY)\w*(?<!_FILE)(?<!_PATH)(?<!_FILEPATH)\s*[=\s]/i,
|
|
34
40
|
label: 'secret baked into ENV/ARG (use build secrets / runtime injection)',
|
|
35
41
|
severity: 'warn',
|
|
36
42
|
},
|
|
@@ -48,17 +48,45 @@ function isAddNotNullNoDefault(line) {
|
|
|
48
48
|
/\bNOT\s+NULL\b/i.test(line) &&
|
|
49
49
|
!/\bDEFAULT\b/i.test(line));
|
|
50
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Drop the comment from an added migration line, keeping the leading `+` so the
|
|
53
|
+
* patterns stay anchored on it.
|
|
54
|
+
*
|
|
55
|
+
* This check does its own matching rather than going through `scanDiffPatterns`,
|
|
56
|
+
* and it inherited none of the comment handling — so a commented-out statement
|
|
57
|
+
* was reported as destructive. `-- DROP TABLE users;` does nothing, and calling
|
|
58
|
+
* it "irreversible data loss" is noise on the most alarming label the product
|
|
59
|
+
* prints. Found by ticket 062's corpus.
|
|
60
|
+
*
|
|
61
|
+
* Only the two markers that are unambiguous in the files this check looks at:
|
|
62
|
+
* `--` in SQL and `#` in Python/Ruby migrations. `--` is required to be preceded
|
|
63
|
+
* by whitespace or the line start, which keeps it away from an identifier like
|
|
64
|
+
* `a--b` and from most string contents; `stripInlineComment` handles `#` with the
|
|
65
|
+
* string awareness it already has.
|
|
66
|
+
*/
|
|
67
|
+
function withoutComment(line) {
|
|
68
|
+
// The diff marker is split off first: `+-- DROP TABLE` puts `--` immediately
|
|
69
|
+
// after the `+`, where a boundary rule looking for whitespace or line-start
|
|
70
|
+
// does not see it — which is exactly the shape a commented-out statement takes.
|
|
71
|
+
const marker = line.startsWith('+') ? '+' : '';
|
|
72
|
+
const body = (0, findings_1.stripInlineComment)(line.slice(marker.length), 'hash').replace(/(^|\s)--.*$/, '$1');
|
|
73
|
+
return marker + body;
|
|
74
|
+
}
|
|
51
75
|
/** Bucket dangerous added lines by risk label, honouring the opt-out directive. */
|
|
52
76
|
function scan(stdout, findings) {
|
|
53
77
|
for (const line of stdout.split('\n')) {
|
|
54
78
|
if (line.includes('pr-checkmate-ignore'))
|
|
55
79
|
continue;
|
|
80
|
+
// The sample keeps the ORIGINAL line: the reader needs to see what was
|
|
81
|
+
// written, comment and all, even though the match ran on the code.
|
|
82
|
+
const code = withoutComment(line);
|
|
83
|
+
const sample = () => line.slice(1).trim().substring(0, 120);
|
|
56
84
|
for (const { pattern, label } of DDL_PATTERNS) {
|
|
57
|
-
if (pattern.test(
|
|
58
|
-
(findings[label] ?? (findings[label] = [])).push(
|
|
85
|
+
if (pattern.test(code))
|
|
86
|
+
(findings[label] ?? (findings[label] = [])).push(sample());
|
|
59
87
|
}
|
|
60
|
-
if (isAddNotNullNoDefault(
|
|
61
|
-
(findings[NOT_NULL_NO_DEFAULT] ?? (findings[NOT_NULL_NO_DEFAULT] = [])).push(
|
|
88
|
+
if (isAddNotNullNoDefault(code)) {
|
|
89
|
+
(findings[NOT_NULL_NO_DEFAULT] ?? (findings[NOT_NULL_NO_DEFAULT] = [])).push(sample());
|
|
62
90
|
}
|
|
63
91
|
}
|
|
64
92
|
}
|
|
@@ -1,4 +1,60 @@
|
|
|
1
1
|
import { Check } from '../../types';
|
|
2
|
+
/**
|
|
3
|
+
* A HOME the scanner can actually write to.
|
|
4
|
+
*
|
|
5
|
+
* The bundled wrapper caches the gitleaks binary in `os.homedir()/.gitleaks-cache`
|
|
6
|
+
* and offers no way to move it — the path is hardcoded. So in any environment
|
|
7
|
+
* where HOME is not writable it crashes before scanning a single commit. That is
|
|
8
|
+
* not exotic: it is what the container does under `--user "$(id -u):$(id -g)"`,
|
|
9
|
+
* the invocation our own entrypoint and docs recommend, where HOME is `/`.
|
|
10
|
+
*
|
|
11
|
+
* Redirecting to the temp directory costs nothing that was available anyway — if
|
|
12
|
+
* HOME cannot be written to, there was no `~/.gitconfig` of ours to lose either.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* How many secrets the scan actually found.
|
|
16
|
+
*
|
|
17
|
+
* The row used to read `❌ Security Scan: potential secrets detected` whether the
|
|
18
|
+
* number was one or thirty-nine, and that is not a cosmetic gap. It is the reason
|
|
19
|
+
* a real regression nearly shipped: an allowlist entry removed six genuine
|
|
20
|
+
* `Generic API Key` findings from a client repository, 39 → 33, and the A/B sweep
|
|
21
|
+
* that compares report rows across 28 repositories saw two identical lines
|
|
22
|
+
* (ticket 085). A check that finds things has to say how many, or nothing can
|
|
23
|
+
* tell whether it started finding fewer.
|
|
24
|
+
*
|
|
25
|
+
* Read from gitleaks' JSON report rather than counted out of its console output —
|
|
26
|
+
* the same lesson as ticket 078, where the clone count was parsed from an English
|
|
27
|
+
* sentence and would have reported zero on a repository full of duplication.
|
|
28
|
+
*
|
|
29
|
+
* @returns The count, or `null` when the report could not be read — in which case
|
|
30
|
+
* the caller keeps its old wording rather than claiming a number it does not have.
|
|
31
|
+
*/
|
|
32
|
+
export declare function countFindings(reportPath: string): number | null;
|
|
33
|
+
/**
|
|
34
|
+
* How the outcome names what was found.
|
|
35
|
+
*
|
|
36
|
+
* Its own function so both halves are reachable without running the scanner. The
|
|
37
|
+
* corpus for this check deliberately does not: the wrapper downloads a gitleaks
|
|
38
|
+
* binary on first use, and a unit suite that reaches the network is the thing the
|
|
39
|
+
* `installer` seam in `ensure.ts` exists to avoid. What broke here was the wording
|
|
40
|
+
* — a row that read the same for one secret and for thirty-nine — and the wording
|
|
41
|
+
* is testable on its own.
|
|
42
|
+
*
|
|
43
|
+
* `null` keeps the old sentence rather than inventing a number: a report we could
|
|
44
|
+
* not read is not a reason to claim a count.
|
|
45
|
+
*/
|
|
46
|
+
export declare function secretsSummary(found: number | null): string;
|
|
47
|
+
/**
|
|
48
|
+
* Whether the wrapper died instead of reporting, judged from its stderr.
|
|
49
|
+
*
|
|
50
|
+
* The wrapper is a Node script, and Node exits **1** on an unhandled exception —
|
|
51
|
+
* the same code gitleaks itself uses for "secrets found". Left undistinguished,
|
|
52
|
+
* a scanner that never ran is reported as `potential secrets detected`, pointing
|
|
53
|
+
* at a report file that was never written. A gate accusing a clean repository is
|
|
54
|
+
* the same class of failure as one that stays quiet about a real leak, and this
|
|
55
|
+
* is the only signal available to tell them apart.
|
|
56
|
+
*/
|
|
57
|
+
export declare function looksLikeCrash(stderr: string): boolean;
|
|
2
58
|
/**
|
|
3
59
|
* Secret scanning via the bundled gitleaks package. Language-agnostic — runs on
|
|
4
60
|
* every repo (Node, Python, C++, mobile). Secrets found → fail (blocking).
|
|
@@ -1,10 +1,107 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
2
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
6
|
exports.securityCheck = void 0;
|
|
7
|
+
exports.countFindings = countFindings;
|
|
8
|
+
exports.secretsSummary = secretsSummary;
|
|
9
|
+
exports.looksLikeCrash = looksLikeCrash;
|
|
10
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
11
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
4
13
|
const config_1 = require("../../../config");
|
|
5
14
|
const utils_1 = require("../../../utils");
|
|
6
15
|
const execa_1 = require("../../../utils/execa");
|
|
7
16
|
const types_1 = require("../../types");
|
|
17
|
+
/**
|
|
18
|
+
* A HOME the scanner can actually write to.
|
|
19
|
+
*
|
|
20
|
+
* The bundled wrapper caches the gitleaks binary in `os.homedir()/.gitleaks-cache`
|
|
21
|
+
* and offers no way to move it — the path is hardcoded. So in any environment
|
|
22
|
+
* where HOME is not writable it crashes before scanning a single commit. That is
|
|
23
|
+
* not exotic: it is what the container does under `--user "$(id -u):$(id -g)"`,
|
|
24
|
+
* the invocation our own entrypoint and docs recommend, where HOME is `/`.
|
|
25
|
+
*
|
|
26
|
+
* Redirecting to the temp directory costs nothing that was available anyway — if
|
|
27
|
+
* HOME cannot be written to, there was no `~/.gitconfig` of ours to lose either.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* How many secrets the scan actually found.
|
|
31
|
+
*
|
|
32
|
+
* The row used to read `❌ Security Scan: potential secrets detected` whether the
|
|
33
|
+
* number was one or thirty-nine, and that is not a cosmetic gap. It is the reason
|
|
34
|
+
* a real regression nearly shipped: an allowlist entry removed six genuine
|
|
35
|
+
* `Generic API Key` findings from a client repository, 39 → 33, and the A/B sweep
|
|
36
|
+
* that compares report rows across 28 repositories saw two identical lines
|
|
37
|
+
* (ticket 085). A check that finds things has to say how many, or nothing can
|
|
38
|
+
* tell whether it started finding fewer.
|
|
39
|
+
*
|
|
40
|
+
* Read from gitleaks' JSON report rather than counted out of its console output —
|
|
41
|
+
* the same lesson as ticket 078, where the clone count was parsed from an English
|
|
42
|
+
* sentence and would have reported zero on a repository full of duplication.
|
|
43
|
+
*
|
|
44
|
+
* @returns The count, or `null` when the report could not be read — in which case
|
|
45
|
+
* the caller keeps its old wording rather than claiming a number it does not have.
|
|
46
|
+
*/
|
|
47
|
+
function countFindings(reportPath) {
|
|
48
|
+
try {
|
|
49
|
+
const report = JSON.parse(node_fs_1.default.readFileSync(reportPath, 'utf-8'));
|
|
50
|
+
return Array.isArray(report) ? report.length : null;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* How the outcome names what was found.
|
|
58
|
+
*
|
|
59
|
+
* Its own function so both halves are reachable without running the scanner. The
|
|
60
|
+
* corpus for this check deliberately does not: the wrapper downloads a gitleaks
|
|
61
|
+
* binary on first use, and a unit suite that reaches the network is the thing the
|
|
62
|
+
* `installer` seam in `ensure.ts` exists to avoid. What broke here was the wording
|
|
63
|
+
* — a row that read the same for one secret and for thirty-nine — and the wording
|
|
64
|
+
* is testable on its own.
|
|
65
|
+
*
|
|
66
|
+
* `null` keeps the old sentence rather than inventing a number: a report we could
|
|
67
|
+
* not read is not a reason to claim a count.
|
|
68
|
+
*/
|
|
69
|
+
function secretsSummary(found) {
|
|
70
|
+
return found === null ? 'potential secrets detected' : `${found} potential secret(s)`;
|
|
71
|
+
}
|
|
72
|
+
function writableHome() {
|
|
73
|
+
const home = node_os_1.default.homedir();
|
|
74
|
+
try {
|
|
75
|
+
node_fs_1.default.accessSync(home, node_fs_1.default.constants.W_OK);
|
|
76
|
+
return home;
|
|
77
|
+
}
|
|
78
|
+
catch { }
|
|
79
|
+
const fallback = node_os_1.default.tmpdir();
|
|
80
|
+
try {
|
|
81
|
+
node_fs_1.default.accessSync(fallback, node_fs_1.default.constants.W_OK);
|
|
82
|
+
utils_1.logger.info(`[Security Scan]: HOME (${home}) is not writable — caching the scanner in ${fallback}`);
|
|
83
|
+
return fallback;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Whether the wrapper died instead of reporting, judged from its stderr.
|
|
91
|
+
*
|
|
92
|
+
* The wrapper is a Node script, and Node exits **1** on an unhandled exception —
|
|
93
|
+
* the same code gitleaks itself uses for "secrets found". Left undistinguished,
|
|
94
|
+
* a scanner that never ran is reported as `potential secrets detected`, pointing
|
|
95
|
+
* at a report file that was never written. A gate accusing a clean repository is
|
|
96
|
+
* the same class of failure as one that stays quiet about a real leak, and this
|
|
97
|
+
* is the only signal available to tell them apart.
|
|
98
|
+
*/
|
|
99
|
+
function looksLikeCrash(stderr) {
|
|
100
|
+
// The errno names are written out rather than factored to a shared `E` prefix:
|
|
101
|
+
// `E(?:ACCES|PERM|…)` saves four characters and costs the reader the ability to
|
|
102
|
+
// grep for the thing they are debugging.
|
|
103
|
+
return /^\s*(?:[A-Za-z]*Error|Uncaught|node:internal)|\b(?:EACCES|EPERM|ENOENT|EROFS)\b/m.test(stderr);
|
|
104
|
+
}
|
|
8
105
|
/**
|
|
9
106
|
* Secret scanning via the bundled gitleaks package. Language-agnostic — runs on
|
|
10
107
|
* every repo (Node, Python, C++, mobile). Secrets found → fail (blocking).
|
|
@@ -29,11 +126,7 @@ exports.securityCheck = {
|
|
|
29
126
|
utils_1.logger.warn('[Security Scan]: ⚠️ bundled gitleaks scanner could not be resolved — skipping');
|
|
30
127
|
return (0, types_1.skip)('gitleaks unavailable');
|
|
31
128
|
}
|
|
32
|
-
const hasClientConfig = (0, config_1.hasLocalConfig)([
|
|
33
|
-
'.gitleaks.toml',
|
|
34
|
-
'gitleaks.toml',
|
|
35
|
-
'.gitleaks/config.toml',
|
|
36
|
-
]);
|
|
129
|
+
const hasClientConfig = (0, config_1.hasLocalConfig)(['.gitleaks.toml', 'gitleaks.toml', '.gitleaks/config.toml'], ctx.cwd);
|
|
37
130
|
// Pin the gitleaks binary version so the scanner never reaches out to the
|
|
38
131
|
// GitHub API for the "latest" version — that network call adds latency and
|
|
39
132
|
// times out in locked-down/offline CI (the scanner already falls back to
|
|
@@ -62,25 +155,56 @@ exports.securityCheck = {
|
|
|
62
155
|
if (!hasClientConfig) {
|
|
63
156
|
args.push('--config', (0, config_1.getPackageConfigPath)('gitleaks.toml'));
|
|
64
157
|
}
|
|
158
|
+
// A machine-readable copy of the findings, purely so the outcome can carry a
|
|
159
|
+
// number. Written to a temp directory, never into the repository under review:
|
|
160
|
+
// a check whose side effect is a new untracked file shows up in the client's
|
|
161
|
+
// next commit. The console output is untouched — `stdio: inherit` below still
|
|
162
|
+
// streams the scanner's own report, which is what a reader actually looks at.
|
|
163
|
+
const reportDir = node_fs_1.default.mkdtempSync(node_path_1.default.join(node_os_1.default.tmpdir(), 'pr-checkmate-gitleaks-'));
|
|
164
|
+
const reportPath = node_path_1.default.join(reportDir, 'findings.json');
|
|
165
|
+
args.push('--report-format', 'json', '--report-path', reportPath);
|
|
65
166
|
utils_1.logger.info(`[Security Scan]: scanning ${isDelta ? 'the diff range' : `full history${depth ? ` (last ${depth} commits)` : ''}`}`);
|
|
167
|
+
const home = writableHome();
|
|
168
|
+
if (!home)
|
|
169
|
+
return (0, types_1.skip)('no writable directory for the scanner cache');
|
|
66
170
|
const env = {
|
|
67
171
|
...process.env,
|
|
172
|
+
HOME: home,
|
|
68
173
|
...(ctx.headSha ? { HEAD_SHA: ctx.headSha } : {}),
|
|
69
174
|
...(ctx.baseSha ? { BASE_SHA: ctx.baseSha } : {}),
|
|
70
175
|
};
|
|
176
|
+
// stdout stays inherited so the scanner's own report streams to the log as it
|
|
177
|
+
// always has; stderr is captured because it is the only thing that separates
|
|
178
|
+
// a crash from a finding — see `crashed`.
|
|
71
179
|
const result = await (0, execa_1.execa)(process.execPath, [gitleaksBin, ...args], {
|
|
72
180
|
cwd: ctx.cwd,
|
|
73
181
|
env,
|
|
74
|
-
stdio: 'inherit',
|
|
182
|
+
stdio: ['ignore', 'inherit', 'pipe'],
|
|
75
183
|
reject: false,
|
|
76
184
|
});
|
|
185
|
+
const stderr = String(result.stderr ?? '');
|
|
186
|
+
if (stderr.trim())
|
|
187
|
+
utils_1.logger.warn(stderr.trimEnd());
|
|
188
|
+
const found = countFindings(reportPath);
|
|
189
|
+
node_fs_1.default.rmSync(reportDir, { recursive: true, force: true });
|
|
77
190
|
if (result.exitCode === 0)
|
|
78
191
|
return (0, types_1.pass)();
|
|
79
|
-
// gitleaks exits 1 specifically when it finds secrets —
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
192
|
+
// gitleaks exits 1 specifically when it finds secrets — but so does Node when
|
|
193
|
+
// the wrapper around it throws. Only the second one leaves a stack trace.
|
|
194
|
+
if (result.exitCode === 1 && !looksLikeCrash(stderr)) {
|
|
195
|
+
// The findings themselves are already above, streamed from the scanner. This
|
|
196
|
+
// used to say "review gitleaks-report.html" — a file the check never wrote:
|
|
197
|
+
// `--html-report` is passed by our own `scan:secrets` npm script and by
|
|
198
|
+
// nothing else, so a blocking check told the client to open something that
|
|
199
|
+
// does not exist (ticket 085).
|
|
200
|
+
const summary = secretsSummary(found);
|
|
201
|
+
utils_1.logger.warn(`⚠️ gitleaks found ${summary} — see the findings above`);
|
|
202
|
+
return (0, types_1.fail)(summary);
|
|
83
203
|
}
|
|
84
|
-
|
|
204
|
+
const detail = looksLikeCrash(stderr)
|
|
205
|
+
? 'scanner failed to start — nothing was scanned'
|
|
206
|
+
: `scanner error (exit ${result.exitCode})`;
|
|
207
|
+
utils_1.logger.warn(`[Security Scan]: ⚠️ ${detail}`);
|
|
208
|
+
return (0, types_1.warn)(detail);
|
|
85
209
|
},
|
|
86
210
|
};
|
|
@@ -3,12 +3,36 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.sensitiveFileGuardCheck = void 0;
|
|
4
4
|
const utils_1 = require("../../../utils");
|
|
5
5
|
const execa_1 = require("../../../utils/execa");
|
|
6
|
+
const targets_1 = require("../../targets");
|
|
6
7
|
const types_1 = require("../../types");
|
|
7
8
|
const findings_1 = require("../findings");
|
|
9
|
+
/**
|
|
10
|
+
* The committed template, in the names every ecosystem uses for it.
|
|
11
|
+
*
|
|
12
|
+
* `.env.example` holds placeholders, is checked in on purpose, and is what the
|
|
13
|
+
* `.env` convention tells people to commit — a repository without one is the
|
|
14
|
+
* unusual case. Flagging it as a secret is not a stylistic quibble now that
|
|
15
|
+
* ticket 055 makes this category block: it turns the recommended practice into a
|
|
16
|
+
* red build, and a gate that fires on correct code is a gate people switch off.
|
|
17
|
+
*
|
|
18
|
+
* Confirmed in the field before it was fixed here: a client shipped
|
|
19
|
+
* `"sensitiveFileGuard": { "ignorePaths": [".env.example"] }` in their config to
|
|
20
|
+
* work around it. Needing a hand-written exclusion for the file the ecosystem
|
|
21
|
+
* mandates is the definition of a false positive.
|
|
22
|
+
*
|
|
23
|
+
* Matched only at the end of the path, so `.env.example.local` — a real file with
|
|
24
|
+
* real values, named to sort next to the template — is still flagged.
|
|
25
|
+
*/
|
|
26
|
+
const TEMPLATE_SUFFIX = /\.(?:example|sample|template|dist|defaults)$/i;
|
|
8
27
|
const SENSITIVE_CATEGORIES = [
|
|
9
28
|
{
|
|
10
29
|
label: 'Environment files (.env)',
|
|
11
|
-
|
|
30
|
+
// The dots are escaped. They were not, and `\/.env$` therefore matched ANY
|
|
31
|
+
// single character before `env`, so a nested file whose name merely ended in
|
|
32
|
+
// those three letters was reported as an environment file. Harmless while
|
|
33
|
+
// this category only warned; a red build over a filename once it blocks.
|
|
34
|
+
patterns: [/^\.env$/, /^\.env\..+/, /\/\.env$/, /\/\.env\..+/],
|
|
35
|
+
except: [TEMPLATE_SUFFIX],
|
|
12
36
|
severity: 'error',
|
|
13
37
|
},
|
|
14
38
|
{
|
|
@@ -61,6 +85,8 @@ function categorizeFiles(changedFiles) {
|
|
|
61
85
|
const warnings = {};
|
|
62
86
|
for (const filePath of changedFiles) {
|
|
63
87
|
for (const category of SENSITIVE_CATEGORIES) {
|
|
88
|
+
if (category.except?.some(p => p.test(filePath)))
|
|
89
|
+
continue;
|
|
64
90
|
if (category.patterns.some(p => p.test(filePath))) {
|
|
65
91
|
const bucket = category.severity === 'error' ? errors : warnings;
|
|
66
92
|
(bucket[_a = category.label] ?? (bucket[_a] = [])).push(filePath);
|
|
@@ -82,7 +108,7 @@ exports.sensitiveFileGuardCheck = {
|
|
|
82
108
|
async run(ctx) {
|
|
83
109
|
try {
|
|
84
110
|
const args = ctx.baseSha
|
|
85
|
-
? ['diff', '--name-only', '--diff-filter=ACMRD', ctx.baseSha, 'HEAD']
|
|
111
|
+
? ['diff', '--name-only', '--diff-filter=ACMRD', ctx.baseSha, ctx.headSha ?? 'HEAD']
|
|
86
112
|
: ['ls-files'];
|
|
87
113
|
if (!ctx.baseSha && ctx.config.run?.fullScanBehaviour === 'skip')
|
|
88
114
|
return (0, types_1.pass)();
|
|
@@ -100,10 +126,18 @@ exports.sensitiveFileGuardCheck = {
|
|
|
100
126
|
// Without this the only exclusion a client has is `ignore`, which suppresses
|
|
101
127
|
// the whole CATEGORY: muting "Private keys and certificates" to excuse one
|
|
102
128
|
// fixture would also excuse the next real key someone commits.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
129
|
+
// `excludePaths` before the check's own `ignorePaths`, and both before
|
|
130
|
+
// categorising.
|
|
131
|
+
//
|
|
132
|
+
// This check deliberately does NOT go through `targets.ts`: a committed
|
|
133
|
+
// `.env` inside `dist/` is still a committed secret, so `ignoreDirs` must
|
|
134
|
+
// not hide it, and a key outside `sourcePath` is still a key. But
|
|
135
|
+
// `excludePaths` is different in kind — it is the client saying "do not
|
|
136
|
+
// look here" in as many words, and the documentation calls it the one
|
|
137
|
+
// exclusion that reaches every check. It did not reach this one, which
|
|
138
|
+
// meant a `.pem` test fixture failed the build and the key the docs told
|
|
139
|
+
// people to reach for did nothing (ticket 080).
|
|
140
|
+
const changedFiles = (0, targets_1.excludeClientPaths)(result.stdout.split('\n').filter(Boolean), ctx.cwd).filter(file => !(0, utils_1.matchesAnyGlob)(file, cfg.ignorePaths ?? []));
|
|
107
141
|
if (changedFiles.length === 0)
|
|
108
142
|
return (0, types_1.pass)();
|
|
109
143
|
const { errors, warnings } = categorizeFiles(changedFiles);
|
|
@@ -79,7 +79,13 @@ exports.workflowSecurityCheck = {
|
|
|
79
79
|
scanInto(await (0, findings_1.diffAddedLines)(ctx, globs));
|
|
80
80
|
}
|
|
81
81
|
else if (ctx.config.run?.fullScanBehaviour !== 'skip') {
|
|
82
|
-
|
|
82
|
+
// `.github` is globally ignored and is the only place these files live,
|
|
83
|
+
// so this caller has to un-ignore it or scan nothing.
|
|
84
|
+
const files = await (0, targets_1.listTrackedFiles)(ctx, globs, ['.'], {
|
|
85
|
+
keepDirs: ['.github', '.gitlab', '.circleci'],
|
|
86
|
+
});
|
|
87
|
+
if (files === null)
|
|
88
|
+
return (0, types_1.skip)('git unavailable');
|
|
83
89
|
await (0, findings_1.scanTrackedFiles)(ctx, files, scanInto);
|
|
84
90
|
}
|
|
85
91
|
return (0, findings_1.reportSeverityBuckets)(found.errors, found.warnings, {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { CheckOutcome } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Where to get the external runners we deliberately do not ship.
|
|
4
|
+
*
|
|
5
|
+
* Eleven checks shell out to a tool that is not ours to bundle — a Kotlin linter,
|
|
6
|
+
* a Swift linter, the Go toolchain. When it is absent the check skips, which is
|
|
7
|
+
* the honest outcome, but `⏭️ ktlint: ktlint not installed` tells the reader
|
|
8
|
+
* something they already knew and nothing they can act on. A skip is only useful
|
|
9
|
+
* if it says what would make the check run.
|
|
10
|
+
*
|
|
11
|
+
* One table rather than a string at each call site: fourteen hand-written
|
|
12
|
+
* messages drift, and half of them end up naming an install route that has since
|
|
13
|
+
* moved.
|
|
14
|
+
*
|
|
15
|
+
* **Repository homepages, not deep documentation links.** A `#installation`
|
|
16
|
+
* anchor is exactly the kind of URL that rots, and a dead link printed into a
|
|
17
|
+
* client's CI log is worse than no link — it reads as carelessness about the
|
|
18
|
+
* thing we are asking them to trust. A project's repository root survives
|
|
19
|
+
* documentation reorganisations and always carries current install instructions.
|
|
20
|
+
*
|
|
21
|
+
* The command is the platform-agnostic one where a project has one, and the
|
|
22
|
+
* common-case one otherwise; the URL covers every other platform.
|
|
23
|
+
*/
|
|
24
|
+
declare const INSTALL_HINTS: {
|
|
25
|
+
readonly ktlint: {
|
|
26
|
+
readonly command: "brew install ktlint";
|
|
27
|
+
readonly url: "https://github.com/pinterest/ktlint";
|
|
28
|
+
};
|
|
29
|
+
readonly swiftlint: {
|
|
30
|
+
readonly command: "brew install swiftlint";
|
|
31
|
+
readonly url: "https://github.com/realm/SwiftLint";
|
|
32
|
+
};
|
|
33
|
+
readonly rubocop: {
|
|
34
|
+
readonly command: "gem install rubocop";
|
|
35
|
+
readonly url: "https://github.com/rubocop/rubocop";
|
|
36
|
+
};
|
|
37
|
+
readonly shellcheck: {
|
|
38
|
+
readonly command: "brew install shellcheck";
|
|
39
|
+
readonly url: "https://github.com/koalaman/shellcheck";
|
|
40
|
+
};
|
|
41
|
+
readonly go: {
|
|
42
|
+
readonly command: null;
|
|
43
|
+
readonly url: "https://go.dev/doc/install";
|
|
44
|
+
};
|
|
45
|
+
readonly gofmt: {
|
|
46
|
+
readonly command: null;
|
|
47
|
+
readonly url: "https://go.dev/doc/install";
|
|
48
|
+
};
|
|
49
|
+
readonly clippy: {
|
|
50
|
+
readonly command: "rustup component add clippy";
|
|
51
|
+
readonly url: "https://github.com/rust-lang/rust-clippy";
|
|
52
|
+
};
|
|
53
|
+
readonly rustfmt: {
|
|
54
|
+
readonly command: "rustup component add rustfmt";
|
|
55
|
+
readonly url: "https://github.com/rust-lang/rustfmt";
|
|
56
|
+
};
|
|
57
|
+
readonly 'php-cs-fixer': {
|
|
58
|
+
readonly command: "composer global require friendsofphp/php-cs-fixer";
|
|
59
|
+
readonly url: "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer";
|
|
60
|
+
};
|
|
61
|
+
readonly dotnet: {
|
|
62
|
+
readonly command: null;
|
|
63
|
+
readonly url: "https://dotnet.microsoft.com/download";
|
|
64
|
+
};
|
|
65
|
+
readonly mypy: {
|
|
66
|
+
readonly command: "pip install mypy";
|
|
67
|
+
readonly url: "https://github.com/python/mypy";
|
|
68
|
+
};
|
|
69
|
+
readonly pyright: {
|
|
70
|
+
readonly command: "npm i -g pyright";
|
|
71
|
+
readonly url: "https://github.com/microsoft/pyright";
|
|
72
|
+
};
|
|
73
|
+
readonly grype: {
|
|
74
|
+
readonly command: "brew install grype";
|
|
75
|
+
readonly url: "https://github.com/anchore/grype";
|
|
76
|
+
};
|
|
77
|
+
readonly 'osv-scanner': {
|
|
78
|
+
readonly command: "brew install osv-scanner";
|
|
79
|
+
readonly url: "https://github.com/google/osv-scanner";
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* A tool this package expects the environment to provide.
|
|
84
|
+
*
|
|
85
|
+
* Exported because the runner specs in `languages/external-checks.ts` name one
|
|
86
|
+
* per check, and that is the point of naming them: a spec that cannot compile
|
|
87
|
+
* with a typo in the key cannot ship a skip pointing at an install route that
|
|
88
|
+
* does not exist.
|
|
89
|
+
*/
|
|
90
|
+
export type ExternalTool = keyof typeof INSTALL_HINTS;
|
|
91
|
+
/**
|
|
92
|
+
* Skip because `tool` is not on the path, saying how to install it.
|
|
93
|
+
*
|
|
94
|
+
* @param tool Which runner is missing.
|
|
95
|
+
* @param label How to name it in the message. Omit when the check's own display
|
|
96
|
+
* name already is the tool's name — `⏭️ ktlint: ktlint not installed` repeats
|
|
97
|
+
* itself, which is what made these messages worth revisiting. Pass a label
|
|
98
|
+
* when the two differ (`Go Vet` needs `go`) or when either of two tools would
|
|
99
|
+
* have done (`mypy/pyright`).
|
|
100
|
+
*/
|
|
101
|
+
export declare function notInstalled(tool: ExternalTool, label?: string): CheckOutcome;
|
|
102
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.notInstalled = notInstalled;
|
|
4
|
+
const types_1 = require("../types");
|
|
5
|
+
/**
|
|
6
|
+
* Where to get the external runners we deliberately do not ship.
|
|
7
|
+
*
|
|
8
|
+
* Eleven checks shell out to a tool that is not ours to bundle — a Kotlin linter,
|
|
9
|
+
* a Swift linter, the Go toolchain. When it is absent the check skips, which is
|
|
10
|
+
* the honest outcome, but `⏭️ ktlint: ktlint not installed` tells the reader
|
|
11
|
+
* something they already knew and nothing they can act on. A skip is only useful
|
|
12
|
+
* if it says what would make the check run.
|
|
13
|
+
*
|
|
14
|
+
* One table rather than a string at each call site: fourteen hand-written
|
|
15
|
+
* messages drift, and half of them end up naming an install route that has since
|
|
16
|
+
* moved.
|
|
17
|
+
*
|
|
18
|
+
* **Repository homepages, not deep documentation links.** A `#installation`
|
|
19
|
+
* anchor is exactly the kind of URL that rots, and a dead link printed into a
|
|
20
|
+
* client's CI log is worse than no link — it reads as carelessness about the
|
|
21
|
+
* thing we are asking them to trust. A project's repository root survives
|
|
22
|
+
* documentation reorganisations and always carries current install instructions.
|
|
23
|
+
*
|
|
24
|
+
* The command is the platform-agnostic one where a project has one, and the
|
|
25
|
+
* common-case one otherwise; the URL covers every other platform.
|
|
26
|
+
*/
|
|
27
|
+
const INSTALL_HINTS = {
|
|
28
|
+
ktlint: { command: 'brew install ktlint', url: 'https://github.com/pinterest/ktlint' },
|
|
29
|
+
swiftlint: { command: 'brew install swiftlint', url: 'https://github.com/realm/SwiftLint' },
|
|
30
|
+
rubocop: { command: 'gem install rubocop', url: 'https://github.com/rubocop/rubocop' },
|
|
31
|
+
shellcheck: {
|
|
32
|
+
command: 'brew install shellcheck',
|
|
33
|
+
url: 'https://github.com/koalaman/shellcheck',
|
|
34
|
+
},
|
|
35
|
+
go: { command: null, url: 'https://go.dev/doc/install' },
|
|
36
|
+
gofmt: { command: null, url: 'https://go.dev/doc/install' },
|
|
37
|
+
clippy: {
|
|
38
|
+
command: 'rustup component add clippy',
|
|
39
|
+
url: 'https://github.com/rust-lang/rust-clippy',
|
|
40
|
+
},
|
|
41
|
+
rustfmt: { command: 'rustup component add rustfmt', url: 'https://github.com/rust-lang/rustfmt' },
|
|
42
|
+
'php-cs-fixer': {
|
|
43
|
+
command: 'composer global require friendsofphp/php-cs-fixer',
|
|
44
|
+
url: 'https://github.com/PHP-CS-Fixer/PHP-CS-Fixer',
|
|
45
|
+
},
|
|
46
|
+
dotnet: { command: null, url: 'https://dotnet.microsoft.com/download' },
|
|
47
|
+
mypy: { command: 'pip install mypy', url: 'https://github.com/python/mypy' },
|
|
48
|
+
pyright: { command: 'npm i -g pyright', url: 'https://github.com/microsoft/pyright' },
|
|
49
|
+
grype: { command: 'brew install grype', url: 'https://github.com/anchore/grype' },
|
|
50
|
+
'osv-scanner': {
|
|
51
|
+
command: 'brew install osv-scanner',
|
|
52
|
+
url: 'https://github.com/google/osv-scanner',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* Skip because `tool` is not on the path, saying how to install it.
|
|
57
|
+
*
|
|
58
|
+
* @param tool Which runner is missing.
|
|
59
|
+
* @param label How to name it in the message. Omit when the check's own display
|
|
60
|
+
* name already is the tool's name — `⏭️ ktlint: ktlint not installed` repeats
|
|
61
|
+
* itself, which is what made these messages worth revisiting. Pass a label
|
|
62
|
+
* when the two differ (`Go Vet` needs `go`) or when either of two tools would
|
|
63
|
+
* have done (`mypy/pyright`).
|
|
64
|
+
*/
|
|
65
|
+
function notInstalled(tool, label) {
|
|
66
|
+
const { command, url } = INSTALL_HINTS[tool];
|
|
67
|
+
const subject = label ? `${label} not installed` : 'not installed';
|
|
68
|
+
return (0, types_1.skip)(command ? `${subject} — ${command} · ${url}` : `${subject} — ${url}`);
|
|
69
|
+
}
|