Repository navigation
Expand file tree
/
Copy pathupload.js
More file actions
312 lines (291 loc) · 11.6 KB
/
Copy pathupload.js
File metadata and controls
312 lines (291 loc) · 11.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
// upload.js: sends a push scan to the node9 workspace the repository is
// connected to (repository dashboard). Runs only when the caller passes
// `node9-upload-key`, only on `push`, and never changes the job's result:
// every path ends with exit code 0, and the gate in comment.js is unaffected.
//
// Credentials: the upload key (a repository secret) and a GitHub OIDC token
// with audience `node9`, which proves which repository, branch, workflow and
// commit produced the scan. The server checks both; the envelope fields below
// must agree with the token's claims.
//
// Dependency-free (Node 22 global fetch). The I/O is injected so the tests can
// drive every path without a network.
'use strict';
const fs = require('fs');
const { execFileSync } = require('child_process');
const INGEST_PATH = '/api/v1/repository-scans/ingest';
const DEFAULT_API = 'https://api.node9.ai';
// The only places a key and a token may be sent. dev-api is node9's own test
// deployment; anything else is refused, including lookalike and http URLs.
const ALLOWED_APIS = ['https://api.node9.ai', 'https://dev-api.node9.ai'];
const KEY_RE = /^n9r_[A-Za-z0-9_-]{43}$/;
// A GitHub OIDC token is a compact JWS: three base64url parts. Anything else
// is not sent (and so never reaches a header or an error message).
const JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
const ATTEMPTS = 3;
const TIMEOUT_MS = 15_000;
const BACKOFF_MS = [2_000, 5_000];
// The server parses at most 1 MiB on this route; a larger body is refused
// before anything is sent.
const MAX_BODY_BYTES = 1024 * 1024;
/** The exact API origin, or null when the input is not an allowed one. */
function apiOrigin(input) {
const value = String(input || DEFAULT_API)
.trim()
.replace(/\/+$/, '');
return ALLOWED_APIS.includes(value) ? value : null;
}
/** One line, bounded: a server message is shown in the log and the summary. */
function oneLine(value) {
return String(value)
.replace(/[\r\n\u2028\u2029]+/g, ' ')
.slice(0, 300);
}
/** The scan result as the server reads it: the findings, the files read and
* whether the scan was complete. The rest of the CLI output (the local path,
* notes) is not needed and is not sent. */
function resultPayload(raw) {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (
!parsed ||
!Array.isArray(parsed.findings) ||
!Array.isArray(parsed.inspected) ||
typeof parsed.incomplete !== 'boolean'
) {
return null;
}
return {
findings: parsed.findings,
inspected: parsed.inspected,
incomplete: parsed.incomplete,
};
}
/** The upload body. Every field comes from the runner's own environment, the
* same source GitHub uses for the token's claims. */
function buildEnvelope(env, result) {
return {
schemaVersion: 1,
repository: { githubId: env.GITHUB_REPOSITORY_ID, fullName: env.GITHUB_REPOSITORY },
ref: env.GITHUB_REF,
commitSha: env.GITHUB_SHA,
workflowRunId: env.GITHUB_RUN_ID,
workflowRunAttempt: Number(env.GITHUB_RUN_ATTEMPT),
// Known limit: matrix legs share GITHUB_JOB, so only the first leg's upload
// is stored for a run attempt. The dashboard workflow has no matrix.
workflowJobKey: env.GITHUB_JOB,
scannerVersion: env.NODE9_SCANNER_VERSION,
startedAt: env.NODE9_SCAN_STARTED_AT,
completedAt: env.NODE9_SCAN_COMPLETED_AT,
result,
};
}
/** Why the envelope cannot be sent, or null. Mirrors the server's schema so a
* runner that lacks a field says so here instead of as a 400. */
function envelopeProblem(e) {
const iso = (v) => typeof v === 'string' && !Number.isNaN(Date.parse(v));
if (!/^\d{1,20}$/.test(e.repository.githubId || '')) return 'GITHUB_REPOSITORY_ID is missing';
if (!e.repository.fullName) return 'GITHUB_REPOSITORY is missing';
if (!e.ref) return 'GITHUB_REF is missing';
if (!/^[0-9a-f]{40}$/.test(e.commitSha || '')) return 'GITHUB_SHA is missing';
if (!/^\d{1,20}$/.test(e.workflowRunId || '')) return 'GITHUB_RUN_ID is missing';
if (!Number.isInteger(e.workflowRunAttempt) || e.workflowRunAttempt < 1)
return 'GITHUB_RUN_ATTEMPT is missing';
if (!/^[A-Za-z0-9_.-]{1,100}$/.test(e.workflowJobKey || '')) return 'GITHUB_JOB is not usable';
if (!/^[0-9A-Za-z.+-]{1,50}$/.test(e.scannerVersion || ''))
return 'the scanner version is unknown';
if (!iso(e.startedAt) || !iso(e.completedAt)) return 'the scan times are missing';
return null;
}
/** A GitHub OIDC token for audience `node9`, or an Error saying why not. */
async function requestOidcToken(env, fetchImpl) {
const url = env.ACTIONS_ID_TOKEN_REQUEST_URL;
const bearer = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
if (!url || !bearer) {
return new Error(
'the job cannot request a GitHub OIDC token. Add `permissions: id-token: write` to the push job'
);
}
try {
const target = new URL(url);
target.searchParams.set('audience', 'node9');
const res = await fetchImpl(target.toString(), {
headers: { Authorization: `Bearer ${bearer}`, Accept: 'application/json' },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) return new Error(`GitHub refused the OIDC token request (${res.status})`);
const body = await res.json();
if (!body || typeof body.value !== 'string' || !JWT_RE.test(body.value)) {
return new Error('GitHub returned no usable OIDC token');
}
return body.value;
} catch (err) {
return new Error(`could not reach GitHub for the OIDC token (${oneLine(err.message)})`);
}
}
/** Whether a failed attempt is worth repeating: the network, a timeout or the
* server itself. A refusal (4xx) or a refused redirect will not change on a
* retry. */
const retryable = (last) => (last.status === 0 && !last.final) || last.status >= 500;
/** Neither credential is ever printed: the log is public on a public
* repository, and a response or an error could repeat a header value. */
function scrub(text, secrets) {
let out = String(text);
for (const s of secrets) if (s) out = out.split(s).join('[redacted]');
return oneLine(out);
}
/** A fetch that threw. A redirect is refused by design (`redirect: 'error'`)
* and is final; anything else thrown is the network or the timeout. */
function thrown(err, secrets) {
const cause = err && err.cause && err.cause.message ? String(err.cause.message) : '';
const message = err && err.message ? err.message : String(err);
return {
status: 0,
final: /redirect/i.test(cause) || /redirect/i.test(message),
message: scrub(cause ? `${message}: ${cause}` : message, secrets),
};
}
async function send(origin, key, token, body, deps) {
let last = { status: 0, message: '' };
for (let attempt = 1; attempt <= ATTEMPTS; attempt++) {
try {
const res = await deps.fetch(`${origin}${INGEST_PATH}`, {
method: 'POST',
headers: {
Authorization: `Bearer ${key}`,
'X-GitHub-OIDC-Token': token,
'Content-Type': 'application/json',
},
body,
// A redirect would carry the token and the scan to another address.
redirect: 'error',
signal: AbortSignal.timeout(TIMEOUT_MS),
});
let payload = null;
try {
payload = await res.json();
} catch {
payload = null;
}
if (res.ok) return { ok: true, status: res.status, payload, attempts: attempt };
const message = payload && payload.message ? payload.message : res.statusText;
const text = Array.isArray(message) ? message.join('; ') : String(message);
last = { status: res.status, message: scrub(text, [key, token]) };
} catch (err) {
last = thrown(err, [key, token]);
}
if (!retryable(last) || attempt === ATTEMPTS) {
return { ok: false, ...last, attempts: attempt };
}
await deps.sleep(BACKOFF_MS[attempt - 1]);
}
return { ok: false, ...last, attempts: ATTEMPTS };
}
/** Runs the upload. Returns the line written to the log and the job summary;
* never throws and never decides the job's result. */
async function run(env, deps) {
const skip = (reason) => ({ uploaded: false, line: `node9 upload skipped: ${reason}.` });
const key = (env.NODE9_UPLOAD_KEY || '').trim();
if (!key) return skip('no upload key');
if (env.GITHUB_EVENT_NAME !== 'push') return skip('only push runs upload');
// The server accepts one trusted branch; a tag push can never match it, so
// it is not worth a request (which would count against the key's budget).
if (env.GITHUB_REF_TYPE && env.GITHUB_REF_TYPE !== 'branch') {
return skip('only a branch push is reported');
}
if (!KEY_RE.test(key)) {
return skip(
'the upload key is not a node9 repository key. Copy it again from the repository page'
);
}
const origin = apiOrigin(env.NODE9_API_URL);
if (!origin) return skip('node9-api-url must be https://api.node9.ai');
let head;
try {
head = deps.gitHead(env.GITHUB_WORKSPACE);
} catch {
head = '';
}
if (head !== env.GITHUB_SHA) {
return skip(
'the checked-out commit is not the pushed commit, so the result would be labelled with the wrong commit'
);
}
if (!env.NODE9_RESULT) return skip('the scan step reported no result file');
let raw;
try {
raw = deps.readFile(env.NODE9_RESULT);
} catch (err) {
return skip(`could not read the scan result (${oneLine((err && err.code) || err)})`);
}
const result = resultPayload(raw);
if (!result) return skip('the scan did not produce a result');
const envelope = buildEnvelope(env, result);
const problem = envelopeProblem(envelope);
if (problem) return skip(problem);
const body = JSON.stringify(envelope);
if (Buffer.byteLength(body, 'utf8') > MAX_BODY_BYTES)
return skip('the result is larger than 1 MiB');
const token = await requestOidcToken(env, deps.fetch);
if (token instanceof Error) return skip(token.message);
const outcome = await send(origin, key, token, body, deps);
if (outcome.ok) {
const again = outcome.payload && outcome.payload.duplicate ? ' (already received)' : '';
return {
uploaded: true,
line: `node9: scan of ${env.GITHUB_SHA.slice(0, 7)} sent to your node9 workspace${again}.`,
};
}
const why = outcome.status ? `${outcome.status} ${outcome.message}` : outcome.message;
// A refusal is the server applying its rules (another branch, a revoked
// key), not a failure of this run.
if (outcome.status >= 400 && outcome.status < 500) {
return {
uploaded: false,
line: `node9 did not accept this scan: ${why}. The check result is not affected.`,
};
}
return {
uploaded: false,
line: `node9 upload failed after ${outcome.attempts} attempt${outcome.attempts === 1 ? '' : 's'}: ${why}. The check result is not affected.`,
};
}
const realDeps = {
fetch: (...args) => fetch(...args),
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
gitHead: (cwd) =>
execFileSync('git', ['-C', cwd || '.', 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(),
readFile: (file) => fs.readFileSync(file, 'utf8'),
};
if (require.main === module) {
run(process.env, realDeps)
.then(({ line }) => {
console.log(line);
if (process.env.GITHUB_STEP_SUMMARY) {
try {
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${line}\n`);
} catch {
// The summary is a convenience; the log line above is enough.
}
}
})
.catch((err) => {
console.log(
`node9 upload failed: ${oneLine(err && err.message)}. The check result is not affected.`
);
})
.finally(() => process.exit(0));
}
module.exports = {
run,
apiOrigin,
resultPayload,
buildEnvelope,
envelopeProblem,
ALLOWED_APIS,
INGEST_PATH,
};