@davidvornholt/standards 0.26.4 → 0.28.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/README.md +36 -1
- package/package.json +6 -2
- package/src/cli.ts +127 -16
- package/src/creds-add-github.ts +12 -4
- package/src/creds-add.ts +14 -1
- package/src/creds-commands.ts +7 -1
- package/src/creds-github-apps.ts +71 -2
- package/src/creds-login-cloudflare.ts +6 -6
- package/src/creds-login-github.ts +6 -2
- package/src/creds-naming.ts +4 -1
- package/src/creds-plan-renew.ts +14 -2
- package/src/creds-plan-report.ts +1 -1
- package/src/creds-plan-unmanaged.ts +4 -5
- package/src/creds-plan.ts +1 -1
- package/src/creds-revoke-duplicate.ts +127 -0
- package/src/creds-revoke.ts +36 -15
- package/src/creds-store-lock-inspection.ts +26 -21
- package/src/creds-store-lock.ts +14 -1
- package/src/dependabot-directory-overlap.ts +100 -8
- package/src/dependabot-update.ts +11 -0
- package/src/dev-env-brokered-allowlist.ts +2 -2
- package/src/dev-env-brokered-preflight.ts +69 -0
- package/src/dev-env-brokered-preserve.ts +2 -1
- package/src/dev-env-brokered-refresh.ts +1 -1
- package/src/dev-env-brokered-resolve.ts +33 -22
- package/src/dev-env-brokered-source.ts +128 -0
- package/src/dev-env-brokered.ts +15 -2
- package/src/dev-env.ts +22 -0
- package/src/github-api.ts +5 -2
- package/src/github-apply.ts +26 -2
- package/src/github-bypass-actors.ts +3 -3
- package/src/github-ruleset-diff.ts +12 -19
- package/src/github-ruleset-visibility.ts +2 -4
- package/src/github-settings-parse.ts +86 -0
- package/src/sops-action-callers.ts +133 -0
- package/src/structure-check.ts +5 -1
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// layer's reference, and a reference that lost the merge is never decrypted.
|
|
5
5
|
|
|
6
6
|
import { lookupS3Pair } from './creds-r2';
|
|
7
|
-
import { resolveTargetRelResult } from './creds-target';
|
|
8
7
|
import type { BrokeredS3Reference } from './dev-env-brokered';
|
|
8
|
+
import { prepareBrokeredSources } from './dev-env-brokered-preflight';
|
|
9
|
+
import { brokeredReferenceIdentity } from './dev-env-brokered-source';
|
|
9
10
|
import type { ComposedDevEnvTarget } from './dev-env-compose';
|
|
10
11
|
import { encodePortableDotenvValue } from './dev-env-dotenv-value';
|
|
11
12
|
import { decryptSopsJson, type SopsJsonResult } from './sops-exec';
|
|
@@ -55,39 +56,49 @@ export const resolveBrokeredReferences = (
|
|
|
55
56
|
): ResolvedDevEnv => {
|
|
56
57
|
const problems: Array<string> = [];
|
|
57
58
|
const documents = new Map<string, SopsJsonResult>();
|
|
58
|
-
const
|
|
59
|
-
|
|
59
|
+
const preflight = prepareBrokeredSources(
|
|
60
|
+
consumer,
|
|
61
|
+
targets,
|
|
62
|
+
allowedReferences,
|
|
63
|
+
preservedReferences,
|
|
64
|
+
);
|
|
65
|
+
if (preflight.problems.length > 0) {
|
|
66
|
+
return {
|
|
67
|
+
problems: preflight.problems,
|
|
68
|
+
targets: targets.map((target) => ({
|
|
69
|
+
...target,
|
|
70
|
+
env: Object.fromEntries(
|
|
71
|
+
Object.entries(target.env).filter(
|
|
72
|
+
(entry): entry is [string, string] => typeof entry[1] === 'string',
|
|
73
|
+
),
|
|
74
|
+
),
|
|
75
|
+
})),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const { sources } = preflight;
|
|
79
|
+
const readDocument = (reference: BrokeredS3Reference): SopsJsonResult => {
|
|
80
|
+
const source = sources.get(reference);
|
|
81
|
+
if (source === undefined) {
|
|
82
|
+
return { ok: false, problem: 'unresolved source target' };
|
|
83
|
+
}
|
|
84
|
+
const documentKey = `${source.root}/${source.rel}`;
|
|
85
|
+
const cached = documents.get(documentKey);
|
|
60
86
|
if (cached !== undefined) {
|
|
61
87
|
return cached;
|
|
62
88
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
? decryptSopsJson(consumer, resolved.rel)
|
|
66
|
-
: {
|
|
67
|
-
ok: false as const,
|
|
68
|
-
problem:
|
|
69
|
-
resolved.kind === 'ambiguous'
|
|
70
|
-
? resolved.problem
|
|
71
|
-
: `secrets target "${targetName}" does not exist; create it and mint the pair with \`bun standards creds add cloudflare --s3\``,
|
|
72
|
-
};
|
|
73
|
-
documents.set(targetName, result);
|
|
89
|
+
const result = decryptSopsJson(source.root, source.rel);
|
|
90
|
+
documents.set(documentKey, result);
|
|
74
91
|
return result;
|
|
75
92
|
};
|
|
76
93
|
const resolveValue = (
|
|
77
94
|
label: string,
|
|
78
95
|
reference: BrokeredS3Reference,
|
|
79
96
|
): { readonly value: string | null; readonly problem: string | null } => {
|
|
80
|
-
const allowlistEntry =
|
|
81
|
-
if (!allowedReferences.has(allowlistEntry)) {
|
|
82
|
-
return {
|
|
83
|
-
value: null,
|
|
84
|
-
problem: `${label}: unauthorized brokered S3 pair; add "${allowlistEntry}" to the encrypted secrets/dev.yaml brokeredReferences allowlist`,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
97
|
+
const allowlistEntry = brokeredReferenceIdentity(reference);
|
|
87
98
|
if (preservedReferences.has(allowlistEntry)) {
|
|
88
99
|
return { value: '', problem: null };
|
|
89
100
|
}
|
|
90
|
-
const document = readDocument(reference
|
|
101
|
+
const document = readDocument(reference);
|
|
91
102
|
if (!document.ok) {
|
|
92
103
|
return { value: null, problem: `${label}: ${document.problem}` };
|
|
93
104
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { parseDestination } from './creds-dest';
|
|
5
|
+
import { resolveTargetRelResult } from './creds-target';
|
|
6
|
+
import type { BrokeredS3Reference } from './dev-env-brokered';
|
|
7
|
+
import { isRecord } from './github-settings-parse';
|
|
8
|
+
|
|
9
|
+
const repositoryPattern =
|
|
10
|
+
/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/u;
|
|
11
|
+
const gitSuffixPattern = /\.git$/u;
|
|
12
|
+
const sshRemotePattern = /^git@github\.com:(?<repository>[^\s]+?)(?:\.git)?$/u;
|
|
13
|
+
|
|
14
|
+
export type BrokeredS3Source = Readonly<{
|
|
15
|
+
repository: string;
|
|
16
|
+
checkout: string;
|
|
17
|
+
}>;
|
|
18
|
+
|
|
19
|
+
export const parseBrokeredSource = (raw: unknown): BrokeredS3Source | null => {
|
|
20
|
+
if (
|
|
21
|
+
!(
|
|
22
|
+
isRecord(raw) &&
|
|
23
|
+
Object.keys(raw).length === 2 &&
|
|
24
|
+
typeof raw.repository === 'string' &&
|
|
25
|
+
repositoryPattern.test(raw.repository) &&
|
|
26
|
+
typeof raw.checkout === 'string' &&
|
|
27
|
+
raw.checkout.trim() === raw.checkout &&
|
|
28
|
+
raw.checkout.length > 0 &&
|
|
29
|
+
!raw.checkout.includes('\\')
|
|
30
|
+
)
|
|
31
|
+
) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
return { repository: raw.repository, checkout: raw.checkout };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const brokeredReferenceIdentity = (
|
|
38
|
+
reference: BrokeredS3Reference,
|
|
39
|
+
): string =>
|
|
40
|
+
`${reference.source === undefined ? '' : `${reference.source.repository}@`}${reference.brokeredS3}:${reference.key}`;
|
|
41
|
+
|
|
42
|
+
export const validBrokeredIdentity = (raw: string): boolean => {
|
|
43
|
+
const separator = raw.indexOf('@');
|
|
44
|
+
return separator === -1
|
|
45
|
+
? parseDestination(raw) !== null
|
|
46
|
+
: repositoryPattern.test(raw.slice(0, separator)) &&
|
|
47
|
+
parseDestination(raw.slice(separator + 1)) !== null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const remoteRepository = (remote: string): string | null => {
|
|
51
|
+
const ssh = sshRemotePattern.exec(remote)?.groups?.repository;
|
|
52
|
+
if (ssh !== undefined) {
|
|
53
|
+
return ssh;
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(remote);
|
|
57
|
+
if (
|
|
58
|
+
url.hostname !== 'github.com' ||
|
|
59
|
+
!['https:', 'ssh:'].includes(url.protocol) ||
|
|
60
|
+
url.port !== '' ||
|
|
61
|
+
url.search !== '' ||
|
|
62
|
+
url.hash !== ''
|
|
63
|
+
) {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
return url.pathname.slice(1).replace(gitSuffixPattern, '');
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
type SourceResolution =
|
|
73
|
+
| Readonly<{ ok: true; root: string; rel: string }>
|
|
74
|
+
| Readonly<{ ok: false; problem: string }>;
|
|
75
|
+
|
|
76
|
+
export const resolveBrokeredSource = (
|
|
77
|
+
consumer: string,
|
|
78
|
+
reference: BrokeredS3Reference,
|
|
79
|
+
): SourceResolution => {
|
|
80
|
+
let root = consumer;
|
|
81
|
+
if (reference.source !== undefined) {
|
|
82
|
+
try {
|
|
83
|
+
root = resolve(consumer, reference.source.checkout);
|
|
84
|
+
if (realpathSync(root) !== root) {
|
|
85
|
+
return {
|
|
86
|
+
ok: false,
|
|
87
|
+
problem: 'source checkout must not contain symlinks',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const git = (args: ReadonlyArray<string>) =>
|
|
91
|
+
execFileSync('git', ['-C', root, ...args], {
|
|
92
|
+
encoding: 'utf8',
|
|
93
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
94
|
+
}).trim();
|
|
95
|
+
if (git(['rev-parse', '--show-toplevel']) !== root) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
problem: 'source checkout must name a repository root',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
remoteRepository(git(['remote', 'get-url', 'origin'])) !==
|
|
103
|
+
reference.source.repository
|
|
104
|
+
) {
|
|
105
|
+
return {
|
|
106
|
+
ok: false,
|
|
107
|
+
problem:
|
|
108
|
+
'source checkout origin does not match the authorized repository',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
problem: 'source checkout is not an accessible Git repository',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const target = resolveTargetRelResult(root, reference.brokeredS3);
|
|
119
|
+
return target.ok
|
|
120
|
+
? { ok: true, root, rel: target.rel }
|
|
121
|
+
: {
|
|
122
|
+
ok: false,
|
|
123
|
+
problem:
|
|
124
|
+
target.kind === 'ambiguous'
|
|
125
|
+
? target.problem
|
|
126
|
+
: `secrets target "${reference.brokeredS3}" does not exist; create it and mint the pair with \`bun standards creds add cloudflare --s3\``,
|
|
127
|
+
};
|
|
128
|
+
};
|
package/src/dev-env-brokered.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
|
|
8
8
|
import { parseDestination } from './creds-dest';
|
|
9
9
|
import { parseSopsKeyPath } from './creds-sops-structure';
|
|
10
|
+
import {
|
|
11
|
+
type BrokeredS3Source,
|
|
12
|
+
parseBrokeredSource,
|
|
13
|
+
} from './dev-env-brokered-source';
|
|
10
14
|
import { isRecord } from './github-settings-parse';
|
|
11
15
|
|
|
12
16
|
export const BROKERED_S3_PARTS = [
|
|
@@ -20,9 +24,10 @@ export type BrokeredS3Reference = {
|
|
|
20
24
|
readonly brokeredS3: string;
|
|
21
25
|
readonly key: string;
|
|
22
26
|
readonly part: BrokeredS3Part;
|
|
27
|
+
readonly source?: BrokeredS3Source;
|
|
23
28
|
};
|
|
24
29
|
|
|
25
|
-
const REFERENCE_PROPERTIES = ['brokeredS3', 'key', 'part'] as const;
|
|
30
|
+
const REFERENCE_PROPERTIES = ['brokeredS3', 'key', 'part', 'source'] as const;
|
|
26
31
|
|
|
27
32
|
export const isBrokeredS3ReferenceShape = (
|
|
28
33
|
value: unknown,
|
|
@@ -46,11 +51,18 @@ export const parseBrokeredS3Reference = (
|
|
|
46
51
|
for (const property of Object.keys(raw)) {
|
|
47
52
|
if (!REFERENCE_PROPERTIES.includes(property as 'brokeredS3')) {
|
|
48
53
|
problems.push(
|
|
49
|
-
`${label} brokered S3 pair reference has unknown property ${JSON.stringify(property)}; allowed properties are brokeredS3, key, and
|
|
54
|
+
`${label} brokered S3 pair reference has unknown property ${JSON.stringify(property)}; allowed properties are brokeredS3, key, part, and source`,
|
|
50
55
|
);
|
|
51
56
|
}
|
|
52
57
|
}
|
|
53
58
|
const { brokeredS3: target, key, part } = raw;
|
|
59
|
+
const source =
|
|
60
|
+
raw.source === undefined ? undefined : parseBrokeredSource(raw.source);
|
|
61
|
+
if (source === null) {
|
|
62
|
+
problems.push(
|
|
63
|
+
`${label} source must contain exactly a GitHub repository owner/name and a checkout path`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
54
66
|
if (
|
|
55
67
|
typeof target !== 'string' ||
|
|
56
68
|
parseDestination(`${target}:reference_validation`) === null
|
|
@@ -78,6 +90,7 @@ export const parseBrokeredS3Reference = (
|
|
|
78
90
|
brokeredS3: target as string,
|
|
79
91
|
key: key as string,
|
|
80
92
|
part: part as BrokeredS3Part,
|
|
93
|
+
...(source === undefined || source === null ? {} : { source }),
|
|
81
94
|
},
|
|
82
95
|
};
|
|
83
96
|
};
|
package/src/dev-env.ts
CHANGED
|
@@ -42,6 +42,21 @@ export type DevEnvPlan = {
|
|
|
42
42
|
readonly problems: ReadonlyArray<string>;
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
const workspaceConfigurationProblems = (
|
|
46
|
+
consumer: string,
|
|
47
|
+
targets: ReadonlyArray<import('./dev-env-compose').ComposedDevEnvTarget>,
|
|
48
|
+
): ReadonlyArray<string> =>
|
|
49
|
+
targets.flatMap((target) => {
|
|
50
|
+
const workspace = `${target.group}/${target.workspace}`;
|
|
51
|
+
if (!existsSync(join(consumer, workspace, 'package.json'))) {
|
|
52
|
+
return [
|
|
53
|
+
`${target.sources.join(' + ')} defines ${target.group}.${target.workspace}, but ${workspace}/package.json does not exist`,
|
|
54
|
+
];
|
|
55
|
+
}
|
|
56
|
+
const problem = devEnvGitIgnoreProblem(consumer, `${workspace}/.env.local`);
|
|
57
|
+
return problem === null ? [] : [problem];
|
|
58
|
+
});
|
|
59
|
+
|
|
45
60
|
export const planDevEnvChanges = (
|
|
46
61
|
consumer: string,
|
|
47
62
|
inputs: DevEnvInputs,
|
|
@@ -66,6 +81,13 @@ export const planDevEnvChanges = (
|
|
|
66
81
|
},
|
|
67
82
|
layer(DEV_LOCAL_FILE, inputs.local),
|
|
68
83
|
);
|
|
84
|
+
const configurationProblems = [
|
|
85
|
+
...composed.problems,
|
|
86
|
+
...workspaceConfigurationProblems(consumer, composed.targets),
|
|
87
|
+
];
|
|
88
|
+
if (configurationProblems.length > 0) {
|
|
89
|
+
return { writes: [], removals: [], problems: configurationProblems };
|
|
90
|
+
}
|
|
69
91
|
const resolved = resolveBrokeredReferences(
|
|
70
92
|
consumer,
|
|
71
93
|
composed.targets,
|
package/src/github-api.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { isRecord } from './github-settings-parse';
|
|
|
16
16
|
const API_ROOT = 'https://api.github.com';
|
|
17
17
|
|
|
18
18
|
const GITHUB_REMOTE_PATTERN =
|
|
19
|
-
|
|
19
|
+
/^(?:https?:\/\/(?:[^/@]+@)?github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)(?<repo>[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+?)(?:\.git)*$/iu;
|
|
20
20
|
|
|
21
21
|
export const HTTP_OK = 200;
|
|
22
22
|
export const HTTP_CREATED = 201;
|
|
@@ -55,7 +55,10 @@ export const resolveToken = (): string | null => {
|
|
|
55
55
|
|
|
56
56
|
export const resolveGithubRepo = (consumer: string): string | null => {
|
|
57
57
|
const url = quietExec('git', ['-C', consumer, 'remote', 'get-url', 'origin']);
|
|
58
|
-
|
|
58
|
+
const repo = url?.match(GITHUB_REMOTE_PATTERN)?.groups?.repo?.toLowerCase();
|
|
59
|
+
return repo === undefined || ['.', '..'].includes(repo.split('/')[1] ?? '')
|
|
60
|
+
? null
|
|
61
|
+
: repo;
|
|
59
62
|
};
|
|
60
63
|
|
|
61
64
|
export type ApiResponse = { readonly status: number; readonly body: unknown };
|
package/src/github-apply.ts
CHANGED
|
@@ -9,9 +9,12 @@ import {
|
|
|
9
9
|
HTTP_OK,
|
|
10
10
|
request,
|
|
11
11
|
} from './github-api';
|
|
12
|
+
import { resolveHiddenBypassActors } from './github-bypass-actors';
|
|
12
13
|
import { diffRepositorySettings } from './github-diff';
|
|
14
|
+
import { fetchBypassActorCountsViaGraphql } from './github-graphql';
|
|
13
15
|
import { fetchLiveRulesets } from './github-ruleset-api';
|
|
14
|
-
import { diffRuleset } from './github-ruleset-diff';
|
|
16
|
+
import { diffRuleset, diffRulesets } from './github-ruleset-diff';
|
|
17
|
+
import { isHiddenBypassActors } from './github-ruleset-visibility';
|
|
15
18
|
import { type GithubSettings, isRecord } from './github-settings-parse';
|
|
16
19
|
|
|
17
20
|
// GitHub answers a PATCH containing a plan-unavailable setting with HTTP 200
|
|
@@ -123,7 +126,28 @@ export const applyRulesets = async (
|
|
|
123
126
|
if (live.rulesets === null) {
|
|
124
127
|
throw new Error(live.problem ?? 'unable to read rulesets');
|
|
125
128
|
}
|
|
126
|
-
|
|
129
|
+
let comparable = live.rulesets;
|
|
130
|
+
const initialDiff = diffRulesets(declared.rulesets, comparable);
|
|
131
|
+
if (initialDiff.unverifiable.some(isHiddenBypassActors)) {
|
|
132
|
+
const answered = await fetchBypassActorCountsViaGraphql(token, repo);
|
|
133
|
+
comparable = resolveHiddenBypassActors(
|
|
134
|
+
declared.rulesets,
|
|
135
|
+
comparable,
|
|
136
|
+
answered.counts,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const invisible = diffRulesets(declared.rulesets, comparable).unverifiable;
|
|
140
|
+
if (invisible.length > 0) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`cannot verify ruleset fields before applying: ${invisible.map(({ name, key }) => `${name}: ${key}`).join('; ')}; use a credential that can read the declared fields`,
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const liveByName = new Map(comparable.map((r) => [String(r.name), r]));
|
|
146
|
+
if (liveByName.size !== comparable.length) {
|
|
147
|
+
throw new Error(
|
|
148
|
+
'duplicate live ruleset names prevent safe reconciliation; remove or rename the duplicate rulesets explicitly',
|
|
149
|
+
);
|
|
150
|
+
}
|
|
127
151
|
const declaredNames = new Set(declared.rulesets.map((r) => String(r.name)));
|
|
128
152
|
const actions: Array<string> = [];
|
|
129
153
|
for (const ruleset of declared.rulesets) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// answers for tokens that cannot read the actors themselves. Pure logic; the
|
|
3
3
|
// GraphQL call lives in github-graphql.ts.
|
|
4
4
|
|
|
5
|
-
import { BYPASS_ACTORS_KEY } from './github-
|
|
5
|
+
import { BYPASS_ACTORS_KEY } from './github-settings-parse';
|
|
6
6
|
|
|
7
7
|
// Stand-ins for bypass actors GraphQL counted but would not name. They exist
|
|
8
8
|
// only to carry the count into the ordinary length comparison, and are attached
|
|
@@ -10,8 +10,8 @@ import { BYPASS_ACTORS_KEY } from './github-ruleset-diff';
|
|
|
10
10
|
// their content is never observed: subsetMatches short-circuits at the length
|
|
11
11
|
// check, two empty lists compare no element, and a declared ruleset with no
|
|
12
12
|
// bypass_actors array never reaches that comparison. Nothing else looks inside
|
|
13
|
-
// them; bypassActorCountDetail reads only `.length`, and
|
|
14
|
-
//
|
|
13
|
+
// them; bypassActorCountDetail reads only `.length`, and both check/apply only use these live records for comparison. Writes
|
|
14
|
+
// always send the original declaration, so a stand-in cannot reach a PATCH body. The guarantee
|
|
15
15
|
// is not local: it also relies on declared ruleset names being unique, which
|
|
16
16
|
// github-settings-parse.ts and github-settings-merge.ts enforce.
|
|
17
17
|
const unreadableBypassActors = (count: number): ReadonlyArray<unknown> =>
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
// general value comparison lives in github-diff.ts.
|
|
4
4
|
|
|
5
5
|
import { subsetMatches } from './github-diff';
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
import {
|
|
7
|
+
BYPASS_ACTORS_KEY,
|
|
8
|
+
isRecord,
|
|
9
|
+
rulesetComparedKeys,
|
|
10
|
+
} from './github-settings-parse';
|
|
9
11
|
|
|
10
12
|
// A declared ruleset field the live side did not carry. It stays structured so
|
|
11
13
|
// the caller owns both the wording and the test for *which* field it is;
|
|
@@ -21,13 +23,6 @@ export type RulesetDiff = {
|
|
|
21
23
|
readonly unverifiable: ReadonlyArray<UnverifiableRulesetField>;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
|
-
const RULESET_COMPARED_KEYS = [
|
|
25
|
-
'target',
|
|
26
|
-
'enforcement',
|
|
27
|
-
'conditions',
|
|
28
|
-
BYPASS_ACTORS_KEY,
|
|
29
|
-
] as const;
|
|
30
|
-
|
|
31
26
|
const diffRules = (
|
|
32
27
|
name: string,
|
|
33
28
|
declared: Readonly<Record<string, unknown>>,
|
|
@@ -99,15 +94,13 @@ export const diffRuleset = (
|
|
|
99
94
|
const name = String(declared.name);
|
|
100
95
|
const drifted: Array<string> = [];
|
|
101
96
|
const unverifiable: Array<UnverifiableRulesetField> = [];
|
|
102
|
-
for (const key of
|
|
103
|
-
if (
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
);
|
|
110
|
-
}
|
|
97
|
+
for (const key of rulesetComparedKeys(declared.target)) {
|
|
98
|
+
if (live[key] === undefined) {
|
|
99
|
+
unverifiable.push({ key, name });
|
|
100
|
+
} else if (!subsetMatches(declared[key], live[key])) {
|
|
101
|
+
drifted.push(
|
|
102
|
+
`ruleset "${name}": ${key} differs from the declared configuration${bypassActorCountDetail(key, declared[key], live[key])}`,
|
|
103
|
+
);
|
|
111
104
|
}
|
|
112
105
|
}
|
|
113
106
|
drifted.push(...diffRules(name, declared, live));
|
|
@@ -8,10 +8,8 @@ import {
|
|
|
8
8
|
ADMIN_VISIBILITY_ADVICE,
|
|
9
9
|
unverifiableProblem,
|
|
10
10
|
} from './github-command-shared';
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
type UnverifiableRulesetField,
|
|
14
|
-
} from './github-ruleset-diff';
|
|
11
|
+
import type { UnverifiableRulesetField } from './github-ruleset-diff';
|
|
12
|
+
import { BYPASS_ACTORS_KEY } from './github-settings-parse';
|
|
15
13
|
|
|
16
14
|
const SCOPE = 'ruleset field(s)';
|
|
17
15
|
|
|
@@ -45,6 +45,89 @@ export const isNamedRuleset = (
|
|
|
45
45
|
): value is Readonly<Record<string, unknown>> & { readonly name: string } =>
|
|
46
46
|
isRecord(value) && typeof value.name === 'string' && value.name.length > 0;
|
|
47
47
|
|
|
48
|
+
export const BYPASS_ACTORS_KEY = 'bypass_actors';
|
|
49
|
+
const RULESET_COMPARED_KEYS = [
|
|
50
|
+
'target',
|
|
51
|
+
'enforcement',
|
|
52
|
+
'conditions',
|
|
53
|
+
BYPASS_ACTORS_KEY,
|
|
54
|
+
] as const;
|
|
55
|
+
|
|
56
|
+
// Push rules cover every push to the repository and have no ref-name scope.
|
|
57
|
+
// The parser and comparison share the same field contract.
|
|
58
|
+
export const rulesetComparedKeys = (target: unknown) =>
|
|
59
|
+
target === 'push'
|
|
60
|
+
? RULESET_COMPARED_KEYS.filter((key) => key !== 'conditions')
|
|
61
|
+
: RULESET_COMPARED_KEYS;
|
|
62
|
+
|
|
63
|
+
const isStringList = (value: unknown): value is ReadonlyArray<string> =>
|
|
64
|
+
Array.isArray(value) && value.every((item) => typeof item === 'string');
|
|
65
|
+
|
|
66
|
+
const isOneOf = (value: unknown, choices: ReadonlyArray<string>): boolean =>
|
|
67
|
+
typeof value === 'string' && choices.includes(value);
|
|
68
|
+
|
|
69
|
+
const rulesetConditionsProblem = (
|
|
70
|
+
ruleset: Readonly<Record<string, unknown>>,
|
|
71
|
+
prefix: string,
|
|
72
|
+
): string | null => {
|
|
73
|
+
if (ruleset.conditions === undefined) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
if (ruleset.target === 'push') {
|
|
77
|
+
return `${prefix} must omit "conditions" for target "push", which applies to every push`;
|
|
78
|
+
}
|
|
79
|
+
const refName = isRecord(ruleset.conditions)
|
|
80
|
+
? ruleset.conditions.ref_name
|
|
81
|
+
: undefined;
|
|
82
|
+
return isRecord(refName) &&
|
|
83
|
+
isStringList(refName.include) &&
|
|
84
|
+
isStringList(refName.exclude)
|
|
85
|
+
? null
|
|
86
|
+
: `${prefix} "conditions" must declare "ref_name.include" and "ref_name.exclude" as arrays of strings`;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const rulesetFieldProblems = (
|
|
90
|
+
ruleset: Readonly<Record<string, unknown>>,
|
|
91
|
+
label: string,
|
|
92
|
+
): ReadonlyArray<string> => {
|
|
93
|
+
const problems: Array<string> = [];
|
|
94
|
+
const prefix = `${label} ruleset "${String(ruleset.name)}"`;
|
|
95
|
+
for (const key of rulesetComparedKeys(ruleset.target)) {
|
|
96
|
+
if (ruleset[key] === undefined) {
|
|
97
|
+
const advice =
|
|
98
|
+
key === BYPASS_ACTORS_KEY ? '; use [] if nobody may bypass' : '';
|
|
99
|
+
problems.push(`${prefix} must declare "${key}"${advice}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
ruleset.target !== undefined &&
|
|
104
|
+
!isOneOf(ruleset.target, ['branch', 'tag', 'push'])
|
|
105
|
+
) {
|
|
106
|
+
problems.push(`${prefix} "target" must be "branch", "tag", or "push"`);
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
ruleset.enforcement !== undefined &&
|
|
110
|
+
!isOneOf(ruleset.enforcement, ['active', 'disabled', 'evaluate'])
|
|
111
|
+
) {
|
|
112
|
+
problems.push(
|
|
113
|
+
`${prefix} "enforcement" must be "active", "disabled", or "evaluate"`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (
|
|
117
|
+
ruleset[BYPASS_ACTORS_KEY] !== undefined &&
|
|
118
|
+
!Array.isArray(ruleset[BYPASS_ACTORS_KEY])
|
|
119
|
+
) {
|
|
120
|
+
problems.push(
|
|
121
|
+
`${prefix} "${BYPASS_ACTORS_KEY}" must be an array; use [] if nobody may bypass`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const conditionsProblem = rulesetConditionsProblem(ruleset, prefix);
|
|
125
|
+
if (conditionsProblem !== null) {
|
|
126
|
+
problems.push(conditionsProblem);
|
|
127
|
+
}
|
|
128
|
+
return problems;
|
|
129
|
+
};
|
|
130
|
+
|
|
48
131
|
const LABEL_COLOR = /^[0-9a-f]{6}$/u;
|
|
49
132
|
const LABEL_DECLARATION_KEY_COUNT = 3;
|
|
50
133
|
|
|
@@ -98,6 +181,9 @@ const rulesetListProblems = (
|
|
|
98
181
|
} else {
|
|
99
182
|
names.add(ruleset.name);
|
|
100
183
|
}
|
|
184
|
+
if (isNamedRuleset(ruleset)) {
|
|
185
|
+
problems.push(...rulesetFieldProblems(ruleset, label));
|
|
186
|
+
}
|
|
101
187
|
}
|
|
102
188
|
return problems;
|
|
103
189
|
};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
3
|
+
import { join, posix } from 'node:path';
|
|
4
|
+
import { isRecord } from './github-settings-parse';
|
|
5
|
+
import type { ManagedEntry } from './managed-files';
|
|
6
|
+
import { parseYaml } from './yaml-parse';
|
|
7
|
+
|
|
8
|
+
const YAML_FILE = /\.ya?ml$/u;
|
|
9
|
+
const ACTION = '.github/actions/sops-secret/action.yml';
|
|
10
|
+
const LOCAL_ACTION = './.github/actions/sops-secret';
|
|
11
|
+
const yamlFiles = async (root: string, rel: string): Promise<Array<string>> => {
|
|
12
|
+
if (!existsSync(join(root, rel))) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const entries = await readdir(join(root, rel), { withFileTypes: true });
|
|
16
|
+
return (
|
|
17
|
+
await Promise.all(
|
|
18
|
+
entries.map((entry) => {
|
|
19
|
+
const path = `${rel}/${entry.name}`;
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
return yamlFiles(root, path);
|
|
22
|
+
}
|
|
23
|
+
return Promise.resolve(
|
|
24
|
+
entry.isFile() && YAML_FILE.test(path) ? [path] : [],
|
|
25
|
+
);
|
|
26
|
+
}),
|
|
27
|
+
)
|
|
28
|
+
).flat();
|
|
29
|
+
};
|
|
30
|
+
const hasLegacyCaller = (value: unknown): boolean => {
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
return value.some(hasLegacyCaller);
|
|
33
|
+
}
|
|
34
|
+
if (!isRecord(value)) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
(typeof value.uses === 'string' &&
|
|
39
|
+
value.uses.startsWith('./') &&
|
|
40
|
+
posix.resolve('/', value.uses) === posix.resolve('/', LOCAL_ACTION) &&
|
|
41
|
+
isRecord(value.with) &&
|
|
42
|
+
Object.hasOwn(value.with, 'env-name')) ||
|
|
43
|
+
Object.values(value).some(hasLegacyCaller)
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const localActions = (value: unknown): Array<string> => {
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
return value.flatMap(localActions);
|
|
50
|
+
}
|
|
51
|
+
if (!isRecord(value)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const reference = value.uses;
|
|
55
|
+
return [
|
|
56
|
+
...(typeof reference === 'string' && reference.startsWith('./')
|
|
57
|
+
? [posix.normalize(reference)]
|
|
58
|
+
: []),
|
|
59
|
+
...Object.values(value).flatMap(localActions),
|
|
60
|
+
];
|
|
61
|
+
};
|
|
62
|
+
const callerProblems = async (
|
|
63
|
+
consumer: string,
|
|
64
|
+
incoming: ReadonlyMap<string, ManagedEntry>,
|
|
65
|
+
): Promise<Array<string>> => {
|
|
66
|
+
const visited = new Set<string>();
|
|
67
|
+
const visit = async (path: string): Promise<Array<string>> => {
|
|
68
|
+
if (visited.has(path)) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
visited.add(path);
|
|
72
|
+
const source = incoming.get(path)?.absolutePath ?? join(consumer, path);
|
|
73
|
+
const parsed = parseYaml(await readFile(source, 'utf8'), path);
|
|
74
|
+
if (parsed.problem !== null) {
|
|
75
|
+
return [parsed.problem];
|
|
76
|
+
}
|
|
77
|
+
const problems =
|
|
78
|
+
!incoming.has(path) && hasLegacyCaller(parsed.value)
|
|
79
|
+
? [
|
|
80
|
+
`${path} still passes env-name to ${LOCAL_ACTION}; before syncing, give the resolver step an id, remove env-name, and pass steps.<id>.outputs.value only to each consuming step's env or action input`,
|
|
81
|
+
]
|
|
82
|
+
: [];
|
|
83
|
+
const nested = await Promise.all(
|
|
84
|
+
localActions(parsed.value).map(async (directory) => {
|
|
85
|
+
if (directory === '..' || directory.startsWith('../')) {
|
|
86
|
+
return [`${path} references a local action outside the repository`];
|
|
87
|
+
}
|
|
88
|
+
const paths = ['action.yml', 'action.yaml']
|
|
89
|
+
.map((filename) => posix.join(directory, filename))
|
|
90
|
+
.filter(
|
|
91
|
+
(target) =>
|
|
92
|
+
incoming.has(target) || existsSync(join(consumer, target)),
|
|
93
|
+
);
|
|
94
|
+
return (await Promise.all(paths.map(visit))).flat();
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
return [...problems, ...nested.flat()];
|
|
98
|
+
};
|
|
99
|
+
return (
|
|
100
|
+
await Promise.all((await yamlFiles(consumer, '.github')).map(visit))
|
|
101
|
+
).flat();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Called before mirror writes. Consumer-owned workflows and composite actions
|
|
105
|
+
// are not silently rewritten, so an action interface migration must stop before
|
|
106
|
+
// replacing the implementation they still invoke with its retired input.
|
|
107
|
+
export const collectSopsActionCallerProblems = async (
|
|
108
|
+
consumer: string,
|
|
109
|
+
incoming: ReadonlyMap<string, ManagedEntry> = new Map(),
|
|
110
|
+
): Promise<ReadonlyArray<string>> => {
|
|
111
|
+
const action = incoming.get(ACTION);
|
|
112
|
+
const actionPath = action?.absolutePath ?? join(consumer, ACTION);
|
|
113
|
+
if (
|
|
114
|
+
!existsSync(actionPath) ||
|
|
115
|
+
(action !== undefined && action.kind !== 'file')
|
|
116
|
+
) {
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
const { value: definition } = parseYaml(
|
|
120
|
+
await readFile(actionPath, 'utf8'),
|
|
121
|
+
ACTION,
|
|
122
|
+
);
|
|
123
|
+
if (
|
|
124
|
+
!(
|
|
125
|
+
isRecord(definition) &&
|
|
126
|
+
isRecord(definition.outputs) &&
|
|
127
|
+
Object.hasOwn(definition.outputs, 'value')
|
|
128
|
+
)
|
|
129
|
+
) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
return callerProblems(consumer, incoming);
|
|
133
|
+
};
|