flecto 3.0.1 → 3.1.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/CHANGELOG.md +567 -1
- package/README.md +15 -2
- package/index.js +544 -154
- package/package.json +4 -1
- package/schemas/flecto-policy-pack-2.0.json +2 -0
- package/src/baseline.js +193 -0
- package/src/config.js +564 -6
- package/src/differ.js +76 -12
- package/src/encrypted.js +16 -13
- package/src/packs/github-actions.json +92 -0
- package/src/parser.js +212 -22
- package/src/policy-test.js +5 -1
- package/src/policy.js +96 -23
- package/src/pr-comment.js +95 -87
- package/src/pr-providers.js +268 -0
- package/src/renderer.js +10 -22
- package/src/report.js +43 -3
- package/src/sarif.js +144 -0
- package/src/secrets.js +68 -7
- package/src/snapshot-store.js +668 -0
- package/src/suppressions.js +431 -0
- package/src/terraform.js +28 -0
package/src/policy.js
CHANGED
|
@@ -29,6 +29,7 @@ import { containsSecret } from './secrets.js';
|
|
|
29
29
|
* beforeLooksSecret?: true,
|
|
30
30
|
* afterLooksSecret?: true,
|
|
31
31
|
* afterMatches?: string,
|
|
32
|
+
* afterAnyMatches?: string,
|
|
32
33
|
* numericJump?: { minMultiple: number },
|
|
33
34
|
* numericDelta?: { min: number },
|
|
34
35
|
* allOf?: PolicyMatchClause[],
|
|
@@ -48,6 +49,7 @@ import { containsSecret } from './secrets.js';
|
|
|
48
49
|
* beforeLooksSecret?: true,
|
|
49
50
|
* afterLooksSecret?: true,
|
|
50
51
|
* afterMatches?: string,
|
|
52
|
+
* afterAnyMatches?: string,
|
|
51
53
|
* numericJump?: { minMultiple: number },
|
|
52
54
|
* numericDelta?: { min: number }
|
|
53
55
|
* }} PolicyMatchClause
|
|
@@ -74,6 +76,7 @@ import { containsSecret } from './secrets.js';
|
|
|
74
76
|
* source?: 'watch' | 'ci' | 'diff',
|
|
75
77
|
* policies?: string[],
|
|
76
78
|
* plugins?: string[],
|
|
79
|
+
* packRoots?: string[],
|
|
77
80
|
* severityRemap?: Record<string, PolicySeverity | 'off'>
|
|
78
81
|
* }} PolicyEvalOptions
|
|
79
82
|
*/
|
|
@@ -85,12 +88,12 @@ const RULE_FIELDS = new Set([
|
|
|
85
88
|
'id', 'severity', 'when', 'match', 'beforeEquals', 'afterEquals',
|
|
86
89
|
'beforeIn', 'afterIn', 'beforeTruthy', 'afterTruthy', 'numericJump',
|
|
87
90
|
'beforeLooksSecret', 'afterLooksSecret',
|
|
88
|
-
'afterMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
|
|
91
|
+
'afterMatches', 'afterAnyMatches', 'numericDelta', 'allOf', 'anyOf', 'message', 'messageTemplate',
|
|
89
92
|
]);
|
|
90
93
|
const CLAUSE_FIELDS = new Set([
|
|
91
94
|
'match', 'beforeEquals', 'afterEquals', 'beforeIn', 'afterIn',
|
|
92
95
|
'beforeTruthy', 'afterTruthy', 'beforeLooksSecret', 'afterLooksSecret',
|
|
93
|
-
'afterMatches', 'numericJump', 'numericDelta',
|
|
96
|
+
'afterMatches', 'afterAnyMatches', 'numericJump', 'numericDelta',
|
|
94
97
|
]);
|
|
95
98
|
const MATCH_FIELDS = new Set(['path', 'pathFlags', 'pathEquals', 'pathPrefix']);
|
|
96
99
|
// Community distribution convention: an npm package named flecto-pack-<id>
|
|
@@ -166,17 +169,34 @@ function validatePack(pack, path) {
|
|
|
166
169
|
}
|
|
167
170
|
|
|
168
171
|
/**
|
|
169
|
-
*
|
|
172
|
+
* Every directory whose `policies/` is searched for a local pack, in order.
|
|
173
|
+
* The first entry is the primary cwd; later entries are fallbacks (the policy
|
|
174
|
+
* fixture harness runs from the fixture directory but packs are installed into
|
|
175
|
+
* the invoking project — see #114).
|
|
176
|
+
* @param {string | string[]} cwd
|
|
177
|
+
* @returns {string[]}
|
|
178
|
+
*/
|
|
179
|
+
function packRoots(cwd) {
|
|
180
|
+
const roots = (Array.isArray(cwd) ? cwd : [cwd])
|
|
181
|
+
.filter((root) => typeof root === 'string' && root)
|
|
182
|
+
.map((root) => resolve(root));
|
|
183
|
+
// Distinct roots only: a fixture run from the project root would otherwise
|
|
184
|
+
// search the same policies/ directory twice and report it twice on failure.
|
|
185
|
+
return [...new Set(roots)];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* @param {string | string[]} cwd
|
|
170
190
|
* @param {string} packId
|
|
171
191
|
* @returns {string | null}
|
|
172
192
|
*/
|
|
173
193
|
function resolvePackPath(cwd, packId) {
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
194
|
+
for (const root of packRoots(cwd)) {
|
|
195
|
+
for (const ext of ['.json', '.yaml', '.yml']) {
|
|
196
|
+
const localPath = resolve(root, 'policies', `${packId}${ext}`);
|
|
197
|
+
if (existsSync(localPath)) return localPath;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
180
200
|
|
|
181
201
|
const builtinJson = join(PACKS_DIR, `${packId}.json`);
|
|
182
202
|
if (existsSync(builtinJson)) return builtinJson;
|
|
@@ -240,6 +260,7 @@ function validateRule(candidate, location, isClause = false) {
|
|
|
240
260
|
validateTruthyPredicate(rule.beforeLooksSecret, 'beforeLooksSecret', location);
|
|
241
261
|
validateTruthyPredicate(rule.afterLooksSecret, 'afterLooksSecret', location);
|
|
242
262
|
validateRegexPredicate(rule.afterMatches, 'afterMatches', location);
|
|
263
|
+
validateRegexPredicate(rule.afterAnyMatches, 'afterAnyMatches', location);
|
|
243
264
|
validateNumericPredicate(rule.numericJump, 'numericJump', 'minMultiple', location, true);
|
|
244
265
|
validateNumericPredicate(rule.numericDelta, 'numericDelta', 'min', location, false);
|
|
245
266
|
for (const name of ['message', 'messageTemplate']) {
|
|
@@ -359,13 +380,16 @@ function validateComposition(clauses, name, location) {
|
|
|
359
380
|
const packCache = new Map();
|
|
360
381
|
|
|
361
382
|
/**
|
|
362
|
-
* @param {string}
|
|
383
|
+
* @param {string[]} roots already-resolved search roots, in order
|
|
363
384
|
* @param {string} path
|
|
364
385
|
* @param {number} mtimeMs
|
|
365
386
|
* @returns {string}
|
|
366
387
|
*/
|
|
367
|
-
function packCacheKey(
|
|
368
|
-
|
|
388
|
+
function packCacheKey(roots, path, mtimeMs) {
|
|
389
|
+
// Roots are NUL-joined for the same reason the other fields are
|
|
390
|
+
// NUL-separated: two different root lists must never concatenate into the
|
|
391
|
+
// same key.
|
|
392
|
+
return `${roots.join('\u0000')}\u0000${path}\u0000${mtimeMs}`;
|
|
369
393
|
}
|
|
370
394
|
|
|
371
395
|
/**
|
|
@@ -385,11 +409,21 @@ export function loadPack(packId, cwd = process.cwd()) {
|
|
|
385
409
|
if (!id) throw new Error('Policy pack id is required');
|
|
386
410
|
const path = resolvePackPath(cwd, id);
|
|
387
411
|
if (!path) {
|
|
388
|
-
|
|
412
|
+
// Name the directories actually searched. The old message always said
|
|
413
|
+
// "Add policies/<id>.json", which was actively misleading when the pack
|
|
414
|
+
// existed but in a directory this lookup never consulted (#114).
|
|
415
|
+
const searched = packRoots(cwd)
|
|
416
|
+
.map((root) => join(root, 'policies'))
|
|
417
|
+
.concat(PACKS_DIR)
|
|
418
|
+
.join(', ');
|
|
419
|
+
throw new Error(
|
|
420
|
+
`Unknown policy pack "${id}". Searched: ${searched}.`
|
|
421
|
+
+ ` Add policies/${id}.json in one of these, or use a built-in pack.`,
|
|
422
|
+
);
|
|
389
423
|
}
|
|
390
424
|
|
|
391
425
|
const mtimeMs = statSync(path).mtimeMs;
|
|
392
|
-
const cacheKey = packCacheKey(cwd, path, mtimeMs);
|
|
426
|
+
const cacheKey = packCacheKey(packRoots(cwd), path, mtimeMs);
|
|
393
427
|
const cached = packCache.get(cacheKey);
|
|
394
428
|
if (cached) return cached;
|
|
395
429
|
|
|
@@ -700,15 +734,16 @@ export function addPolicyPackFromPackage(name, options = {}) {
|
|
|
700
734
|
}
|
|
701
735
|
|
|
702
736
|
/**
|
|
703
|
-
* Compile each rule's `match.path` and `
|
|
704
|
-
* once, at pack-load time, storing them as non-enumerable
|
|
705
|
-
* loaded rule/clause objects. matchClause() runs per change
|
|
706
|
-
* rule with a path match, once per event per rule — so compiling
|
|
707
|
-
* of inside that loop turns a `new RegExp(...)` call into a
|
|
737
|
+
* Compile each rule's `match.path`, `afterMatches`, and `afterAnyMatches`
|
|
738
|
+
* regular expressions once, at pack-load time, storing them as non-enumerable
|
|
739
|
+
* properties on the loaded rule/clause objects. matchClause() runs per change
|
|
740
|
+
* event — for a rule with a path match, once per event per rule — so compiling
|
|
741
|
+
* here instead of inside that loop turns a `new RegExp(...)` call into a
|
|
742
|
+
* property read.
|
|
708
743
|
*
|
|
709
|
-
* validatePack() has already proven, by this point, that every `match.path
|
|
710
|
-
* and `
|
|
711
|
-
* cannot throw a new error the caller hasn't already seen.
|
|
744
|
+
* validatePack() has already proven, by this point, that every `match.path`,
|
|
745
|
+
* `afterMatches`, and `afterAnyMatches` string constructs a valid RegExp, so
|
|
746
|
+
* construction here cannot throw a new error the caller hasn't already seen.
|
|
712
747
|
* @param {PolicyPack} pack
|
|
713
748
|
*/
|
|
714
749
|
function compilePackRegexes(pack) {
|
|
@@ -733,6 +768,12 @@ function compileClauseRegexes(clause) {
|
|
|
733
768
|
enumerable: false,
|
|
734
769
|
});
|
|
735
770
|
}
|
|
771
|
+
if (clause.afterAnyMatches !== undefined) {
|
|
772
|
+
Object.defineProperty(clause, '_afterAnyMatchesRegex', {
|
|
773
|
+
value: new RegExp(clause.afterAnyMatches),
|
|
774
|
+
enumerable: false,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
736
777
|
}
|
|
737
778
|
|
|
738
779
|
/**
|
|
@@ -754,6 +795,29 @@ function afterMatchesRegexFor(clause) {
|
|
|
754
795
|
return clause._afterMatchesRegex ?? new RegExp(clause.afterMatches);
|
|
755
796
|
}
|
|
756
797
|
|
|
798
|
+
/**
|
|
799
|
+
* @param {{ afterAnyMatches?: string, _afterAnyMatchesRegex?: RegExp }} clause
|
|
800
|
+
* @returns {RegExp}
|
|
801
|
+
*/
|
|
802
|
+
function afterAnyMatchesRegexFor(clause) {
|
|
803
|
+
return clause._afterAnyMatchesRegex ?? new RegExp(clause.afterAnyMatches);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* True when `value` is an array carrying at least one string element that
|
|
808
|
+
* matches. Deliberately a flat scan: it does not descend into nested arrays or
|
|
809
|
+
* objects, because a rule's text should say what it matches without the reader
|
|
810
|
+
* having to reason about how deep the walk goes. Non-array values never match,
|
|
811
|
+
* which is what keeps `afterMatches` and this predicate from overlapping.
|
|
812
|
+
* @param {unknown} value
|
|
813
|
+
* @param {RegExp} regex
|
|
814
|
+
* @returns {boolean}
|
|
815
|
+
*/
|
|
816
|
+
function anyElementMatches(value, regex) {
|
|
817
|
+
return Array.isArray(value)
|
|
818
|
+
&& value.some((element) => typeof element === 'string' && regex.test(element));
|
|
819
|
+
}
|
|
820
|
+
|
|
757
821
|
/**
|
|
758
822
|
* @param {PolicyRule} rule
|
|
759
823
|
* @param {import('./differ.js').ChangeEvent} change
|
|
@@ -791,6 +855,10 @@ function matchClause(clause, change) {
|
|
|
791
855
|
if (clause.beforeLooksSecret && !containsSecret(change.before)) return false;
|
|
792
856
|
if (clause.afterLooksSecret && !containsSecret(change.after)) return false;
|
|
793
857
|
if (clause.afterMatches && (typeof change.after !== 'string' || !afterMatchesRegexFor(clause).test(change.after))) return false;
|
|
858
|
+
// The list counterpart, for the change that turns a scalar into a list in
|
|
859
|
+
// one edit -- reported as a single `changed` event whose `after` is an array,
|
|
860
|
+
// which no scalar predicate can see into.
|
|
861
|
+
if (clause.afterAnyMatches && !anyElementMatches(change.after, afterAnyMatchesRegexFor(clause))) return false;
|
|
794
862
|
|
|
795
863
|
if (clause.numericJump) {
|
|
796
864
|
const before = change.before;
|
|
@@ -1020,10 +1088,15 @@ export async function evaluatePolicies(changes, options = {}) {
|
|
|
1020
1088
|
const packIds = options.policies?.length ? options.policies : ['default'];
|
|
1021
1089
|
const plugins = options.plugins ?? [];
|
|
1022
1090
|
const severityRemap = options.severityRemap ?? {};
|
|
1091
|
+
// Packs may resolve from additional roots (the fixture harness adds the
|
|
1092
|
+
// invoking project, #114). Plugin resolution deliberately stays anchored to
|
|
1093
|
+
// the single `cwd` below: widening where executable code loads from is not
|
|
1094
|
+
// the same decision as widening where declarative packs load from.
|
|
1095
|
+
const packSearchRoots = options.packRoots?.length ? [cwd, ...options.packRoots] : cwd;
|
|
1023
1096
|
|
|
1024
1097
|
/** @type {PolicyFinding[]} */
|
|
1025
1098
|
const findings = [];
|
|
1026
|
-
const packs = packIds.map((packId) => loadPack(packId,
|
|
1099
|
+
const packs = packIds.map((packId) => loadPack(packId, packSearchRoots));
|
|
1027
1100
|
const knownRuleIds = new Set(packs.flatMap((pack) => pack.rules.map((rule) => String(rule.id))));
|
|
1028
1101
|
for (const ruleId of Object.keys(severityRemap)) {
|
|
1029
1102
|
if (!knownRuleIds.has(ruleId)) {
|
package/src/pr-comment.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { readFileSync } from 'fs';
|
|
2
2
|
import { isAbsolute, relative } from 'path';
|
|
3
3
|
|
|
4
|
+
import { PR_PROVIDERS, selectPrProvider } from './pr-providers.js';
|
|
5
|
+
|
|
4
6
|
/**
|
|
5
7
|
* Hidden marker embedded in every rendered body. Flecto finds its own comment by
|
|
6
8
|
* searching for this string, so repeated runs update one sticky comment instead
|
|
@@ -8,21 +10,23 @@ import { isAbsolute, relative } from 'path';
|
|
|
8
10
|
*/
|
|
9
11
|
export const PR_COMMENT_MARKER = '<!-- flecto:pr-comment -->';
|
|
10
12
|
|
|
11
|
-
const DEFAULT_API_URL = 'https://api.github.com';
|
|
12
13
|
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
13
14
|
/** GitHub rejects comment bodies longer than 65536 characters. */
|
|
14
15
|
const MAX_BODY_CHARS = 60_000;
|
|
15
16
|
const MAX_INLINE_CHANGES = 10;
|
|
16
17
|
const MAX_VALUE_CHARS = 120;
|
|
17
18
|
const MAX_COMMENT_PAGES = 10;
|
|
18
|
-
const COMMENTS_PER_PAGE = 100;
|
|
19
19
|
const SEVERITY_ORDER = ['error', 'warn', 'info'];
|
|
20
20
|
const SEVERITY_HEADINGS = { error: 'Errors', warn: 'Warnings', info: 'Notices' };
|
|
21
21
|
const SEVERITY_NOUNS = { error: 'error', warn: 'warning', info: 'notice' };
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* @typedef {{ file: string, envelope: import('./envelope.js').FlectoEnvelope, policies?: import('./policy.js').PolicyFinding[] }} CiResult
|
|
25
|
-
* @typedef {{
|
|
25
|
+
* @typedef {{ provider?: string, prNumber: number, token: string, apiUrl: string,
|
|
26
|
+
* repo?: string, projectId?: string, webUrl?: string, workspace?: string, repoSlug?: string
|
|
27
|
+
* }} PrCommentContext The fields a delivery adapter resolved; which ones are
|
|
28
|
+
* present depends on the provider. A context built by hand carries no
|
|
29
|
+
* `provider` and is treated as GitHub.
|
|
26
30
|
*/
|
|
27
31
|
|
|
28
32
|
/**
|
|
@@ -233,70 +237,30 @@ export function renderPrComment(results, options = {}) {
|
|
|
233
237
|
}
|
|
234
238
|
|
|
235
239
|
/**
|
|
236
|
-
*
|
|
237
|
-
* @param {(path: string) => string} readEventFile
|
|
238
|
-
* @returns {number | null}
|
|
239
|
-
*/
|
|
240
|
-
function resolvePrNumber(env, readEventFile) {
|
|
241
|
-
const fromRef = /^refs\/pull\/(\d+)\/(?:merge|head)$/u.exec(env.GITHUB_REF ?? '');
|
|
242
|
-
if (fromRef) return Number(fromRef[1]);
|
|
243
|
-
|
|
244
|
-
const eventPath = env.GITHUB_EVENT_PATH;
|
|
245
|
-
if (!eventPath) return null;
|
|
246
|
-
|
|
247
|
-
let event;
|
|
248
|
-
try {
|
|
249
|
-
event = JSON.parse(readEventFile(eventPath));
|
|
250
|
-
} catch {
|
|
251
|
-
return null;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
const candidates = [
|
|
255
|
-
event?.pull_request?.number,
|
|
256
|
-
// issue_comment events on a PR carry the PR number under `issue`, but a
|
|
257
|
-
// plain issue must not be mistaken for one.
|
|
258
|
-
event?.issue?.pull_request ? event?.issue?.number : undefined,
|
|
259
|
-
event?.number,
|
|
260
|
-
];
|
|
261
|
-
for (const candidate of candidates) {
|
|
262
|
-
const value = Number(candidate);
|
|
263
|
-
if (Number.isInteger(value) && value > 0) return value;
|
|
264
|
-
}
|
|
265
|
-
return null;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Resolve the GitHub API context needed to post a pull request comment.
|
|
240
|
+
* Resolve the API context needed to post a merge request comment.
|
|
270
241
|
*
|
|
271
|
-
*
|
|
272
|
-
* `
|
|
273
|
-
*
|
|
242
|
+
* The provider is detected from CI environment variables, or forced with
|
|
243
|
+
* `provider`. GitHub is the fallback, so an unrecognized environment reports
|
|
244
|
+
* the message it always did rather than a new one about detection.
|
|
274
245
|
* @param {Record<string, string | undefined>} [env]
|
|
275
|
-
* @param {{ readEventFile?: (path: string) => string }} [options]
|
|
246
|
+
* @param {{ readEventFile?: (path: string) => string, provider?: string }} [options]
|
|
276
247
|
* @returns {{ ok: true, context: PrCommentContext } | { ok: false, reason: string }}
|
|
277
248
|
*/
|
|
278
249
|
export function resolvePrCommentContext(env = process.env, options = {}) {
|
|
279
250
|
const readEventFile = options.readEventFile ?? ((path) => readFileSync(path, 'utf8'));
|
|
280
|
-
const
|
|
281
|
-
if (!
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const repo = String(env.GITHUB_REPOSITORY ?? '').trim();
|
|
286
|
-
if (!/^[^/\s]+\/[^/\s]+$/u.test(repo)) {
|
|
287
|
-
return { ok: false, reason: 'GITHUB_REPOSITORY is not set to "owner/repo"' };
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const prNumber = resolvePrNumber(env, readEventFile);
|
|
291
|
-
if (!prNumber) {
|
|
292
|
-
return {
|
|
293
|
-
ok: false,
|
|
294
|
-
reason: 'no pull request number in GITHUB_REF or GITHUB_EVENT_PATH (not a pull request run)',
|
|
295
|
-
};
|
|
296
|
-
}
|
|
251
|
+
const selected = selectPrProvider(env, options.provider);
|
|
252
|
+
if (!selected.ok) return selected;
|
|
253
|
+
return selected.provider.resolve(env, { readEventFile });
|
|
254
|
+
}
|
|
297
255
|
|
|
298
|
-
|
|
299
|
-
|
|
256
|
+
/**
|
|
257
|
+
* The adapter a resolved context belongs to. Contexts built by hand — as tests
|
|
258
|
+
* and embedders do — carry no provider and are GitHub, which is what they were
|
|
259
|
+
* before there was a choice.
|
|
260
|
+
* @param {{ provider?: string }} context
|
|
261
|
+
*/
|
|
262
|
+
function providerFor(context) {
|
|
263
|
+
return PR_PROVIDERS.find((p) => p.id === context?.provider) ?? PR_PROVIDERS[0];
|
|
300
264
|
}
|
|
301
265
|
|
|
302
266
|
/**
|
|
@@ -333,10 +297,37 @@ async function errorDetail(response, token) {
|
|
|
333
297
|
}
|
|
334
298
|
|
|
335
299
|
/**
|
|
336
|
-
* @param {
|
|
300
|
+
* @param {Response} response
|
|
301
|
+
* @returns {boolean}
|
|
302
|
+
*/
|
|
303
|
+
function isRedirect(response) {
|
|
304
|
+
// `redirect: 'manual'` surfaces the 3xx itself in Node; a browser-shaped
|
|
305
|
+
// opaque redirect reports status 0 with `type: 'opaqueredirect'`.
|
|
306
|
+
return (response.status >= 300 && response.status < 400) || response.type === 'opaqueredirect';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The origin a redirect points at, for the error message. Only the origin: the
|
|
311
|
+
* rest of a `Location` is not worth echoing into a log.
|
|
312
|
+
* @param {Response} response
|
|
313
|
+
* @param {string} url
|
|
314
|
+
* @returns {string}
|
|
315
|
+
*/
|
|
316
|
+
function redirectTarget(response, url) {
|
|
317
|
+
const location = response.headers?.get?.('location');
|
|
318
|
+
if (!location) return '';
|
|
319
|
+
try {
|
|
320
|
+
return ` to ${new URL(location, url).origin}`;
|
|
321
|
+
} catch {
|
|
322
|
+
return '';
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* @param {{ fetchImpl: typeof fetch, provider: object, url: string, method: string, token: string, body?: unknown, timeoutMs: number }} request
|
|
337
328
|
* @returns {Promise<Response>}
|
|
338
329
|
*/
|
|
339
|
-
async function
|
|
330
|
+
async function apiRequest({ fetchImpl, provider, url, method, token, body, timeoutMs }) {
|
|
340
331
|
const controller = new AbortController();
|
|
341
332
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
342
333
|
let response;
|
|
@@ -344,27 +335,41 @@ async function githubRequest({ fetchImpl, url, method, token, body, timeoutMs })
|
|
|
344
335
|
response = await fetchImpl(url, {
|
|
345
336
|
method,
|
|
346
337
|
headers: {
|
|
347
|
-
Accept: 'application/
|
|
348
|
-
Authorization: `Bearer ${token}`,
|
|
338
|
+
Accept: provider.accept ?? 'application/json',
|
|
349
339
|
'Content-Type': 'application/json',
|
|
350
340
|
'User-Agent': 'flecto',
|
|
351
|
-
|
|
341
|
+
...provider.authHeaders(token),
|
|
352
342
|
},
|
|
353
343
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
354
344
|
signal: controller.signal,
|
|
345
|
+
// Never follow a redirect with a credential attached. `fetch` strips
|
|
346
|
+
// `Authorization` when a redirect crosses origins, but it strips *only*
|
|
347
|
+
// that header — GitLab authenticates with `PRIVATE-TOKEN`, which is
|
|
348
|
+
// forwarded to wherever the API host points, verified against a local
|
|
349
|
+
// server. Refusing the redirect is the whole fix: these endpoints do not
|
|
350
|
+
// legitimately redirect, and one that does is worth seeing.
|
|
351
|
+
redirect: 'manual',
|
|
355
352
|
});
|
|
356
353
|
} catch (err) {
|
|
357
354
|
const reason = err?.name === 'AbortError'
|
|
358
355
|
? `timed out after ${timeoutMs}ms`
|
|
359
356
|
: redact(err?.message ?? String(err), token);
|
|
360
|
-
throw new Error(
|
|
357
|
+
throw new Error(`${provider.label} API ${method} failed: ${reason}`);
|
|
361
358
|
} finally {
|
|
362
359
|
clearTimeout(timer);
|
|
363
360
|
}
|
|
364
361
|
|
|
362
|
+
if (isRedirect(response)) {
|
|
363
|
+
throw new Error(
|
|
364
|
+
`${provider.label} API ${method} was redirected (HTTP ${response.status}`
|
|
365
|
+
+ `${redirectTarget(response, url)}), and Flecto does not follow a redirect with an API`
|
|
366
|
+
+ ' token attached. Point the API URL at the host that answers directly.',
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
365
370
|
if (!response.ok) {
|
|
366
371
|
throw new Error(
|
|
367
|
-
|
|
372
|
+
`${provider.label} API ${method} returned HTTP ${response.status}${await errorDetail(response, token)}`,
|
|
368
373
|
);
|
|
369
374
|
}
|
|
370
375
|
return response;
|
|
@@ -374,20 +379,19 @@ async function githubRequest({ fetchImpl, url, method, token, body, timeoutMs })
|
|
|
374
379
|
* Find the sticky Flecto comment on a pull request, if one exists.
|
|
375
380
|
* @param {PrCommentContext} context
|
|
376
381
|
* @param {{ fetchImpl: typeof fetch, marker: string, timeoutMs: number }} options
|
|
377
|
-
* @returns {Promise<{ id: number, body?: string,
|
|
382
|
+
* @returns {Promise<{ id: string | number, body?: string, url?: string } | null>}
|
|
378
383
|
*/
|
|
379
384
|
async function findStickyComment(context, { fetchImpl, marker, timeoutMs }) {
|
|
385
|
+
const provider = providerFor(context);
|
|
380
386
|
for (let page = 1; page <= MAX_COMMENT_PAGES; page += 1) {
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
const response = await githubRequest({
|
|
384
|
-
fetchImpl, url, method: 'GET', token: context.token, timeoutMs,
|
|
387
|
+
const response = await apiRequest({
|
|
388
|
+
fetchImpl, provider, url: provider.listUrl(context, page), method: 'GET', token: context.token, timeoutMs,
|
|
385
389
|
});
|
|
386
|
-
const comments = await response.json();
|
|
387
|
-
if (
|
|
390
|
+
const comments = provider.readList(await response.json());
|
|
391
|
+
if (comments.length === 0) return null;
|
|
388
392
|
const match = comments.find((c) => typeof c?.body === 'string' && c.body.includes(marker));
|
|
389
393
|
if (match) return match;
|
|
390
|
-
if (comments.length <
|
|
394
|
+
if (comments.length < provider.perPage) return null;
|
|
391
395
|
}
|
|
392
396
|
return null;
|
|
393
397
|
}
|
|
@@ -404,6 +408,7 @@ export async function upsertPrComment(body, context, options = {}) {
|
|
|
404
408
|
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
405
409
|
const marker = options.marker ?? PR_COMMENT_MARKER;
|
|
406
410
|
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
411
|
+
const provider = providerFor(context);
|
|
407
412
|
if (typeof fetchImpl !== 'function') {
|
|
408
413
|
throw new Error('Global fetch unavailable. Use Node.js >= 20.19.0.');
|
|
409
414
|
}
|
|
@@ -411,39 +416,41 @@ export async function upsertPrComment(body, context, options = {}) {
|
|
|
411
416
|
const existing = await findStickyComment(context, { fetchImpl, marker, timeoutMs });
|
|
412
417
|
|
|
413
418
|
if (!existing) {
|
|
414
|
-
const response = await
|
|
419
|
+
const response = await apiRequest({
|
|
415
420
|
fetchImpl,
|
|
416
|
-
|
|
421
|
+
provider,
|
|
422
|
+
url: provider.createUrl(context),
|
|
417
423
|
method: 'POST',
|
|
418
424
|
token: context.token,
|
|
419
|
-
body:
|
|
425
|
+
body: provider.payload(body),
|
|
420
426
|
timeoutMs,
|
|
421
427
|
});
|
|
422
428
|
const created = await response.json().catch(() => ({}));
|
|
423
|
-
return { action: 'created', url: created
|
|
429
|
+
return { action: 'created', url: provider.readOne(created, context).url };
|
|
424
430
|
}
|
|
425
431
|
|
|
426
432
|
// Rendering is deterministic, so an identical body means nothing moved since
|
|
427
433
|
// the last run — skip the write and the "edited" noise it creates.
|
|
428
434
|
if (existing.body === body) {
|
|
429
|
-
return { action: 'unchanged', url: existing.
|
|
435
|
+
return { action: 'unchanged', url: existing.url };
|
|
430
436
|
}
|
|
431
437
|
|
|
432
|
-
const response = await
|
|
438
|
+
const response = await apiRequest({
|
|
433
439
|
fetchImpl,
|
|
434
|
-
|
|
435
|
-
|
|
440
|
+
provider,
|
|
441
|
+
url: provider.updateUrl(context, existing.id),
|
|
442
|
+
method: provider.updateMethod,
|
|
436
443
|
token: context.token,
|
|
437
|
-
body:
|
|
444
|
+
body: provider.payload(body),
|
|
438
445
|
timeoutMs,
|
|
439
446
|
});
|
|
440
447
|
const updated = await response.json().catch(() => ({}));
|
|
441
|
-
return { action: 'updated', url: updated
|
|
448
|
+
return { action: 'updated', url: provider.readOne(updated, context).url ?? existing.url };
|
|
442
449
|
}
|
|
443
450
|
|
|
444
451
|
/**
|
|
445
452
|
* Post the sticky comment when — and only when — posting was explicitly enabled
|
|
446
|
-
* and a complete
|
|
453
|
+
* and a complete merge request context is present.
|
|
447
454
|
*
|
|
448
455
|
* Never throws and never reports the token: a delivery problem must not change
|
|
449
456
|
* the CI exit code, which belongs to the diff and policy result alone.
|
|
@@ -454,7 +461,8 @@ export async function upsertPrComment(body, context, options = {}) {
|
|
|
454
461
|
* fetchImpl?: typeof fetch,
|
|
455
462
|
* marker?: string,
|
|
456
463
|
* timeoutMs?: number,
|
|
457
|
-
* readEventFile?: (path: string) => string
|
|
464
|
+
* readEventFile?: (path: string) => string,
|
|
465
|
+
* provider?: string
|
|
458
466
|
* }} [options]
|
|
459
467
|
* @returns {Promise<{ posted: boolean, action?: 'created' | 'updated' | 'unchanged', url?: string, reason?: string }>}
|
|
460
468
|
*/
|