@arjunkhera/atlas 0.4.24 → 0.4.26
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/.claude-plugin/plugin.json +1 -1
- package/door/jobs/gate-audit.yaml +3 -1
- package/door/jobs/npm-release.yaml +24 -0
- package/door/lib/gate-check.mjs +46 -6
- package/door/lib/gate.mjs +30 -5
- package/door/lib/install.mjs +6 -5
- package/door/lib/jobs.mjs +98 -2
- package/door/lib/privacy.mjs +25 -3
- package/door/lib/private-terms.mjs +65 -0
- package/door/workflows/atlas-gate-audit.yml +2 -0
- package/door/workflows/atlas-gate.yml +3 -0
- package/package.json +1 -1
- package/shape/check.mjs +18 -2
- package/shape/releases.json +6 -0
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
# the same findings, with the same keys, so one break opens one issue.
|
|
5
5
|
#
|
|
6
6
|
# kind is one of: push (no pull request merged it), force-push,
|
|
7
|
-
# merge-before-green, gate-copy (a gate run not from the base workflow file)
|
|
7
|
+
# merge-before-green, gate-copy (a gate run not from the base workflow file),
|
|
8
|
+
# owner-unsigned (the gate sends the merge to the owner, and no signed act
|
|
9
|
+
# names its head; the push audit only, because it needs the clone).
|
|
8
10
|
# A closed issue does not stop a new one while its commit is in the window:
|
|
9
11
|
# anyone with the shared login can close or hand over an issue.
|
|
10
12
|
title: Each commit on the main branch came through the gate
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# An Atlas job (the routines design, records part). A job does nothing until a
|
|
2
|
+
# repo names it in its atlas.yaml, with the npm packages it publishes:
|
|
3
|
+
#
|
|
4
|
+
# jobs:
|
|
5
|
+
# atlas: [npm-release]
|
|
6
|
+
# npm: ['@scope/name']
|
|
7
|
+
#
|
|
8
|
+
# It fits a repo that publishes to npm through a release.yml on master that
|
|
9
|
+
# keeps the Atlas release record, as Atlas does. A service, a schema repo or a
|
|
10
|
+
# repo that publishes nothing does not name it.
|
|
11
|
+
#
|
|
12
|
+
# The check is Atlas code (door/lib/jobs.mjs). Each version that npm got in the
|
|
13
|
+
# last 2 days must have the proof that `atlas upgrade` asks for: a successful
|
|
14
|
+
# push run of release.yml on a master commit that recorded that version with
|
|
15
|
+
# the sha256 of its tarball. A branch can publish before any merge (the gate
|
|
16
|
+
# probes, P7), so a version with no such run is a finding. `problem` is a
|
|
17
|
+
# code: no-tag, not-on-master, never-tip or no-record. Only a person can act
|
|
18
|
+
# on it (deprecate the version on npm), so the job may only report.
|
|
19
|
+
title: Each new npm version was published by a release run on master
|
|
20
|
+
when: daily
|
|
21
|
+
needs: []
|
|
22
|
+
act: atlas:npm-release
|
|
23
|
+
may: report
|
|
24
|
+
fields: [package, version, problem, commit]
|
package/door/lib/gate-check.mjs
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
10
10
|
//
|
|
11
11
|
// The answer is success, owner or red:
|
|
12
12
|
//
|
|
13
|
-
// red a secret in an added line;
|
|
14
|
-
//
|
|
13
|
+
// red a secret in an added line; a private term in an added line or a
|
|
14
|
+
// path that ships, or no terms when atlas.yaml names shipped
|
|
15
|
+
// paths; product code that changed with no test case added or
|
|
16
|
+
// changed; a head that git cannot read
|
|
15
17
|
// owner a path or a sign of the owner-only list; a head job named like a
|
|
16
18
|
// check; a refactor claim (until the review job, roadmap R28); a
|
|
17
19
|
// file too large to scan; the brake (roadmap R13, brake.mjs)
|
|
@@ -31,7 +33,8 @@
|
|
|
31
33
|
import { spawnSync } from 'node:child_process';
|
|
32
34
|
import { readFileSync, appendFileSync } from 'node:fs';
|
|
33
35
|
import { resolve } from 'node:path';
|
|
34
|
-
import { classify, changedPaths, compile, matcher, fakeSecret } from './owner-only.mjs';
|
|
36
|
+
import { classify, changedPaths, compile, matcher, fakeSecret, repoFacts } from './owner-only.mjs';
|
|
37
|
+
import { MISSING, HIT, fileHits, hidePath, shippedPaths, ships, termsFromEnv } from './private-terms.mjs';
|
|
35
38
|
import { listChanged } from './changed-tests.mjs';
|
|
36
39
|
import { parseYaml, SECRET_PATTERNS } from '../../shape/check.mjs';
|
|
37
40
|
import { parseSigners, verifyTexts, findAct, SIGNERS_FILE, BRANCH_FORM } from './signed-act.mjs';
|
|
@@ -115,6 +118,39 @@ export function secretRows(root, base, head, changes) {
|
|
|
115
118
|
return rows;
|
|
116
119
|
}
|
|
117
120
|
|
|
121
|
+
// ------------------------------------------------------------------ private terms
|
|
122
|
+
|
|
123
|
+
// The private-terms rule (roadmap R18, design docs/design-docs/private-terms/).
|
|
124
|
+
// It holds when the base atlas.yaml names `private_terms: { paths }`, the
|
|
125
|
+
// paths that ship in public. `terms` comes from the repo secret. It reads the
|
|
126
|
+
// whole head text of each changed file that ships, binary or not, so a term
|
|
127
|
+
// split over two lines, or already in an old line, is found too. A row names
|
|
128
|
+
// the path, with [hidden] for a term in it, and the line, never the term.
|
|
129
|
+
export function termRows(root, base, head, changes, terms) {
|
|
130
|
+
const paths = shippedPaths(repoFacts(root, base).facts);
|
|
131
|
+
if (paths === null) return [];
|
|
132
|
+
if (!paths.length) return [{ kind: 'red', class: 'private-term', cause: 'atlas.yaml private_terms names no paths' }];
|
|
133
|
+
if (!terms.length) return [{ kind: 'red', class: 'private-term', cause: MISSING }];
|
|
134
|
+
const rows = [];
|
|
135
|
+
for (const change of changes) {
|
|
136
|
+
if (change.status === 'D' || change.newMode === '160000' || !ships(paths, change.path)) continue;
|
|
137
|
+
const size = Number((git(root, ['cat-file', '-s', `${head}:${change.path}`], { allowFail: true }) ?? '0').trim());
|
|
138
|
+
if (size > SCAN_LIMIT) { rows.push({ kind: 'red', class: 'private-term', path: change.path, cause: `${size} bytes: too large to check for private terms` }); continue; }
|
|
139
|
+
for (const hit of fileHits(change.path, textAt(root, head, change.path) ?? '', terms)) {
|
|
140
|
+
rows.push({ kind: 'red', class: 'private-term', path: hit.path, ...(hit.line ? { line: hit.line } : {}), cause: hit.line ? `the line ${HIT}` : `the path ${HIT}` });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
rows.push({ kind: 'note', class: 'private-term', cause: `private terms: ${terms.length} term(s), ${rows.length} hit(s)` });
|
|
144
|
+
return rows;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The answer with each term hidden in every path and cause, so no row of any
|
|
148
|
+
// rule shows a term in the summary, the log or the JSON.
|
|
149
|
+
export function hideTerms(answer, terms) {
|
|
150
|
+
if (!terms?.length) return answer;
|
|
151
|
+
return { ...answer, rows: answer.rows.map((row) => ({ ...row, ...(row.path ? { path: hidePath(row.path, terms) } : {}), cause: hidePath(String(row.cause ?? ''), terms) })) };
|
|
152
|
+
}
|
|
153
|
+
|
|
118
154
|
// ------------------------------------------------------------------ jobs named like a check
|
|
119
155
|
|
|
120
156
|
const WORKFLOW = /^\.github\/workflows\/[^/]+\.ya?ml$/;
|
|
@@ -230,7 +266,9 @@ function testRows({ root, mergeBase, head, body, redMain, rules, undo = null })
|
|
|
230
266
|
// the replay does, because the brake depends on the day of the run. `signed`
|
|
231
267
|
// is { repo, pr, baseRef, texts }: the texts are the comments of the pull
|
|
232
268
|
// request, read for a signed act, and baseRef is the name of its base branch.
|
|
233
|
-
|
|
269
|
+
// `terms` is the list of private terms, or undefined to skip that rule (the
|
|
270
|
+
// replay, which has no secret).
|
|
271
|
+
export function judge({ root, base, head, body = '', redMain = [], rules = null, brake = {}, now = new Date(), signed = null, terms = undefined }) {
|
|
234
272
|
const top = resolve(root);
|
|
235
273
|
const baseSha = commitOf(top, base);
|
|
236
274
|
if (!baseSha) throw new GateCheckError(`the base "${base}" is not a commit in this repo.`);
|
|
@@ -257,6 +295,7 @@ export function judge({ root, base, head, body = '', redMain = [], rules = null,
|
|
|
257
295
|
for (const row of classify({ root: top, base: mergeBase, head: headSha, rules }).rows) answer.rows.push({ kind: 'owner', class: row.class ?? 'unlisted', path: row.path, cause: row.cause });
|
|
258
296
|
answer.rows.push(...jobRows(top, rules ?? mergeBase, headSha, changes));
|
|
259
297
|
answer.rows.push(...secretRows(top, mergeBase, headSha, changes));
|
|
298
|
+
if (terms !== undefined) answer.rows.push(...termRows(top, baseSha, headSha, changes, terms));
|
|
260
299
|
answer.rows.push(...testRows({ root: top, mergeBase, head: headSha, body, redMain, rules, undo }));
|
|
261
300
|
if (signed) signedRows(top, baseSha, headSha, signed, answer);
|
|
262
301
|
return finish(answer);
|
|
@@ -353,7 +392,8 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
|
|
|
353
392
|
try { texts = comments(); } catch { texts = null; }
|
|
354
393
|
signed = { repo, pr: Number(pr), baseRef: baseRef && baseRef !== true ? String(baseRef) : null, texts };
|
|
355
394
|
}
|
|
356
|
-
const
|
|
395
|
+
const { terms } = termsFromEnv();
|
|
396
|
+
const answer = hideTerms(judge({ root, base, head, body, redMain, brake: Array.isArray(got) ? {} : got.brake ?? {}, signed, terms }), terms);
|
|
357
397
|
const text = renderText(answer);
|
|
358
398
|
if (json) process.stdout.write(`${JSON.stringify(answer, null, 2)}\n`);
|
|
359
399
|
else for (const each of text) line(each);
|
|
@@ -362,7 +402,7 @@ export function checkCommand({ root, base, head, body: bodyFile, json = false, s
|
|
|
362
402
|
if (output && output !== true) appendFileSync(output, `result=${answer.result}\nclass=${/^[a-z-]+$/.test(answer.class ?? '') ? answer.class : 'none'}\n`);
|
|
363
403
|
return answer.result === 'success' ? 0 : 1;
|
|
364
404
|
} catch (error) {
|
|
365
|
-
line(`gate check: ${error.message}`);
|
|
405
|
+
line(`gate check: ${hidePath(error.message, termsFromEnv().terms)}`);
|
|
366
406
|
// In the gate workflow (it passes --output), an error leaves a note with a
|
|
367
407
|
// fixed title. The audit reads it to tell an error from a result (roadmap
|
|
368
408
|
// R14 done line 5).
|
package/door/lib/gate.mjs
CHANGED
|
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
|
|
|
20
20
|
import { dirname, join } from 'node:path';
|
|
21
21
|
import { childEnvironment } from '../../work/lib/child-env.mjs';
|
|
22
22
|
import { parseYaml } from '../../shape/check.mjs';
|
|
23
|
-
import { checkCommand, replayCommand, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
|
|
23
|
+
import { checkCommand, replayCommand, judge, jobNames, GATE_CHECK, GATE_WORKFLOW } from './gate-check.mjs';
|
|
24
24
|
import { keepFindings, gateErrors, pinCode, slug, AUDIT_JOB } from './gate-audit.mjs';
|
|
25
25
|
import { MARKER as SIGNED_MARKER } from './signed-act.mjs';
|
|
26
26
|
import { brakeFacts, windowCommits, ciTrips, BrakeError } from './brake.mjs';
|
|
@@ -422,7 +422,7 @@ export function keepReady({ repo, pr, sha, code, home = homedir(), now = new Dat
|
|
|
422
422
|
|
|
423
423
|
// `atlas gate audit --since <date>`: each pull request merged since the date
|
|
424
424
|
// whose required checks were not all green at the moment of the merge.
|
|
425
|
-
export function auditCommand({ repo, since, commit, errors = false, forced = false, findings }, line, gh = ghApi) {
|
|
425
|
+
export function auditCommand({ repo, root, since, commit, errors = false, forced = false, findings }, line, gh = ghApi) {
|
|
426
426
|
// R14: with --findings, each finding also goes into the file, and `atlas
|
|
427
427
|
// jobs file` opens one issue for each. A call that stops with an error
|
|
428
428
|
// marks the file broken, so the file job ends red.
|
|
@@ -431,7 +431,7 @@ export function auditCommand({ repo, since, commit, errors = false, forced = fal
|
|
|
431
431
|
try {
|
|
432
432
|
let code;
|
|
433
433
|
if (errors) code = gateErrors({ repo, found }, line, gh);
|
|
434
|
-
else if (commit !== undefined) code = commitAudit({ repo, commit, forced, found }, line, gh);
|
|
434
|
+
else if (commit !== undefined) code = commitAudit({ repo, root, commit, forced, found }, line, gh);
|
|
435
435
|
else code = sinceAudit({ repo, since }, line, gh);
|
|
436
436
|
keep();
|
|
437
437
|
return code;
|
|
@@ -472,7 +472,22 @@ function sinceAudit({ repo, since }, line, gh) {
|
|
|
472
472
|
// gate run of that pull request came from the base copy of the gate
|
|
473
473
|
// workflow. A commit that no pull request merged was pushed past the gate, so
|
|
474
474
|
// it is red. With --forced, the push that brought it was a force push.
|
|
475
|
-
|
|
475
|
+
// R14 done line 3: a merge that the gate sent to the owner needs the owner's
|
|
476
|
+
// signed act for its head (R72). The audit judges the merged head again with
|
|
477
|
+
// the base commit's rules, at the time of the merge, and reads the signed acts
|
|
478
|
+
// of the pull request. It needs both commits in the clone. Returns 'owner'
|
|
479
|
+
// when no signed act clears it, 'unread' when it cannot judge, else null.
|
|
480
|
+
export function ownerUnsigned({ root, repo, pull, parent }, gh) {
|
|
481
|
+
const head = pull.head?.sha;
|
|
482
|
+
const has = (sha) => { try { execFileSync('git', ['-C', root, 'cat-file', '-e', `${sha}^{commit}`], { stdio: 'ignore' }); return true; } catch { return false; } };
|
|
483
|
+
if (!/^[0-9a-f]{40}$/.test(String(head)) || !/^[0-9a-f]{40}$/.test(String(parent)) || !has(head) || !has(parent)) return 'unread';
|
|
484
|
+
let texts = null;
|
|
485
|
+
try { texts = prComments(gh, repo, pull.number); } catch { texts = null; }
|
|
486
|
+
const answer = judge({ root, base: parent, head, body: String(pull.body ?? ''), now: new Date(pull.merged_at), signed: { repo, pr: Number(pull.number), baseRef: pull.base?.ref ?? null, texts } });
|
|
487
|
+
return answer.result === 'owner' ? 'owner' : null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export function commitAudit({ repo, root = null, commit, forced = false, found = [] }, line, gh) {
|
|
476
491
|
if (!commit || commit === true || !/^[0-9a-f]{7,40}$/i.test(String(commit))) throw new GateError('--commit needs a commit SHA.');
|
|
477
492
|
// GitHub expands a short SHA, and names the first parent: the main branch
|
|
478
493
|
// just before this commit, whose atlas.yaml held the gate at that moment.
|
|
@@ -498,6 +513,16 @@ export function commitAudit({ repo, commit, forced = false, found = [] }, line,
|
|
|
498
513
|
found.push({ job: AUDIT_JOB, key: `gate-copy:${sha}`, kind: 'gate-copy', sha, pr: Number(pull.number), ...(one.run ? { run_id: Number(one.run) } : {}), pin: pinCode(one.why) });
|
|
499
514
|
}
|
|
500
515
|
const parent = fetched.parents?.[0]?.sha ?? pull.base.sha;
|
|
516
|
+
if (root) {
|
|
517
|
+
const owner = ownerUnsigned({ root, repo, pull, parent }, gh);
|
|
518
|
+
if (owner) {
|
|
519
|
+
red = true;
|
|
520
|
+
line(owner === 'owner'
|
|
521
|
+
? `- ✗ #${pull.number} merged while the gate sent it to the owner, and no signed act names its head ${String(pull.head?.sha).slice(0, 7)}.`
|
|
522
|
+
: `- ✗ #${pull.number}: the head or the base commit is not in this clone, so the audit cannot judge it.`);
|
|
523
|
+
found.push({ job: AUDIT_JOB, key: `owner-unsigned:${sha}`, kind: 'owner-unsigned', sha, pr: Number(pull.number), ...(owner === 'unread' ? { pin: 'unread' } : {}) });
|
|
524
|
+
}
|
|
525
|
+
} else line(`- atlas gate audit: no clone (--root), so the owner check of #${pull.number} did not run.`);
|
|
501
526
|
let checks;
|
|
502
527
|
try { checks = checksAt(gh, repo, parent); } catch (error) {
|
|
503
528
|
if (!(error instanceof GateError)) throw error;
|
|
@@ -629,7 +654,7 @@ export function gateCommand({ what, repo, root, dryRun = false, off = false, on
|
|
|
629
654
|
}
|
|
630
655
|
return code;
|
|
631
656
|
}
|
|
632
|
-
if (what === 'audit') return auditCommand({ repo, since, commit, errors, forced, findings }, line, gh);
|
|
657
|
+
if (what === 'audit') return auditCommand({ repo, root, since, commit, errors, forced, findings }, line, gh);
|
|
633
658
|
if (off && on) throw new GateError('pick --off or --on, not both.');
|
|
634
659
|
const mode = off ? 'off' : on ? 'on' : 'apply';
|
|
635
660
|
const facts = mode === 'apply' ? gateFacts(root) : null;
|
package/door/lib/install.mjs
CHANGED
|
@@ -218,12 +218,13 @@ export const recordName = (version, sha256) => `atlas-release-${version}-${sha25
|
|
|
218
218
|
// commit must once have been the tip of master. A tag that a person made or
|
|
219
219
|
// moved finds no record, so the upgrade refuses. With no sha256 (a loaded
|
|
220
220
|
// copy has no tarball), any record of this version counts, and the byte
|
|
221
|
-
// compare of every file does the rest.
|
|
221
|
+
// compare of every file does the rest. A refusal carries a `code` too, for
|
|
222
|
+
// the npm-release job of the watcher (jobs.mjs).
|
|
222
223
|
export function findRelease({ gh, repo, version, sha256 }) {
|
|
223
224
|
const commit = tagCommit(gh, repo, version);
|
|
224
|
-
if (!commit) return { problem: `no tag v${version} names a commit` };
|
|
225
|
-
if (!onMaster(gh, repo, commit)) return { problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which is not on master` };
|
|
226
|
-
if (!wasTip(gh, repo, commit)) return { problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which was never the tip of master through a merge or a push` };
|
|
225
|
+
if (!commit) return { code: 'no-tag', problem: `no tag v${version} names a commit` };
|
|
226
|
+
if (!onMaster(gh, repo, commit)) return { code: 'not-on-master', commit, problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which is not on master` };
|
|
227
|
+
if (!wasTip(gh, repo, commit)) return { code: 'never-tip', commit, problem: `the tag v${version} names commit ${commit.slice(0, 12)}, which was never the tip of master through a merge or a push` };
|
|
227
228
|
const runs = gh(['api', '--method', 'GET', `repos/${repo}/actions/workflows/release.yml/runs?event=push&status=success&head_sha=${commit}&per_page=100`]).workflow_runs ?? [];
|
|
228
229
|
const prefix = recordName(version, '');
|
|
229
230
|
const named = (name) => (sha256 ? name === recordName(version, sha256) : name.startsWith(prefix) && /^[0-9a-f]{64}$/.test(name.slice(prefix.length)));
|
|
@@ -234,7 +235,7 @@ export function findRelease({ gh, repo, version, sha256 }) {
|
|
|
234
235
|
const artifacts = gh(['api', '--method', 'GET', `repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`]).artifacts ?? [];
|
|
235
236
|
if (artifacts.some((artifact) => named(artifact.name ?? ''))) return { run, commit };
|
|
236
237
|
}
|
|
237
|
-
return { problem: `no successful release run on master commit ${commit.slice(0, 12)} recorded ${version}${sha256 ? ' with this tarball' : ''}` };
|
|
238
|
+
return { code: 'no-record', commit, problem: `no successful release run on master commit ${commit.slice(0, 12)} recorded ${version}${sha256 ? ' with this tarball' : ''}` };
|
|
238
239
|
}
|
|
239
240
|
|
|
240
241
|
// The blob ids that the version files of the tarball must have: the files of
|
package/door/lib/jobs.mjs
CHANGED
|
@@ -24,7 +24,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
|
|
|
24
24
|
import { dirname, join, resolve } from 'node:path';
|
|
25
25
|
import { fileURLToPath } from 'node:url';
|
|
26
26
|
import { parseYaml } from '../../shape/check.mjs';
|
|
27
|
+
import { createHash } from 'node:crypto';
|
|
27
28
|
import { api, apiBase, repoOk, openIssues, TOKEN_SHAPE, DEFAULT_BOT, DEFAULT_MAX } from './issues.mjs';
|
|
29
|
+
import { findRelease, sha512Integrity } from './install.mjs';
|
|
28
30
|
import { checkGateFiles, checkGateAudit, checkGateErrors } from './gate-audit.mjs';
|
|
29
31
|
import { commitAudit, ghApi } from './gate.mjs';
|
|
30
32
|
|
|
@@ -107,7 +109,100 @@ export async function checkRedMain({ repo, branch, env }) {
|
|
|
107
109
|
return findings;
|
|
108
110
|
}
|
|
109
111
|
|
|
110
|
-
//
|
|
112
|
+
// ---------------------------------------------------------------- the check of npm-release
|
|
113
|
+
|
|
114
|
+
// A branch of the repo can run its own release.yml and publish to npm before any merge (the gate
|
|
115
|
+
// probes, P7). So each version that npm got in the last NPM_DAYS days must have the proof that
|
|
116
|
+
// `atlas upgrade` asks for: a successful push run of release.yml on a master commit that recorded
|
|
117
|
+
// this version with the sha256 of this exact tarball (findRelease, install.mjs). The watcher runs
|
|
118
|
+
// once a day, so two days cover one missed run. A longer window reaches versions from before the
|
|
119
|
+
// release record existed (Atlas 0.4.0), and the tip proof reads only the newest 100 changes of master.
|
|
120
|
+
export const NPM_DAYS = 2;
|
|
121
|
+
export const NPM_MAX = 50;
|
|
122
|
+
const PACKAGE = /^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/;
|
|
123
|
+
|
|
124
|
+
// The npm packages that atlas.yaml names under `jobs: { npm: [...] }`.
|
|
125
|
+
export function npmPackages(root) {
|
|
126
|
+
const facts = parseYaml(readFileSync(join(root, 'atlas.yaml'), 'utf8')) ?? {};
|
|
127
|
+
const named = facts.jobs?.npm;
|
|
128
|
+
if (!Array.isArray(named) || !named.length) throw new Error('atlas.yaml names no package under jobs.npm');
|
|
129
|
+
const bad = named.map(String).find((name) => !PACKAGE.test(name));
|
|
130
|
+
if (bad !== undefined) throw new Error('jobs.npm in atlas.yaml holds a name that is not an npm package name');
|
|
131
|
+
return named.map(String);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// https, or http for this machine only (a test uses a fake registry on 127.0.0.1).
|
|
135
|
+
function registryBase(env) {
|
|
136
|
+
const text = String(env.NPM_REGISTRY_URL || 'https://registry.npmjs.org');
|
|
137
|
+
let url;
|
|
138
|
+
try { url = new URL(text); } catch { throw usage('NPM_REGISTRY_URL is not a URL'); }
|
|
139
|
+
const local = url.hostname === '127.0.0.1' || url.hostname === 'localhost';
|
|
140
|
+
if (!(url.protocol === 'https:' || (url.protocol === 'http:' && local))) throw usage('NPM_REGISTRY_URL must be https (http is allowed only for 127.0.0.1 or localhost)');
|
|
141
|
+
return url;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// findRelease reads GitHub through a sync `gh`. Here each read is a fetch, so the proof runs again
|
|
145
|
+
// after each read it asked for, until it needs none. Every read is a GET, and the same path gives the
|
|
146
|
+
// same answer, so a replay changes nothing.
|
|
147
|
+
export async function replayRelease({ base, key, repo, version, sha256 }) {
|
|
148
|
+
const answers = new Map();
|
|
149
|
+
for (let round = 0; round < 50; round += 1) {
|
|
150
|
+
try {
|
|
151
|
+
return findRelease({
|
|
152
|
+
repo, version, sha256,
|
|
153
|
+
gh: (args) => {
|
|
154
|
+
const path = `/${args.at(-1)}`;
|
|
155
|
+
if (!answers.has(path)) throw Object.assign(new Error('a read is still to come'), { need: path });
|
|
156
|
+
const answer = answers.get(path);
|
|
157
|
+
if (answer instanceof Error) throw answer;
|
|
158
|
+
return answer;
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (!error.need) throw error;
|
|
163
|
+
const r = await api(base, key, 'GET', error.need);
|
|
164
|
+
if (r.status === 404) answers.set(error.need, Object.assign(new Error('Not Found'), { stderr: 'HTTP 404' }));
|
|
165
|
+
else if (!r.ok) throw new Error(`GitHub answered ${r.status} when the release of ${version} was read`);
|
|
166
|
+
else answers.set(error.need, r.data);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
throw new Error(`the release of ${version} needed more than 50 reads`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function checkNpmRelease({ repo, env, root, now = Date.now(), fetchImpl = fetch }) {
|
|
173
|
+
const base = apiBase(env);
|
|
174
|
+
const key = env.GITHUB_TOKEN || '';
|
|
175
|
+
if (!key) throw usage('there is no token: set GITHUB_TOKEN in the environment');
|
|
176
|
+
const registry = registryBase(env);
|
|
177
|
+
const findings = [];
|
|
178
|
+
for (const name of npmPackages(root)) {
|
|
179
|
+
const response = await fetchImpl(new URL(name.replace('/', '%2F'), `${registry.href.replace(/\/?$/, '/')}`));
|
|
180
|
+
if (!response.ok) throw new Error(`npm answered ${response.status} when ${name} was read`);
|
|
181
|
+
const doc = await response.json();
|
|
182
|
+
// A broken answer must read broken, never none: each version needs a publish time.
|
|
183
|
+
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
184
|
+
if (!object(doc?.versions) || !object(doc?.time)) throw new Error(`npm sent no versions and times for ${name}`);
|
|
185
|
+
if (Object.keys(doc.versions).some((version) => !Number.isFinite(Date.parse(doc.time[version])))) throw new Error(`npm sent a version of ${name} with no publish time`);
|
|
186
|
+
const recent = Object.keys(doc.versions).filter((version) => now - Date.parse(doc.time[version]) <= NPM_DAYS * DAY);
|
|
187
|
+
if (recent.length > NPM_MAX) throw new Error(`npm holds ${recent.length} versions of ${name} from the last ${NPM_DAYS} days, more than ${NPM_MAX}`);
|
|
188
|
+
for (const version of recent.sort()) {
|
|
189
|
+
if (!VALUE.test(version)) throw new Error(`npm holds a version of ${name} that is not a plain version`);
|
|
190
|
+
const dist = doc.versions[version].dist ?? {};
|
|
191
|
+
const tarball = new URL(String(dist.tarball ?? ''), registry);
|
|
192
|
+
if (tarball.origin !== registry.origin) throw new Error(`the tarball of ${name}@${version} is not on the registry`);
|
|
193
|
+
const got = await fetchImpl(tarball);
|
|
194
|
+
if (!got.ok) throw new Error(`npm answered ${got.status} when the tarball of ${name}@${version} was read`);
|
|
195
|
+
const bytes = Buffer.from(await got.arrayBuffer());
|
|
196
|
+
if (sha512Integrity(bytes) !== dist.integrity) throw new Error(`the tarball of ${name}@${version} does not match the integrity npm records`);
|
|
197
|
+
const release = await replayRelease({ base, key, repo, version, sha256: createHash('sha256').update(bytes).digest('hex') });
|
|
198
|
+
if (!release.problem) continue;
|
|
199
|
+
findings.push({ job: 'npm-release', key: `${name}@${version}`, package: name, version, problem: release.code, ...(release.commit ? { commit: release.commit } : {}) });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return findings;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// The check of each Atlas job, by its id, else by its `when`. A job whose check is not built yet reads `none` and
|
|
111
206
|
// says so in the notes; it never reads `found` from nothing.
|
|
112
207
|
// The gate jobs (roadmap R14) read GitHub through `gh`, which the Actions
|
|
113
208
|
// runner holds; their token is the run's own GITHUB_TOKEN.
|
|
@@ -116,6 +211,7 @@ const CHECKS = {
|
|
|
116
211
|
'gate-files': ({ root, env, fetchImpl }) => checkGateFiles({ root, env, fetchImpl }),
|
|
117
212
|
'gate-audit': ({ repo, branch, gh }) => checkGateAudit({ repo, branch, gh, audit: commitAudit }),
|
|
118
213
|
'gate-errors': ({ repo, gh }) => checkGateErrors({ repo, gh }),
|
|
214
|
+
'npm-release': checkNpmRelease,
|
|
119
215
|
};
|
|
120
216
|
|
|
121
217
|
export async function jobsCheck({ root, repo, branch = null, env = process.env, jobs = atlasJobs(), gh = ghApi, fetchImpl = fetch }) {
|
|
@@ -128,7 +224,7 @@ export async function jobsCheck({ root, repo, branch = null, env = process.env,
|
|
|
128
224
|
for (const id of named) {
|
|
129
225
|
const job = jobs.get(id);
|
|
130
226
|
if (!job) { notes.push(`${JOB_ID.test(id) ? id : '(a bad id)'}: no Atlas job has this id`); continue; }
|
|
131
|
-
const check = CHECKS[job.when];
|
|
227
|
+
const check = CHECKS[job.id] ?? CHECKS[job.when];
|
|
132
228
|
if (!check) { notes.push(`${id}: its check is not built yet`); ran.push({ job: id, state: 'none' }); continue; }
|
|
133
229
|
try {
|
|
134
230
|
if (!mainBranch) mainBranch = await defaultBranch({ repo, env });
|
package/door/lib/privacy.mjs
CHANGED
|
@@ -36,15 +36,37 @@ export function loadPrivateTerms({ path = null, home = homedir() } = {}) {
|
|
|
36
36
|
if (path) throw new Error(`the private terms file ${path} does not exist.`);
|
|
37
37
|
return [];
|
|
38
38
|
}
|
|
39
|
-
return readFileSync(file, 'utf8')
|
|
39
|
+
return parseTerms(readFileSync(file, 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// The terms of one terms file's text: one term on each line, and a line that
|
|
43
|
+
// starts with # is a note. The Mac file and the repo secret share this form.
|
|
44
|
+
export function parseTerms(text) {
|
|
45
|
+
return String(text ?? '').split('\n').map((line) => line.trim()).filter((line) => line && !line.startsWith('#'));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Text as the term rules read it: NFKC form, with the marks that do not
|
|
49
|
+
// show (a soft hyphen, the zero-width marks, a byte-order mark) taken out.
|
|
50
|
+
// It keeps every line break, so a line number stays true.
|
|
51
|
+
const INVISIBLE = /[\u00AD\u200B-\u200D\u2060\uFEFF]/g;
|
|
52
|
+
export const termText = (text) => String(text ?? '').normalize('NFKC').replace(INVISIBLE, '');
|
|
53
|
+
|
|
54
|
+
// One rule for each private term. A term matches only as a whole word, in any
|
|
55
|
+
// case. The words of a term may be split by any run of white space, also a
|
|
56
|
+
// line break, so a wrapped paragraph still matches when the caller reads the
|
|
57
|
+
// whole text.
|
|
58
|
+
export function termRules(terms = []) {
|
|
59
|
+
return terms.map((term) => {
|
|
60
|
+
const words = termText(term).split(/\s+/).filter(Boolean).map(escape);
|
|
61
|
+
return { id: 'private-term', holds: 'One of the owner\'s private terms.', pattern: new RegExp(`(?<![\\p{L}\\p{N}])${words.join('\\s+')}(?![\\p{L}\\p{N}])`, 'iu') };
|
|
62
|
+
});
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
// Every rule this filter applies: the secret patterns, the generic patterns,
|
|
43
66
|
// then one rule for each private term.
|
|
44
67
|
export function privacyRules({ terms = [] } = {}) {
|
|
45
68
|
const secrets = secretPatterns().map((pattern, index) => ({ id: `secret-${SECRET_PATTERNS[index].id}`, holds: SECRET_PATTERNS[index].holds, pattern }));
|
|
46
|
-
|
|
47
|
-
return [...secrets, ...PRIVACY_PATTERNS, ...own];
|
|
69
|
+
return [...secrets, ...PRIVACY_PATTERNS, ...termRules(terms)];
|
|
48
70
|
}
|
|
49
71
|
|
|
50
72
|
// Each finding names its line and its rule, never the text it matched, so a
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The private-terms check of a public package (roadmap R18, tracker item
|
|
2
|
+
// 437b99c1; design docs/design-docs/private-terms/). The release build runs
|
|
3
|
+
// it on each file of the tarball, and the gate runs it on each added line
|
|
4
|
+
// under the shipped paths of a pull request.
|
|
5
|
+
//
|
|
6
|
+
// The terms come from the repo secret ATLAS_PRIVATE_TERMS, in the form of the
|
|
7
|
+
// owner's terms file. This plan gives a private repo no environments, so any
|
|
8
|
+
// workflow run of this repo can read that secret, on any branch. A fork
|
|
9
|
+
// cannot. The design accepts that: who can push here can already publish.
|
|
10
|
+
//
|
|
11
|
+
// A hit names the path and the line, never the term. A path that holds a
|
|
12
|
+
// term shows [hidden] in its place. The text is read in NFKC form with the
|
|
13
|
+
// marks that do not show taken out, and the words of a term may be split by
|
|
14
|
+
// any white space.
|
|
15
|
+
import { parseTerms, redact, termRules, termText } from './privacy.mjs';
|
|
16
|
+
|
|
17
|
+
export const TERMS_ENV = 'ATLAS_PRIVATE_TERMS';
|
|
18
|
+
export const MISSING = `the private terms are missing: the repo secret ${TERMS_ENV} is empty`;
|
|
19
|
+
export const HIT = 'holds a private term';
|
|
20
|
+
|
|
21
|
+
// The terms from the environment. `set` is false when the variable is absent
|
|
22
|
+
// or holds notes only.
|
|
23
|
+
export function termsFromEnv(env = process.env) {
|
|
24
|
+
const terms = parseTerms(env[TERMS_ENV]);
|
|
25
|
+
return { set: terms.length > 0, terms };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// The text with each term replaced by [hidden]. Text with no term comes back
|
|
29
|
+
// as it was.
|
|
30
|
+
export function hidePath(text, terms) {
|
|
31
|
+
if (!terms?.length) return text;
|
|
32
|
+
const clean = termText(text);
|
|
33
|
+
const hidden = redact(clean, { rules: termRules(terms) }).replace(/\[hidden: private-term\]/g, '[hidden]');
|
|
34
|
+
return hidden === clean ? text : hidden;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The hits of one file: { path, line } for the first line of each match, and
|
|
38
|
+
// { path, line: null } when the path holds a term. `path` is already hidden.
|
|
39
|
+
// The whole text is read at once, so a term split over two lines still
|
|
40
|
+
// matches.
|
|
41
|
+
export function fileHits(path, text, terms) {
|
|
42
|
+
const rules = termRules(terms);
|
|
43
|
+
const shown = hidePath(path, terms);
|
|
44
|
+
const hits = [];
|
|
45
|
+
if (rules.some((rule) => rule.pattern.test(termText(path)))) hits.push({ path: shown, line: null });
|
|
46
|
+
const body = termText(text);
|
|
47
|
+
const lines = new Set();
|
|
48
|
+
for (const rule of rules) {
|
|
49
|
+
const all = new RegExp(rule.pattern.source, `${rule.pattern.flags}g`);
|
|
50
|
+
for (const match of body.matchAll(all)) lines.add(body.slice(0, match.index).split('\n').length);
|
|
51
|
+
}
|
|
52
|
+
for (const line of [...lines].sort((a, b) => a - b)) hits.push({ path: shown, line });
|
|
53
|
+
return hits;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The shipped paths that atlas.yaml names under private_terms.paths, or null
|
|
57
|
+
// when the key is not there.
|
|
58
|
+
export function shippedPaths(facts) {
|
|
59
|
+
const entry = facts?.private_terms;
|
|
60
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
|
61
|
+
const paths = Array.isArray(entry.paths) ? entry.paths.filter((path) => typeof path === 'string' && path) : [];
|
|
62
|
+
return paths;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const ships = (paths, path) => paths.some((prefix) => (prefix.endsWith('/') ? path.startsWith(prefix) : path === prefix || path.startsWith(`${prefix}/`)));
|
|
@@ -12,6 +12,8 @@ on:
|
|
|
12
12
|
# 3. A merge made before the required checks of its pull request were
|
|
13
13
|
# green. A check counts only from a run of the base file that defines it.
|
|
14
14
|
# 4. A gate run that is not from the base copy of atlas-gate.yml.
|
|
15
|
+
# 5. A merge that the gate sends to the owner, with no signed act of the
|
|
16
|
+
# owner for its head (R72). The audit judges the head again from git.
|
|
15
17
|
# Each finding opens one `atlas-job` issue, and the run ends red. It does not
|
|
16
18
|
# undo the push.
|
|
17
19
|
#
|
|
@@ -81,6 +81,8 @@ jobs:
|
|
|
81
81
|
- name: Read the pull request text from the event
|
|
82
82
|
run: node -e 'const fs=require("fs");const e=JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH,"utf8"));fs.writeFileSync(process.env.RUNNER_TEMP+"/body.md",String(e.pull_request.body??""))'
|
|
83
83
|
|
|
84
|
+
# Only this step gets the private terms (roadmap R18). It runs the base
|
|
85
|
+
# code, and reads the head as git data.
|
|
84
86
|
- name: Gate check
|
|
85
87
|
id: check
|
|
86
88
|
env:
|
|
@@ -88,6 +90,7 @@ jobs:
|
|
|
88
90
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
89
91
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
|
90
92
|
GH_TOKEN: ${{ github.token }}
|
|
93
|
+
ATLAS_PRIVATE_TERMS: ${{ secrets.ATLAS_PRIVATE_TERMS }}
|
|
91
94
|
run: |
|
|
92
95
|
set -euo pipefail
|
|
93
96
|
node packages/atlas/bin/atlas gate check --root . --repo "$GITHUB_REPOSITORY" --base "$GITHUB_SHA" --head "$HEAD_SHA" --pr "$PR" --base-ref "$BASE_REF" --body "$RUNNER_TEMP/body.md" --summary "$GITHUB_STEP_SUMMARY" --output "$GITHUB_OUTPUT"
|
package/package.json
CHANGED
package/shape/check.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export const sha256 = (text) => createHash('sha256').update(String(text ?? ''),
|
|
|
38
38
|
// Whether a copy is current or behind is not decided here. CI has no package
|
|
39
39
|
// and no network. The session verb decides it, from the release list that
|
|
40
40
|
// ships beside this file in the package, shape/releases.json.
|
|
41
|
-
export const CHECK_VERSION =
|
|
41
|
+
export const CHECK_VERSION = 8;
|
|
42
42
|
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
44
|
// The spec. One object per version. The check reads a version it has here and
|
|
@@ -1232,6 +1232,22 @@ function workflowStructure(document) {
|
|
|
1232
1232
|
return out;
|
|
1233
1233
|
}
|
|
1234
1234
|
|
|
1235
|
+
// The shape check takes no secret. The rule reads the job that runs the check
|
|
1236
|
+
// and the workflow-wide env, which every job gets. Another job of the same file
|
|
1237
|
+
// may give one step a secret: the gate step gets the private terms (roadmap
|
|
1238
|
+
// R18), and it runs the base code too. When the file cannot be read, or no job
|
|
1239
|
+
// runs the check, the rule reads the whole text.
|
|
1240
|
+
function readsSecret(text) {
|
|
1241
|
+
const READS = /\bsecrets\./;
|
|
1242
|
+
let document = null;
|
|
1243
|
+
try { document = parseYaml(text); } catch { return READS.test(text); }
|
|
1244
|
+
const jobs = document?.jobs && typeof document.jobs === 'object' ? Object.values(document.jobs) : [];
|
|
1245
|
+
const job = jobs.find((candidate) => (Array.isArray(candidate?.steps) ? candidate.steps : [])
|
|
1246
|
+
.some((step) => typeof step?.run === 'string' && step.run.includes(GATE_RUNNER.runs)));
|
|
1247
|
+
if (!job) return READS.test(text);
|
|
1248
|
+
return READS.test(JSON.stringify(job)) || READS.test(JSON.stringify(document.env ?? null));
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1235
1251
|
function checkWorkflow(context, part) {
|
|
1236
1252
|
const text = readText(context.root, part.path);
|
|
1237
1253
|
if (text === null || !text.trim()) return result(part, 'empty', [`${part.path} is missing or holds nothing`]);
|
|
@@ -1254,7 +1270,7 @@ function checkWorkflow(context, part) {
|
|
|
1254
1270
|
// a block map and a flow map for both permissions and steps, so read both
|
|
1255
1271
|
// spellings or a workflow can widen itself past the check (review R-3).
|
|
1256
1272
|
for (const grant of writeGrants(text)) findings.push(`the workflow grants ${grant}; this check needs read access only`);
|
|
1257
|
-
if (
|
|
1273
|
+
if (readsSecret(text)) findings.push('the workflow reads a repository secret; this check takes none');
|
|
1258
1274
|
const unpinned = [...text.matchAll(/uses[ \t]*:[ \t]*["']?([^\s,}\]"']+)/g)]
|
|
1259
1275
|
.map(([, value]) => value)
|
|
1260
1276
|
.filter((value) => !value.startsWith('./'))
|
package/shape/releases.json
CHANGED
|
@@ -40,6 +40,12 @@
|
|
|
40
40
|
{
|
|
41
41
|
"check_version": 7,
|
|
42
42
|
"sha256": "b29fdb036170419b9f0642ef0ca7da45a80ff53492421eb57beaf157df13d057",
|
|
43
|
+
"first_package": "0.4.17",
|
|
44
|
+
"workflow_sha256": "fcddebef030e6564f97e1a5fb81c8ad1b3084ebef9622167c6565698312dcdd7"
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
"check_version": 8,
|
|
48
|
+
"sha256": "8cf521f6e8f89ae65a257c521c583281e0c3c27ef968b4bd96eb336a8d095795",
|
|
43
49
|
"workflow_sha256": "fcddebef030e6564f97e1a5fb81c8ad1b3084ebef9622167c6565698312dcdd7"
|
|
44
50
|
}
|
|
45
51
|
]
|