@secureci/cli 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +230 -2
- package/dist/cli.js +545 -0
- package/dist/config.js +190 -0
- package/dist/index.js +23 -0
- package/dist/init.js +45 -0
- package/dist/inventory.js +212 -0
- package/dist/parser/pipeline-contract-parser.js +131 -0
- package/dist/parser/pipeline-contract-validator.js +448 -0
- package/dist/parser/validator.js +232 -0
- package/dist/parser/workflow-parser.js +122 -0
- package/dist/report.js +75 -0
- package/dist/runner/action-fetcher.js +431 -0
- package/dist/runner/actions.js +495 -0
- package/dist/runner/builtin-actions.js +484 -0
- package/dist/runner/checkout.js +142 -0
- package/dist/runner/container-runtime.js +374 -0
- package/dist/runner/docker-action.js +179 -0
- package/dist/runner/dry-run.js +142 -0
- package/dist/runner/executor.js +554 -0
- package/dist/runner/expression.js +676 -0
- package/dist/runner/github-context.js +235 -0
- package/dist/runner/hash-files.js +123 -0
- package/dist/runner/job-container.js +322 -0
- package/dist/runner/job-plan.js +138 -0
- package/dist/runner/job-runtime.js +338 -0
- package/dist/runner/node-action.js +278 -0
- package/dist/runner/runner-env.js +303 -0
- package/dist/runner/shell.js +61 -0
- package/dist/runner/steps.js +250 -0
- package/dist/types/index.js +10 -0
- package/dist/utils/ansi.js +47 -0
- package/dist/utils/key-value.js +33 -0
- package/dist/utils/logger.js +84 -0
- package/dist/utils/repository.js +51 -0
- package/package.json +76 -4
package/dist/cli.js
ADDED
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
const commander_1 = require("commander");
|
|
38
|
+
const dry_run_1 = require("./runner/dry-run");
|
|
39
|
+
const executor_1 = require("./runner/executor");
|
|
40
|
+
const action_fetcher_1 = require("./runner/action-fetcher");
|
|
41
|
+
const validator_1 = require("./parser/validator");
|
|
42
|
+
const workflow_parser_1 = require("./parser/workflow-parser");
|
|
43
|
+
const pipeline_contract_parser_1 = require("./parser/pipeline-contract-parser");
|
|
44
|
+
const pipeline_contract_validator_1 = require("./parser/pipeline-contract-validator");
|
|
45
|
+
const report_1 = require("./report");
|
|
46
|
+
const init_1 = require("./init");
|
|
47
|
+
const inventory_1 = require("./inventory");
|
|
48
|
+
const logger_1 = require("./utils/logger");
|
|
49
|
+
const key_value_1 = require("./utils/key-value");
|
|
50
|
+
const config_1 = require("./config");
|
|
51
|
+
const fs = __importStar(require("fs"));
|
|
52
|
+
const path = __importStar(require("path"));
|
|
53
|
+
/** Committed at the repo root, like package-lock.json / yarn.lock. */
|
|
54
|
+
const LOCKFILE_NAME = 'secureci.lock';
|
|
55
|
+
function readVersion() {
|
|
56
|
+
try {
|
|
57
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
58
|
+
return String(pkg.version);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return '0.0.0';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const program = new commander_1.Command();
|
|
65
|
+
const globalConfig = (0, config_1.loadConfig)();
|
|
66
|
+
// An invalid container-runtime in the config file only stops `run`, so
|
|
67
|
+
// `secureci config set` can still fix it.
|
|
68
|
+
let configError;
|
|
69
|
+
let defaultContainerRuntime;
|
|
70
|
+
try {
|
|
71
|
+
defaultContainerRuntime = (0, config_1.getDefaultContainerRuntime)(globalConfig);
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
configError = error instanceof Error ? error.message : String(error);
|
|
75
|
+
defaultContainerRuntime = 'docker';
|
|
76
|
+
}
|
|
77
|
+
program
|
|
78
|
+
.name('secureci')
|
|
79
|
+
.description('SecureCI: validate, check and run GitHub Actions workflows locally')
|
|
80
|
+
.version(readVersion(), '-V, --version', 'Print the version')
|
|
81
|
+
.helpOption('-h, --help', 'Show help')
|
|
82
|
+
.addHelpCommand('help [command]', 'Show help for a command')
|
|
83
|
+
.option('--verbose', 'Log debug output')
|
|
84
|
+
.showHelpAfterError()
|
|
85
|
+
.hook('preAction', (thisCommand) => {
|
|
86
|
+
if (thisCommand.opts().verbose) {
|
|
87
|
+
logger_1.logger.setLevel(logger_1.LogLevel.DEBUG);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
function requireFile(file) {
|
|
91
|
+
if (!fs.existsSync(file)) {
|
|
92
|
+
logger_1.logger.error(`File not found: ${file}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
program
|
|
97
|
+
.command('run')
|
|
98
|
+
.description('Run a workflow (or composite action) on this machine, each job in a container')
|
|
99
|
+
.argument('<file>', 'Path to a workflow or action YAML file')
|
|
100
|
+
.option('-d, --dry-run', 'Show what would run, without executing anything')
|
|
101
|
+
.option('-i, --input <key=value...>', 'Set an input for the inputs context. Repeatable')
|
|
102
|
+
.option('-s, --secret <key=value|NAME...>', 'Set a secret for the secrets context. A bare NAME reads $NAME from your shell, keeping the value off the command line. Repeatable')
|
|
103
|
+
.option('--env <key=value|NAME...>', 'Set an env var for every step, below workflow/job/step env:. A bare NAME passes $NAME from your shell (steps get nothing else from it). Repeatable')
|
|
104
|
+
.option('--var <key=value...>', 'Set a configuration variable for the vars context. Repeatable')
|
|
105
|
+
.option('--event-path <file>', 'Read the event payload (github.event, $GITHUB_EVENT_PATH) from a JSON file')
|
|
106
|
+
.option('--event-name <name>', 'Set github.event_name. Default: $GITHUB_EVENT_NAME, else workflow_dispatch if declared, else the first trigger')
|
|
107
|
+
.option('-j, --job <name>', 'Run only this job')
|
|
108
|
+
.option('--offline', 'Use only cached remote actions and workflows; never download')
|
|
109
|
+
.option('--no-local-checkout', 'Run the real actions/checkout for this repository instead of using the working copy')
|
|
110
|
+
.addOption(new commander_1.Option('--container-runtime <name>', 'Container engine for jobs. "host" runs steps directly on this machine, without a container. Config: container-runtime')
|
|
111
|
+
.choices(['auto', 'docker', 'podman', 'containerd', 'host'])
|
|
112
|
+
.default(defaultContainerRuntime))
|
|
113
|
+
.option('--container-image <image>', 'Use this image for every job without jobs.<id>.container, instead of the image for its runs-on (config: runner-images, container-image)')
|
|
114
|
+
.option('--container-timeout <ms>', 'Fail a step that runs longer than this, in milliseconds (container mode). Config: container-timeout', globalConfig['container-timeout'] !== undefined
|
|
115
|
+
? String(globalConfig['container-timeout'])
|
|
116
|
+
: undefined)
|
|
117
|
+
.option('--container-memory <limit>', 'Limit job container memory, e.g. 512m. Config: container-memory', globalConfig['container-memory'])
|
|
118
|
+
.option('--no-network', 'Turn off container network access (on by default)')
|
|
119
|
+
.option('--allow-network', 'Turn on container network access, overriding allow-network: false in the config')
|
|
120
|
+
.action(async (file, options) => {
|
|
121
|
+
if (configError) {
|
|
122
|
+
logger_1.logger.error(configError);
|
|
123
|
+
process.exit(1);
|
|
124
|
+
}
|
|
125
|
+
requireFile(file);
|
|
126
|
+
if (options.dryRun) {
|
|
127
|
+
const result = (0, dry_run_1.dryRun)(file);
|
|
128
|
+
if (!result.success) {
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
logger_1.logger.info("Running the workflow for real (not a dry run). Use --dry-run to simulate without executing, or 'secureci validate' to only check for errors.");
|
|
134
|
+
try {
|
|
135
|
+
const inputs = (0, key_value_1.parseKeyValuePairs)(options.input, '--input');
|
|
136
|
+
const secrets = (0, key_value_1.parseKeyValuePairs)(options.secret, '--secret', {
|
|
137
|
+
redact: true,
|
|
138
|
+
fromEnv: process.env
|
|
139
|
+
});
|
|
140
|
+
const vars = (0, key_value_1.parseKeyValuePairs)(options.var, '--var');
|
|
141
|
+
const env = (0, key_value_1.parseKeyValuePairs)(options.env, '--env', {
|
|
142
|
+
fromEnv: process.env
|
|
143
|
+
});
|
|
144
|
+
const result = await (0, executor_1.executeWorkflow)(file, {
|
|
145
|
+
dryRun: false,
|
|
146
|
+
inputs,
|
|
147
|
+
secrets,
|
|
148
|
+
vars,
|
|
149
|
+
env,
|
|
150
|
+
eventPath: options.eventPath,
|
|
151
|
+
eventName: options.eventName,
|
|
152
|
+
job: options.job,
|
|
153
|
+
localCheckout: options.localCheckout,
|
|
154
|
+
offline: options.offline,
|
|
155
|
+
verbose: program.opts().verbose,
|
|
156
|
+
containerRuntime: options.containerRuntime,
|
|
157
|
+
containerImage: options.containerImage,
|
|
158
|
+
runnerImages: globalConfig['runner-images'],
|
|
159
|
+
defaultContainerImage: globalConfig['container-image'],
|
|
160
|
+
containerTimeoutMs: options.containerTimeout
|
|
161
|
+
? Number(options.containerTimeout)
|
|
162
|
+
: undefined,
|
|
163
|
+
containerMemoryLimit: options.containerMemory,
|
|
164
|
+
networkDisabled: (0, config_1.resolveNetworkDisabled)({ network: options.network, allowNetwork: options.allowNetwork }, globalConfig)
|
|
165
|
+
});
|
|
166
|
+
if (!result.success) {
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
logger_1.logger.error(`Failed to process file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
172
|
+
process.exit(1);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
program
|
|
176
|
+
.command('validate')
|
|
177
|
+
.description('Validate one or more workflow or action files, optionally against a pipeline contract')
|
|
178
|
+
.argument('<file...>', 'Paths to workflow or action YAML files')
|
|
179
|
+
.option('-c, --contract <file>', 'Also check the workflow against a pipeline contract (required/forbidden actions, minimum versions, ordering)')
|
|
180
|
+
.addOption(new commander_1.Option('--contract-recurse <scope>', 'Composite actions / reusable workflows to look inside for --contract: none, local (./...) or all (also downloads remote ones)')
|
|
181
|
+
.choices(['none', 'local', 'all'])
|
|
182
|
+
.default('local'))
|
|
183
|
+
.addOption(new commander_1.Option('--format <format>', 'Output format: text (default), or json / sarif for machine-readable results (sarif uploads to GitHub code scanning)')
|
|
184
|
+
.choices(['text', 'json', 'sarif'])
|
|
185
|
+
.default('text'))
|
|
186
|
+
.action(async (files, options) => {
|
|
187
|
+
files.forEach(requireFile);
|
|
188
|
+
await validateFiles(files, options.contract, options.contractRecurse, options.format);
|
|
189
|
+
});
|
|
190
|
+
program
|
|
191
|
+
.command('inventory')
|
|
192
|
+
.description('List the actions and reusable workflows a workflow uses (resolved into called workflows/actions), with their commit SHAs; track supply-chain drift with a committed secureci.lock')
|
|
193
|
+
.argument('[file...]', 'Workflow files (default: every file in .github/workflows)')
|
|
194
|
+
.option('--offline', 'Do not resolve tags to commit SHAs over the network (already-pinned refs still resolve)')
|
|
195
|
+
.option('--dry-run', `Print the resolved inventory as JSON without writing or checking ${LOCKFILE_NAME}`)
|
|
196
|
+
.option('--update-lock', `Write/update ./${LOCKFILE_NAME} with the current commit SHAs (commit it to your repo)`)
|
|
197
|
+
.option('--update-lockfile', `Alias of --update-lock`)
|
|
198
|
+
.option('--check', `Compare the current commit SHAs against ./${LOCKFILE_NAME} and fail on drift`)
|
|
199
|
+
.option('--compare', `Alias of --check`)
|
|
200
|
+
.action(async (files, options) => {
|
|
201
|
+
await runInventory(files, options);
|
|
202
|
+
});
|
|
203
|
+
program
|
|
204
|
+
.command('list')
|
|
205
|
+
.description('List workflows in .github/workflows/')
|
|
206
|
+
.action(() => {
|
|
207
|
+
listWorkflows();
|
|
208
|
+
});
|
|
209
|
+
const initCommand = program
|
|
210
|
+
.command('init')
|
|
211
|
+
.description('Scaffold SecureCI files (run a subcommand, e.g. `init workflow`)')
|
|
212
|
+
.action(() => {
|
|
213
|
+
// Bare `secureci init` is ambiguous about what it creates, so show help
|
|
214
|
+
// (and its subcommands) instead of generating anything.
|
|
215
|
+
initCommand.help();
|
|
216
|
+
});
|
|
217
|
+
initCommand
|
|
218
|
+
.command('workflow')
|
|
219
|
+
.description('Generate a GitHub Actions workflow (.github/workflows/secureci.yml) that runs SecureCI on pull requests')
|
|
220
|
+
.option('-c, --contract <file>', 'Check workflows against this pipeline contract in the generated workflow')
|
|
221
|
+
.option('--no-upload', 'Do not upload SARIF to code scanning (keeps contents: read only)')
|
|
222
|
+
.option('-o, --output <path>', 'Where to write the workflow', '.github/workflows/secureci.yml')
|
|
223
|
+
.option('-f, --force', 'Overwrite the output file if it already exists')
|
|
224
|
+
.action((options) => {
|
|
225
|
+
runInit(options);
|
|
226
|
+
});
|
|
227
|
+
function defaultWorkflowFiles() {
|
|
228
|
+
const dir = path.join(process.cwd(), '.github', 'workflows');
|
|
229
|
+
if (!fs.existsSync(dir))
|
|
230
|
+
return [];
|
|
231
|
+
return fs
|
|
232
|
+
.readdirSync(dir)
|
|
233
|
+
.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'))
|
|
234
|
+
.map((f) => path.join('.github', 'workflows', f));
|
|
235
|
+
}
|
|
236
|
+
async function runInventory(files, options) {
|
|
237
|
+
const update = options.updateLock || options.updateLockfile;
|
|
238
|
+
const check = options.check || options.compare;
|
|
239
|
+
if (update && check) {
|
|
240
|
+
logger_1.logger.error('Use either --update-lock or --check, not both.');
|
|
241
|
+
process.exit(1);
|
|
242
|
+
}
|
|
243
|
+
const targets = files.length > 0 ? files : defaultWorkflowFiles();
|
|
244
|
+
if (targets.length === 0) {
|
|
245
|
+
logger_1.logger.error('No workflow files given and none found in .github/workflows');
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
targets.forEach(requireFile);
|
|
249
|
+
const resolver = options.offline
|
|
250
|
+
? undefined
|
|
251
|
+
: (uses) => (0, action_fetcher_1.resolveActionSha)(uses);
|
|
252
|
+
// Merge every file's remote actions by `uses`.
|
|
253
|
+
const byUses = new Map();
|
|
254
|
+
for (const file of targets) {
|
|
255
|
+
try {
|
|
256
|
+
const parsed = (0, workflow_parser_1.parseFile)(file);
|
|
257
|
+
if (parsed.type !== 'workflow')
|
|
258
|
+
continue;
|
|
259
|
+
const inv = await (0, inventory_1.buildInventory)({ type: 'workflow', path: file, file: parsed.file }, resolver);
|
|
260
|
+
for (const action of inv.actions) {
|
|
261
|
+
const existing = byUses.get(action.uses);
|
|
262
|
+
if (existing)
|
|
263
|
+
existing.paths.push(...action.paths);
|
|
264
|
+
else
|
|
265
|
+
byUses.set(action.uses, { ...action });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
logger_1.logger.error(`Could not read ${file}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
270
|
+
process.exit(1);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const actions = [...byUses.values()].sort((a, b) => a.uses.localeCompare(b.uses));
|
|
274
|
+
const current = {};
|
|
275
|
+
for (const action of actions) {
|
|
276
|
+
if (action.resolvedSha)
|
|
277
|
+
current[action.uses] = action.resolvedSha;
|
|
278
|
+
}
|
|
279
|
+
// --dry-run: print the resolved inventory as JSON, touching nothing.
|
|
280
|
+
if (options.dryRun) {
|
|
281
|
+
// eslint-disable-next-line no-console
|
|
282
|
+
console.log(JSON.stringify({ actions }, null, 2));
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const lockPath = path.join(process.cwd(), LOCKFILE_NAME);
|
|
286
|
+
const lockExists = fs.existsSync(lockPath);
|
|
287
|
+
const writeLock = () => {
|
|
288
|
+
const updated = { version: 1, actions: current };
|
|
289
|
+
fs.writeFileSync(lockPath, JSON.stringify(updated, null, 2) + '\n');
|
|
290
|
+
logger_1.logger.success(`Wrote ${Object.keys(current).length} action(s) to ${LOCKFILE_NAME}. Commit it to track supply-chain drift.`);
|
|
291
|
+
};
|
|
292
|
+
// `--update-lock`, or bare `inventory` when no lockfile exists yet: write it.
|
|
293
|
+
if (update || (!check && !lockExists)) {
|
|
294
|
+
writeLock();
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// Otherwise compare (explicit `--check`, or bare `inventory` with a lockfile).
|
|
298
|
+
if (!lockExists) {
|
|
299
|
+
logger_1.logger.error(`No ${LOCKFILE_NAME} found. Create one with: secureci inventory --update-lock`);
|
|
300
|
+
process.exit(1);
|
|
301
|
+
}
|
|
302
|
+
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
|
303
|
+
const diff = (0, inventory_1.diffLock)(current, lock);
|
|
304
|
+
for (const d of diff.drifted) {
|
|
305
|
+
logger_1.logger.error(`Drift: ${d.uses} now resolves to ${d.to.slice(0, 12)} (was ${d.from.slice(0, 12)})`);
|
|
306
|
+
}
|
|
307
|
+
diff.added.forEach((u) => logger_1.logger.warn(`New action not in ${LOCKFILE_NAME}: ${u}`));
|
|
308
|
+
diff.removed.forEach((u) => logger_1.logger.info(`In ${LOCKFILE_NAME} but no longer used: ${u}`));
|
|
309
|
+
if (diff.drifted.length > 0) {
|
|
310
|
+
logger_1.logger.error(`Action commits drifted from ${LOCKFILE_NAME}. Review the change, then run with --update-lock to accept it.`);
|
|
311
|
+
process.exit(1);
|
|
312
|
+
}
|
|
313
|
+
if (diff.added.length > 0) {
|
|
314
|
+
logger_1.logger.warn(`New actions are not yet in ${LOCKFILE_NAME} (run --update-lock).`);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
logger_1.logger.success(`No action drift against ${LOCKFILE_NAME}.`);
|
|
318
|
+
}
|
|
319
|
+
async function validateOneFile(filePath, contractFile, recurse, say) {
|
|
320
|
+
const parsed = (0, workflow_parser_1.parseFile)(filePath);
|
|
321
|
+
let result;
|
|
322
|
+
if (parsed.type === 'workflow') {
|
|
323
|
+
result = (0, validator_1.validateWorkflow)(filePath);
|
|
324
|
+
say(() => logger_1.logger.info(`Validating workflow: ${filePath}`));
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
result = (0, validator_1.validateAction)(filePath);
|
|
328
|
+
say(() => logger_1.logger.info(`Validating action: ${filePath}`));
|
|
329
|
+
}
|
|
330
|
+
if (contractFile) {
|
|
331
|
+
say(() => logger_1.logger.info(`Validating against pipeline contract: ${contractFile}`));
|
|
332
|
+
if (parsed.type !== 'workflow') {
|
|
333
|
+
result = {
|
|
334
|
+
valid: false,
|
|
335
|
+
errors: [
|
|
336
|
+
...result.errors,
|
|
337
|
+
{
|
|
338
|
+
path: 'root',
|
|
339
|
+
message: 'Pipeline contracts validate job/step sequences and only apply to workflow files, not actions',
|
|
340
|
+
severity: 'error'
|
|
341
|
+
}
|
|
342
|
+
]
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
else {
|
|
346
|
+
const spec = (0, pipeline_contract_parser_1.parsePipelineContract)(contractFile);
|
|
347
|
+
const contractResult = await (0, pipeline_contract_validator_1.validatePipelineContract)(parsed, spec, {
|
|
348
|
+
recurse
|
|
349
|
+
});
|
|
350
|
+
result = {
|
|
351
|
+
valid: result.valid && contractResult.valid,
|
|
352
|
+
errors: [...result.errors, ...contractResult.errors]
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return result;
|
|
357
|
+
}
|
|
358
|
+
async function validateFiles(files, contractFile, recurse = 'local', format = 'text') {
|
|
359
|
+
// Only text mode narrates; json/sarif write nothing but the report to
|
|
360
|
+
// stdout, so the output is pipeable.
|
|
361
|
+
const quiet = format !== 'text';
|
|
362
|
+
const say = (fn) => {
|
|
363
|
+
if (!quiet)
|
|
364
|
+
fn();
|
|
365
|
+
};
|
|
366
|
+
const entries = [];
|
|
367
|
+
for (const filePath of files) {
|
|
368
|
+
try {
|
|
369
|
+
const result = await validateOneFile(filePath, contractFile, recurse, say);
|
|
370
|
+
entries.push({ file: filePath, result });
|
|
371
|
+
}
|
|
372
|
+
catch (error) {
|
|
373
|
+
entries.push({
|
|
374
|
+
file: filePath,
|
|
375
|
+
result: {
|
|
376
|
+
valid: false,
|
|
377
|
+
errors: [
|
|
378
|
+
{
|
|
379
|
+
path: 'root',
|
|
380
|
+
message: error instanceof Error ? error.message : 'Unknown error',
|
|
381
|
+
severity: 'error'
|
|
382
|
+
}
|
|
383
|
+
]
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const allValid = entries.every((e) => e.result.valid);
|
|
389
|
+
if (quiet) {
|
|
390
|
+
// eslint-disable-next-line no-console
|
|
391
|
+
console.log((0, report_1.formatReport)(entries, format));
|
|
392
|
+
if (!allValid)
|
|
393
|
+
process.exit(1);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
for (const { file, result } of entries) {
|
|
397
|
+
if (result.valid) {
|
|
398
|
+
logger_1.logger.success(`Validation passed: ${file}`);
|
|
399
|
+
result.errors.forEach((error) => logger_1.logger.warn(` [${error.severity.toUpperCase()}] ${error.path}: ${error.message}`));
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
logger_1.logger.error(`Validation failed: ${file}`);
|
|
403
|
+
result.errors.forEach((error) => logger_1.logger.error(` [${error.severity.toUpperCase()}] ${error.path}: ${error.message}`));
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (!allValid)
|
|
407
|
+
process.exit(1);
|
|
408
|
+
}
|
|
409
|
+
function runInit(options) {
|
|
410
|
+
const output = path.resolve(process.cwd(), options.output);
|
|
411
|
+
if (fs.existsSync(output) && !options.force) {
|
|
412
|
+
logger_1.logger.error(`${options.output} already exists; pass --force to overwrite it.`);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
}
|
|
415
|
+
const content = (0, init_1.renderInitWorkflow)({
|
|
416
|
+
contract: options.contract,
|
|
417
|
+
upload: options.upload
|
|
418
|
+
});
|
|
419
|
+
fs.mkdirSync(path.dirname(output), { recursive: true });
|
|
420
|
+
fs.writeFileSync(output, content);
|
|
421
|
+
logger_1.logger.success(`Wrote ${options.output}`);
|
|
422
|
+
if (options.upload) {
|
|
423
|
+
logger_1.logger.info('The workflow uploads SARIF to code scanning (needs security-events: write).');
|
|
424
|
+
}
|
|
425
|
+
logger_1.logger.info('Commit it and open a pull request to see SecureCI run.');
|
|
426
|
+
}
|
|
427
|
+
function listWorkflows() {
|
|
428
|
+
const workflowsDir = path.join(process.cwd(), '.github', 'workflows');
|
|
429
|
+
if (!fs.existsSync(workflowsDir)) {
|
|
430
|
+
logger_1.logger.warn('No .github/workflows directory found in current directory');
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const files = fs
|
|
434
|
+
.readdirSync(workflowsDir)
|
|
435
|
+
.filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
|
|
436
|
+
if (files.length === 0) {
|
|
437
|
+
logger_1.logger.info('No workflow files found');
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
logger_1.logger.info('Available workflows:');
|
|
441
|
+
files.forEach((file) => {
|
|
442
|
+
const filePath = path.join(workflowsDir, file);
|
|
443
|
+
try {
|
|
444
|
+
const parsed = (0, workflow_parser_1.parseFile)(filePath);
|
|
445
|
+
if (parsed.type === 'workflow') {
|
|
446
|
+
logger_1.logger.info(` - ${file}: ${parsed.file.name}`);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
logger_1.logger.warn(` - ${file}: (failed to parse)`);
|
|
451
|
+
}
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
const configCmd = program
|
|
455
|
+
.command('config')
|
|
456
|
+
.description('Manage global configuration (~/.secureci/config.yml)');
|
|
457
|
+
configCmd
|
|
458
|
+
.command('path')
|
|
459
|
+
.description('Print the config file path')
|
|
460
|
+
.action(() => {
|
|
461
|
+
logger_1.logger.info((0, config_1.getConfigFilePath)());
|
|
462
|
+
});
|
|
463
|
+
configCmd
|
|
464
|
+
.command('list')
|
|
465
|
+
.description('List config values, including built-in defaults')
|
|
466
|
+
.action(() => {
|
|
467
|
+
const config = (0, config_1.loadConfig)();
|
|
468
|
+
for (const key of config_1.CONFIG_KEYS) {
|
|
469
|
+
const value = config[key];
|
|
470
|
+
const fallback = config_1.DEFAULT_CONFIG[key];
|
|
471
|
+
logger_1.logger.info(`${key}=${value !== undefined
|
|
472
|
+
? value
|
|
473
|
+
: fallback !== undefined
|
|
474
|
+
? `${fallback} (default)`
|
|
475
|
+
: '(unset)'}`);
|
|
476
|
+
}
|
|
477
|
+
for (const [label, image] of Object.entries(config['runner-images'] ?? {})) {
|
|
478
|
+
logger_1.logger.info(`runner-images.${label}=${image}`);
|
|
479
|
+
}
|
|
480
|
+
});
|
|
481
|
+
configCmd
|
|
482
|
+
.command('get <key>')
|
|
483
|
+
.description('Print a config value')
|
|
484
|
+
.action((key) => {
|
|
485
|
+
if (!(0, config_1.isValidConfigKey)(key)) {
|
|
486
|
+
logger_1.logger.error(`Unknown config key "${key}". Valid keys: ${config_1.CONFIG_KEYS.join(', ')}, runner-images.<label>`);
|
|
487
|
+
process.exit(1);
|
|
488
|
+
}
|
|
489
|
+
const value = (0, config_1.getConfigValue)((0, config_1.loadConfig)(), key);
|
|
490
|
+
logger_1.logger.info(value === undefined ? '(unset)' : String(value));
|
|
491
|
+
});
|
|
492
|
+
configCmd
|
|
493
|
+
.command('set <key> <value>')
|
|
494
|
+
.description('Set a config value')
|
|
495
|
+
.action((key, value) => {
|
|
496
|
+
if (!(0, config_1.isValidConfigKey)(key)) {
|
|
497
|
+
logger_1.logger.error(`Unknown config key "${key}". Valid keys: ${config_1.CONFIG_KEYS.join(', ')}, runner-images.<label>`);
|
|
498
|
+
process.exit(1);
|
|
499
|
+
}
|
|
500
|
+
try {
|
|
501
|
+
const config = (0, config_1.loadConfig)();
|
|
502
|
+
const parsedValue = (0, config_1.setConfigValue)(config, key, value);
|
|
503
|
+
(0, config_1.saveConfig)(config);
|
|
504
|
+
logger_1.logger.success(`Set ${key}=${parsedValue} in ${(0, config_1.getConfigFilePath)()}`);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
logger_1.logger.error(error instanceof Error ? error.message : 'Invalid value');
|
|
508
|
+
process.exit(1);
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
configCmd
|
|
512
|
+
.command('unset <key>')
|
|
513
|
+
.description('Remove a config value, reverting to the built-in default')
|
|
514
|
+
.action((key) => {
|
|
515
|
+
if (!(0, config_1.isValidConfigKey)(key)) {
|
|
516
|
+
logger_1.logger.error(`Unknown config key "${key}". Valid keys: ${config_1.CONFIG_KEYS.join(', ')}, runner-images.<label>`);
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
const config = (0, config_1.loadConfig)();
|
|
520
|
+
(0, config_1.unsetConfigValue)(config, key);
|
|
521
|
+
(0, config_1.saveConfig)(config);
|
|
522
|
+
logger_1.logger.success(`Unset ${key} in ${(0, config_1.getConfigFilePath)()}`);
|
|
523
|
+
});
|
|
524
|
+
const cacheCmd = program
|
|
525
|
+
.command('cache')
|
|
526
|
+
.description('Manage the cache of downloaded remote actions');
|
|
527
|
+
cacheCmd
|
|
528
|
+
.command('path')
|
|
529
|
+
.description('Print the action cache directory')
|
|
530
|
+
.action(() => {
|
|
531
|
+
logger_1.logger.info((0, action_fetcher_1.getActionCacheDir)());
|
|
532
|
+
});
|
|
533
|
+
cacheCmd
|
|
534
|
+
.command('clear')
|
|
535
|
+
.description('Delete all cached remote actions')
|
|
536
|
+
.action(() => {
|
|
537
|
+
(0, action_fetcher_1.clearActionCache)();
|
|
538
|
+
});
|
|
539
|
+
// With no command, show help and fail, like other vendor CLIs.
|
|
540
|
+
if (process.argv.length <= 2) {
|
|
541
|
+
program.outputHelp({ error: true });
|
|
542
|
+
process.exit(1);
|
|
543
|
+
}
|
|
544
|
+
program.parse();
|
|
545
|
+
//# sourceMappingURL=cli.js.map
|