@secureci/cli 0.6.0 → 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 +10 -8
- package/dist/cli.js +8 -5
- package/dist/parser/allow-comments.js +113 -0
- package/dist/parser/dangerous-triggers.js +43 -5
- package/dist/parser/validator.js +36 -1
- package/dist/report.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -16,14 +16,16 @@ of [SecureCI](https://secure-ci.dev).
|
|
|
16
16
|
|
|
17
17
|
A single CLI that understands GitHub Actions the way the runner does:
|
|
18
18
|
|
|
19
|
-
- **Run workflows locally** — jobs in `needs:` order
|
|
20
|
-
workflows, composite / JavaScript / Docker actions, and the full
|
|
19
|
+
- **Run workflows locally** — jobs in `needs:` order (independent jobs and
|
|
20
|
+
matrix combinations in parallel), matrix builds, reusable workflows, composite / JavaScript / Docker actions, and the full
|
|
21
21
|
`${{ }}` / `if:` expression language.
|
|
22
22
|
- **Validate** workflow and action files, flag **template injection**
|
|
23
|
-
(untrusted `${{ }}` input expanded into `run:` scripts)
|
|
24
|
-
`GITHUB_TOKEN` permissions
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
(untrusted `${{ }}` input expanded into `run:` scripts), over-broad
|
|
24
|
+
`GITHUB_TOKEN` permissions and `pull_request_target` / `workflow_run` jobs
|
|
25
|
+
that run untrusted code (noting whether `actions/checkout`'s safe default
|
|
26
|
+
covers the checkout), and **check them against a pipeline contract**
|
|
27
|
+
(required/forbidden actions, ordering, minimum versions, SHA-pinning,
|
|
28
|
+
permissions, forbidden triggers and advisory severity rules).
|
|
27
29
|
- **Inventory** every action a workflow pulls in, resolve each tag to a commit
|
|
28
30
|
SHA, and **catch supply-chain drift** when a tag quietly starts pointing
|
|
29
31
|
somewhere new.
|
|
@@ -235,9 +237,9 @@ the feature named, and none runs at install time.
|
|
|
235
237
|
| Capability | Where | Why |
|
|
236
238
|
| ------------------------ | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
237
239
|
| Shell / process spawning | `run:` steps, JavaScript actions, `docker`/`podman`/`nerdctl` | Runs workflow steps in a container (or on your host with `--container-runtime host`). `commander` also loads `child_process`, used only for executable subcommands this CLI does not use. |
|
|
238
|
-
| Network | `github.com`, `api.github.com` (or the GHES host you set) | Downloads actions and workflows,
|
|
240
|
+
| Network | `github.com`, `api.github.com` (or the GHES host you set) | Downloads actions and workflows, resolves tags to commit SHAs, and looks up security advisories for the actions you use (skipped with `--offline`). |
|
|
239
241
|
| Environment variables | `HOME`, `PATH`, `GITHUB_*`, `GITHUB_TOKEN`, `GH_TOKEN` | Builds the runner environment and authenticates GitHub downloads. |
|
|
240
|
-
| Filesystem | `~/.secureci/`, the working directory, temp dirs | Caches actions, stores artifacts and config, and reads your workflow files.
|
|
242
|
+
| Filesystem | `~/.secureci/`, the working directory, temp dirs | Caches actions and advisories, stores artifacts and config, and reads your workflow files. |
|
|
241
243
|
| Hard-coded URLs | GitHub documentation and JSON Schema URLs in messages and editor hints | Informational only; the CLI does not fetch them. |
|
|
242
244
|
|
|
243
245
|
No install scripts run for consumers. The published tarball contains only
|
package/dist/cli.js
CHANGED
|
@@ -795,13 +795,16 @@ async function validateFiles(files, contractFile, recurse, format, offline) {
|
|
|
795
795
|
return;
|
|
796
796
|
}
|
|
797
797
|
for (const { file, result } of entries) {
|
|
798
|
-
if (result.valid)
|
|
798
|
+
if (result.valid)
|
|
799
799
|
logger_1.logger.success(`Validation passed: ${file}`);
|
|
800
|
-
|
|
801
|
-
}
|
|
802
|
-
else {
|
|
800
|
+
else
|
|
803
801
|
logger_1.logger.error(`Validation failed: ${file}`);
|
|
804
|
-
|
|
802
|
+
for (const error of result.errors) {
|
|
803
|
+
const line = ` [${error.severity.toUpperCase()}] ${error.path}: ${error.message}`;
|
|
804
|
+
if (error.severity === 'warning')
|
|
805
|
+
logger_1.logger.warn(line);
|
|
806
|
+
else
|
|
807
|
+
logger_1.logger.error(line);
|
|
805
808
|
}
|
|
806
809
|
}
|
|
807
810
|
if (!allValid)
|
|
@@ -0,0 +1,113 @@
|
|
|
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.stepAllowComments = stepAllowComments;
|
|
37
|
+
const YAML = __importStar(require("yaml"));
|
|
38
|
+
/** `# secureci-allow <rule>` with an optional `: <reason>`. */
|
|
39
|
+
function directive(rule) {
|
|
40
|
+
const name = rule.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
41
|
+
return new RegExp(`^#\\s*secureci-allow\\s+${name}(?=\\s|:|$)(?::(.*))?$`);
|
|
42
|
+
}
|
|
43
|
+
/** Source offset of every comment, mapped to its text (`# ...`). */
|
|
44
|
+
function commentOffsets(content) {
|
|
45
|
+
const comments = new Map();
|
|
46
|
+
let offset = 0;
|
|
47
|
+
for (const token of new YAML.Lexer().lex(content)) {
|
|
48
|
+
const type = YAML.CST.tokenType(token);
|
|
49
|
+
if (type === 'comment')
|
|
50
|
+
comments.set(offset, token);
|
|
51
|
+
// Control tokens mark the parser's state; they aren't in the source.
|
|
52
|
+
if (type !== 'doc-mode' && type !== 'flow-error-end' && type !== 'scalar') {
|
|
53
|
+
offset += token.length;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return comments;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Steps carrying a `# secureci-allow <rule>: <reason>` comment, as
|
|
60
|
+
* `jobs.<id>.steps[<i>]` paths mapped to the reason ('' when it's missing).
|
|
61
|
+
* The comment goes at the end of the step's first line (`- uses: ...`) or
|
|
62
|
+
* on a line of its own directly above the step; comments separated by a
|
|
63
|
+
* blank line, or inside the step, don't count.
|
|
64
|
+
*/
|
|
65
|
+
function stepAllowComments(content, rule) {
|
|
66
|
+
const allowed = new Map();
|
|
67
|
+
const doc = YAML.parseDocument(content);
|
|
68
|
+
if (doc.errors.length > 0)
|
|
69
|
+
return allowed;
|
|
70
|
+
const jobs = doc.get('jobs');
|
|
71
|
+
if (!YAML.isMap(jobs))
|
|
72
|
+
return allowed;
|
|
73
|
+
const comments = commentOffsets(content);
|
|
74
|
+
const pattern = directive(rule);
|
|
75
|
+
const lineStart = (offset) => content.lastIndexOf('\n', offset - 1) + 1;
|
|
76
|
+
for (const pair of jobs.items) {
|
|
77
|
+
const steps = YAML.isMap(pair.value) ? pair.value.get('steps') : undefined;
|
|
78
|
+
if (!YAML.isSeq(steps))
|
|
79
|
+
continue;
|
|
80
|
+
steps.items.forEach((step, i) => {
|
|
81
|
+
if (!YAML.isNode(step) || !step.range)
|
|
82
|
+
return;
|
|
83
|
+
const start = step.range[0];
|
|
84
|
+
const lineEnd = content.indexOf('\n', start);
|
|
85
|
+
const texts = [];
|
|
86
|
+
for (const [offset, text] of comments) {
|
|
87
|
+
if (offset >= start && (lineEnd === -1 || offset < lineEnd)) {
|
|
88
|
+
texts.push(text);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Whole-line comments directly above the step.
|
|
92
|
+
let line = lineStart(start);
|
|
93
|
+
while (line > 0) {
|
|
94
|
+
const above = lineStart(line - 1);
|
|
95
|
+
const indent = content.slice(above, line).search(/\S/);
|
|
96
|
+
const text = indent === -1 ? undefined : comments.get(above + indent);
|
|
97
|
+
if (text === undefined)
|
|
98
|
+
break;
|
|
99
|
+
texts.push(text);
|
|
100
|
+
line = above;
|
|
101
|
+
}
|
|
102
|
+
for (const text of texts) {
|
|
103
|
+
const match = pattern.exec(text.trimEnd());
|
|
104
|
+
if (match) {
|
|
105
|
+
allowed.set(`jobs.${String(pair.key)}.steps[${i}]`, (match[1] ?? '').trim());
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
return allowed;
|
|
112
|
+
}
|
|
113
|
+
//# sourceMappingURL=allow-comments.js.map
|
|
@@ -4,6 +4,8 @@ exports.workflowTriggers = workflowTriggers;
|
|
|
4
4
|
exports.dangerousTriggerErrors = dangerousTriggerErrors;
|
|
5
5
|
exports.forbiddenTriggerErrors = forbiddenTriggerErrors;
|
|
6
6
|
const RULE = 'secureci/dangerous-trigger';
|
|
7
|
+
/** The comment that downgrades a finding on a checkout step. */
|
|
8
|
+
const ALLOW = '# secureci-allow dangerous-trigger';
|
|
7
9
|
/**
|
|
8
10
|
* Triggers whose runs get the base repository's secrets and a read/write
|
|
9
11
|
* `GITHUB_TOKEN` even when the change came from a fork.
|
|
@@ -51,6 +53,30 @@ function untrustedCheckoutInput(step) {
|
|
|
51
53
|
}
|
|
52
54
|
return undefined;
|
|
53
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Whether `actions/checkout` itself refuses the untrusted checkout: since
|
|
58
|
+
* June 2026 it won't check out a fork's code under these triggers unless
|
|
59
|
+
* `allow-unsafe-pr-checkout` is set. v7 has it, and floating major tags
|
|
60
|
+
* (`@v4`) and branches got it backported; SHA, minor or patch pins and v1
|
|
61
|
+
* did not.
|
|
62
|
+
*/
|
|
63
|
+
function checkoutProtection(step) {
|
|
64
|
+
const optOut = step.with?.['allow-unsafe-pr-checkout'];
|
|
65
|
+
if (String(optOut).toLowerCase() === 'true') {
|
|
66
|
+
return (`allow-unsafe-pr-checkout: true explicitly turns off actions/checkout's refusal to ` +
|
|
67
|
+
`check out a fork's code under these triggers`);
|
|
68
|
+
}
|
|
69
|
+
const version = step.uses?.split('@')[1];
|
|
70
|
+
if (version !== undefined &&
|
|
71
|
+
(/^[0-9a-f]{40}$/i.test(version) ||
|
|
72
|
+
/^v\d+\.\d+/.test(version) ||
|
|
73
|
+
/^v1$/.test(version))) {
|
|
74
|
+
return (`the safe default of actions/checkout does not apply to @${version} ` +
|
|
75
|
+
`(only v7, floating major tags like @v4 and branches have it), so the checkout succeeds`);
|
|
76
|
+
}
|
|
77
|
+
return (`actions/checkout refuses this checkout by default, so the step fails until someone ` +
|
|
78
|
+
`sets allow-unsafe-pr-checkout: true, which would run the fork's code`);
|
|
79
|
+
}
|
|
54
80
|
/** How a step would execute checked-out code, or undefined if it doesn't. */
|
|
55
81
|
function executes(step) {
|
|
56
82
|
if (step.run)
|
|
@@ -72,8 +98,12 @@ function executes(step) {
|
|
|
72
98
|
* - warning: a privileged trigger with no such job, once per trigger, so
|
|
73
99
|
* teams can audit it. Omitted when a job-level finding is reported, so
|
|
74
100
|
* a workflow is never reported twice for the same problem.
|
|
101
|
+
*
|
|
102
|
+
* `allowed` maps checkout step paths with a `# secureci-allow
|
|
103
|
+
* dangerous-trigger: <reason>` comment to the reason (see
|
|
104
|
+
* stepAllowComments); a reason downgrades that job's error to a warning.
|
|
75
105
|
*/
|
|
76
|
-
function dangerousTriggerErrors(workflow) {
|
|
106
|
+
function dangerousTriggerErrors(workflow, allowed = new Map()) {
|
|
77
107
|
const triggers = workflowTriggers(workflow.on).filter((t) => PRIVILEGED_TRIGGERS.includes(t));
|
|
78
108
|
if (triggers.length === 0)
|
|
79
109
|
return [];
|
|
@@ -87,16 +117,24 @@ function dangerousTriggerErrors(workflow) {
|
|
|
87
117
|
const runIdx = steps.findIndex((s, i) => i > checkoutIdx && executes(s) !== undefined);
|
|
88
118
|
if (runIdx === -1)
|
|
89
119
|
continue;
|
|
120
|
+
const path = `jobs.${jobId}.steps[${checkoutIdx}]`;
|
|
90
121
|
const gated = LABEL_GATE.test(String(job.if ?? ''));
|
|
122
|
+
const reason = allowed.get(path);
|
|
91
123
|
findings.push({
|
|
92
|
-
path
|
|
124
|
+
path,
|
|
93
125
|
message: `Job "${jobId}" checks out untrusted code (${untrustedCheckoutInput(steps[checkoutIdx])}) ` +
|
|
94
126
|
`in a workflow triggered by ${named}, then executes it at steps[${runIdx}] (${executes(steps[runIdx])}). ` +
|
|
95
|
-
`These triggers run with the repository's secrets and a write token, so a fork can run its own code with them` +
|
|
127
|
+
`These triggers run with the repository's secrets and a write token, so a fork can run its own code with them; ` +
|
|
128
|
+
`${checkoutProtection(steps[checkoutIdx])}` +
|
|
96
129
|
(gated
|
|
97
130
|
? `. The job is gated by a label check in its if:, so review the pull request's code before applying the label`
|
|
98
|
-
:
|
|
99
|
-
|
|
131
|
+
: `. Check out the base ref instead, or move the build to an unprivileged pull_request workflow`) +
|
|
132
|
+
(reason
|
|
133
|
+
? `. Downgraded to a warning by "${ALLOW}" on this step: ${reason}`
|
|
134
|
+
: reason === ''
|
|
135
|
+
? `. The "${ALLOW}" comment on this step needs a reason (${ALLOW}: <reason>) to downgrade this to a warning`
|
|
136
|
+
: ''),
|
|
137
|
+
severity: gated || reason ? 'warning' : 'error',
|
|
100
138
|
rule: RULE
|
|
101
139
|
});
|
|
102
140
|
}
|
package/dist/parser/validator.js
CHANGED
|
@@ -1,15 +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;
|
|
6
39
|
exports.mergeValidationResults = mergeValidationResults;
|
|
7
40
|
exports.validateAction = validateAction;
|
|
41
|
+
const fs = __importStar(require("fs"));
|
|
8
42
|
const workflow_parser_1 = require("./workflow-parser");
|
|
9
43
|
const expression_1 = require("../runner/expression");
|
|
10
44
|
const injection_1 = require("./injection");
|
|
11
45
|
const permissions_1 = require("./permissions");
|
|
12
46
|
const dangerous_triggers_1 = require("./dangerous-triggers");
|
|
47
|
+
const allow_comments_1 = require("./allow-comments");
|
|
13
48
|
/**
|
|
14
49
|
* Invalid `if:` expressions in a workflow: syntax errors, or contexts and
|
|
15
50
|
* functions not available where they're used. GitHub rejects such a
|
|
@@ -126,7 +161,7 @@ function validateWorkflow(filePath) {
|
|
|
126
161
|
errors.push(...workflowExpressionErrors(workflow));
|
|
127
162
|
errors.push(...(0, injection_1.workflowInjectionErrors)(workflow, filePath));
|
|
128
163
|
errors.push(...(0, permissions_1.workflowPermissionErrors)(workflow));
|
|
129
|
-
errors.push(...(0, dangerous_triggers_1.dangerousTriggerErrors)(workflow));
|
|
164
|
+
errors.push(...(0, dangerous_triggers_1.dangerousTriggerErrors)(workflow, (0, allow_comments_1.stepAllowComments)(fs.readFileSync(filePath, 'utf-8'), 'dangerous-trigger')));
|
|
130
165
|
}
|
|
131
166
|
catch (error) {
|
|
132
167
|
errors.push({
|
package/dist/report.js
CHANGED