@secureci/cli 0.5.1 → 0.7.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 +74 -15
- package/dist/advisories.js +603 -0
- package/dist/cli.js +263 -38
- package/dist/config.js +23 -1
- package/dist/init.js +12 -5
- package/dist/parser/allow-comments.js +113 -0
- package/dist/parser/dangerous-triggers.js +163 -0
- package/dist/parser/injection.js +281 -0
- package/dist/parser/permissions.js +145 -0
- package/dist/parser/pipeline-contract-parser.js +70 -0
- package/dist/parser/pipeline-contract-validator.js +18 -4
- package/dist/parser/validator.js +61 -1
- package/dist/pin.js +311 -0
- package/dist/report.js +9 -2
- package/dist/runner/action-fetcher.js +100 -4
- package/dist/runner/builtin-actions.js +13 -3
- package/dist/runner/dry-run.js +57 -12
- package/dist/runner/executor.js +368 -254
- package/dist/runner/expression.js +106 -8
- package/dist/runner/job-plan.js +58 -1
- package/dist/runner/job-runtime.js +3 -3
- package/dist/runner/node-action.js +11 -3
- package/dist/runner/node-binary.js +108 -0
- package/dist/runner/scheduler.js +207 -0
- package/dist/runner/steps.js +54 -2
- package/dist/utils/env-file.js +185 -0
- package/dist/utils/logger.js +36 -7
- package/dist/version.js +56 -0
- package/package.json +3 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.GITHUB_TOKEN_SCOPES = void 0;
|
|
4
|
+
exports.workflowPermissionErrors = workflowPermissionErrors;
|
|
5
|
+
exports.contractPermissionErrors = contractPermissionErrors;
|
|
6
|
+
/** SARIF rule for every GITHUB_TOKEN permissions finding. */
|
|
7
|
+
const RULE = 'secureci/permissions';
|
|
8
|
+
/**
|
|
9
|
+
* Scopes `permissions:` accepts, per GitHub's workflow syntax.
|
|
10
|
+
* `repository-projects` is kept for older workflows (classic Projects).
|
|
11
|
+
*/
|
|
12
|
+
exports.GITHUB_TOKEN_SCOPES = [
|
|
13
|
+
'actions',
|
|
14
|
+
'artifact-metadata',
|
|
15
|
+
'attestations',
|
|
16
|
+
'checks',
|
|
17
|
+
'contents',
|
|
18
|
+
'deployments',
|
|
19
|
+
'discussions',
|
|
20
|
+
'id-token',
|
|
21
|
+
'issues',
|
|
22
|
+
'models',
|
|
23
|
+
'packages',
|
|
24
|
+
'pages',
|
|
25
|
+
'pull-requests',
|
|
26
|
+
'repository-projects',
|
|
27
|
+
'security-events',
|
|
28
|
+
'statuses'
|
|
29
|
+
];
|
|
30
|
+
const RANK = { none: 0, read: 1, write: 2 };
|
|
31
|
+
const hasOwn = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
|
32
|
+
/** Rank of a level, or undefined for anything that isn't one. */
|
|
33
|
+
const rankOf = (level) => typeof level === 'string' && hasOwn(RANK, level)
|
|
34
|
+
? RANK[level]
|
|
35
|
+
: undefined;
|
|
36
|
+
const declared = (permissions) => permissions !== undefined && permissions !== null;
|
|
37
|
+
const isScopeMap = (permissions) => typeof permissions === 'object' &&
|
|
38
|
+
permissions !== null &&
|
|
39
|
+
!Array.isArray(permissions);
|
|
40
|
+
/**
|
|
41
|
+
* A reusable workflow runs with its caller's token, so a workflow whose only
|
|
42
|
+
* trigger is `workflow_call` doesn't need its own top-level block.
|
|
43
|
+
*/
|
|
44
|
+
function onlyWorkflowCall(on) {
|
|
45
|
+
const events = typeof on === 'string'
|
|
46
|
+
? [on]
|
|
47
|
+
: Array.isArray(on)
|
|
48
|
+
? on
|
|
49
|
+
: on && typeof on === 'object'
|
|
50
|
+
? Object.keys(on)
|
|
51
|
+
: [];
|
|
52
|
+
return events.length > 0 && events.every((e) => e === 'workflow_call');
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Jobs whose token falls back to the repository/organization default:
|
|
56
|
+
* no top-level `permissions:` and none on the job. Empty when the workflow
|
|
57
|
+
* declares a top-level block or only runs as a reusable workflow.
|
|
58
|
+
*/
|
|
59
|
+
function uncoveredJobs(workflow) {
|
|
60
|
+
if (declared(workflow.permissions) || onlyWorkflowCall(workflow.on)) {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
return Object.entries(workflow.jobs ?? {})
|
|
64
|
+
.filter(([, job]) => !declared(job?.permissions))
|
|
65
|
+
.map(([id]) => id);
|
|
66
|
+
}
|
|
67
|
+
const quoteList = (ids) => ids.map((id) => `"${id}"`).join(', ');
|
|
68
|
+
const finding = (path, message, severity) => ({ path, message, severity, rule: RULE });
|
|
69
|
+
/**
|
|
70
|
+
* Default GITHUB_TOKEN checks (warnings, so existing workflows keep
|
|
71
|
+
* validating): jobs left on the default token, `write-all` at either level,
|
|
72
|
+
* and write scopes granted to every job at the workflow level.
|
|
73
|
+
*/
|
|
74
|
+
function workflowPermissionErrors(workflow) {
|
|
75
|
+
const errors = [];
|
|
76
|
+
const uncovered = uncoveredJobs(workflow);
|
|
77
|
+
if (uncovered.length > 0) {
|
|
78
|
+
errors.push(finding('permissions', `No top-level permissions: and job(s) ${quoteList(uncovered)} declare none, so their GITHUB_TOKEN gets the repository's default permissions (read/write on every scope in many repositories). Add a top-level block such as "permissions: { contents: read }", or declare permissions on each job`, 'warning'));
|
|
79
|
+
}
|
|
80
|
+
const top = workflow.permissions;
|
|
81
|
+
if (top === 'write-all') {
|
|
82
|
+
errors.push(finding('permissions', 'permissions: write-all gives every job write access to every scope. Keep the top level read-only (e.g. "contents: read") and grant write scopes on the jobs that need them', 'warning'));
|
|
83
|
+
}
|
|
84
|
+
else if (isScopeMap(top)) {
|
|
85
|
+
for (const [scope, level] of Object.entries(top)) {
|
|
86
|
+
if (level !== 'write')
|
|
87
|
+
continue;
|
|
88
|
+
errors.push(finding(`permissions.${scope}`, `Workflow-level "${scope}: write" applies to every job. Declare it on the job(s) that need it instead (job-level permissions replace the top-level ones)`, 'warning'));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const [jobId, job] of Object.entries(workflow.jobs ?? {})) {
|
|
92
|
+
if (job?.permissions === 'write-all') {
|
|
93
|
+
errors.push(finding(`jobs.${jobId}.permissions`, `Job "${jobId}" uses permissions: write-all, giving its GITHUB_TOKEN write access to every scope. List only the scopes it needs`, 'warning'));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return errors;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Contract `permissions:` policy (errors). `required`: every job must have
|
|
100
|
+
* declared permissions, top-level or its own. `max`: a ceiling on the
|
|
101
|
+
* top-level block (unlisted scopes are capped at `none`); job-level
|
|
102
|
+
* permissions aren't capped, so write scopes go per job. A workflow whose
|
|
103
|
+
* only trigger is `workflow_call` needs no top-level block for either.
|
|
104
|
+
*/
|
|
105
|
+
function contractPermissionErrors(workflow, policy) {
|
|
106
|
+
const uncovered = uncoveredJobs(workflow);
|
|
107
|
+
const max = policy.max;
|
|
108
|
+
if (uncovered.length > 0 && (policy.required || max)) {
|
|
109
|
+
return [
|
|
110
|
+
finding('permissions', policy.required
|
|
111
|
+
? `The contract requires declared GITHUB_TOKEN permissions, but the workflow has no top-level permissions: and job(s) ${quoteList(uncovered)} declare none. Add a top-level permissions: block, or declare permissions on each job`
|
|
112
|
+
: `The contract caps workflow-level permissions at (${formatCeiling(max)}), but the workflow has no top-level permissions:, so job(s) ${quoteList(uncovered)} get the repository's default token permissions. Add a top-level block within the cap, or declare permissions on each job`, 'error')
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
if (!max)
|
|
116
|
+
return [];
|
|
117
|
+
const ceiling = (scope) => (hasOwn(max, scope) ? rankOf(max[scope]) : undefined) ?? RANK.none;
|
|
118
|
+
const top = workflow.permissions;
|
|
119
|
+
if (top === 'read-all' || top === 'write-all') {
|
|
120
|
+
const needed = top === 'read-all' ? RANK.read : RANK.write;
|
|
121
|
+
if (exports.GITHUB_TOKEN_SCOPES.some((scope) => ceiling(scope) < needed)) {
|
|
122
|
+
return [
|
|
123
|
+
finding('permissions', `Workflow-level "${top}" exceeds the contract's cap on workflow-level permissions (${formatCeiling(max)}). List only the scopes the cap allows, and grant the rest on the jobs that need them`, 'error')
|
|
124
|
+
];
|
|
125
|
+
}
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const errors = [];
|
|
129
|
+
if (isScopeMap(top)) {
|
|
130
|
+
for (const [scope, level] of Object.entries(top)) {
|
|
131
|
+
const rank = rankOf(level);
|
|
132
|
+
if (rank !== undefined && rank > ceiling(scope)) {
|
|
133
|
+
errors.push(finding(`permissions.${scope}`, `Workflow-level "${scope}: ${level}" exceeds the contract's cap on workflow-level permissions (${formatCeiling(max)}). Grant it on the job(s) that need it instead`, 'error'));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return errors;
|
|
138
|
+
}
|
|
139
|
+
function formatCeiling(max) {
|
|
140
|
+
const entries = Object.entries(max);
|
|
141
|
+
return entries.length === 0
|
|
142
|
+
? 'none'
|
|
143
|
+
: entries.map(([scope, level]) => `${scope}: ${level}`).join(', ');
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=permissions.js.map
|
|
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.parsePipelineContract = parsePipelineContract;
|
|
37
37
|
const fs = __importStar(require("fs"));
|
|
38
38
|
const YAML = __importStar(require("yaml"));
|
|
39
|
+
const permissions_1 = require("./permissions");
|
|
40
|
+
const advisories_1 = require("../advisories");
|
|
39
41
|
/**
|
|
40
42
|
* Parse a pipeline contract file (`secureci validate --contract <file>`).
|
|
41
43
|
*
|
|
@@ -48,6 +50,10 @@ const YAML = __importStar(require("yaml"));
|
|
|
48
50
|
* - uses: org/deploy
|
|
49
51
|
* forbidden:
|
|
50
52
|
* - uses: org/legacy-deploy
|
|
53
|
+
* forbidden-triggers:
|
|
54
|
+
* - pull_request_target
|
|
55
|
+
* advisories:
|
|
56
|
+
* fail-on: high # critical | high (default) | medium | low
|
|
51
57
|
* ```
|
|
52
58
|
*/
|
|
53
59
|
function parsePipelineContract(filePath) {
|
|
@@ -111,11 +117,75 @@ function parsePipelineContract(filePath) {
|
|
|
111
117
|
result.lockfile.required = lockfile.required;
|
|
112
118
|
}
|
|
113
119
|
}
|
|
120
|
+
if (spec.permissions !== undefined) {
|
|
121
|
+
result.permissions = parsePermissionsPolicy(spec.permissions);
|
|
122
|
+
}
|
|
123
|
+
if (spec.advisories !== undefined) {
|
|
124
|
+
if (typeof spec.advisories !== 'object' ||
|
|
125
|
+
spec.advisories === null ||
|
|
126
|
+
Array.isArray(spec.advisories)) {
|
|
127
|
+
throw new Error('Contract "advisories" must be a mapping');
|
|
128
|
+
}
|
|
129
|
+
const advisories = spec.advisories;
|
|
130
|
+
result.advisories = {};
|
|
131
|
+
if (advisories['fail-on'] !== undefined) {
|
|
132
|
+
const level = String(advisories['fail-on']).toLowerCase();
|
|
133
|
+
if (!advisories_1.FAIL_ON_LEVELS.includes(level)) {
|
|
134
|
+
throw new Error(`Contract "advisories.fail-on" must be one of ${advisories_1.FAIL_ON_LEVELS.join(', ')}, got "${String(advisories['fail-on'])}"`);
|
|
135
|
+
}
|
|
136
|
+
result.advisories.failOn = level;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
114
139
|
if (spec['allowed-owners'] !== undefined) {
|
|
115
140
|
result.allowedOwners = parseStringList(spec['allowed-owners'], 'allowed-owners');
|
|
116
141
|
}
|
|
142
|
+
if (spec['forbidden-triggers'] !== undefined) {
|
|
143
|
+
result.forbiddenTriggers = parseStringList(spec['forbidden-triggers'], 'forbidden-triggers');
|
|
144
|
+
}
|
|
117
145
|
return result;
|
|
118
146
|
}
|
|
147
|
+
const isMapping = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
148
|
+
/**
|
|
149
|
+
* ```yaml
|
|
150
|
+
* permissions:
|
|
151
|
+
* required: true # every workflow (or job) declares permissions
|
|
152
|
+
* max: { contents: read } # ceiling for workflow-level permissions
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
function parsePermissionsPolicy(value) {
|
|
156
|
+
if (!isMapping(value)) {
|
|
157
|
+
throw new Error('Contract "permissions" must be a mapping');
|
|
158
|
+
}
|
|
159
|
+
for (const key of Object.keys(value)) {
|
|
160
|
+
if (key !== 'required' && key !== 'max') {
|
|
161
|
+
throw new Error(`Contract "permissions" has an unknown key "${key}" (expected "required" or "max")`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const policy = {};
|
|
165
|
+
if (value.required !== undefined) {
|
|
166
|
+
if (typeof value.required !== 'boolean') {
|
|
167
|
+
throw new Error('Contract "permissions.required" must be true or false');
|
|
168
|
+
}
|
|
169
|
+
policy.required = value.required;
|
|
170
|
+
}
|
|
171
|
+
if (value.max !== undefined) {
|
|
172
|
+
if (!isMapping(value.max)) {
|
|
173
|
+
throw new Error('Contract "permissions.max" must be a mapping of scope to read, write or none');
|
|
174
|
+
}
|
|
175
|
+
const max = {};
|
|
176
|
+
for (const [scope, level] of Object.entries(value.max)) {
|
|
177
|
+
if (!permissions_1.GITHUB_TOKEN_SCOPES.includes(scope)) {
|
|
178
|
+
throw new Error(`Contract "permissions.max" has an unknown scope "${scope}" (expected one of: ${permissions_1.GITHUB_TOKEN_SCOPES.join(', ')})`);
|
|
179
|
+
}
|
|
180
|
+
if (level !== 'read' && level !== 'write' && level !== 'none') {
|
|
181
|
+
throw new Error(`Contract "permissions.max.${scope}" must be "read", "write" or "none", got "${String(level)}"`);
|
|
182
|
+
}
|
|
183
|
+
max[scope] = level;
|
|
184
|
+
}
|
|
185
|
+
policy.max = max;
|
|
186
|
+
}
|
|
187
|
+
return policy;
|
|
188
|
+
}
|
|
119
189
|
function parseStringList(value, section) {
|
|
120
190
|
if (!Array.isArray(value) || value.some((v) => typeof v !== 'string')) {
|
|
121
191
|
throw new Error(`Contract "${section}" must be a list of strings`);
|
|
@@ -38,9 +38,11 @@ const fs = __importStar(require("fs"));
|
|
|
38
38
|
const path = __importStar(require("path"));
|
|
39
39
|
const job_plan_1 = require("../runner/job-plan");
|
|
40
40
|
const workflow_parser_1 = require("./workflow-parser");
|
|
41
|
+
const dangerous_triggers_1 = require("./dangerous-triggers");
|
|
41
42
|
const repository_1 = require("../utils/repository");
|
|
42
43
|
const action_fetcher_1 = require("../runner/action-fetcher");
|
|
43
44
|
const github_lockfile_1 = require("../github-lockfile");
|
|
45
|
+
const permissions_1 = require("./permissions");
|
|
44
46
|
/** Maximum nesting of composite actions / reusable workflows. */
|
|
45
47
|
const MAX_DEPTH = 10;
|
|
46
48
|
/**
|
|
@@ -51,13 +53,13 @@ const MAX_DEPTH = 10;
|
|
|
51
53
|
* Composite actions (`uses: ./path`) and reusable workflows
|
|
52
54
|
* (`jobs.<id>.uses: ./path.yml`) are resolved recursively, so wrapping an
|
|
53
55
|
* action doesn't hide it: local ones by default, remote ones too with
|
|
54
|
-
* `recurse: 'all'
|
|
56
|
+
* `recurse: 'all'` (`offline`: only from the cache). Ordering follows
|
|
55
57
|
* what GitHub guarantees: steps of a job run in order, and a job runs after
|
|
56
58
|
* the jobs it (transitively) `needs:`; jobs with no such relationship may
|
|
57
59
|
* run in parallel, so neither is "before" the other.
|
|
58
60
|
*/
|
|
59
61
|
async function validatePipelineContract(parsed, spec, options = {}) {
|
|
60
|
-
const graph = await buildGraph(parsed, options.recurse ?? 'local');
|
|
62
|
+
const graph = await buildGraph(parsed, options.recurse ?? 'local', options.offline ?? false);
|
|
61
63
|
const { sequence, nodes } = graph;
|
|
62
64
|
const errors = [...graph.errors];
|
|
63
65
|
const ordering = spec.ordering ?? 'relative';
|
|
@@ -199,6 +201,16 @@ async function validatePipelineContract(parsed, spec, options = {}) {
|
|
|
199
201
|
if (spec.lockfile?.required) {
|
|
200
202
|
errors.push(...checkLockfile(parsed, sequence.filter(isRemote)));
|
|
201
203
|
}
|
|
204
|
+
// The workflow file's own permissions; called workflows are validated as
|
|
205
|
+
// files of their own.
|
|
206
|
+
if (spec.permissions) {
|
|
207
|
+
errors.push(...(0, permissions_1.contractPermissionErrors)(parsed.file, spec.permissions));
|
|
208
|
+
}
|
|
209
|
+
// Only the workflow's own on: matters: a called reusable workflow runs
|
|
210
|
+
// under its caller's event.
|
|
211
|
+
if (spec.forbiddenTriggers) {
|
|
212
|
+
errors.push(...(0, dangerous_triggers_1.forbiddenTriggerErrors)(parsed.file, spec.forbiddenTriggers));
|
|
213
|
+
}
|
|
202
214
|
return {
|
|
203
215
|
valid: errors.every((e) => e.severity !== 'error'),
|
|
204
216
|
errors
|
|
@@ -260,7 +272,7 @@ function needsTransitively(nodes, from, to) {
|
|
|
260
272
|
}
|
|
261
273
|
return false;
|
|
262
274
|
}
|
|
263
|
-
async function buildGraph(parsed, recurse) {
|
|
275
|
+
async function buildGraph(parsed, recurse, offline) {
|
|
264
276
|
const graph = { sequence: [], nodes: new Map(), errors: [] };
|
|
265
277
|
const root = (0, repository_1.repositoryRoot)(parsed.path);
|
|
266
278
|
const isLocal = (uses) => uses.startsWith('./') || uses.startsWith('../');
|
|
@@ -295,7 +307,9 @@ async function buildGraph(parsed, recurse) {
|
|
|
295
307
|
}
|
|
296
308
|
if (recurse !== 'all' || uses.startsWith('docker://'))
|
|
297
309
|
return undefined;
|
|
298
|
-
const file = kind === 'action'
|
|
310
|
+
const file = kind === 'action'
|
|
311
|
+
? await (0, action_fetcher_1.fetchAction)(uses, { offline })
|
|
312
|
+
: await (0, action_fetcher_1.fetchRepoFile)(uses, { offline });
|
|
299
313
|
if (!file) {
|
|
300
314
|
graph.errors.push({
|
|
301
315
|
path: at,
|
package/dist/parser/validator.js
CHANGED
|
@@ -1,11 +1,50 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.workflowExpressionErrors = workflowExpressionErrors;
|
|
4
37
|
exports.actionExpressionErrors = actionExpressionErrors;
|
|
5
38
|
exports.validateWorkflow = validateWorkflow;
|
|
39
|
+
exports.mergeValidationResults = mergeValidationResults;
|
|
6
40
|
exports.validateAction = validateAction;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
7
42
|
const workflow_parser_1 = require("./workflow-parser");
|
|
8
43
|
const expression_1 = require("../runner/expression");
|
|
44
|
+
const injection_1 = require("./injection");
|
|
45
|
+
const permissions_1 = require("./permissions");
|
|
46
|
+
const dangerous_triggers_1 = require("./dangerous-triggers");
|
|
47
|
+
const allow_comments_1 = require("./allow-comments");
|
|
9
48
|
/**
|
|
10
49
|
* Invalid `if:` expressions in a workflow: syntax errors, or contexts and
|
|
11
50
|
* functions not available where they're used. GitHub rejects such a
|
|
@@ -120,6 +159,9 @@ function validateWorkflow(filePath) {
|
|
|
120
159
|
});
|
|
121
160
|
}
|
|
122
161
|
errors.push(...workflowExpressionErrors(workflow));
|
|
162
|
+
errors.push(...(0, injection_1.workflowInjectionErrors)(workflow, filePath));
|
|
163
|
+
errors.push(...(0, permissions_1.workflowPermissionErrors)(workflow));
|
|
164
|
+
errors.push(...(0, dangerous_triggers_1.dangerousTriggerErrors)(workflow, (0, allow_comments_1.stepAllowComments)(fs.readFileSync(filePath, 'utf-8'), 'dangerous-trigger')));
|
|
123
165
|
}
|
|
124
166
|
catch (error) {
|
|
125
167
|
errors.push({
|
|
@@ -129,10 +171,27 @@ function validateWorkflow(filePath) {
|
|
|
129
171
|
});
|
|
130
172
|
}
|
|
131
173
|
return {
|
|
132
|
-
valid: errors.
|
|
174
|
+
valid: errors.every((e) => e.severity !== 'error'),
|
|
133
175
|
errors
|
|
134
176
|
};
|
|
135
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Combine validation results (e.g. syntax checks and a contract). A warning
|
|
180
|
+
* is dropped when an error with the same rule and path supersedes it, such
|
|
181
|
+
* as a default permissions warning the contract turns into an error.
|
|
182
|
+
*/
|
|
183
|
+
function mergeValidationResults(...results) {
|
|
184
|
+
const all = results.flatMap((r) => r.errors);
|
|
185
|
+
const superseding = new Set(all
|
|
186
|
+
.filter((e) => e.severity === 'error' && e.rule)
|
|
187
|
+
.map((e) => `${e.rule}\0${e.path}`));
|
|
188
|
+
return {
|
|
189
|
+
valid: results.every((r) => r.valid),
|
|
190
|
+
errors: all.filter((e) => e.severity !== 'warning' ||
|
|
191
|
+
!e.rule ||
|
|
192
|
+
!superseding.has(`${e.rule}\0${e.path}`))
|
|
193
|
+
};
|
|
194
|
+
}
|
|
136
195
|
/**
|
|
137
196
|
* Validate an action file
|
|
138
197
|
*/
|
|
@@ -201,6 +260,7 @@ function validateAction(filePath) {
|
|
|
201
260
|
}
|
|
202
261
|
});
|
|
203
262
|
errors.push(...actionExpressionErrors(action));
|
|
263
|
+
errors.push(...(0, injection_1.actionInjectionErrors)(action, filePath));
|
|
204
264
|
}
|
|
205
265
|
}
|
|
206
266
|
}
|