flecto 3.0.1 → 3.0.2

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.
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Delivery adapters for the sticky review comment.
3
+ *
4
+ * Everything upstream of delivery — the differ, the policy engine, the
5
+ * envelope, and the rendered comment body — is provider-agnostic markdown.
6
+ * Only the last step differs, so only the last step lives here: which
7
+ * environment variables identify a merge request, how the host authenticates,
8
+ * and the three URLs needed to list, create, and update a comment.
9
+ *
10
+ * Adding a provider means adding one object to {@link PR_PROVIDERS}. Nothing
11
+ * else in Flecto needs to know it exists.
12
+ */
13
+
14
+ const GITHUB_API_URL = 'https://api.github.com';
15
+ const GITLAB_API_URL = 'https://gitlab.com/api/v4';
16
+ const BITBUCKET_API_URL = 'https://api.bitbucket.org/2.0';
17
+
18
+ /**
19
+ * @typedef {{
20
+ * id: string,
21
+ * label: string,
22
+ * perPage: number,
23
+ * detect: (env: Record<string, string | undefined>) => boolean,
24
+ * resolve: (
25
+ * env: Record<string, string | undefined>,
26
+ * helpers: { readEventFile: (path: string) => string },
27
+ * ) => { ok: true, context: object } | { ok: false, reason: string },
28
+ * authHeaders: (token: string) => Record<string, string>,
29
+ * accept?: string,
30
+ * listUrl: (context: any, page: number) => string,
31
+ * readList: (payload: unknown) => { id: string | number, body: string, url?: string }[],
32
+ * createUrl: (context: any) => string,
33
+ * updateUrl: (context: any, id: string | number) => string,
34
+ * updateMethod: string,
35
+ * payload: (body: string) => object,
36
+ * readOne: (payload: any, context: any) => { url?: string },
37
+ * }} PrProvider
38
+ */
39
+
40
+ /** Trim trailing slashes from a base URL. */
41
+ function trimUrl(value, fallback) {
42
+ return String(value || fallback).replace(/\/+$/u, '');
43
+ }
44
+
45
+ /**
46
+ * Pull request number from the GitHub ref, then the event payload.
47
+ * @param {Record<string, string | undefined>} env
48
+ * @param {(path: string) => string} readEventFile
49
+ * @returns {number | null}
50
+ */
51
+ function githubPrNumber(env, readEventFile) {
52
+ const fromRef = /^refs\/pull\/(\d+)\/(?:merge|head)$/u.exec(env.GITHUB_REF ?? '');
53
+ if (fromRef) return Number(fromRef[1]);
54
+
55
+ const eventPath = env.GITHUB_EVENT_PATH;
56
+ if (!eventPath) return null;
57
+
58
+ let event;
59
+ try {
60
+ event = JSON.parse(readEventFile(eventPath));
61
+ } catch {
62
+ return null;
63
+ }
64
+
65
+ const candidates = [
66
+ event?.pull_request?.number,
67
+ // issue_comment events on a PR carry the PR number under `issue`, but a
68
+ // plain issue must not be mistaken for one.
69
+ event?.issue?.pull_request ? event?.issue?.number : undefined,
70
+ event?.number,
71
+ ];
72
+ for (const candidate of candidates) {
73
+ const value = Number(candidate);
74
+ if (Number.isInteger(value) && value > 0) return value;
75
+ }
76
+ return null;
77
+ }
78
+
79
+ /** @type {PrProvider} */
80
+ const github = {
81
+ id: 'github',
82
+ label: 'GitHub',
83
+ perPage: 100,
84
+ accept: 'application/vnd.github+json',
85
+ detect: (env) => Boolean(env.GITHUB_ACTIONS || env.GITHUB_REPOSITORY || env.GITHUB_EVENT_PATH),
86
+
87
+ resolve(env, { readEventFile }) {
88
+ // Only GITHUB_TOKEN is honored — GH_TOKEN is deliberately ignored because
89
+ // `gh auth login` exports it on developer machines, where posting would be
90
+ // a surprise.
91
+ const token = String(env.GITHUB_TOKEN ?? '').trim();
92
+ if (!token) return { ok: false, reason: 'GITHUB_TOKEN is not set' };
93
+
94
+ const repo = String(env.GITHUB_REPOSITORY ?? '').trim();
95
+ if (!/^[^/\s]+\/[^/\s]+$/u.test(repo)) {
96
+ return { ok: false, reason: 'GITHUB_REPOSITORY is not set to "owner/repo"' };
97
+ }
98
+
99
+ const prNumber = githubPrNumber(env, readEventFile);
100
+ if (!prNumber) {
101
+ return {
102
+ ok: false,
103
+ reason: 'no pull request number in GITHUB_REF or GITHUB_EVENT_PATH (not a pull request run)',
104
+ };
105
+ }
106
+
107
+ return {
108
+ ok: true,
109
+ context: { provider: 'github', repo, prNumber, token, apiUrl: trimUrl(env.GITHUB_API_URL, GITHUB_API_URL) },
110
+ };
111
+ },
112
+
113
+ authHeaders: (token) => ({ Authorization: `Bearer ${token}`, 'X-GitHub-Api-Version': '2022-11-28' }),
114
+ listUrl: (c, page) => `${c.apiUrl}/repos/${c.repo}/issues/${c.prNumber}/comments?per_page=${github.perPage}&page=${page}`,
115
+ readList: (payload) => (Array.isArray(payload) ? payload : []).map((c) => ({ id: c?.id, body: c?.body, url: c?.html_url })),
116
+ createUrl: (c) => `${c.apiUrl}/repos/${c.repo}/issues/${c.prNumber}/comments`,
117
+ updateUrl: (c, id) => `${c.apiUrl}/repos/${c.repo}/issues/comments/${id}`,
118
+ updateMethod: 'PATCH',
119
+ payload: (body) => ({ body }),
120
+ readOne: (payload) => ({ url: payload?.html_url }),
121
+ };
122
+
123
+ /** @type {PrProvider} */
124
+ const gitlab = {
125
+ id: 'gitlab',
126
+ label: 'GitLab',
127
+ perPage: 100,
128
+ detect: (env) => Boolean(env.GITLAB_CI || env.CI_MERGE_REQUEST_IID),
129
+
130
+ resolve(env) {
131
+ // CI_JOB_TOKEN is present on every GitLab job and cannot write notes, so
132
+ // silently trying it would produce a 401 that reads like a broken setup.
133
+ // Name the fix instead.
134
+ const token = String(env.FLECTO_GITLAB_TOKEN ?? env.GITLAB_TOKEN ?? '').trim();
135
+ if (!token) {
136
+ return {
137
+ ok: false,
138
+ reason: env.CI_JOB_TOKEN
139
+ ? 'no GitLab API token: CI_JOB_TOKEN cannot post merge request notes. '
140
+ + 'Set FLECTO_GITLAB_TOKEN to a project or group access token with the "api" scope.'
141
+ : 'FLECTO_GITLAB_TOKEN (or GITLAB_TOKEN) is not set',
142
+ };
143
+ }
144
+
145
+ const projectId = String(env.CI_PROJECT_ID ?? '').trim();
146
+ if (!projectId) return { ok: false, reason: 'CI_PROJECT_ID is not set' };
147
+
148
+ const iid = Number(env.CI_MERGE_REQUEST_IID);
149
+ if (!Number.isInteger(iid) || iid <= 0) {
150
+ return { ok: false, reason: 'CI_MERGE_REQUEST_IID is not set (not a merge request pipeline)' };
151
+ }
152
+
153
+ return {
154
+ ok: true,
155
+ context: {
156
+ provider: 'gitlab',
157
+ projectId,
158
+ prNumber: iid,
159
+ token,
160
+ apiUrl: trimUrl(env.CI_API_V4_URL, GITLAB_API_URL),
161
+ webUrl: String(env.CI_MERGE_REQUEST_PROJECT_URL ?? '').replace(/\/+$/u, ''),
162
+ },
163
+ };
164
+ },
165
+
166
+ authHeaders: (token) => ({ 'PRIVATE-TOKEN': token }),
167
+ listUrl: (c, page) => `${gitlabNotesBase(c)}?per_page=${gitlab.perPage}&page=${page}`,
168
+ readList: (payload) => (Array.isArray(payload) ? payload : []).map((n) => ({ id: n?.id, body: n?.body })),
169
+ createUrl: (c) => gitlabNotesBase(c),
170
+ updateUrl: (c, id) => `${gitlabNotesBase(c)}/${id}`,
171
+ updateMethod: 'PUT',
172
+ payload: (body) => ({ body }),
173
+ readOne: (payload, c) => (c?.webUrl && payload?.id
174
+ ? { url: `${c.webUrl}/-/merge_requests/${c.prNumber}#note_${payload.id}` }
175
+ : {}),
176
+ };
177
+
178
+ /** Notes collection for the merge request. Project ids may be paths, so encode. */
179
+ function gitlabNotesBase(c) {
180
+ return `${c.apiUrl}/projects/${encodeURIComponent(c.projectId)}/merge_requests/${c.prNumber}/notes`;
181
+ }
182
+
183
+ /** @type {PrProvider} */
184
+ const bitbucket = {
185
+ id: 'bitbucket',
186
+ label: 'Bitbucket',
187
+ perPage: 100,
188
+ detect: (env) => Boolean(env.BITBUCKET_PR_ID || env.BITBUCKET_REPO_SLUG),
189
+
190
+ resolve(env) {
191
+ const token = String(env.FLECTO_BITBUCKET_TOKEN ?? env.BITBUCKET_TOKEN ?? '').trim();
192
+ if (!token) return { ok: false, reason: 'FLECTO_BITBUCKET_TOKEN (or BITBUCKET_TOKEN) is not set' };
193
+
194
+ const workspace = String(env.BITBUCKET_WORKSPACE ?? '').trim();
195
+ const repoSlug = String(env.BITBUCKET_REPO_SLUG ?? '').trim();
196
+ if (!workspace || !repoSlug) {
197
+ return { ok: false, reason: 'BITBUCKET_WORKSPACE and BITBUCKET_REPO_SLUG must both be set' };
198
+ }
199
+
200
+ const prId = Number(env.BITBUCKET_PR_ID);
201
+ if (!Number.isInteger(prId) || prId <= 0) {
202
+ return { ok: false, reason: 'BITBUCKET_PR_ID is not set (not a pull request pipeline)' };
203
+ }
204
+
205
+ return {
206
+ ok: true,
207
+ context: {
208
+ provider: 'bitbucket',
209
+ workspace,
210
+ repoSlug,
211
+ prNumber: prId,
212
+ token,
213
+ apiUrl: trimUrl(env.BITBUCKET_API_URL, BITBUCKET_API_URL),
214
+ },
215
+ };
216
+ },
217
+
218
+ authHeaders: (token) => ({ Authorization: `Bearer ${token}` }),
219
+ listUrl: (c, page) => `${bitbucketCommentsBase(c)}?pagelen=${bitbucket.perPage}&page=${page}`,
220
+ readList: (payload) => (Array.isArray(payload?.values) ? payload.values : []).map((c) => ({
221
+ id: c?.id,
222
+ body: c?.content?.raw,
223
+ url: c?.links?.html?.href,
224
+ })),
225
+ createUrl: (c) => bitbucketCommentsBase(c),
226
+ updateUrl: (c, id) => `${bitbucketCommentsBase(c)}/${id}`,
227
+ updateMethod: 'PUT',
228
+ payload: (body) => ({ content: { raw: body } }),
229
+ readOne: (payload) => ({ url: payload?.links?.html?.href }),
230
+ };
231
+
232
+ function bitbucketCommentsBase(c) {
233
+ return `${c.apiUrl}/repositories/${c.workspace}/${c.repoSlug}/pullrequests/${c.prNumber}/comments`;
234
+ }
235
+
236
+ /** Detection order. GitHub stays first so its behavior is unchanged. */
237
+ export const PR_PROVIDERS = [github, gitlab, bitbucket];
238
+
239
+ export const PR_PROVIDER_IDS = PR_PROVIDERS.map((p) => p.id);
240
+
241
+ /**
242
+ * Pick the delivery adapter.
243
+ *
244
+ * An explicit id always wins. Otherwise the first provider whose CI variables
245
+ * are present is used, and GitHub is the fallback so an unrecognized
246
+ * environment produces the same message it always did rather than a new one
247
+ * about provider detection.
248
+ * @param {Record<string, string | undefined>} env
249
+ * @param {string} [explicit]
250
+ * @returns {{ ok: true, provider: PrProvider } | { ok: false, reason: string }}
251
+ */
252
+ export function selectPrProvider(env, explicit) {
253
+ if (explicit) {
254
+ const found = PR_PROVIDERS.find((p) => p.id === explicit);
255
+ if (!found) {
256
+ return { ok: false, reason: `unknown pr provider "${explicit}" (expected ${PR_PROVIDER_IDS.join(', ')})` };
257
+ }
258
+ return { ok: true, provider: found };
259
+ }
260
+ return { ok: true, provider: PR_PROVIDERS.find((p) => p.detect(env)) ?? github };
261
+ }
package/src/renderer.js CHANGED
@@ -211,13 +211,13 @@ export function maskSensitiveValue(value, path = '') {
211
211
  && typeof value === 'object'
212
212
  && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
213
213
  ) {
214
- /** @type {Record<string, unknown>} */
215
- const out = {};
216
- for (const [k, v] of Object.entries(value)) {
217
- const child = path ? `${path}.${k}` : k;
218
- out[k] = maskSensitiveValue(v, child);
219
- }
220
- return out;
214
+ // Object.fromEntries rather than `out[k] = ...`: assigning a key literally
215
+ // named "__proto__" runs the prototype setter instead of creating an own
216
+ // property, so that whole subtree would vanish from the masked output
217
+ // rather than being rendered masked.
218
+ return Object.fromEntries(
219
+ Object.entries(value).map(([k, v]) => [k, maskSensitiveValue(v, path ? `${path}.${k}` : k)]),
220
+ );
221
221
  }
222
222
  if (typeof value === 'string') return redactSecretString(value);
223
223
  return value;
package/src/report.js CHANGED
@@ -245,6 +245,13 @@ function snapshotCard(snapshot, index) {
245
245
  } else if (count > 0) {
246
246
  // changeCount without events: the caller summarized but did not diff.
247
247
  body.push(`<p class="empty">${escapeHtml(plural(count, 'change'))} recorded.</p>`);
248
+ } else if (!previous) {
249
+ // Nothing was compared here, so "no changes" would be a claim this card
250
+ // cannot support (#141). Say what actually happened instead.
251
+ body.push(
252
+ '<p class="empty">First snapshot of this file — there is no earlier state to'
253
+ + ' compare it against. That is <strong>no history</strong>, not no drift.</p>',
254
+ );
248
255
  } else {
249
256
  body.push('<p class="empty">No semantic changes from the previous snapshot.</p>');
250
257
  }
@@ -257,11 +264,14 @@ function snapshotCard(snapshot, index) {
257
264
  }
258
265
 
259
266
  const countClass = count > 0 ? 'count count-active' : 'count';
267
+ // A first snapshot is labelled "baseline" rather than "0 changes": the count
268
+ // is only meaningful once there is something on the other side of it.
269
+ const countLabel = previous ? plural(count, 'change') : 'baseline';
260
270
  return [
261
271
  `<details class="card" open id="snapshot-${escapeHtml(String(index))}">`,
262
272
  '<summary>',
263
273
  `<time class="stamp" datetime="${escapeHtml(time.iso)}">${escapeHtml(time.label)}</time>`,
264
- `<span class="${countClass}">${escapeHtml(plural(count, 'change'))}</span>`,
274
+ `<span class="${countClass}">${escapeHtml(countLabel)}</span>`,
265
275
  baselineNote,
266
276
  '</summary>',
267
277
  `<div class="card-body">${body.join('')}</div>`,
@@ -442,6 +452,14 @@ tr:last-child td { border-bottom: 0; }
442
452
  .sev-warn { color: var(--warn); }
443
453
  .sev-info { color: var(--info); }
444
454
  .empty { color: var(--muted); margin: 8px 0; }
455
+ .banner {
456
+ border: 1px solid var(--warn);
457
+ border-left-width: 4px;
458
+ border-radius: 6px;
459
+ background: var(--panel);
460
+ padding: 10px 14px;
461
+ margin: 0 0 24px;
462
+ }
445
463
  .no-matches { color: var(--muted); margin: 16px 0; }
446
464
  footer { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.82rem; }
447
465
  .hidden { display: none !important; }
@@ -513,8 +531,13 @@ export function renderReportHtml(data = {}) {
513
531
  /** @type {Array<{ file: string, snapshot: ReportSnapshot, finding: import('./policy.js').PolicyFinding }>} */
514
532
  const allFindings = [];
515
533
  let totalChanges = 0;
534
+ // How many of these snapshots actually had an earlier one to be compared
535
+ // against. Zero means the report compared nothing, which is a different
536
+ // statement from "compared everything and found nothing" (#141).
537
+ let comparisons = 0;
516
538
  for (const snapshot of snapshots) {
517
539
  const changes = changesOf(snapshot);
540
+ if (snapshot.previousCreatedAt) comparisons += 1;
518
541
  // Prefer the events actually carried; fall back to the count for callers
519
542
  // that summarized without diffing.
520
543
  totalChanges += Array.isArray(snapshot.changes)
@@ -556,12 +579,26 @@ export function renderReportHtml(data = {}) {
556
579
  '<section class="stats">',
557
580
  statTile('Snapshots', String(snapshots.length)),
558
581
  statTile('Files', String(groups.length)),
582
+ // "Changes" alone reads as an all-clear at 0 whether or not anything was
583
+ // ever compared, so the number of comparisons behind it sits next to it.
584
+ statTile('Comparisons', String(comparisons)),
559
585
  statTile('Changes', String(totalChanges)),
560
586
  statTile('Policy errors', String(severityCounts.error), 'error'),
561
587
  statTile('Policy warnings', String(severityCounts.warn), 'warn'),
562
588
  '</section>',
563
589
  ].join('');
564
590
 
591
+ // The failure mode this guards against: a CI job takes its first snapshot and
592
+ // renders a report from it, and the page reads as "nothing drifted" when the
593
+ // truth is that there was no history to look at. Say so above the fold.
594
+ const noHistoryBanner = comparisons === 0
595
+ ? '<p class="banner">Nothing in this report was compared. Every snapshot here is'
596
+ + ' the first one of its file, so there is no earlier state to measure drift'
597
+ + ' against — this is <strong>no history</strong>, not <strong>no drift</strong>.'
598
+ + ' Snapshot history is local to the working directory, so a fresh CI runner'
599
+ + ' starts with none of it.</p>'
600
+ : '';
601
+
565
602
  const findingsSection = allFindings.length === 0
566
603
  ? '<h2>Policy findings</h2><p class="empty">No policy findings across these snapshots.</p>'
567
604
  : [
@@ -603,6 +640,7 @@ export function renderReportHtml(data = {}) {
603
640
  return htmlDocument([
604
641
  head,
605
642
  stats,
643
+ noHistoryBanner,
606
644
  findingsSection,
607
645
  `<h2>Snapshot timeline (${escapeHtml(plural(snapshots.length, 'snapshot'))})</h2>`,
608
646
  controls,
package/src/sarif.js ADDED
@@ -0,0 +1,144 @@
1
+ import { relative } from 'path';
2
+
3
+ /**
4
+ * SARIF 2.1.0 output for `flecto ci --format sarif`, for upload to GitHub code
5
+ * scanning (github/codeql-action/upload-sarif) and any other SARIF consumer.
6
+ *
7
+ * What Flecto emits as SARIF *results* is its policy findings — a finding has a
8
+ * rule id, a severity, a file, and a path, which is exactly the shape SARIF
9
+ * models. Raw change events are not results: they carry no rule id, so they have
10
+ * nothing to be a `ruleId` of. A run that gates on changes alone (`--fail-on
11
+ * changed`) still exits non-zero; SARIF simply reports the policy findings.
12
+ *
13
+ * Line numbers: Flecto reports a *semantic path* (`Deployment/prod/api.spec.
14
+ * replicas`), not a source line, and resolving one to the other means a
15
+ * line-tracking parser for every format. Until that exists, results are
16
+ * file-level: the physical location is the file with `startLine: 1`, and the
17
+ * semantic path is preserved losslessly as a SARIF `logicalLocation`. GitHub
18
+ * still renders the alert, dedupes it, and tracks when it is fixed — the
19
+ * file-level tradeoff the issue (#120) calls out. The logical location means the
20
+ * path a reviewer needs is never lost, only not yet a clickable line.
21
+ */
22
+
23
+ const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
24
+ const INFORMATION_URI = 'https://github.com/myselfsiddharth/Flecto';
25
+
26
+ /**
27
+ * Map a Flecto severity to a SARIF result level.
28
+ * @param {string} severity
29
+ * @returns {'error' | 'warning' | 'note'}
30
+ */
31
+ function sarifLevel(severity) {
32
+ if (severity === 'error') return 'error';
33
+ if (severity === 'info') return 'note';
34
+ return 'warning';
35
+ }
36
+
37
+ /**
38
+ * A repo-relative, POSIX-slashed URI for a file. SARIF consumers (GitHub in
39
+ * particular) map results onto the tree by relative URI; an absolute path does
40
+ * not resolve, so anything outside the working directory falls back to its base
41
+ * name rather than leaking an absolute path that would not map anyway.
42
+ * @param {string} file
43
+ * @param {string} cwd
44
+ * @returns {string}
45
+ */
46
+ function artifactUri(file, cwd) {
47
+ const rel = relative(cwd, file);
48
+ if (!rel || rel.startsWith('..') || rel.includes(`..${'/'}`)) {
49
+ return file.split(/[\\/]/).pop() ?? file;
50
+ }
51
+ return rel.split('\\').join('/');
52
+ }
53
+
54
+ /**
55
+ * Build a SARIF 2.1.0 log from CI results.
56
+ *
57
+ * `results` is the same array `printCiOutput` receives: each entry is
58
+ * `{ file, policies }`, where `policies` is the already-mask-processed finding
59
+ * list. Because SARIF is built from that same masked list, `--mask-secrets`
60
+ * applies to the SARIF file with no extra work — which matters, since a SARIF
61
+ * file is uploaded to GitHub and retained.
62
+ * @param {Array<{ file: string, policies: import('./policy.js').PolicyFinding[] }>} results
63
+ * @param {{ cwd?: string, toolVersion?: string }} [options]
64
+ * @returns {object}
65
+ */
66
+ export function buildSarif(results, options = {}) {
67
+ const cwd = options.cwd ?? process.cwd();
68
+ const version = options.toolVersion ?? '0.0.0';
69
+
70
+ /** @type {Map<string, { index: number, descriptor: object }>} */
71
+ const ruleIndex = new Map();
72
+ /** @type {object[]} */
73
+ const sarifResults = [];
74
+
75
+ for (const result of results) {
76
+ for (const finding of result.policies ?? []) {
77
+ const ruleId = String(finding.id);
78
+ if (!ruleIndex.has(ruleId)) {
79
+ ruleIndex.set(ruleId, {
80
+ index: ruleIndex.size,
81
+ descriptor: {
82
+ id: ruleId,
83
+ name: ruleId,
84
+ shortDescription: { text: shortDescriptionFor(finding) },
85
+ defaultConfiguration: { level: sarifLevel(finding.severity) },
86
+ ...(finding.pack ? { properties: { pack: String(finding.pack) } } : {}),
87
+ },
88
+ });
89
+ }
90
+ const path = String(finding.path ?? '');
91
+ const uri = artifactUri(result.file, cwd);
92
+ sarifResults.push({
93
+ ruleId,
94
+ ruleIndex: ruleIndex.get(ruleId).index,
95
+ level: sarifLevel(finding.severity),
96
+ message: { text: String(finding.message ?? `Policy ${ruleId} matched`) },
97
+ locations: [{
98
+ physicalLocation: {
99
+ artifactLocation: { uri },
100
+ region: { startLine: 1 },
101
+ },
102
+ ...(path
103
+ ? { logicalLocations: [{ fullyQualifiedName: path, kind: 'member' }] }
104
+ : {}),
105
+ }],
106
+ // Keeps a finding stable across runs as its line would drift, so GitHub
107
+ // dedupes and tracks fixes by (rule, file, semantic path) rather than by
108
+ // a line number Flecto does not have.
109
+ partialFingerprints: { flectoPathV1: `${ruleId}::${uri}::${path}` },
110
+ });
111
+ }
112
+ }
113
+
114
+ const rules = [...ruleIndex.values()].map((entry) => entry.descriptor);
115
+
116
+ return {
117
+ $schema: SARIF_SCHEMA,
118
+ version: '2.1.0',
119
+ runs: [{
120
+ tool: {
121
+ driver: {
122
+ name: 'Flecto',
123
+ informationUri: INFORMATION_URI,
124
+ version,
125
+ rules,
126
+ },
127
+ },
128
+ results: sarifResults,
129
+ }],
130
+ };
131
+ }
132
+
133
+ /**
134
+ * A stable short description for a rule descriptor. The finding's message is
135
+ * per-occurrence (it can interpolate values), so it is not ideal as a rule-level
136
+ * description, but it is the most specific text available and reads better than a
137
+ * generic placeholder. Trimmed to a single line.
138
+ * @param {import('./policy.js').PolicyFinding} finding
139
+ * @returns {string}
140
+ */
141
+ function shortDescriptionFor(finding) {
142
+ const message = String(finding.message ?? '').split('\n')[0].trim();
143
+ return message || `Policy rule ${finding.id}`;
144
+ }
package/src/secrets.js CHANGED
@@ -57,15 +57,22 @@ const KNOWN_FORMATS = [
57
57
  { kind: 'stripe-secret-key', re: /\b[sr]k_live_[0-9A-Za-z]{16,}\b/g },
58
58
  // JWT: base64url header starting with "eyJ" ('{"'), payload, signature.
59
59
  { kind: 'jwt', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
60
- // PEM private key blocks, including PGP blocks and unterminated fragments.
61
- {
62
- kind: 'private-key-block',
63
- re: /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----|$)/g,
64
- },
65
60
  ];
66
61
 
67
- /** Credentials embedded in a URL authority: scheme://user:PASSWORD@host. */
68
- const URL_CREDENTIALS_RE = /[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:([^\s/@]+)@/gi;
62
+ // PEM/PGP private-key block markers, matched linearly. A single regex spanning
63
+ // BEGIN…END with `[\s\S]*?…$` backtracks quadratically on a long BEGIN-prefixed
64
+ // value with no END — a denial-of-service vector, since secret detection runs
65
+ // on every changed string value. Instead we find the markers with anchored,
66
+ // non-spanning regexes and pair them by position (see findPrivateKeyBlocks).
67
+ const PRIVATE_KEY_BEGIN_RE = /-----BEGIN (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/g;
68
+ const PRIVATE_KEY_END_RE = /-----END (?:[A-Z0-9]+ )*PRIVATE KEY(?: BLOCK)?-----/g;
69
+
70
+ // Credentials embedded in a URL authority: scheme://user:PASSWORD@host. The
71
+ // scheme run is length-bounded ({0,32}); an unbounded `*` before the required
72
+ // `://` backtracks quadratically on a long value that never contains `://`. No
73
+ // real URL scheme approaches 32 characters, so the bound changes nothing that
74
+ // matters and removes the ReDoS.
75
+ const URL_CREDENTIALS_RE = /[a-z][a-z0-9+.-]{0,32}:\/\/[^\s/:@]+:([^\s/@]+)@/gi;
69
76
 
70
77
  /**
71
78
  * Values that only *reference* a secret. Redacting these adds noise and, worse,
@@ -205,6 +212,31 @@ function isHighEntropySecret(value) {
205
212
  return true;
206
213
  }
207
214
 
215
+ /**
216
+ * PEM/PGP private-key spans, found linearly. Each BEGIN marker pairs with the
217
+ * next END marker after it; a BEGIN with no following END runs to end of string
218
+ * (an unterminated fragment is still a leaked key). Pairing by position avoids
219
+ * the quadratic backtracking a single BEGIN…END regex incurs.
220
+ * @param {string} value
221
+ * @returns {SecretMatch[]}
222
+ */
223
+ function findPrivateKeyBlocks(value) {
224
+ /** @type {SecretMatch[]} */
225
+ const spans = [];
226
+ PRIVATE_KEY_BEGIN_RE.lastIndex = 0;
227
+ let begin;
228
+ while ((begin = PRIVATE_KEY_BEGIN_RE.exec(value)) !== null) {
229
+ const bodyStart = begin.index + begin[0].length;
230
+ PRIVATE_KEY_END_RE.lastIndex = bodyStart;
231
+ const end = PRIVATE_KEY_END_RE.exec(value);
232
+ const spanEnd = end ? end.index + end[0].length : value.length;
233
+ spans.push({ kind: 'private-key-block', start: begin.index, end: spanEnd });
234
+ // Resume scanning past this block so overlapping BEGINs inside it are skipped.
235
+ PRIVATE_KEY_BEGIN_RE.lastIndex = spanEnd;
236
+ }
237
+ return spans;
238
+ }
239
+
208
240
  /**
209
241
  * Locate every secret-shaped span inside a string, sorted and non-overlapping.
210
242
  * @param {string} value
@@ -223,6 +255,8 @@ function findSecretMatches(value) {
223
255
  }
224
256
  }
225
257
 
258
+ matches.push(...findPrivateKeyBlocks(value));
259
+
226
260
  URL_CREDENTIALS_RE.lastIndex = 0;
227
261
  let credentials;
228
262
  while ((credentials = URL_CREDENTIALS_RE.exec(value)) !== null) {