@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
|
@@ -0,0 +1,122 @@
|
|
|
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.parseWorkflow = parseWorkflow;
|
|
37
|
+
exports.parseAction = parseAction;
|
|
38
|
+
exports.parseFile = parseFile;
|
|
39
|
+
const fs = __importStar(require("fs"));
|
|
40
|
+
const YAML = __importStar(require("yaml"));
|
|
41
|
+
/**
|
|
42
|
+
* Parse a GitHub workflow YAML file
|
|
43
|
+
*/
|
|
44
|
+
function parseWorkflow(filePath) {
|
|
45
|
+
if (!fs.existsSync(filePath)) {
|
|
46
|
+
throw new Error(`Workflow file not found: ${filePath}`);
|
|
47
|
+
}
|
|
48
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
49
|
+
try {
|
|
50
|
+
const parsed = YAML.parse(content);
|
|
51
|
+
if (!parsed.name) {
|
|
52
|
+
throw new Error('Workflow must have a name');
|
|
53
|
+
}
|
|
54
|
+
if (!parsed.jobs || Object.keys(parsed.jobs).length === 0) {
|
|
55
|
+
throw new Error('Workflow must have at least one job');
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
type: 'workflow',
|
|
59
|
+
file: parsed,
|
|
60
|
+
path: filePath
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error instanceof Error) {
|
|
65
|
+
throw new Error(`Failed to parse workflow: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Parse a GitHub action YAML file
|
|
72
|
+
*/
|
|
73
|
+
function parseAction(filePath) {
|
|
74
|
+
if (!fs.existsSync(filePath)) {
|
|
75
|
+
throw new Error(`Action file not found: ${filePath}`);
|
|
76
|
+
}
|
|
77
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
78
|
+
try {
|
|
79
|
+
const parsed = YAML.parse(content);
|
|
80
|
+
if (!parsed.name) {
|
|
81
|
+
throw new Error('Action must have a name');
|
|
82
|
+
}
|
|
83
|
+
if (!parsed.description) {
|
|
84
|
+
throw new Error('Action must have a description');
|
|
85
|
+
}
|
|
86
|
+
if (!parsed.runs) {
|
|
87
|
+
throw new Error('Action must have a runs section');
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
type: 'action',
|
|
91
|
+
file: parsed,
|
|
92
|
+
path: filePath
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
if (error instanceof Error) {
|
|
97
|
+
throw new Error(`Failed to parse action: ${error.message}`);
|
|
98
|
+
}
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Auto-detect and parse either workflow or action file
|
|
104
|
+
*/
|
|
105
|
+
function parseFile(filePath) {
|
|
106
|
+
if (!fs.existsSync(filePath)) {
|
|
107
|
+
throw new Error(`File not found: ${filePath}`);
|
|
108
|
+
}
|
|
109
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
110
|
+
const parsed = YAML.parse(content);
|
|
111
|
+
// Detect if it's a workflow or action based on structure
|
|
112
|
+
if (parsed.jobs) {
|
|
113
|
+
return parseWorkflow(filePath);
|
|
114
|
+
}
|
|
115
|
+
else if (parsed.runs) {
|
|
116
|
+
return parseAction(filePath);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
throw new Error('Unable to determine file type. Must be either a workflow (with jobs) or action (with runs)');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=workflow-parser.js.map
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatReport = formatReport;
|
|
4
|
+
exports.formatValidation = formatValidation;
|
|
5
|
+
/** A SARIF rule id per error, so code-scanning can group findings. */
|
|
6
|
+
function ruleIdFor(error) {
|
|
7
|
+
if (/^required\[|^forbidden\[/.test(error.path))
|
|
8
|
+
return 'secureci/contract';
|
|
9
|
+
if (/\.if$|\bif:/.test(error.path))
|
|
10
|
+
return 'secureci/expression';
|
|
11
|
+
return 'secureci/validation';
|
|
12
|
+
}
|
|
13
|
+
const RULES = {
|
|
14
|
+
'secureci/validation': 'Workflow or action structure',
|
|
15
|
+
'secureci/contract': 'Pipeline contract',
|
|
16
|
+
'secureci/expression': 'Expression / if: condition'
|
|
17
|
+
};
|
|
18
|
+
function jsonForFile(entry) {
|
|
19
|
+
return {
|
|
20
|
+
file: entry.file,
|
|
21
|
+
valid: entry.result.valid,
|
|
22
|
+
errors: entry.result.errors.map((e) => ({
|
|
23
|
+
path: e.path,
|
|
24
|
+
message: e.message,
|
|
25
|
+
severity: e.severity
|
|
26
|
+
}))
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function sarifResults(entry) {
|
|
30
|
+
return entry.result.errors.map((error) => ({
|
|
31
|
+
ruleId: ruleIdFor(error),
|
|
32
|
+
level: error.severity === 'warning' ? 'warning' : 'error',
|
|
33
|
+
message: { text: `${error.path}: ${error.message}` },
|
|
34
|
+
locations: [{ physicalLocation: { artifactLocation: { uri: entry.file } } }]
|
|
35
|
+
}));
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Render one or more validated files for `--format`. `json` is a compact
|
|
39
|
+
* object (a single file keeps the flat `{ file, valid, errors }` shape; many
|
|
40
|
+
* files become `{ valid, files: [...] }`). `sarif` is one SARIF 2.1.0 run
|
|
41
|
+
* carrying every file's findings, suitable for GitHub code scanning (upload
|
|
42
|
+
* with `github/codeql-action/upload-sarif`).
|
|
43
|
+
*/
|
|
44
|
+
function formatReport(entries, format) {
|
|
45
|
+
if (format === 'json') {
|
|
46
|
+
const body = entries.length === 1
|
|
47
|
+
? jsonForFile(entries[0])
|
|
48
|
+
: {
|
|
49
|
+
valid: entries.every((e) => e.result.valid),
|
|
50
|
+
files: entries.map(jsonForFile)
|
|
51
|
+
};
|
|
52
|
+
return JSON.stringify(body, null, 2);
|
|
53
|
+
}
|
|
54
|
+
return JSON.stringify({
|
|
55
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
56
|
+
version: '2.1.0',
|
|
57
|
+
runs: [
|
|
58
|
+
{
|
|
59
|
+
tool: {
|
|
60
|
+
driver: {
|
|
61
|
+
name: 'secureci',
|
|
62
|
+
informationUri: 'https://www.secure-ci.dev',
|
|
63
|
+
rules: Object.entries(RULES).map(([id, name]) => ({ id, name }))
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
results: entries.flatMap(sarifResults)
|
|
67
|
+
}
|
|
68
|
+
]
|
|
69
|
+
}, null, 2);
|
|
70
|
+
}
|
|
71
|
+
/** Render a single validated file (see formatReport). */
|
|
72
|
+
function formatValidation(file, result, format) {
|
|
73
|
+
return formatReport([{ file, result }], format);
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=report.js.map
|
|
@@ -0,0 +1,431 @@
|
|
|
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.parseActionReference = parseActionReference;
|
|
37
|
+
exports.getActionCacheDir = getActionCacheDir;
|
|
38
|
+
exports.githubToken = githubToken;
|
|
39
|
+
exports._resetGithubTokenForTests = _resetGithubTokenForTests;
|
|
40
|
+
exports.lsRemoteSha = lsRemoteSha;
|
|
41
|
+
exports.fetchAction = fetchAction;
|
|
42
|
+
exports.fetchRepoFile = fetchRepoFile;
|
|
43
|
+
exports.clearActionCache = clearActionCache;
|
|
44
|
+
exports.resolveActionSha = resolveActionSha;
|
|
45
|
+
const fs = __importStar(require("fs"));
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
const http = __importStar(require("http"));
|
|
48
|
+
const https = __importStar(require("https"));
|
|
49
|
+
const util_1 = require("util");
|
|
50
|
+
const child_process_1 = require("child_process");
|
|
51
|
+
const logger_1 = require("../utils/logger");
|
|
52
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
53
|
+
/** Default connect/idle timeout for action downloads. */
|
|
54
|
+
const DOWNLOAD_TIMEOUT_MS = 30000;
|
|
55
|
+
const MAX_REDIRECTS = 5;
|
|
56
|
+
/**
|
|
57
|
+
* Parse an action reference like "actions/checkout@v3" or "owner/repo/path@ref"
|
|
58
|
+
*/
|
|
59
|
+
function parseActionReference(uses) {
|
|
60
|
+
// Format: owner/repo[@ref] or owner/repo/path[@ref]
|
|
61
|
+
const match = uses.match(/^([^/]+)\/([^/@]+)(?:\/([^@]+))?@(.+)$/);
|
|
62
|
+
if (!match) {
|
|
63
|
+
logger_1.logger.warn(`Invalid action reference format: ${uses}`);
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
const [, owner, repo, actionPath, ref] = match;
|
|
67
|
+
return {
|
|
68
|
+
owner,
|
|
69
|
+
repo,
|
|
70
|
+
ref,
|
|
71
|
+
path: actionPath || undefined
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
/** The cache directory for downloaded actions and repositories. */
|
|
75
|
+
function getActionCacheDir() {
|
|
76
|
+
const homeDir = process.env.HOME || process.env.USERPROFILE || '/tmp';
|
|
77
|
+
return path.join(homeDir, '.secureci', 'actions-cache');
|
|
78
|
+
}
|
|
79
|
+
let discoveredToken;
|
|
80
|
+
/**
|
|
81
|
+
* A GitHub token for downloading actions: $GITHUB_TOKEN, $GH_TOKEN, else
|
|
82
|
+
* `gh auth token`. Only ever sent to the GitHub API/server, never passed
|
|
83
|
+
* to actions, and masked in logs.
|
|
84
|
+
*/
|
|
85
|
+
function githubToken() {
|
|
86
|
+
if (discoveredToken !== undefined)
|
|
87
|
+
return discoveredToken;
|
|
88
|
+
let token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || '';
|
|
89
|
+
if (!token) {
|
|
90
|
+
try {
|
|
91
|
+
token = (0, child_process_1.execFileSync)('gh', ['auth', 'token'], {
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
94
|
+
timeout: 3000
|
|
95
|
+
}).trim();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
token = '';
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
discoveredToken = token || null;
|
|
102
|
+
if (token)
|
|
103
|
+
logger_1.logger.registerSecrets({ 'github-token': token });
|
|
104
|
+
return discoveredToken;
|
|
105
|
+
}
|
|
106
|
+
/** Test-only: forget the discovered token. */
|
|
107
|
+
function _resetGithubTokenForTests() {
|
|
108
|
+
discoveredToken = undefined;
|
|
109
|
+
}
|
|
110
|
+
function serverUrl() {
|
|
111
|
+
return (process.env.GITHUB_SERVER_URL || 'https://github.com').replace(/\/+$/, '');
|
|
112
|
+
}
|
|
113
|
+
function apiUrl() {
|
|
114
|
+
if (process.env.GITHUB_API_URL) {
|
|
115
|
+
return process.env.GITHUB_API_URL.replace(/\/+$/, '');
|
|
116
|
+
}
|
|
117
|
+
const server = serverUrl();
|
|
118
|
+
return server === 'https://github.com'
|
|
119
|
+
? 'https://api.github.com'
|
|
120
|
+
: `${server}/api/v3`;
|
|
121
|
+
}
|
|
122
|
+
const FULL_SHA = /^[0-9a-f]{40}$/i;
|
|
123
|
+
/**
|
|
124
|
+
* The commit a branch or tag points at, via `git ls-remote` (anonymous, so
|
|
125
|
+
* no API rate limit). Annotated tags resolve to their commit.
|
|
126
|
+
*/
|
|
127
|
+
async function lsRemoteSha(url, ref) {
|
|
128
|
+
let output;
|
|
129
|
+
try {
|
|
130
|
+
({ stdout: output } = await execFileAsync('git', [
|
|
131
|
+
'ls-remote',
|
|
132
|
+
url,
|
|
133
|
+
`refs/tags/${ref}`,
|
|
134
|
+
`refs/tags/${ref}^{}`,
|
|
135
|
+
`refs/heads/${ref}`
|
|
136
|
+
], {
|
|
137
|
+
encoding: 'utf8',
|
|
138
|
+
timeout: 15000,
|
|
139
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
140
|
+
}));
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
const refs = new Map();
|
|
146
|
+
for (const line of output.split('\n')) {
|
|
147
|
+
const [sha, name] = line.trim().split(/\s+/);
|
|
148
|
+
if (sha && name)
|
|
149
|
+
refs.set(name, sha);
|
|
150
|
+
}
|
|
151
|
+
return (refs.get(`refs/tags/${ref}^{}`) ??
|
|
152
|
+
refs.get(`refs/tags/${ref}`) ??
|
|
153
|
+
refs.get(`refs/heads/${ref}`));
|
|
154
|
+
}
|
|
155
|
+
/** GET a small text response (the API commit lookup). */
|
|
156
|
+
function getText(url, headers, timeoutMs) {
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
const client = url.startsWith('http://') ? http : https;
|
|
159
|
+
const request = client.get(url, { headers }, (response) => {
|
|
160
|
+
let body = '';
|
|
161
|
+
response.setEncoding('utf8');
|
|
162
|
+
response.on('data', (chunk) => (body += chunk));
|
|
163
|
+
response.on('end', () => response.statusCode === 200
|
|
164
|
+
? resolve(body)
|
|
165
|
+
: reject(Object.assign(new Error(`HTTP ${response.statusCode}`), {
|
|
166
|
+
status: response.statusCode
|
|
167
|
+
})));
|
|
168
|
+
response.on('error', reject);
|
|
169
|
+
});
|
|
170
|
+
request.setTimeout(timeoutMs, () => request.destroy(new Error(`Timed out after ${timeoutMs}ms: ${url}`)));
|
|
171
|
+
request.on('error', reject);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* The commit SHA for `ref`, or undefined if it can't be resolved now.
|
|
176
|
+
* `unauthorized` is set when GitHub rejected the token.
|
|
177
|
+
*/
|
|
178
|
+
async function resolveSha(ref, token, timeoutMs) {
|
|
179
|
+
if (FULL_SHA.test(ref.ref))
|
|
180
|
+
return { sha: ref.ref.toLowerCase() };
|
|
181
|
+
if (token) {
|
|
182
|
+
try {
|
|
183
|
+
const sha = (await getText(`${apiUrl()}/repos/${ref.owner}/${ref.repo}/commits/${encodeURIComponent(ref.ref)}`, {
|
|
184
|
+
...requestHeaders(token),
|
|
185
|
+
Accept: 'application/vnd.github.sha'
|
|
186
|
+
}, timeoutMs)).trim();
|
|
187
|
+
return { sha: FULL_SHA.test(sha) ? sha.toLowerCase() : undefined };
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
return { unauthorized: error.status === 401 };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
sha: await lsRemoteSha(`${serverUrl()}/${ref.owner}/${ref.repo}.git`, ref.ref)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Download a URL to a file, following up to MAX_REDIRECTS redirects. The
|
|
199
|
+
* Authorization header is only sent to the original host (GitHub's API
|
|
200
|
+
* redirects tarballs to a pre-signed codeload URL). Every response that
|
|
201
|
+
* isn't piped to the file is drained, so the socket is released, and the
|
|
202
|
+
* request is aborted if it stalls for `timeoutMs`.
|
|
203
|
+
*/
|
|
204
|
+
function downloadFile(url, dest, timeoutMs, headers, redirectsLeft = MAX_REDIRECTS) {
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
const client = url.startsWith('http://') ? http : https;
|
|
207
|
+
const request = client.get(url, { headers }, (response) => {
|
|
208
|
+
const status = response.statusCode ?? 0;
|
|
209
|
+
if ([301, 302, 303, 307, 308].includes(status)) {
|
|
210
|
+
response.resume();
|
|
211
|
+
const location = response.headers.location;
|
|
212
|
+
if (!location) {
|
|
213
|
+
reject(new Error(`Redirect (${status}) without a Location header`));
|
|
214
|
+
}
|
|
215
|
+
else if (redirectsLeft <= 0) {
|
|
216
|
+
reject(new Error(`Too many redirects downloading ${url}`));
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
const next = new URL(location, url);
|
|
220
|
+
const nextHeaders = { ...headers };
|
|
221
|
+
if (next.host !== new URL(url).host) {
|
|
222
|
+
delete nextHeaders.Authorization;
|
|
223
|
+
}
|
|
224
|
+
downloadFile(next.toString(), dest, timeoutMs, nextHeaders, redirectsLeft - 1).then(resolve, reject);
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (status !== 200) {
|
|
229
|
+
response.resume();
|
|
230
|
+
const hint = status === 404
|
|
231
|
+
? headers.Authorization
|
|
232
|
+
? ' (check the action name and ref, and that your token can read the repository)'
|
|
233
|
+
: ' (check the action name and ref; for a private repository, set GITHUB_TOKEN or log in with `gh auth login`)'
|
|
234
|
+
: '';
|
|
235
|
+
reject(new Error(`Failed to download: ${status}${hint}`));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const file = fs.createWriteStream(dest);
|
|
239
|
+
response.pipe(file);
|
|
240
|
+
file.on('finish', () => file.close(() => resolve()));
|
|
241
|
+
file.on('error', reject);
|
|
242
|
+
response.on('error', reject);
|
|
243
|
+
});
|
|
244
|
+
request.setTimeout(timeoutMs, () => {
|
|
245
|
+
request.destroy(new Error(`Download timed out after ${timeoutMs}ms: ${url}`));
|
|
246
|
+
});
|
|
247
|
+
request.on('error', reject);
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
function requestHeaders(token) {
|
|
251
|
+
return {
|
|
252
|
+
'User-Agent': 'secureci-cli',
|
|
253
|
+
...(token ? { Authorization: `Bearer ${token}` } : {})
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/** Where a repository's commits are cached, and its ref -> SHA index. */
|
|
257
|
+
function repoCacheDir(ref) {
|
|
258
|
+
return path.join(getActionCacheDir(), ref.owner.toLowerCase(), ref.repo.toLowerCase());
|
|
259
|
+
}
|
|
260
|
+
function readRefIndex(dir) {
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(fs.readFileSync(path.join(dir, 'refs.json'), 'utf8'));
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return {};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function writeRefIndex(dir, refs) {
|
|
269
|
+
fs.writeFileSync(path.join(dir, 'refs.json'), JSON.stringify(refs, null, 2));
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Download (or reuse from the cache) the repository tree of an action
|
|
273
|
+
* reference, keyed by commit SHA: a tag or SHA is fetched once, and a
|
|
274
|
+
* moving ref (`@main`) is re-resolved on every run, so new commits are
|
|
275
|
+
* picked up. If the ref can't be resolved (offline, rate-limited), the
|
|
276
|
+
* last commit cached for it is used. Returns the repository root.
|
|
277
|
+
*/
|
|
278
|
+
async function fetchRepository(ref, uses, options) {
|
|
279
|
+
const dir = repoCacheDir(ref);
|
|
280
|
+
const refs = readRefIndex(dir);
|
|
281
|
+
const cachedSha = FULL_SHA.test(ref.ref)
|
|
282
|
+
? ref.ref.toLowerCase()
|
|
283
|
+
: refs[ref.ref];
|
|
284
|
+
const cached = (sha) => sha && fs.existsSync(path.join(dir, sha)) ? path.join(dir, sha) : null;
|
|
285
|
+
if (options.offline) {
|
|
286
|
+
const root = cached(cachedSha);
|
|
287
|
+
if (!root) {
|
|
288
|
+
logger_1.logger.error(`Action ${uses} is not cached (offline mode)`);
|
|
289
|
+
}
|
|
290
|
+
return root;
|
|
291
|
+
}
|
|
292
|
+
const timeoutMs = options.timeoutMs ?? DOWNLOAD_TIMEOUT_MS;
|
|
293
|
+
let token = options.token === undefined ? githubToken() : options.token;
|
|
294
|
+
const resolved = await resolveSha(ref, token, timeoutMs);
|
|
295
|
+
let sha = resolved.sha;
|
|
296
|
+
if (resolved.unauthorized) {
|
|
297
|
+
// An expired or revoked token shouldn't break public actions.
|
|
298
|
+
logger_1.logger.warn('GitHub rejected the token (401); continuing without it for this run');
|
|
299
|
+
if (options.token === undefined)
|
|
300
|
+
discoveredToken = null;
|
|
301
|
+
token = null;
|
|
302
|
+
({ sha } = await resolveSha(ref, token, timeoutMs));
|
|
303
|
+
}
|
|
304
|
+
if (sha && cached(sha)) {
|
|
305
|
+
logger_1.logger.debug(`Using cached action: ${uses} (${sha.slice(0, 7)})`);
|
|
306
|
+
return cached(sha);
|
|
307
|
+
}
|
|
308
|
+
if (!sha && cached(cachedSha)) {
|
|
309
|
+
logger_1.logger.warn(`Could not resolve ${uses}; using the cached commit ${cachedSha.slice(0, 7)}`);
|
|
310
|
+
return cached(cachedSha);
|
|
311
|
+
}
|
|
312
|
+
logger_1.logger.info(`Fetching action: ${uses}`);
|
|
313
|
+
// Without a SHA (e.g. no token and no git), download by ref name into a
|
|
314
|
+
// slot that is replaced on every run rather than trusted later.
|
|
315
|
+
const key = sha ?? `unresolved-${ref.ref.replace(/[^\w.-]/g, '_')}`;
|
|
316
|
+
const target = path.join(dir, key);
|
|
317
|
+
const createdDir = !fs.existsSync(dir);
|
|
318
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
319
|
+
const tmp = fs.mkdtempSync(path.join(dir, '.download-'));
|
|
320
|
+
try {
|
|
321
|
+
const url = token
|
|
322
|
+
? `${apiUrl()}/repos/${ref.owner}/${ref.repo}/tarball/${sha ?? ref.ref}`
|
|
323
|
+
: `${serverUrl()}/${ref.owner}/${ref.repo}/archive/${sha ?? ref.ref}.tar.gz`;
|
|
324
|
+
const tarballPath = path.join(tmp, 'source.tar.gz');
|
|
325
|
+
logger_1.logger.debug(`Downloading from: ${url}`);
|
|
326
|
+
await downloadFile(url, tarballPath, timeoutMs, requestHeaders(token));
|
|
327
|
+
const extractDir = path.join(tmp, 'extracted');
|
|
328
|
+
fs.mkdirSync(extractDir);
|
|
329
|
+
await execFileAsync('tar', ['-xzf', tarballPath, '-C', extractDir]);
|
|
330
|
+
// GitHub tarballs hold a single top-level directory.
|
|
331
|
+
const [top] = fs.readdirSync(extractDir);
|
|
332
|
+
if (!top)
|
|
333
|
+
throw new Error('No files extracted from tarball');
|
|
334
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
335
|
+
fs.renameSync(path.join(extractDir, top), target);
|
|
336
|
+
if (sha) {
|
|
337
|
+
writeRefIndex(dir, { ...readRefIndex(dir), [ref.ref]: sha });
|
|
338
|
+
}
|
|
339
|
+
logger_1.logger.success(`Action fetched successfully: ${uses}`);
|
|
340
|
+
return target;
|
|
341
|
+
}
|
|
342
|
+
catch (error) {
|
|
343
|
+
logger_1.logger.error(`Failed to fetch action ${uses}: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
344
|
+
if (createdDir) {
|
|
345
|
+
// Don't leave an empty cache entry behind.
|
|
346
|
+
fs.rmSync(path.join(getActionCacheDir(), ref.owner.toLowerCase()), {
|
|
347
|
+
recursive: true,
|
|
348
|
+
force: true
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
finally {
|
|
354
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/** `root/sub`, or null if `sub` escapes `root`. */
|
|
358
|
+
function within(root, sub = '') {
|
|
359
|
+
const resolved = path.resolve(root, sub);
|
|
360
|
+
return resolved === root || resolved.startsWith(root + path.sep)
|
|
361
|
+
? resolved
|
|
362
|
+
: null;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Download a remote GitHub action, using the local cache when possible,
|
|
366
|
+
* and return the path to its action.yml / action.yaml.
|
|
367
|
+
*/
|
|
368
|
+
async function fetchAction(uses, options = {}) {
|
|
369
|
+
const ref = parseActionReference(uses);
|
|
370
|
+
if (!ref)
|
|
371
|
+
return null;
|
|
372
|
+
const root = await fetchRepository(ref, uses, options);
|
|
373
|
+
if (!root)
|
|
374
|
+
return null;
|
|
375
|
+
const actionDir = within(root, ref.path);
|
|
376
|
+
for (const name of ['action.yml', 'action.yaml']) {
|
|
377
|
+
const file = actionDir && path.join(actionDir, name);
|
|
378
|
+
if (file && fs.existsSync(file))
|
|
379
|
+
return file;
|
|
380
|
+
}
|
|
381
|
+
logger_1.logger.error(`No action.yml or action.yaml found in ${uses}`);
|
|
382
|
+
return null;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Download the repository of `owner/repo/path/to/file@ref` (e.g. a remote
|
|
386
|
+
* reusable workflow) and return the path to that file, or null.
|
|
387
|
+
*/
|
|
388
|
+
async function fetchRepoFile(uses, options = {}) {
|
|
389
|
+
const ref = parseActionReference(uses);
|
|
390
|
+
if (!ref?.path)
|
|
391
|
+
return null;
|
|
392
|
+
const root = await fetchRepository(ref, uses, options);
|
|
393
|
+
const file = root && within(root, ref.path);
|
|
394
|
+
if (file && fs.existsSync(file) && fs.statSync(file).isFile())
|
|
395
|
+
return file;
|
|
396
|
+
if (root)
|
|
397
|
+
logger_1.logger.error(`File not found in ${uses}`);
|
|
398
|
+
return null;
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Clear the action cache
|
|
402
|
+
*/
|
|
403
|
+
function clearActionCache() {
|
|
404
|
+
const cacheDir = getActionCacheDir();
|
|
405
|
+
if (fs.existsSync(cacheDir)) {
|
|
406
|
+
fs.rmSync(cacheDir, { recursive: true, force: true });
|
|
407
|
+
logger_1.logger.info('Action cache cleared');
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Resolve a `uses:` action reference to its current commit SHA, for the
|
|
412
|
+
* inventory and lockfile. Returns undefined if the ref can't be resolved
|
|
413
|
+
* (not an action reference, offline, or the lookup failed). Uses a token
|
|
414
|
+
* when one is available (githubToken), else anonymous `git ls-remote`.
|
|
415
|
+
*/
|
|
416
|
+
async function resolveActionSha(uses, options = {}) {
|
|
417
|
+
const ref = parseActionReference(uses);
|
|
418
|
+
if (!ref)
|
|
419
|
+
return undefined;
|
|
420
|
+
const timeoutMs = options.timeoutMs ?? DOWNLOAD_TIMEOUT_MS;
|
|
421
|
+
const token = options.token === undefined ? githubToken() : options.token;
|
|
422
|
+
const first = await resolveSha(ref, token, timeoutMs);
|
|
423
|
+
if (first.sha)
|
|
424
|
+
return first.sha;
|
|
425
|
+
// A rejected token shouldn't block resolution: retry anonymously.
|
|
426
|
+
if (first.unauthorized) {
|
|
427
|
+
return (await resolveSha(ref, null, timeoutMs)).sha;
|
|
428
|
+
}
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
//# sourceMappingURL=action-fetcher.js.map
|