@secureci/cli 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -11
- package/dist/advisories.js +603 -0
- package/dist/cli.js +365 -63
- package/dist/config.js +23 -1
- package/dist/init.js +12 -5
- package/dist/inventory.js +1 -0
- package/dist/parser/dangerous-triggers.js +125 -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 +26 -1
- package/dist/pin.js +311 -0
- package/dist/report.js +7 -1
- package/dist/runner/action-fetcher.js +137 -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 +53 -7
- package/dist/utils/progress.js +58 -0
- package/dist/version.js +56 -0
- package/package.json +3 -1
|
@@ -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
|
@@ -3,9 +3,13 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.workflowExpressionErrors = workflowExpressionErrors;
|
|
4
4
|
exports.actionExpressionErrors = actionExpressionErrors;
|
|
5
5
|
exports.validateWorkflow = validateWorkflow;
|
|
6
|
+
exports.mergeValidationResults = mergeValidationResults;
|
|
6
7
|
exports.validateAction = validateAction;
|
|
7
8
|
const workflow_parser_1 = require("./workflow-parser");
|
|
8
9
|
const expression_1 = require("../runner/expression");
|
|
10
|
+
const injection_1 = require("./injection");
|
|
11
|
+
const permissions_1 = require("./permissions");
|
|
12
|
+
const dangerous_triggers_1 = require("./dangerous-triggers");
|
|
9
13
|
/**
|
|
10
14
|
* Invalid `if:` expressions in a workflow: syntax errors, or contexts and
|
|
11
15
|
* functions not available where they're used. GitHub rejects such a
|
|
@@ -120,6 +124,9 @@ function validateWorkflow(filePath) {
|
|
|
120
124
|
});
|
|
121
125
|
}
|
|
122
126
|
errors.push(...workflowExpressionErrors(workflow));
|
|
127
|
+
errors.push(...(0, injection_1.workflowInjectionErrors)(workflow, filePath));
|
|
128
|
+
errors.push(...(0, permissions_1.workflowPermissionErrors)(workflow));
|
|
129
|
+
errors.push(...(0, dangerous_triggers_1.dangerousTriggerErrors)(workflow));
|
|
123
130
|
}
|
|
124
131
|
catch (error) {
|
|
125
132
|
errors.push({
|
|
@@ -129,10 +136,27 @@ function validateWorkflow(filePath) {
|
|
|
129
136
|
});
|
|
130
137
|
}
|
|
131
138
|
return {
|
|
132
|
-
valid: errors.
|
|
139
|
+
valid: errors.every((e) => e.severity !== 'error'),
|
|
133
140
|
errors
|
|
134
141
|
};
|
|
135
142
|
}
|
|
143
|
+
/**
|
|
144
|
+
* Combine validation results (e.g. syntax checks and a contract). A warning
|
|
145
|
+
* is dropped when an error with the same rule and path supersedes it, such
|
|
146
|
+
* as a default permissions warning the contract turns into an error.
|
|
147
|
+
*/
|
|
148
|
+
function mergeValidationResults(...results) {
|
|
149
|
+
const all = results.flatMap((r) => r.errors);
|
|
150
|
+
const superseding = new Set(all
|
|
151
|
+
.filter((e) => e.severity === 'error' && e.rule)
|
|
152
|
+
.map((e) => `${e.rule}\0${e.path}`));
|
|
153
|
+
return {
|
|
154
|
+
valid: results.every((r) => r.valid),
|
|
155
|
+
errors: all.filter((e) => e.severity !== 'warning' ||
|
|
156
|
+
!e.rule ||
|
|
157
|
+
!superseding.has(`${e.rule}\0${e.path}`))
|
|
158
|
+
};
|
|
159
|
+
}
|
|
136
160
|
/**
|
|
137
161
|
* Validate an action file
|
|
138
162
|
*/
|
|
@@ -201,6 +225,7 @@ function validateAction(filePath) {
|
|
|
201
225
|
}
|
|
202
226
|
});
|
|
203
227
|
errors.push(...actionExpressionErrors(action));
|
|
228
|
+
errors.push(...(0, injection_1.actionInjectionErrors)(action, filePath));
|
|
204
229
|
}
|
|
205
230
|
}
|
|
206
231
|
}
|
package/dist/pin.js
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
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
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.findUsesSites = findUsesSites;
|
|
37
|
+
exports.isPinAllowed = isPinAllowed;
|
|
38
|
+
exports.pinContent = pinContent;
|
|
39
|
+
exports.localPinTargets = localPinTargets;
|
|
40
|
+
const fs = __importStar(require("fs"));
|
|
41
|
+
const path = __importStar(require("path"));
|
|
42
|
+
const YAML = __importStar(require("yaml"));
|
|
43
|
+
const repository_1 = require("./utils/repository");
|
|
44
|
+
const FULL_SHA = /^[0-9a-f]{40}$/i;
|
|
45
|
+
/**
|
|
46
|
+
* Every `uses:` a runner reads: `jobs.<id>.uses` (reusable workflows),
|
|
47
|
+
* `jobs.<id>.steps[*].uses` and a composite action's `runs.steps[*].uses`.
|
|
48
|
+
* Read from the YAML syntax tree, so `uses:` inside a script or an input
|
|
49
|
+
* isn't mistaken for one, with source offsets for in-place edits.
|
|
50
|
+
*/
|
|
51
|
+
function findUsesSites(content) {
|
|
52
|
+
const doc = parseYaml(content);
|
|
53
|
+
const root = doc.contents;
|
|
54
|
+
if (!YAML.isMap(root))
|
|
55
|
+
return [];
|
|
56
|
+
const nodes = [];
|
|
57
|
+
const usesOf = (node) => {
|
|
58
|
+
if (!YAML.isMap(node))
|
|
59
|
+
return;
|
|
60
|
+
const uses = node.get('uses', true);
|
|
61
|
+
if (YAML.isScalar(uses))
|
|
62
|
+
nodes.push(uses);
|
|
63
|
+
};
|
|
64
|
+
const stepsOf = (node) => {
|
|
65
|
+
if (!YAML.isMap(node))
|
|
66
|
+
return;
|
|
67
|
+
const steps = node.get('steps', true);
|
|
68
|
+
if (YAML.isSeq(steps))
|
|
69
|
+
steps.items.forEach(usesOf);
|
|
70
|
+
};
|
|
71
|
+
const jobs = root.get('jobs', true);
|
|
72
|
+
if (YAML.isMap(jobs)) {
|
|
73
|
+
for (const pair of jobs.items) {
|
|
74
|
+
usesOf(pair.value);
|
|
75
|
+
stepsOf(pair.value);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
stepsOf(root.get('runs', true));
|
|
79
|
+
return nodes
|
|
80
|
+
.filter((node) => node.range && typeof node.value === 'string')
|
|
81
|
+
.map((node) => {
|
|
82
|
+
const [start, end] = node.range;
|
|
83
|
+
return {
|
|
84
|
+
line: content.slice(0, start).split('\n').length,
|
|
85
|
+
uses: node.value,
|
|
86
|
+
start,
|
|
87
|
+
end,
|
|
88
|
+
style: scalarStyle(node.type)
|
|
89
|
+
};
|
|
90
|
+
})
|
|
91
|
+
.sort((a, b) => a.start - b.start);
|
|
92
|
+
}
|
|
93
|
+
function parseYaml(content) {
|
|
94
|
+
const doc = YAML.parseDocument(content);
|
|
95
|
+
if (doc.errors.length > 0) {
|
|
96
|
+
throw new Error(`File is not valid YAML: ${doc.errors[0].message}`);
|
|
97
|
+
}
|
|
98
|
+
return doc;
|
|
99
|
+
}
|
|
100
|
+
function scalarStyle(type) {
|
|
101
|
+
if (type === 'QUOTE_SINGLE')
|
|
102
|
+
return 'single';
|
|
103
|
+
if (type === 'QUOTE_DOUBLE')
|
|
104
|
+
return 'double';
|
|
105
|
+
if (type === 'BLOCK_LITERAL' || type === 'BLOCK_FOLDED')
|
|
106
|
+
return 'block';
|
|
107
|
+
return 'plain';
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Whether a contract's `pins.allow` exempts a reference from pinning: an
|
|
111
|
+
* owner, or the exact action name, case-insensitively for owner and repo.
|
|
112
|
+
* This mirrors the contract's own pins check, so `pin` never leaves a
|
|
113
|
+
* reference on a tag that `validate --contract` would then reject.
|
|
114
|
+
*/
|
|
115
|
+
function isPinAllowed(uses, allow) {
|
|
116
|
+
const at = uses.lastIndexOf('@');
|
|
117
|
+
const name = normalizeName(at === -1 ? uses : uses.slice(0, at));
|
|
118
|
+
const owner = name.split('/')[0];
|
|
119
|
+
const allowed = new Set(allow.map(normalizeName));
|
|
120
|
+
return allowed.has(name) || allowed.has(owner);
|
|
121
|
+
}
|
|
122
|
+
function normalizeName(name) {
|
|
123
|
+
const parts = name.split('/');
|
|
124
|
+
return [
|
|
125
|
+
...parts.slice(0, 2).map((p) => p.toLowerCase()),
|
|
126
|
+
...parts.slice(2)
|
|
127
|
+
].join('/');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Pin every remote `uses:` reference in a workflow or action file to the
|
|
131
|
+
* commit SHA `resolve` returns, keeping the tag in a trailing comment
|
|
132
|
+
* (`owner/repo@<sha> # v4`) so Dependabot and Renovate can still update
|
|
133
|
+
* it. The text is edited in place (only the value and its comment change),
|
|
134
|
+
* never re-serialised, so formatting and other comments survive.
|
|
135
|
+
*
|
|
136
|
+
* Left alone: local (`./`) and `docker://` references, references already
|
|
137
|
+
* pinned to a full SHA, and those `allow` exempts. A reference that can't
|
|
138
|
+
* be resolved is reported in `unresolved` and not changed.
|
|
139
|
+
*/
|
|
140
|
+
async function pinContent(content, resolve, options = {}) {
|
|
141
|
+
const sites = findUsesSites(content);
|
|
142
|
+
const result = {
|
|
143
|
+
content,
|
|
144
|
+
changes: [],
|
|
145
|
+
unresolved: [],
|
|
146
|
+
allowed: []
|
|
147
|
+
};
|
|
148
|
+
const lineCounts = new Map();
|
|
149
|
+
sites.forEach((s) => lineCounts.set(s.line, (lineCounts.get(s.line) ?? 0) + 1));
|
|
150
|
+
const resolved = new Map();
|
|
151
|
+
const edits = [];
|
|
152
|
+
for (const site of sites) {
|
|
153
|
+
const { uses, line } = site;
|
|
154
|
+
if (/^(\.\.?\/|docker:\/\/)/.test(uses))
|
|
155
|
+
continue;
|
|
156
|
+
const at = uses.lastIndexOf('@');
|
|
157
|
+
const name = at === -1 ? '' : uses.slice(0, at);
|
|
158
|
+
const ref = uses.slice(at + 1);
|
|
159
|
+
const parts = name.split('/');
|
|
160
|
+
if (at === -1 || !ref || parts.length < 2 || parts.some((p) => !p)) {
|
|
161
|
+
result.unresolved.push({
|
|
162
|
+
line,
|
|
163
|
+
uses,
|
|
164
|
+
reason: 'is not an owner/repo@ref reference'
|
|
165
|
+
});
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (FULL_SHA.test(ref))
|
|
169
|
+
continue;
|
|
170
|
+
if (isPinAllowed(uses, options.allow ?? [])) {
|
|
171
|
+
result.allowed.push({ line, uses });
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (site.style === 'block') {
|
|
175
|
+
result.unresolved.push({
|
|
176
|
+
line,
|
|
177
|
+
uses,
|
|
178
|
+
reason: 'is written as a block scalar; pin it by hand'
|
|
179
|
+
});
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (!resolved.has(uses))
|
|
183
|
+
resolved.set(uses, resolve(uses));
|
|
184
|
+
const sha = (await resolved.get(uses))?.toLowerCase();
|
|
185
|
+
if (!sha || !FULL_SHA.test(sha)) {
|
|
186
|
+
result.unresolved.push({
|
|
187
|
+
line,
|
|
188
|
+
uses,
|
|
189
|
+
reason: 'could not be resolved to a commit'
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const pinned = `${name}@${sha}`;
|
|
194
|
+
const quote = site.style === 'single' ? "'" : site.style === 'double' ? '"' : '';
|
|
195
|
+
edits.push({
|
|
196
|
+
start: site.start,
|
|
197
|
+
end: site.end,
|
|
198
|
+
text: `${quote}${pinned}${quote}`
|
|
199
|
+
});
|
|
200
|
+
// Several references on one line (a flow sequence) can't each have a
|
|
201
|
+
// trailing comment, so none is written rather than a wrong one.
|
|
202
|
+
if (lineCounts.get(line) === 1) {
|
|
203
|
+
edits.push(commentEdit(content, site.end, ref));
|
|
204
|
+
}
|
|
205
|
+
result.changes.push({ line, uses, ref, sha, pinned });
|
|
206
|
+
}
|
|
207
|
+
result.content = edits
|
|
208
|
+
.sort((a, b) => b.start - a.start)
|
|
209
|
+
.reduce((text, e) => text.slice(0, e.start) + e.text + text.slice(e.end), content);
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* The edit that leaves `# <ref>` as the comment of the line a value ends
|
|
214
|
+
* on: an old version comment is replaced, any other comment kept after
|
|
215
|
+
* the ref, and with no comment one is added at the end of the line.
|
|
216
|
+
*/
|
|
217
|
+
function commentEdit(content, valueEnd, ref) {
|
|
218
|
+
let lineEnd = content.indexOf('\n', valueEnd);
|
|
219
|
+
if (lineEnd === -1)
|
|
220
|
+
lineEnd = content.length;
|
|
221
|
+
if (content[lineEnd - 1] === '\r')
|
|
222
|
+
lineEnd--;
|
|
223
|
+
const tail = content.slice(valueEnd, lineEnd);
|
|
224
|
+
const hash = commentStart(tail);
|
|
225
|
+
if (hash === -1) {
|
|
226
|
+
const at = valueEnd + tail.trimEnd().length;
|
|
227
|
+
return { start: at, end: at, text: ` # ${ref}` };
|
|
228
|
+
}
|
|
229
|
+
const body = tail.slice(hash + 1).trim();
|
|
230
|
+
return {
|
|
231
|
+
start: valueEnd + hash,
|
|
232
|
+
end: lineEnd,
|
|
233
|
+
text: `# ${ref}${commentRest(body, ref)}`
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
/** Where a YAML comment starts in the rest of a line, or -1. */
|
|
237
|
+
function commentStart(tail) {
|
|
238
|
+
let quote;
|
|
239
|
+
for (let i = 0; i < tail.length; i++) {
|
|
240
|
+
const c = tail[i];
|
|
241
|
+
if (quote) {
|
|
242
|
+
if (c === '\\' && quote === '"')
|
|
243
|
+
i++;
|
|
244
|
+
else if (c === quote)
|
|
245
|
+
quote = undefined;
|
|
246
|
+
}
|
|
247
|
+
else if (c === "'" || c === '"') {
|
|
248
|
+
quote = c;
|
|
249
|
+
}
|
|
250
|
+
else if (c === '#' && i > 0 && /\s/.test(tail[i - 1])) {
|
|
251
|
+
return i;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return -1;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* What to keep of an existing comment after the new `# <ref>`: the text
|
|
258
|
+
* after an old version (`v3`, `3.2.1`, `tag=v3`, `pin @v3`, or the old
|
|
259
|
+
* ref itself), else the whole comment.
|
|
260
|
+
*/
|
|
261
|
+
function commentRest(body, ref) {
|
|
262
|
+
if (!body)
|
|
263
|
+
return '';
|
|
264
|
+
const version = body.match(/^(?:tag\s*=\s*|pin\s*@?\s*)?@?v?\d[\w.+-]*(.*)$/i);
|
|
265
|
+
const rest = version
|
|
266
|
+
? version[1]
|
|
267
|
+
: body === ref || body.startsWith(`${ref} `)
|
|
268
|
+
? body.slice(ref.length)
|
|
269
|
+
: undefined;
|
|
270
|
+
if (rest === undefined)
|
|
271
|
+
return ` - ${body}`;
|
|
272
|
+
return rest.trim() ? ` ${rest.trim()}` : '';
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The given files plus the local composite actions (`./path`) and local
|
|
276
|
+
* reusable workflows they use, recursively, so a whole repository is
|
|
277
|
+
* pinned in one go. `./` resolves against the repository root, as on
|
|
278
|
+
* GitHub. Files are listed once; one that can't be read is kept (for the
|
|
279
|
+
* caller to report) but not followed.
|
|
280
|
+
*/
|
|
281
|
+
function localPinTargets(files) {
|
|
282
|
+
const seen = new Set(files.map((f) => path.resolve(f)));
|
|
283
|
+
const result = [...files];
|
|
284
|
+
const follow = (file, root) => {
|
|
285
|
+
let sites;
|
|
286
|
+
try {
|
|
287
|
+
sites = findUsesSites(fs.readFileSync(file, 'utf8'));
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
for (const { uses } of sites) {
|
|
293
|
+
if (!uses.startsWith('./') && !uses.startsWith('../'))
|
|
294
|
+
continue;
|
|
295
|
+
const target = path.resolve(root, uses);
|
|
296
|
+
const local = [
|
|
297
|
+
target,
|
|
298
|
+
...['action.yml', 'action.yaml'].map((n) => path.join(target, n))
|
|
299
|
+
].find((c) => fs.existsSync(c) && fs.statSync(c).isFile());
|
|
300
|
+
if (!local || seen.has(local))
|
|
301
|
+
continue;
|
|
302
|
+
seen.add(local);
|
|
303
|
+
result.push(local);
|
|
304
|
+
follow(local, root);
|
|
305
|
+
}
|
|
306
|
+
};
|
|
307
|
+
for (const file of files)
|
|
308
|
+
follow(file, (0, repository_1.repositoryRoot)(file));
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
//# sourceMappingURL=pin.js.map
|
package/dist/report.js
CHANGED
|
@@ -4,6 +4,8 @@ exports.formatReport = formatReport;
|
|
|
4
4
|
exports.formatValidation = formatValidation;
|
|
5
5
|
/** A SARIF rule id per error, so code-scanning can group findings. */
|
|
6
6
|
function ruleIdFor(error) {
|
|
7
|
+
if (error.rule)
|
|
8
|
+
return error.rule;
|
|
7
9
|
if (/^required\[|^forbidden\[/.test(error.path))
|
|
8
10
|
return 'secureci/contract';
|
|
9
11
|
if (/\.if$|\bif:/.test(error.path))
|
|
@@ -13,7 +15,11 @@ function ruleIdFor(error) {
|
|
|
13
15
|
const RULES = {
|
|
14
16
|
'secureci/validation': 'Workflow or action structure',
|
|
15
17
|
'secureci/contract': 'Pipeline contract',
|
|
16
|
-
'secureci/expression': 'Expression / if: condition'
|
|
18
|
+
'secureci/expression': 'Expression / if: condition',
|
|
19
|
+
'secureci/injection': 'Untrusted input in a shell or script',
|
|
20
|
+
'secureci/permissions': 'GITHUB_TOKEN permissions',
|
|
21
|
+
'secureci/dangerous-trigger': 'Privileged trigger with untrusted code',
|
|
22
|
+
'secureci/vulnerable-action': 'Action with a known security advisory'
|
|
17
23
|
};
|
|
18
24
|
function jsonForFile(entry) {
|
|
19
25
|
return {
|