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
|
@@ -0,0 +1,268 @@
|
|
|
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
|
+
/**
|
|
233
|
+
* Comments collection for the pull request. The workspace and slug are encoded
|
|
234
|
+
* for the same reason GitLab's project id is: they are interpolated into a URL
|
|
235
|
+
* path, and a value carrying `/`, `?`, or `#` would otherwise restructure the
|
|
236
|
+
* request rather than name a repository.
|
|
237
|
+
*/
|
|
238
|
+
function bitbucketCommentsBase(c) {
|
|
239
|
+
return `${c.apiUrl}/repositories/${encodeURIComponent(c.workspace)}`
|
|
240
|
+
+ `/${encodeURIComponent(c.repoSlug)}/pullrequests/${c.prNumber}/comments`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Detection order. GitHub stays first so its behavior is unchanged. */
|
|
244
|
+
export const PR_PROVIDERS = [github, gitlab, bitbucket];
|
|
245
|
+
|
|
246
|
+
export const PR_PROVIDER_IDS = PR_PROVIDERS.map((p) => p.id);
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Pick the delivery adapter.
|
|
250
|
+
*
|
|
251
|
+
* An explicit id always wins. Otherwise the first provider whose CI variables
|
|
252
|
+
* are present is used, and GitHub is the fallback so an unrecognized
|
|
253
|
+
* environment produces the same message it always did rather than a new one
|
|
254
|
+
* about provider detection.
|
|
255
|
+
* @param {Record<string, string | undefined>} env
|
|
256
|
+
* @param {string} [explicit]
|
|
257
|
+
* @returns {{ ok: true, provider: PrProvider } | { ok: false, reason: string }}
|
|
258
|
+
*/
|
|
259
|
+
export function selectPrProvider(env, explicit) {
|
|
260
|
+
if (explicit) {
|
|
261
|
+
const found = PR_PROVIDERS.find((p) => p.id === explicit);
|
|
262
|
+
if (!found) {
|
|
263
|
+
return { ok: false, reason: `unknown pr provider "${explicit}" (expected ${PR_PROVIDER_IDS.join(', ')})` };
|
|
264
|
+
}
|
|
265
|
+
return { ok: true, provider: found };
|
|
266
|
+
}
|
|
267
|
+
return { ok: true, provider: PR_PROVIDERS.find((p) => p.detect(env)) ?? github };
|
|
268
|
+
}
|
package/src/renderer.js
CHANGED
|
@@ -1,20 +1,8 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { redactSecretString } from './secrets.js';
|
|
2
|
+
import { looksLikeSecretPath, redactSecretString } from './secrets.js';
|
|
3
3
|
import { ENCRYPTED_DISPLAY, displayEncrypted, isEncryptedSentinel } from './encrypted.js';
|
|
4
4
|
import { secretMatchPath } from './differ.js';
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* Key names that mean "this value is a credential".
|
|
8
|
-
*
|
|
9
|
-
* Matched against the *configuration* path only. A multi-document file prefixes
|
|
10
|
-
* every path with the document's identity — `Deployment/prod/token-service.…` —
|
|
11
|
-
* and that identity is a resource name the user chose, not a key name. Letting
|
|
12
|
-
* it match here masked every value in the document, numbers and booleans
|
|
13
|
-
* included, so the path reaching this regex is always the one
|
|
14
|
-
* {@link secretMatchPath} produced.
|
|
15
|
-
*/
|
|
16
|
-
const SECRET_PATH_RE = /(secret|token|password|api[_-]?key|private[_-]?key|credential)/i;
|
|
17
|
-
|
|
18
6
|
/**
|
|
19
7
|
* Format a scalar value for display. Strings get quoted; others are JSON-stringified.
|
|
20
8
|
* @param {unknown} v
|
|
@@ -28,7 +16,7 @@ function fmt(v, opts = {}) {
|
|
|
28
16
|
if (isEncryptedSentinel(v)) return chalk.dim(ENCRYPTED_DISPLAY);
|
|
29
17
|
let value = displayEncrypted(v);
|
|
30
18
|
if (opts.maskSecrets) {
|
|
31
|
-
if (opts.path &&
|
|
19
|
+
if (opts.path && looksLikeSecretPath(opts.path)) {
|
|
32
20
|
return chalk.dim('"***"');
|
|
33
21
|
}
|
|
34
22
|
// The changed path itself can look benign while the value carries secrets,
|
|
@@ -202,7 +190,7 @@ export function renderPolicyFindings(findings) {
|
|
|
202
190
|
* @returns {unknown}
|
|
203
191
|
*/
|
|
204
192
|
export function maskSensitiveValue(value, path = '') {
|
|
205
|
-
if (
|
|
193
|
+
if (looksLikeSecretPath(path)) return '***';
|
|
206
194
|
if (Array.isArray(value)) {
|
|
207
195
|
return value.map((v, i) => maskSensitiveValue(v, `${path}[${i}]`));
|
|
208
196
|
}
|
|
@@ -211,13 +199,13 @@ export function maskSensitiveValue(value, path = '') {
|
|
|
211
199
|
&& typeof value === 'object'
|
|
212
200
|
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null)
|
|
213
201
|
) {
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
202
|
+
// Object.fromEntries rather than `out[k] = ...`: assigning a key literally
|
|
203
|
+
// named "__proto__" runs the prototype setter instead of creating an own
|
|
204
|
+
// property, so that whole subtree would vanish from the masked output
|
|
205
|
+
// rather than being rendered masked.
|
|
206
|
+
return Object.fromEntries(
|
|
207
|
+
Object.entries(value).map(([k, v]) => [k, maskSensitiveValue(v, path ? `${path}.${k}` : k)]),
|
|
208
|
+
);
|
|
221
209
|
}
|
|
222
210
|
if (typeof value === 'string') return redactSecretString(value);
|
|
223
211
|
return value;
|
package/src/report.js
CHANGED
|
@@ -31,7 +31,8 @@ import { isAbsolute, relative } from 'path';
|
|
|
31
31
|
* cwd?: string,
|
|
32
32
|
* version?: string,
|
|
33
33
|
* limit?: number,
|
|
34
|
-
* maskSecrets?: boolean
|
|
34
|
+
* maskSecrets?: boolean,
|
|
35
|
+
* store?: string
|
|
35
36
|
* }} ReportData
|
|
36
37
|
*/
|
|
37
38
|
|
|
@@ -245,6 +246,13 @@ function snapshotCard(snapshot, index) {
|
|
|
245
246
|
} else if (count > 0) {
|
|
246
247
|
// changeCount without events: the caller summarized but did not diff.
|
|
247
248
|
body.push(`<p class="empty">${escapeHtml(plural(count, 'change'))} recorded.</p>`);
|
|
249
|
+
} else if (!previous) {
|
|
250
|
+
// Nothing was compared here, so "no changes" would be a claim this card
|
|
251
|
+
// cannot support (#141). Say what actually happened instead.
|
|
252
|
+
body.push(
|
|
253
|
+
'<p class="empty">First snapshot of this file — there is no earlier state to'
|
|
254
|
+
+ ' compare it against. That is <strong>no history</strong>, not no drift.</p>',
|
|
255
|
+
);
|
|
248
256
|
} else {
|
|
249
257
|
body.push('<p class="empty">No semantic changes from the previous snapshot.</p>');
|
|
250
258
|
}
|
|
@@ -257,11 +265,14 @@ function snapshotCard(snapshot, index) {
|
|
|
257
265
|
}
|
|
258
266
|
|
|
259
267
|
const countClass = count > 0 ? 'count count-active' : 'count';
|
|
268
|
+
// A first snapshot is labelled "baseline" rather than "0 changes": the count
|
|
269
|
+
// is only meaningful once there is something on the other side of it.
|
|
270
|
+
const countLabel = previous ? plural(count, 'change') : 'baseline';
|
|
260
271
|
return [
|
|
261
272
|
`<details class="card" open id="snapshot-${escapeHtml(String(index))}">`,
|
|
262
273
|
'<summary>',
|
|
263
274
|
`<time class="stamp" datetime="${escapeHtml(time.iso)}">${escapeHtml(time.label)}</time>`,
|
|
264
|
-
`<span class="${countClass}">${escapeHtml(
|
|
275
|
+
`<span class="${countClass}">${escapeHtml(countLabel)}</span>`,
|
|
265
276
|
baselineNote,
|
|
266
277
|
'</summary>',
|
|
267
278
|
`<div class="card-body">${body.join('')}</div>`,
|
|
@@ -442,6 +453,14 @@ tr:last-child td { border-bottom: 0; }
|
|
|
442
453
|
.sev-warn { color: var(--warn); }
|
|
443
454
|
.sev-info { color: var(--info); }
|
|
444
455
|
.empty { color: var(--muted); margin: 8px 0; }
|
|
456
|
+
.banner {
|
|
457
|
+
border: 1px solid var(--warn);
|
|
458
|
+
border-left-width: 4px;
|
|
459
|
+
border-radius: 6px;
|
|
460
|
+
background: var(--panel);
|
|
461
|
+
padding: 10px 14px;
|
|
462
|
+
margin: 0 0 24px;
|
|
463
|
+
}
|
|
445
464
|
.no-matches { color: var(--muted); margin: 16px 0; }
|
|
446
465
|
footer { margin-top: 40px; padding-top: 14px; border-top: 1px solid var(--border); color: var(--muted); font-size: 0.82rem; }
|
|
447
466
|
.hidden { display: none !important; }
|
|
@@ -513,8 +532,13 @@ export function renderReportHtml(data = {}) {
|
|
|
513
532
|
/** @type {Array<{ file: string, snapshot: ReportSnapshot, finding: import('./policy.js').PolicyFinding }>} */
|
|
514
533
|
const allFindings = [];
|
|
515
534
|
let totalChanges = 0;
|
|
535
|
+
// How many of these snapshots actually had an earlier one to be compared
|
|
536
|
+
// against. Zero means the report compared nothing, which is a different
|
|
537
|
+
// statement from "compared everything and found nothing" (#141).
|
|
538
|
+
let comparisons = 0;
|
|
516
539
|
for (const snapshot of snapshots) {
|
|
517
540
|
const changes = changesOf(snapshot);
|
|
541
|
+
if (snapshot.previousCreatedAt) comparisons += 1;
|
|
518
542
|
// Prefer the events actually carried; fall back to the count for callers
|
|
519
543
|
// that summarized without diffing.
|
|
520
544
|
totalChanges += Array.isArray(snapshot.changes)
|
|
@@ -556,12 +580,26 @@ export function renderReportHtml(data = {}) {
|
|
|
556
580
|
'<section class="stats">',
|
|
557
581
|
statTile('Snapshots', String(snapshots.length)),
|
|
558
582
|
statTile('Files', String(groups.length)),
|
|
583
|
+
// "Changes" alone reads as an all-clear at 0 whether or not anything was
|
|
584
|
+
// ever compared, so the number of comparisons behind it sits next to it.
|
|
585
|
+
statTile('Comparisons', String(comparisons)),
|
|
559
586
|
statTile('Changes', String(totalChanges)),
|
|
560
587
|
statTile('Policy errors', String(severityCounts.error), 'error'),
|
|
561
588
|
statTile('Policy warnings', String(severityCounts.warn), 'warn'),
|
|
562
589
|
'</section>',
|
|
563
590
|
].join('');
|
|
564
591
|
|
|
592
|
+
// The failure mode this guards against: a CI job takes its first snapshot and
|
|
593
|
+
// renders a report from it, and the page reads as "nothing drifted" when the
|
|
594
|
+
// truth is that there was no history to look at. Say so above the fold.
|
|
595
|
+
const noHistoryBanner = comparisons === 0
|
|
596
|
+
? '<p class="banner">Nothing in this report was compared. Every snapshot here is'
|
|
597
|
+
+ ' the first one of its file, so there is no earlier state to measure drift'
|
|
598
|
+
+ ' against — this is <strong>no history</strong>, not <strong>no drift</strong>.'
|
|
599
|
+
+ ' Snapshot history is local to the working directory, so a fresh CI runner'
|
|
600
|
+
+ ' starts with none of it.</p>'
|
|
601
|
+
: '';
|
|
602
|
+
|
|
565
603
|
const findingsSection = allFindings.length === 0
|
|
566
604
|
? '<h2>Policy findings</h2><p class="empty">No policy findings across these snapshots.</p>'
|
|
567
605
|
: [
|
|
@@ -603,6 +641,7 @@ export function renderReportHtml(data = {}) {
|
|
|
603
641
|
return htmlDocument([
|
|
604
642
|
head,
|
|
605
643
|
stats,
|
|
644
|
+
noHistoryBanner,
|
|
606
645
|
findingsSection,
|
|
607
646
|
`<h2>Snapshot timeline (${escapeHtml(plural(snapshots.length, 'snapshot'))})</h2>`,
|
|
608
647
|
controls,
|
|
@@ -619,7 +658,8 @@ function footer(data) {
|
|
|
619
658
|
const limit = Number.isInteger(data.limit)
|
|
620
659
|
? ` Limited to the ${escapeHtml(plural(data.limit, 'most recent snapshot'))}.`
|
|
621
660
|
: '';
|
|
622
|
-
|
|
661
|
+
const store = escapeHtml(String(data.store ?? '.flecto-snapshots/'));
|
|
662
|
+
return `<footer>Generated by Flecto from <code>${store}</code>.`
|
|
623
663
|
+ ' This file is self-contained: no external scripts, fonts, or images, and nothing'
|
|
624
664
|
+ ` is sent anywhere when you open it.${limit}</footer>`;
|
|
625
665
|
}
|
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
|
+
}
|