mandrel-platform 1.13.0 → 1.13.2
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 +27 -3
- package/config/stryker.base.json +2 -0
- package/package.json +4 -2
- package/scripts/audit-check.mjs +210 -47
- package/scripts/audit-check.test.mjs +479 -49
- package/scripts/check-action-download-retries.mjs +155 -11
- package/scripts/check-action-download-retries.test.mjs +168 -3
- package/scripts/check-advisory-scan-setup.test.mjs +186 -5
- package/scripts/check-coverage-threshold.mjs +112 -17
- package/scripts/check-coverage-threshold.test.mjs +335 -0
- package/scripts/check-husky-hook-modes.test.mjs +134 -0
- package/scripts/check-runner-runs-on.test.mjs +223 -2
- package/scripts/check-semgrep-lockfile.test.mjs +238 -7
- package/scripts/check-workflow-lint-tier.test.mjs +176 -0
- package/scripts/check-workflow-portability.mjs +6 -3
- package/scripts/check-workflow-portability.test.mjs +1 -1
- package/scripts/env-doctor.mjs +493 -62
- package/scripts/env-doctor.test.mjs +529 -3
- package/scripts/fixtures/npm-audit-v2.json +66 -0
- package/scripts/install-git-hooks.mjs +123 -0
- package/scripts/install-git-hooks.test.mjs +160 -0
- package/scripts/issue-intake.test.mjs +351 -21
- package/scripts/runner-toggle.test.mjs +407 -0
- package/scripts/select-semgrep-python.sh +98 -17
- package/scripts/select-semgrep-python.test.mjs +139 -12
- package/scripts/stryker-base-config.test.mjs +34 -0
- package/scripts/update-semgrep-rules.mjs +83 -5
- package/scripts/update-semgrep-rules.test.mjs +55 -1
- package/scripts/workflow-lint-gate.test.mjs +128 -0
|
@@ -25,14 +25,38 @@
|
|
|
25
25
|
*
|
|
26
26
|
* WHAT COUNTS AS A DOWNLOAD
|
|
27
27
|
* -------------------------
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
28
|
+
* Any fetch that lands in a file, in every spelling a shell author reaches for
|
|
29
|
+
* — narrowing the scope to one spelling is how a real download escapes the
|
|
30
|
+
* guard. In scope:
|
|
31
|
+
* • `curl -o <path>` / `curl --output <path>` / `curl --output=<path>`;
|
|
32
|
+
* • a combined curl short group whose value-taking tail is the output flag,
|
|
33
|
+
* e.g. `curl -sSLo <path>` — the same command, written shorter;
|
|
34
|
+
* • `curl -O` / `curl --remote-name`, which name the file from the URL
|
|
35
|
+
* instead of taking a path (and so may sit anywhere in a short group,
|
|
36
|
+
* `-fsSLO` included, because they take no value);
|
|
37
|
+
* • `wget -O <path>` / `wget --output-document <path>` — reported against
|
|
38
|
+
* curl's flag spelling below, deliberately: the first-party fetches are
|
|
39
|
+
* all curl, and a new wget one should join them rather than invent a
|
|
40
|
+
* second resilience contract.
|
|
41
|
+
* Deliberately NOT a download, and so not flagged:
|
|
42
|
+
* • a `curl` with no output flag at all (a status probe, a POST, a fetch
|
|
43
|
+
* piped straight into another command);
|
|
44
|
+
* • a write to stdout — `-o -`, `--output -`, and wget's `-O -`. `-O` is the
|
|
45
|
+
* two commands' false friend: curl's takes no argument (so `curl -O -`
|
|
46
|
+
* fetches the URL `-`), while wget's is the output path;
|
|
31
47
|
* • `-o /dev/null`, which is a reachability/status check, not an asset fetch
|
|
32
48
|
* (`pr-quality.yml`'s fail-fast cancellation POST is exactly this shape).
|
|
33
49
|
* Retrying a POST is a different decision with different safety, and this lint
|
|
34
50
|
* deliberately does not make it.
|
|
35
51
|
*
|
|
52
|
+
* HOW FLAGS ARE MATCHED
|
|
53
|
+
* ---------------------
|
|
54
|
+
* As whole shell words, never as substrings: `--retry-connrefused` does not
|
|
55
|
+
* satisfy `--retry`, and a flag sitting inside a trailing `#` comment does not
|
|
56
|
+
* count. A `#` opens a comment only at start of line or after whitespace and
|
|
57
|
+
* outside quotes, so `$#`, `${#arr[@]}` and a fragment `#` inside a quoted URL
|
|
58
|
+
* are left alone. Every pattern here is a literal regex.
|
|
59
|
+
*
|
|
36
60
|
* SCOPE: `.github/actions/**` action manifests. Exit 0 when clean, 1 when any
|
|
37
61
|
* download is missing a required flag (prints file:line and the missing set).
|
|
38
62
|
*/
|
|
@@ -64,6 +88,21 @@ export const REQUIRED_FLAGS = Object.freeze([
|
|
|
64
88
|
}),
|
|
65
89
|
]);
|
|
66
90
|
|
|
91
|
+
/** `curl` invoked as a command — after whitespace or a shell operator. */
|
|
92
|
+
const CURL_COMMAND = /(^|[\s;&|(])curl(\s|$)/;
|
|
93
|
+
/** `wget` invoked as a command — same boundary rule. */
|
|
94
|
+
const WGET_COMMAND = /(^|[\s;&|(])wget(\s|$)/;
|
|
95
|
+
/** A combined short-option group: one `-` followed by letters only. */
|
|
96
|
+
const SHORT_GROUP = /^-[A-Za-z]+$/;
|
|
97
|
+
/** Quote characters wrapping a word, stripped so a path compares as itself. */
|
|
98
|
+
const LEADING_QUOTES = /^['"]+/;
|
|
99
|
+
const TRAILING_QUOTES = /['"]+$/;
|
|
100
|
+
/** Output targets that are not a fetched asset on disk. */
|
|
101
|
+
const STDOUT_TARGETS = new Set(['-', '/dev/null']);
|
|
102
|
+
/** Long output flags in their `--flag=value` spelling. */
|
|
103
|
+
const CURL_OUTPUT_EQ = '--output=';
|
|
104
|
+
const WGET_OUTPUT_EQ = '--output-document=';
|
|
105
|
+
|
|
67
106
|
/**
|
|
68
107
|
* Pure: fold shell line-continuations so a `curl` split across several lines is
|
|
69
108
|
* linted as the single command it is. The logical line keeps the 1-based number
|
|
@@ -95,18 +134,122 @@ export function collapseContinuations(source) {
|
|
|
95
134
|
}
|
|
96
135
|
|
|
97
136
|
/**
|
|
98
|
-
* Pure:
|
|
99
|
-
*
|
|
137
|
+
* Pure: drop a shell comment and everything after it.
|
|
138
|
+
*
|
|
139
|
+
* `#` only opens a comment at the start of the line or after whitespace, and
|
|
140
|
+
* only outside quotes — which is what keeps `$#`, `${#arr[@]}` and the fragment
|
|
141
|
+
* in `"https://host/p#frag"` from truncating the command that carries them.
|
|
142
|
+
*
|
|
143
|
+
* @param {string} text
|
|
144
|
+
* @returns {string}
|
|
145
|
+
*/
|
|
146
|
+
export function stripShellComment(text) {
|
|
147
|
+
let quote = null;
|
|
148
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
149
|
+
const char = text[i];
|
|
150
|
+
if (char === '\\' && quote !== "'") {
|
|
151
|
+
i += 1;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (quote !== null) {
|
|
155
|
+
if (char === quote) quote = null;
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (char === '"' || char === "'") {
|
|
159
|
+
quote = char;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const opensComment =
|
|
163
|
+
char === '#' && (i === 0 || text[i - 1] === ' ' || text[i - 1] === '\t');
|
|
164
|
+
if (opensComment) return text.slice(0, i);
|
|
165
|
+
}
|
|
166
|
+
return text;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Pure: the shell words of a logical line — comment dropped, split on
|
|
171
|
+
* whitespace, wrapping quotes removed so `"-"` compares equal to `-`.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} text
|
|
174
|
+
* @returns {string[]}
|
|
175
|
+
*/
|
|
176
|
+
export function shellWords(text) {
|
|
177
|
+
return stripShellComment(text)
|
|
178
|
+
.split(/\s+/)
|
|
179
|
+
.filter((word) => word.length > 0)
|
|
180
|
+
.map((word) => word.replace(LEADING_QUOTES, '').replace(TRAILING_QUOTES, ''));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Pure: does this curl invocation write its body to a real file? See the
|
|
185
|
+
* header for the spellings in scope and the ones deliberately excluded.
|
|
186
|
+
*
|
|
187
|
+
* @param {string[]} words
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
function curlWritesFile(words) {
|
|
191
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
192
|
+
const word = words[i];
|
|
193
|
+
const shortGroup = SHORT_GROUP.test(word);
|
|
194
|
+
// `-O` takes no value, so it may sit anywhere in a group (`-fsSLO`).
|
|
195
|
+
if (word === '--remote-name' || (shortGroup && word.includes('O'))) return true;
|
|
196
|
+
if (word.startsWith(CURL_OUTPUT_EQ)) {
|
|
197
|
+
return !STDOUT_TARGETS.has(word.slice(CURL_OUTPUT_EQ.length));
|
|
198
|
+
}
|
|
199
|
+
// `-o` takes a value, so in a group it must be the tail: `-sSLo <path>`.
|
|
200
|
+
if (word === '--output' || (shortGroup && word.endsWith('o'))) {
|
|
201
|
+
return !STDOUT_TARGETS.has(words[i + 1] ?? '-');
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Pure: does this wget invocation write its body to a real file? Unlike curl,
|
|
209
|
+
* wget's `-O` IS the output path — `wget -O - "$url"` is a stdout pipe.
|
|
210
|
+
*
|
|
211
|
+
* @param {string[]} words
|
|
212
|
+
* @returns {boolean}
|
|
213
|
+
*/
|
|
214
|
+
function wgetWritesFile(words) {
|
|
215
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
216
|
+
const word = words[i];
|
|
217
|
+
if (word.startsWith(WGET_OUTPUT_EQ)) {
|
|
218
|
+
return !STDOUT_TARGETS.has(word.slice(WGET_OUTPUT_EQ.length));
|
|
219
|
+
}
|
|
220
|
+
if (word === '-O' || word === '--output-document') {
|
|
221
|
+
return !STDOUT_TARGETS.has(words[i + 1] ?? '-');
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Pure: does this logical line fetch an artifact into a file? See the header
|
|
229
|
+
* for every spelling in scope and for what is deliberately excluded.
|
|
100
230
|
*
|
|
101
231
|
* @param {string} text
|
|
102
232
|
* @returns {boolean}
|
|
103
233
|
*/
|
|
104
234
|
export function isAssetDownload(text) {
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
return
|
|
235
|
+
const command = stripShellComment(text);
|
|
236
|
+
const words = shellWords(command);
|
|
237
|
+
if (CURL_COMMAND.test(command)) return curlWritesFile(words);
|
|
238
|
+
if (WGET_COMMAND.test(command)) return wgetWritesFile(words);
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Pure: is `flag` present as its own argument — on its own or as `flag=value`?
|
|
244
|
+
* Substring matching is what let `--retry-connrefused` satisfy `--retry`.
|
|
245
|
+
*
|
|
246
|
+
* @param {string[]} words
|
|
247
|
+
* @param {string} flag
|
|
248
|
+
* @returns {boolean}
|
|
249
|
+
*/
|
|
250
|
+
function hasFlag(words, flag) {
|
|
251
|
+
const assigned = `${flag}=`;
|
|
252
|
+
return words.some((word) => word === flag || word.startsWith(assigned));
|
|
110
253
|
}
|
|
111
254
|
|
|
112
255
|
/**
|
|
@@ -116,7 +259,8 @@ export function isAssetDownload(text) {
|
|
|
116
259
|
* @returns {string[]}
|
|
117
260
|
*/
|
|
118
261
|
export function missingFlags(text) {
|
|
119
|
-
|
|
262
|
+
const words = shellWords(text);
|
|
263
|
+
return REQUIRED_FLAGS.filter(({ flag }) => !hasFlag(words, flag)).map(
|
|
120
264
|
({ flag }) => flag,
|
|
121
265
|
);
|
|
122
266
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Unit coverage for the asset-download retry lint (Story #446).
|
|
1
|
+
// Unit coverage for the asset-download retry lint (Story #446, tightened in #490).
|
|
2
2
|
//
|
|
3
3
|
// The claim worth pinning is that this guard FAILS on the defect it exists to
|
|
4
4
|
// catch. A lint only ever asserted against a passing tree is indistinguishable
|
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
// this repo has been bitten by before, so every rejection case below feeds the
|
|
7
7
|
// checker a fixture it must refuse.
|
|
8
8
|
//
|
|
9
|
+
// Story #490 added the second half of that claim: a guard that can be SATISFIED
|
|
10
|
+
// without the invariant does not guard it either. `--retry-connrefused` used to
|
|
11
|
+
// satisfy `--retry` by substring, a flag inside a `#` comment counted, and four
|
|
12
|
+
// of the five ways to spell a download were outside the checker's scope.
|
|
13
|
+
//
|
|
9
14
|
// Run: node --test scripts/check-action-download-retries.test.mjs
|
|
10
15
|
|
|
11
16
|
import { test } from "node:test";
|
|
@@ -19,6 +24,8 @@ import {
|
|
|
19
24
|
isAssetDownload,
|
|
20
25
|
lintSource,
|
|
21
26
|
missingFlags,
|
|
27
|
+
shellWords,
|
|
28
|
+
stripShellComment,
|
|
22
29
|
} from "./check-action-download-retries.mjs";
|
|
23
30
|
|
|
24
31
|
/** The exact shape that failed run 34133838332. */
|
|
@@ -38,6 +45,9 @@ const HARDENED_DOWNLOAD = BARE_DOWNLOAD.replace(
|
|
|
38
45
|
"curl -fsSL --retry 3 --retry-connrefused --max-time 300 ",
|
|
39
46
|
);
|
|
40
47
|
|
|
48
|
+
/** Every required flag, in table order — the full-miss expectation. */
|
|
49
|
+
const ALL_REQUIRED = REQUIRED_FLAGS.map(({ flag }) => flag);
|
|
50
|
+
|
|
41
51
|
// ---------------------------------------------------------------------------
|
|
42
52
|
// The guard must REJECT — the case that makes it worth having
|
|
43
53
|
// ---------------------------------------------------------------------------
|
|
@@ -66,6 +76,141 @@ test("a download carrying only SOME required flags is still rejected", () => {
|
|
|
66
76
|
assert.deepEqual(finding.missing, ["--retry-connrefused", "--max-time"]);
|
|
67
77
|
});
|
|
68
78
|
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
// Flags are whole words, not substrings (Story #490 / AC-1)
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
test("a sibling flag does not satisfy the flag it contains", () => {
|
|
84
|
+
// `--retry-connrefused` contains `--retry`, which is exactly how a download
|
|
85
|
+
// with no retry budget at all used to pass this lint.
|
|
86
|
+
assert.deepEqual(
|
|
87
|
+
missingFlags('curl -fsSL --retry-connrefused --max-time 300 "$u" -o x'),
|
|
88
|
+
["--retry"],
|
|
89
|
+
);
|
|
90
|
+
const [finding] = lintSource(
|
|
91
|
+
"action.yml",
|
|
92
|
+
BARE_DOWNLOAD.replace(
|
|
93
|
+
"curl -fsSL ",
|
|
94
|
+
"curl -fsSL --retry-connrefused --max-time 300 ",
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(finding.missing, ["--retry"], "the lint reports it too");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("a flag is present as its own word or as `flag=value`", () => {
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
missingFlags('curl --retry=3 --retry-connrefused --max-time=300 -o x "$u"'),
|
|
103
|
+
[],
|
|
104
|
+
"curl's `--flag=value` spelling counts",
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
missingFlags('curl --retry-max-time 60 -o x "$u"'),
|
|
108
|
+
ALL_REQUIRED,
|
|
109
|
+
"a longer flag that merely starts with a required one satisfies nothing",
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// A `#` comment hides nothing, and shell `#` is not a comment (AC-2, AC-4)
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
test("a flag inside a trailing comment does not count", () => {
|
|
118
|
+
assert.deepEqual(
|
|
119
|
+
missingFlags('curl -fsSL -o x "$u" # --retry 3 --retry-connrefused --max-time 300'),
|
|
120
|
+
ALL_REQUIRED,
|
|
121
|
+
);
|
|
122
|
+
assert.deepEqual(
|
|
123
|
+
missingFlags("# curl --retry 3 --retry-connrefused --max-time 300 -o x"),
|
|
124
|
+
ALL_REQUIRED,
|
|
125
|
+
"a whole-line comment carries no flags either",
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("a `#` inside a quoted URL does not truncate the command", () => {
|
|
130
|
+
assert.deepEqual(
|
|
131
|
+
missingFlags(
|
|
132
|
+
'curl -o x "https://host/p#frag" --retry 3 --retry-connrefused --max-time 300',
|
|
133
|
+
),
|
|
134
|
+
[],
|
|
135
|
+
"flags after the fragment are still matched",
|
|
136
|
+
);
|
|
137
|
+
assert.equal(
|
|
138
|
+
isAssetDownload('curl -fsSL "https://host/p#frag" -o "$f"'),
|
|
139
|
+
true,
|
|
140
|
+
"the fragment does not hide the output flag either",
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("shell `#` expansions are not comments", () => {
|
|
145
|
+
assert.deepEqual(
|
|
146
|
+
missingFlags(
|
|
147
|
+
'curl "${#arr[@]}" --retry 3 --retry-connrefused --max-time 300 -o x "$u"',
|
|
148
|
+
),
|
|
149
|
+
[],
|
|
150
|
+
"`${#arr[@]}` before the flags leaves them visible",
|
|
151
|
+
);
|
|
152
|
+
assert.equal(stripShellComment('echo "$#" --retry'), 'echo "$#" --retry');
|
|
153
|
+
assert.equal(stripShellComment("len=${#arr[@]} --retry"), "len=${#arr[@]} --retry");
|
|
154
|
+
assert.equal(stripShellComment("curl -o x \\# --retry"), "curl -o x \\# --retry");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("stripShellComment cuts at the first real comment", () => {
|
|
158
|
+
assert.equal(stripShellComment("curl -o x # note"), "curl -o x ");
|
|
159
|
+
assert.equal(stripShellComment("#note"), "");
|
|
160
|
+
assert.equal(stripShellComment("curl -o x\t# note"), "curl -o x\t");
|
|
161
|
+
assert.equal(stripShellComment("curl -o x"), "curl -o x");
|
|
162
|
+
assert.equal(stripShellComment("echo 'a # b' # note"), "echo 'a # b' ");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("shellWords splits on whitespace and unwraps quoting", () => {
|
|
166
|
+
assert.deepEqual(shellWords(' curl -o "$f" "$u" # x'), ["curl", "-o", "$f", "$u"]);
|
|
167
|
+
assert.deepEqual(shellWords('curl -o "-"'), ["curl", "-o", "-"]);
|
|
168
|
+
assert.deepEqual(shellWords(""), []);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Every download spelling is in scope (AC-3)
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
test("every spelling that writes a fetched artifact to a file is a download", () => {
|
|
176
|
+
const downloads = [
|
|
177
|
+
'curl -fsSL "$u" -o "${tmp}/${asset}"',
|
|
178
|
+
'curl -sSLo "$f" "$u"',
|
|
179
|
+
'curl --output "$f" "$u"',
|
|
180
|
+
'curl --output="$f" "$u"',
|
|
181
|
+
'curl -fsSL -O "$u"',
|
|
182
|
+
'curl -fsSLO "$u"',
|
|
183
|
+
'curl --remote-name "$u"',
|
|
184
|
+
'wget -O x "$u"',
|
|
185
|
+
'wget --output-document x "$u"',
|
|
186
|
+
'wget --output-document=x "$u"',
|
|
187
|
+
];
|
|
188
|
+
for (const line of downloads) {
|
|
189
|
+
assert.equal(isAssetDownload(line), true, `${line} is a download`);
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a fetch that never lands in a file is not a download", () => {
|
|
194
|
+
const notDownloads = [
|
|
195
|
+
'curl -o - "$u"',
|
|
196
|
+
'curl --output - "$u"',
|
|
197
|
+
'curl -sSLo - "$u"',
|
|
198
|
+
'wget -O - "$u"',
|
|
199
|
+
'curl -fsSL "$u" | tar -xz',
|
|
200
|
+
'curl -fsSL "https://example.test/health"',
|
|
201
|
+
'curl -sS -o /dev/null -w "%{http_code}" -X POST "$api"',
|
|
202
|
+
];
|
|
203
|
+
for (const line of notDownloads) {
|
|
204
|
+
assert.equal(isAssetDownload(line), false, `${line} is not a download`);
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test("a wget download is held to the same contract", () => {
|
|
209
|
+
const [finding] = lintSource("action.yml", ' wget -O "$f" "$u"');
|
|
210
|
+
assert.ok(finding, "an unhardened wget fetch is caught, not silently skipped");
|
|
211
|
+
assert.deepEqual(finding.missing, ALL_REQUIRED);
|
|
212
|
+
});
|
|
213
|
+
|
|
69
214
|
// ---------------------------------------------------------------------------
|
|
70
215
|
// The guard must ACCEPT — no false positives on the shapes that are fine
|
|
71
216
|
// ---------------------------------------------------------------------------
|
|
@@ -83,13 +228,18 @@ test("a curl that is not an asset download is not a violation", () => {
|
|
|
83
228
|
' curl -fsSL "https://example.test/health"',
|
|
84
229
|
].join("\n");
|
|
85
230
|
assert.deepEqual(lintSource("workflow.yml", probes), []);
|
|
86
|
-
assert.equal(isAssetDownload(probes.split("\n")[0]), false, "-o /dev/null is a probe");
|
|
87
231
|
assert.equal(isAssetDownload(probes.split("\n")[1]), false, "no -o is not a download");
|
|
232
|
+
assert.equal(
|
|
233
|
+
isAssetDownload(' curl -sS -o /dev/null -w "%{http_code}" -X POST "$api"'),
|
|
234
|
+
false,
|
|
235
|
+
"-o /dev/null is a probe",
|
|
236
|
+
);
|
|
88
237
|
});
|
|
89
238
|
|
|
90
239
|
test("a line that merely mentions curl in prose is not a download", () => {
|
|
91
|
-
assert.equal(isAssetDownload(" # predecessor used `curl --retry 3`"), false);
|
|
240
|
+
assert.equal(isAssetDownload(" # predecessor used `curl --retry 3` -o x"), false);
|
|
92
241
|
assert.equal(isAssetDownload(" echo curling the asset"), false);
|
|
242
|
+
assert.equal(isAssetDownload(" echo wgetting the asset -O x"), false);
|
|
93
243
|
});
|
|
94
244
|
|
|
95
245
|
// ---------------------------------------------------------------------------
|
|
@@ -139,6 +289,21 @@ test("missingFlags reports in table order and REQUIRED_FLAGS each carry a why",
|
|
|
139
289
|
test("every first-party action manifest in this repo passes", () => {
|
|
140
290
|
const manifests = findActionManifests();
|
|
141
291
|
assert.ok(manifests.length > 0, "the action surface is discoverable");
|
|
292
|
+
assert.ok(
|
|
293
|
+
manifests.some((p) => p.includes("gitleaks-scan")),
|
|
294
|
+
"the action whose 504 motivated this lint is in scope",
|
|
295
|
+
);
|
|
142
296
|
const findings = manifests.flatMap((p) => lintSource(p, readFileSync(p, "utf8")));
|
|
143
297
|
assert.deepEqual(findings, [], "no action ships an unhardened asset download");
|
|
144
298
|
});
|
|
299
|
+
|
|
300
|
+
test("the shipped downloads are still recognised as downloads", () => {
|
|
301
|
+
// The tightening must not narrow the lint into vacuous success: the live
|
|
302
|
+
// manifests must still present downloads for it to have judged.
|
|
303
|
+
const seen = findActionManifests().flatMap((p) =>
|
|
304
|
+
collapseContinuations(readFileSync(p, "utf8"))
|
|
305
|
+
.map(({ text }) => text)
|
|
306
|
+
.filter((text) => isAssetDownload(text)),
|
|
307
|
+
);
|
|
308
|
+
assert.ok(seen.length >= 4, `expected the tree's asset downloads, saw ${seen.length}`);
|
|
309
|
+
});
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
* because the defect class here is an expression that reads correctly and
|
|
19
19
|
* evaluates wrong.
|
|
20
20
|
*
|
|
21
|
+
* The install-free assertion is JOB-wide (Story #494). It used to slice the
|
|
22
|
+
* "Setup Node.js (install-free)" step alone, which proves nothing: the step
|
|
23
|
+
* that provisions Node was never the one likely to grow an install. What the
|
|
24
|
+
* path promises is that NOTHING in the job installs when `setup: node` is
|
|
25
|
+
* selected, so the check enumerates every step of the job, keeps the ones
|
|
26
|
+
* whose `if:` guard actually selects them under that input, and asserts the
|
|
27
|
+
* absence across the set.
|
|
28
|
+
*
|
|
21
29
|
* Run: node --test scripts/check-advisory-scan-setup.test.mjs
|
|
22
30
|
*/
|
|
23
31
|
|
|
@@ -59,11 +67,114 @@ function stepByName(text, name) {
|
|
|
59
67
|
return out.join("\n");
|
|
60
68
|
}
|
|
61
69
|
|
|
70
|
+
/** Strip a `${{ … }}` wrapper, leaving the bare Actions expression. */
|
|
71
|
+
function bareExpression(raw) {
|
|
72
|
+
return raw.trim().replace(/^\$\{\{/, "").replace(/\}\}$/, "").trim();
|
|
73
|
+
}
|
|
74
|
+
|
|
62
75
|
/** The `${{ … }}`-free body of a step's `if:` condition. */
|
|
63
76
|
function ifExpression(step, name) {
|
|
64
77
|
const m = step.match(/^\s*if:\s*(.+)$/m);
|
|
65
78
|
assert.ok(m, `step "${name}" has no \`if:\` guard`);
|
|
66
|
-
return m[1]
|
|
79
|
+
return bareExpression(m[1]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The line span of one top-level job — from its ` <id>:` header to the next
|
|
84
|
+
* key at that indent, trailing blank lines excluded. A plain line comparison
|
|
85
|
+
* rather than a regex built around `job`: a dynamically-constructed RegExp is
|
|
86
|
+
* a SAST finding, and a job header is an exact line anyway.
|
|
87
|
+
*/
|
|
88
|
+
function jobRange(text, job) {
|
|
89
|
+
const lines = text.split("\n");
|
|
90
|
+
const start = lines.findIndex((l) => l.trimEnd() === ` ${job}:`);
|
|
91
|
+
assert.notEqual(start, -1, `${ADVISORY}: job '${job}' not found`);
|
|
92
|
+
let end = lines.length;
|
|
93
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
94
|
+
if (/^ {2}\S/.test(lines[i])) {
|
|
95
|
+
end = i;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
while (end > start + 1 && lines[end - 1].trim() === "") end--;
|
|
100
|
+
return { lines, start, end };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Every step of a job, in order, as its own block of text. Steps are split on
|
|
105
|
+
* the `- ` bullets under `steps:` at the bullet's own indent, so a nested
|
|
106
|
+
* `with:` / `env:` mapping stays with the step that owns it.
|
|
107
|
+
*/
|
|
108
|
+
function jobSteps(text, job) {
|
|
109
|
+
const { lines, start, end } = jobRange(text, job);
|
|
110
|
+
const body = lines.slice(start + 1, end);
|
|
111
|
+
const stepsIdx = body.findIndex((l) => l.trim() === "steps:");
|
|
112
|
+
assert.notEqual(stepsIdx, -1, `${ADVISORY}: job '${job}' declares no \`steps:\``);
|
|
113
|
+
const rest = body.slice(stepsIdx + 1);
|
|
114
|
+
const first = rest.findIndex((l) => /^\s*-\s/.test(l));
|
|
115
|
+
assert.notEqual(first, -1, `${ADVISORY}: job '${job}' declares no steps`);
|
|
116
|
+
const indent = rest[first].match(/^(\s*)/)[1].length;
|
|
117
|
+
const steps = [];
|
|
118
|
+
for (const line of rest.slice(first)) {
|
|
119
|
+
const bullet = /^\s*-\s/.test(line) && line.match(/^(\s*)/)[1].length === indent;
|
|
120
|
+
if (bullet) steps.push([]);
|
|
121
|
+
steps[steps.length - 1].push(line);
|
|
122
|
+
}
|
|
123
|
+
return steps.map((step) => step.join("\n"));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** A step's `name:`, else its opening line — for a readable failure message. */
|
|
127
|
+
function stepLabel(step) {
|
|
128
|
+
const m = step.match(/^\s*(?:-\s*)?name:\s*(.+)$/m);
|
|
129
|
+
return m ? m[1].trim() : step.split("\n")[0].trim();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Whether a step is selected for the run under the given `inputs` context. */
|
|
133
|
+
function runsWhen(step, inputs) {
|
|
134
|
+
const m = step.match(/^\s*if:\s*(.+)$/m);
|
|
135
|
+
if (!m) return true;
|
|
136
|
+
try {
|
|
137
|
+
return evaluate(bareExpression(m[1]), inputs) === true;
|
|
138
|
+
} catch {
|
|
139
|
+
// A guard this evaluator cannot run counts as SELECTED. Fail closed: an
|
|
140
|
+
// expression nobody can evaluate must never be the reason an install slips
|
|
141
|
+
// past the check below — at worst it costs a loud failure a human reads.
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Install commands, matched against the step's YAML with comment-only lines
|
|
147
|
+
// removed: the job's comments discuss `pnpm install` at length, and prose is
|
|
148
|
+
// not a command. Literal patterns, never a built RegExp.
|
|
149
|
+
const INSTALL_COMMANDS = [
|
|
150
|
+
{ label: "pnpm install", pattern: /\bpnpm install\b/ },
|
|
151
|
+
{ label: "npm ci", pattern: /\bnpm ci\b/ },
|
|
152
|
+
{ label: "npm install", pattern: /\bnpm install\b/ },
|
|
153
|
+
{ label: "yarn install", pattern: /\byarn install\b/ },
|
|
154
|
+
];
|
|
155
|
+
|
|
156
|
+
/** Every step selected by `setup` that runs an install, with what it runs. */
|
|
157
|
+
function installingSteps(text, setup) {
|
|
158
|
+
return jobSteps(text, JOB)
|
|
159
|
+
.filter((step) => runsWhen(step, { setup }))
|
|
160
|
+
.map((step) => ({
|
|
161
|
+
step: stepLabel(step),
|
|
162
|
+
commands: INSTALL_COMMANDS.filter(({ pattern }) =>
|
|
163
|
+
pattern.test(
|
|
164
|
+
step
|
|
165
|
+
.split("\n")
|
|
166
|
+
.filter((l) => !l.trim().startsWith("#"))
|
|
167
|
+
.join("\n"),
|
|
168
|
+
),
|
|
169
|
+
).map(({ label }) => label),
|
|
170
|
+
}))
|
|
171
|
+
.filter(({ commands }) => commands.length > 0);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** `text` with one extra step spliced in after the job's last existing step. */
|
|
175
|
+
function withStepAppended(text, job, step) {
|
|
176
|
+
const { lines, end } = jobRange(text, job);
|
|
177
|
+
return [...lines.slice(0, end), ...step.split("\n"), ...lines.slice(end)].join("\n");
|
|
67
178
|
}
|
|
68
179
|
|
|
69
180
|
/** The literal `default:` of the named workflow_call input. */
|
|
@@ -80,6 +191,7 @@ function inputDefault(text, name) {
|
|
|
80
191
|
return assert.fail(`${ADVISORY}: input \`${name}\` has no default`);
|
|
81
192
|
}
|
|
82
193
|
|
|
194
|
+
const JOB = "advisory-scan";
|
|
83
195
|
const TOOLCHAIN_STEP = "Setup toolchain";
|
|
84
196
|
const NODE_STEP = "Setup Node.js (install-free)";
|
|
85
197
|
const GUARD_STEP = "Validate setup input";
|
|
@@ -135,12 +247,54 @@ test("the install-free step pins Node from .nvmrc via a SHA-pinned setup-node",
|
|
|
135
247
|
);
|
|
136
248
|
});
|
|
137
249
|
|
|
138
|
-
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
// Story #494 — "install-free" is a property of the JOB, not of one step.
|
|
252
|
+
// ---------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
test("no dependency install runs ANYWHERE in the job on the install-free path", () => {
|
|
139
255
|
// The whole point of the path: osv-scanner reads lockfiles off disk, and both
|
|
140
256
|
// composites' gate scripts import only node builtins and relative siblings.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
assert.
|
|
257
|
+
// So nothing in the job needs a dependency tree — and the step that
|
|
258
|
+
// provisions Node was never the step likely to grow an install anyway.
|
|
259
|
+
assert.deepEqual(
|
|
260
|
+
installingSteps(advisory, "node"),
|
|
261
|
+
[],
|
|
262
|
+
"a step selected by `setup: node` installs dependencies",
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("the check is job-wide: an install appended after the last step is caught", () => {
|
|
267
|
+
// The mutation the step-scoped version missed — it sliced the install-free
|
|
268
|
+
// step alone, so this fixture passed while the path's one promise was broken.
|
|
269
|
+
const mutated = withStepAppended(
|
|
270
|
+
advisory,
|
|
271
|
+
JOB,
|
|
272
|
+
[" - name: Restore dependencies", " run: npm ci"].join("\n"),
|
|
273
|
+
);
|
|
274
|
+
assert.deepEqual(installingSteps(mutated, "node"), [
|
|
275
|
+
{ step: "Restore dependencies", commands: ["npm ci"] },
|
|
276
|
+
]);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("an install guarded onto the toolchain path is not charged to the node path", () => {
|
|
280
|
+
// The other half of the mutation, and the proof that the `if:` guards are
|
|
281
|
+
// still EVALUATED here rather than grepped: the same appended step is
|
|
282
|
+
// invisible under `setup: node` and visible under `setup: toolchain`. Drop
|
|
283
|
+
// the evaluation and this check either flags every guarded install or, if it
|
|
284
|
+
// ignored guards entirely, would have to ignore unguarded ones too.
|
|
285
|
+
const mutated = withStepAppended(
|
|
286
|
+
advisory,
|
|
287
|
+
JOB,
|
|
288
|
+
[
|
|
289
|
+
" - name: Restore pnpm dependencies",
|
|
290
|
+
" if: inputs.setup == 'toolchain'",
|
|
291
|
+
" run: pnpm install --frozen-lockfile",
|
|
292
|
+
].join("\n"),
|
|
293
|
+
);
|
|
294
|
+
assert.deepEqual(installingSteps(mutated, "node"), []);
|
|
295
|
+
assert.deepEqual(installingSteps(mutated, "toolchain"), [
|
|
296
|
+
{ step: "Restore pnpm dependencies", commands: ["pnpm install"] },
|
|
297
|
+
]);
|
|
144
298
|
});
|
|
145
299
|
|
|
146
300
|
// ---------------------------------------------------------------------------
|
|
@@ -222,6 +376,33 @@ test("this repo really is the npm case the caller claims", () => {
|
|
|
222
376
|
);
|
|
223
377
|
});
|
|
224
378
|
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
// Story #494 — the copy-paste path documents the input that decides the job.
|
|
381
|
+
// ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
test("the header's consumer snippet shows `setup:` and names both accepted values", () => {
|
|
384
|
+
// The snippet is what a consumer copies; an input missing from it is one they
|
|
385
|
+
// never learn they had. `setup` is the input whose wrong value kills the job
|
|
386
|
+
// at setup-node before the scan runs — the seven silent weeks above — so the
|
|
387
|
+
// copy path has to carry it, and has to name the other value it accepts.
|
|
388
|
+
const header = advisory.split("\nname:")[0];
|
|
389
|
+
const snippet = header.split("\n").filter((l) => l.startsWith("#"));
|
|
390
|
+
const withIdx = snippet.findIndex((l) => /^#\s+with:\s*$/.test(l));
|
|
391
|
+
assert.notEqual(withIdx, -1, `${ADVISORY}: the consumer snippet has no \`with:\` block`);
|
|
392
|
+
const withBlock = snippet.slice(withIdx).join("\n");
|
|
393
|
+
assert.match(
|
|
394
|
+
withBlock,
|
|
395
|
+
/^#\s+setup:\s*(toolchain|node)\s*$/m,
|
|
396
|
+
"the consumer snippet must pass `setup:`",
|
|
397
|
+
);
|
|
398
|
+
for (const value of ["toolchain", "node"]) {
|
|
399
|
+
assert.ok(
|
|
400
|
+
withBlock.includes(value),
|
|
401
|
+
`the consumer snippet must name the accepted value '${value}'`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
225
406
|
// ---------------------------------------------------------------------------
|
|
226
407
|
// AC-8 / AC-9 — the documented contract.
|
|
227
408
|
// ---------------------------------------------------------------------------
|