atris 3.82.4 → 3.82.7

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.
@@ -112,8 +112,11 @@ function cleanRepo(name) {
112
112
  return REPO_SHAPE.test(s) && !strictSecret(s) ? s : 'project';
113
113
  }
114
114
 
115
+ // The project name for any Sentinel upload: lib/sentinel-guard.js
116
+ // projectName (git remote repo name, else folder name) passed through the
117
+ // secret check above.
115
118
  function repoName(root) {
116
- return cleanRepo(path.basename(path.resolve(root || process.cwd())));
119
+ return cleanRepo(require('./sentinel-guard').projectName(root || process.cwd()));
117
120
  }
118
121
 
119
122
  function makeEvent({ kind, repo, severity = 'info', title, detail = '', status, fingerprint: fp, occurredAt = new Date() }) {
@@ -249,6 +252,112 @@ async function reportFindings(root, kind, openEvents, { token, base, env = proce
249
252
  return { ...res, sent: res.ok ? plan.events.length + extra.length : 0, resolved: plan.resolved.length };
250
253
  }
251
254
 
255
+ // ---------------------------------------------------------------- review fixes
256
+
257
+ // Review findings a later review closed (status fixed in a review upload).
258
+ // The overview counts a fix when a stored open event comes back resolved,
259
+ // so a finding never sent open goes first as open (dated when its review
260
+ // found it) and then as resolved in a second call. One call would store it
261
+ // resolved and the fix would never count. Local state remembers what was
262
+ // sent: uploading the same review again never counts a fix twice.
263
+ // Open review findings are not sent as events: the overview already counts
264
+ // them from the review itself.
265
+ //
266
+ // The record is keyed by the shared project name, not the checkout path, so
267
+ // every copy of one project on this machine shares it. Each entry is keyed
268
+ // by the closure id: the project name plus the finding's fingerprint, the
269
+ // same pair the server stores the event under.
270
+ //
271
+ // Accepted limit: the server has no idempotency field yet. Two machines
272
+ // (or two uploads racing on one) can each send the open then resolved pair
273
+ // for one finding. Both land on the same stored event and it stays one
274
+ // row. Its fixed time moves to the later send.
275
+ const REVIEW_STATE_KIND = 'review';
276
+ const FIXED_DETAIL = 'A later review found the line that closes this problem.';
277
+
278
+ function reviewStatePath(repo, env) {
279
+ return path.join(sentinelDir(env), 'state', `review-${hash(cleanRepo(repo)).slice(0, 16)}.json`);
280
+ }
281
+
282
+ function closureId(repo, fp) {
283
+ return `${cleanRepo(repo)}/${fp}`;
284
+ }
285
+
286
+ function readStateFile(file) {
287
+ try {
288
+ const s = JSON.parse(fs.readFileSync(file, 'utf8'));
289
+ return { open: (s && s.open) || {}, resolved: (s && s.resolved) || {} };
290
+ } catch { return { open: {}, resolved: {} }; }
291
+ }
292
+
293
+ // Earlier builds keyed this record by checkout path; fold that one in so a
294
+ // fix already sent from this checkout is not sent again.
295
+ function readReviewState(root, repo, env) {
296
+ const state = readStateFile(reviewStatePath(repo, env));
297
+ if (root) {
298
+ const legacy = readStateFile(statePath(root, REVIEW_STATE_KIND, env));
299
+ for (const part of ['open', 'resolved']) {
300
+ for (const [fp, at] of Object.entries(legacy[part])) {
301
+ const id = fp.includes('/') ? fp : closureId(repo, fp);
302
+ if (!state[part][id]) state[part][id] = at;
303
+ }
304
+ }
305
+ }
306
+ return state;
307
+ }
308
+
309
+ function writeReviewState(repo, state, env) {
310
+ const file = reviewStatePath(repo, env);
311
+ fs.mkdirSync(path.dirname(file), { recursive: true });
312
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
313
+ fs.writeFileSync(tmp, `${JSON.stringify({ repo: cleanRepo(repo), kind: REVIEW_STATE_KIND, ...state, updated_at: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
314
+ fs.renameSync(tmp, file);
315
+ }
316
+
317
+ function reviewFixEvent(body, f, status, at) {
318
+ return makeEvent({
319
+ kind: 'threat',
320
+ repo: body.repo,
321
+ severity: f.severity === 'informational' ? 'info' : f.severity,
322
+ title: f.title,
323
+ detail: status === 'resolved' ? FIXED_DETAIL : '',
324
+ status,
325
+ fingerprint: f.id,
326
+ occurredAt: at,
327
+ });
328
+ }
329
+
330
+ async function reportReviewFixes(root, body, { token, base, env = process.env, foundAt = null, now = new Date() } = {}) {
331
+ const repo = cleanRepo(body && body.repo);
332
+ const findings = ((body && body.findings) || []).filter((f) => f && /^f-[0-9a-f]{12}$/.test(String(f.id || '')));
333
+ const fixed = findings.filter((f) => f.status === 'fixed');
334
+ const state = readReviewState(root, repo, env);
335
+ const id = (f) => closureId(repo, f.id);
336
+ // A finding open again can be fixed again later and count again.
337
+ const back = findings.filter((f) => f.status === 'open' && state.resolved[id(f)]);
338
+ if (back.length) {
339
+ for (const f of back) delete state.resolved[id(f)];
340
+ try { writeReviewState(repo, state, env); } catch {}
341
+ }
342
+ if (!fixed.length) return { ok: true, sent: 0, fixed: 0 };
343
+ const todo = fixed.filter((f) => !state.resolved[id(f)]);
344
+ if (!todo.length) return { ok: true, sent: 0, fixed: 0, already: fixed.length };
345
+ if (!token) return { ok: false, reason: 'not logged in', sent: 0, fixed: 0 };
346
+ const found = foundAt && !Number.isNaN(Date.parse(foundAt)) ? new Date(foundAt) : now;
347
+ const opening = todo.filter((f) => !state.open[id(f)]);
348
+ if (opening.length) {
349
+ const res = await postEvents(opening.map((f) => reviewFixEvent(body, f, 'open', found)), { token, base });
350
+ if (!res.ok) return { ...res, sent: 0, fixed: 0 };
351
+ for (const f of opening) state.open[id(f)] = found.toISOString();
352
+ try { writeReviewState(repo, state, env); } catch {}
353
+ }
354
+ const res = await postEvents(todo.map((f) => reviewFixEvent(body, f, 'resolved', now)), { token, base });
355
+ if (!res.ok) return { ...res, sent: opening.length, fixed: 0 };
356
+ for (const f of todo) { state.resolved[id(f)] = now.toISOString(); delete state.open[id(f)]; }
357
+ try { writeReviewState(repo, state, env); } catch {}
358
+ return { ok: true, sent: opening.length + todo.length, fixed: todo.length };
359
+ }
360
+
252
361
  // ---------------------------------------------------------------- queue
253
362
 
254
363
  function queuePath(env = process.env) {
@@ -307,10 +416,12 @@ module.exports = {
307
416
  fillPlain,
308
417
  slotOk,
309
418
  repoName,
419
+ cleanRepo,
310
420
  makeEvent,
311
421
  postEvents,
312
422
  chunks,
313
423
  reportFindings,
424
+ reportReviewFixes,
314
425
  readState,
315
426
  statePath,
316
427
  queuePath,