@davidvornholt/standards 0.13.0 → 0.14.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.
@@ -0,0 +1,185 @@
1
+ // Pure reconciliation between a repository's SOPS key structure and the
2
+ // Cloudflare tokens the broker minted for it (matched by the deterministic
3
+ // naming scheme). A brokered token whose secret key vanished from SOPS is
4
+ // revoked; one nearing expiry is replaced while copying its live policy and
5
+ // lifetime. Secret keys without a brokered token are simply unmanaged — most
6
+ // secrets are — and are never touched. Execution lives in creds-plan-run.ts.
7
+
8
+ import type { CloudflareToken, TokenPolicy } from './creds-cloudflare-api';
9
+ import type { TokenCondition } from './creds-cloudflare-condition';
10
+ import { parseTokenName } from './creds-naming';
11
+
12
+ const DEFAULT_RENEW_WITHIN_DAYS = 30;
13
+ const DAY_MS = 86_400_000;
14
+
15
+ export type AccountToken = {
16
+ readonly accountId: string;
17
+ readonly token: CloudflareToken;
18
+ };
19
+
20
+ export type PlannedAction =
21
+ | {
22
+ readonly kind: 'revoke';
23
+ readonly accountId: string;
24
+ readonly tokenId: string;
25
+ readonly name: string;
26
+ readonly reason: string;
27
+ }
28
+ | {
29
+ readonly kind: 'renew';
30
+ readonly accountId: string;
31
+ readonly tokenId: string;
32
+ readonly name: string;
33
+ readonly target: string;
34
+ readonly key: string;
35
+ readonly policies: ReadonlyArray<TokenPolicy>;
36
+ readonly condition: TokenCondition | null;
37
+ readonly replacementExpiresOn: string;
38
+ readonly reason: string;
39
+ };
40
+
41
+ export type CredsPlan = {
42
+ readonly actions: ReadonlyArray<PlannedAction>;
43
+ readonly findings: ReadonlyArray<string>;
44
+ readonly healthy: number;
45
+ };
46
+
47
+ type ManagedToken = AccountToken & {
48
+ readonly ref: { readonly target: string; readonly key: string };
49
+ };
50
+
51
+ type Disposition =
52
+ | { readonly kind: 'action'; readonly action: PlannedAction }
53
+ | { readonly kind: 'finding'; readonly finding: string }
54
+ | { readonly kind: 'healthy' };
55
+
56
+ const dispositionOf = (
57
+ entry: ManagedToken,
58
+ input: {
59
+ readonly keysByTarget: ReadonlyMap<string, ReadonlySet<string>>;
60
+ readonly now: Date;
61
+ },
62
+ renewWithin: number,
63
+ ): Disposition => {
64
+ const { accountId, token, ref } = entry;
65
+ const base = { accountId, tokenId: token.id, name: token.name };
66
+ if (token.status !== 'active') {
67
+ return {
68
+ kind: 'finding',
69
+ finding: `${token.name} (${accountId}/${token.id}) has status ${token.status}; it is not healthy and will not be mutated automatically`,
70
+ };
71
+ }
72
+ if (!input.keysByTarget.get(ref.target)?.has(ref.key)) {
73
+ return {
74
+ kind: 'action',
75
+ action: {
76
+ ...base,
77
+ kind: 'revoke',
78
+ reason: `secret ${ref.target}:${ref.key} no longer exists`,
79
+ },
80
+ };
81
+ }
82
+ if (!token.condition.supported) {
83
+ return {
84
+ kind: 'finding',
85
+ finding: `${token.name} has an unsupported condition shape; it will not be mutated automatically`,
86
+ };
87
+ }
88
+ if (token.expiresOn === null) {
89
+ return { kind: 'healthy' };
90
+ }
91
+ const expiry = Date.parse(token.expiresOn);
92
+ if (!Number.isFinite(expiry)) {
93
+ return {
94
+ kind: 'finding',
95
+ finding: `${token.name} has an invalid expires_on value`,
96
+ };
97
+ }
98
+ if (expiry - input.now.getTime() > renewWithin) {
99
+ return { kind: 'healthy' };
100
+ }
101
+ const issued =
102
+ token.issuedOn === null ? Number.NaN : Date.parse(token.issuedOn);
103
+ if (
104
+ !Number.isFinite(issued) ||
105
+ issued >= expiry ||
106
+ token.policies === null ||
107
+ token.policies.length === 0
108
+ ) {
109
+ return {
110
+ kind: 'finding',
111
+ finding: `${token.name} cannot be renewed safely because its issued_on, expires_on, or policies are incomplete`,
112
+ };
113
+ }
114
+ return {
115
+ kind: 'action',
116
+ action: {
117
+ ...base,
118
+ kind: 'renew',
119
+ target: ref.target,
120
+ key: ref.key,
121
+ policies: token.policies,
122
+ condition: token.condition.value,
123
+ replacementExpiresOn: new Date(
124
+ input.now.getTime() + expiry - issued,
125
+ ).toISOString(),
126
+ reason: `expires ${token.expiresOn}`,
127
+ },
128
+ };
129
+ };
130
+
131
+ const dispositionForGroup = (
132
+ group: ReadonlyArray<ManagedToken>,
133
+ input: {
134
+ readonly keysByTarget: ReadonlyMap<string, ReadonlySet<string>>;
135
+ readonly now: Date;
136
+ },
137
+ renewWithin: number,
138
+ ): Disposition => {
139
+ if (group.length > 1) {
140
+ const [first] = group;
141
+ return {
142
+ kind: 'finding',
143
+ finding: `ambiguous Cloudflare tokens target ${first?.ref.target}:${first?.ref.key} across ${group.map((candidate) => `${candidate.accountId}/${candidate.token.id}`).join(', ')}; revoke duplicates manually before plan/apply`,
144
+ };
145
+ }
146
+ const [entry] = group;
147
+ return entry === undefined
148
+ ? { kind: 'healthy' }
149
+ : dispositionOf(entry, input, renewWithin);
150
+ };
151
+
152
+ export const computeCredsPlan = (input: {
153
+ readonly repo: string;
154
+ readonly keysByTarget: ReadonlyMap<string, ReadonlySet<string>>;
155
+ readonly tokens: ReadonlyArray<AccountToken>;
156
+ readonly now: Date;
157
+ readonly renewWithinDays?: number;
158
+ }): CredsPlan => {
159
+ const renewWithin =
160
+ (input.renewWithinDays ?? DEFAULT_RENEW_WITHIN_DAYS) * DAY_MS;
161
+ const actions: Array<PlannedAction> = [];
162
+ const findings: Array<string> = [];
163
+ let healthy = 0;
164
+ const managed = input.tokens.flatMap((entry) => {
165
+ const ref = parseTokenName(entry.token.name, input.repo);
166
+ return ref === null ? [] : [{ ...entry, ref }];
167
+ });
168
+ const byDestination = Map.groupBy(
169
+ managed,
170
+ ({ ref }) => `${ref.target}\0${ref.key}`,
171
+ );
172
+ const dispositions = [...byDestination.values()].map((group) =>
173
+ dispositionForGroup(group, input, renewWithin),
174
+ );
175
+ for (const disposition of dispositions) {
176
+ if (disposition.kind === 'action') {
177
+ actions.push(disposition.action);
178
+ } else if (disposition.kind === 'finding') {
179
+ findings.push(disposition.finding);
180
+ } else {
181
+ healthy += 1;
182
+ }
183
+ }
184
+ return { actions, findings, healthy };
185
+ };
@@ -0,0 +1,62 @@
1
+ // Terminal prompts for login flows. Hidden input never echoes: pasted
2
+ // bootstrap tokens must not land in terminal scrollback or transcripts.
3
+
4
+ import process from 'node:process';
5
+ import { createInterface } from 'node:readline/promises';
6
+
7
+ const END_OF_TRANSMISSION = '\u0004';
8
+ const INTERRUPT = '\u0003';
9
+ const DELETE = '\u007f';
10
+
11
+ export const promptLine = async (question: string): Promise<string> => {
12
+ const readline = createInterface({
13
+ input: process.stdin,
14
+ output: process.stdout,
15
+ });
16
+ try {
17
+ return (await readline.question(question)).trim();
18
+ } finally {
19
+ readline.close();
20
+ }
21
+ };
22
+
23
+ const isSubmit = (char: string): boolean =>
24
+ char === '\r' || char === '\n' || char === END_OF_TRANSMISSION;
25
+
26
+ const applyChar = (value: string, char: string): string =>
27
+ char === DELETE || char === '\b' ? value.slice(0, -1) : value + char;
28
+
29
+ export const promptHidden = (question: string): Promise<string> => {
30
+ const { stdin, stdout } = process;
31
+ if (!stdin.isTTY) {
32
+ return promptLine(question);
33
+ }
34
+ stdout.write(question);
35
+ return new Promise((resolvePromise) => {
36
+ stdin.setRawMode(true);
37
+ stdin.resume();
38
+ let value = '';
39
+ const finish = (): void => {
40
+ stdin.setRawMode(false);
41
+ stdin.pause();
42
+ stdin.off('data', onData);
43
+ stdout.write('\n');
44
+ };
45
+ const onData = (chunk: Buffer): void => {
46
+ for (const char of chunk.toString('utf8')) {
47
+ if (isSubmit(char)) {
48
+ finish();
49
+ resolvePromise(value.trim());
50
+ return;
51
+ }
52
+ if (char === INTERRUPT) {
53
+ finish();
54
+ process.kill(process.pid, 'SIGINT');
55
+ return;
56
+ }
57
+ value = applyChar(value, char);
58
+ }
59
+ };
60
+ stdin.on('data', onData);
61
+ });
62
+ };
@@ -0,0 +1,170 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import process from 'node:process';
3
+ import { parseDocument } from 'yaml';
4
+ import { assertWritableSopsPath } from './creds-dest';
5
+ import {
6
+ inspectSopsStructure,
7
+ parseSopsKeyPath,
8
+ type SopsShapeFailure,
9
+ type SopsStructureResult,
10
+ } from './creds-sops-structure';
11
+ import { isRecord } from './github-settings-parse';
12
+
13
+ export const SET_CHANGES_ENV = 'STANDARDS_SOPS_SET_CHANGES';
14
+ export type SopsValueChange = {
15
+ readonly path: string;
16
+ readonly value: string;
17
+ };
18
+ export type SopsScalarStructureResult =
19
+ | { readonly ok: true; readonly state: 'absent' | 'scalar' }
20
+ | SopsShapeFailure
21
+ | {
22
+ readonly ok: false;
23
+ readonly kind: 'collection' | 'blocked-by-scalar';
24
+ readonly problem: string;
25
+ };
26
+
27
+ export const inspectSopsScalarStructure = (
28
+ structure: SopsStructureResult,
29
+ dottedPath: string,
30
+ ): SopsScalarStructureResult => {
31
+ if (!structure.ok) {
32
+ return structure;
33
+ }
34
+ const path = parseSopsKeyPath(dottedPath);
35
+ if (path === null) {
36
+ return {
37
+ ok: false,
38
+ kind: 'unsupported-shape',
39
+ problem: `invalid SOPS key path: ${dottedPath}`,
40
+ };
41
+ }
42
+ let node: unknown = structure.root;
43
+ for (const [index, segment] of path.entries()) {
44
+ if (!isRecord(node)) {
45
+ return {
46
+ ok: false,
47
+ kind: 'blocked-by-scalar',
48
+ problem: `SOPS key path is blocked by a scalar: ${dottedPath}`,
49
+ };
50
+ }
51
+ const next = node[segment];
52
+ if (next === undefined) {
53
+ return { ok: true, state: 'absent' };
54
+ }
55
+ if (index === path.length - 1) {
56
+ return isRecord(next)
57
+ ? {
58
+ ok: false,
59
+ kind: 'collection',
60
+ problem: `SOPS key path names a mapping: ${dottedPath}`,
61
+ }
62
+ : { ok: true, state: 'scalar' };
63
+ }
64
+ node = next;
65
+ }
66
+ return { ok: true, state: 'absent' };
67
+ };
68
+
69
+ const parseChanges = (raw: string): ReadonlyArray<SopsValueChange> => {
70
+ let parsed: unknown;
71
+ try {
72
+ parsed = JSON.parse(raw);
73
+ } catch (error) {
74
+ throw new Error(`${SET_CHANGES_ENV} must contain valid JSON`, {
75
+ cause: error,
76
+ });
77
+ }
78
+ if (
79
+ !(
80
+ Array.isArray(parsed) &&
81
+ parsed.every(
82
+ (change) =>
83
+ isRecord(change) &&
84
+ typeof change.path === 'string' &&
85
+ typeof change.value === 'string',
86
+ )
87
+ )
88
+ ) {
89
+ throw new Error(`${SET_CHANGES_ENV} must contain SOPS value changes`);
90
+ }
91
+ return parsed;
92
+ };
93
+ const parseDistinctPaths = (
94
+ changes: ReadonlyArray<SopsValueChange>,
95
+ ): ReadonlyArray<ReadonlyArray<string>> => {
96
+ if (changes.length === 0) {
97
+ throw new Error('at least one SOPS change is required');
98
+ }
99
+ const paths = changes.map(({ path }) => {
100
+ const parsed = parseSopsKeyPath(path);
101
+ if (parsed === null) {
102
+ throw new Error(`invalid SOPS key path: ${path}`);
103
+ }
104
+ return parsed;
105
+ });
106
+ for (const [index, path] of paths.entries()) {
107
+ for (const other of paths.slice(index + 1)) {
108
+ const pathContainsOther = path.every(
109
+ (segment, part) => other[part] === segment,
110
+ );
111
+ const otherContainsPath = other.every(
112
+ (segment, part) => path[part] === segment,
113
+ );
114
+ if (pathContainsOther || otherContainsPath) {
115
+ throw new Error(
116
+ 'SOPS changes must have distinct, non-overlapping paths',
117
+ );
118
+ }
119
+ }
120
+ }
121
+ return paths;
122
+ };
123
+ export const applySopsEditorChanges = (
124
+ text: string,
125
+ changes: ReadonlyArray<SopsValueChange>,
126
+ ): string => {
127
+ const structure = inspectSopsStructure(text, false);
128
+ if (!structure.ok) {
129
+ throw new Error(structure.problem);
130
+ }
131
+ const paths = parseDistinctPaths(changes);
132
+ for (const path of paths) {
133
+ assertWritableSopsPath(structure.root, path);
134
+ }
135
+ const document = parseDocument(text);
136
+ for (const [index, change] of changes.entries()) {
137
+ const path = paths[index];
138
+ if (path === undefined) {
139
+ // paths is derived index-aligned from changes; a miss is a programming
140
+ // error, and the old `?? []` fallback would have replaced the document
141
+ // root — the most destructive possible default.
142
+ throw new Error(`SOPS change path misalignment at index ${index}`);
143
+ }
144
+ document.setIn(path, change.value);
145
+ }
146
+ return document.toString();
147
+ };
148
+ const runEditor = (): void => {
149
+ const [, , file] = process.argv;
150
+ const changes = process.env[SET_CHANGES_ENV];
151
+ if (file === undefined || changes === undefined) {
152
+ console.error(`creds-sops-editor: requires a file and ${SET_CHANGES_ENV}`);
153
+ process.exitCode = 1;
154
+ return;
155
+ }
156
+ try {
157
+ const changed = applySopsEditorChanges(
158
+ readFileSync(file, 'utf8'),
159
+ parseChanges(changes),
160
+ );
161
+ writeFileSync(file, changed);
162
+ } catch (error) {
163
+ const message = error instanceof Error ? error.message : 'failure';
164
+ console.error(`creds-sops-editor: ${message}`);
165
+ process.exitCode = 1;
166
+ }
167
+ };
168
+ if (import.meta.main) {
169
+ runEditor();
170
+ }
@@ -0,0 +1,199 @@
1
+ import { lstatSync, realpathSync, type Stats } from 'node:fs';
2
+ import { isAbsolute, join, relative, sep } from 'node:path';
3
+ import {
4
+ isAlias,
5
+ isMap,
6
+ isScalar,
7
+ isSeq,
8
+ parseDocument,
9
+ type YAMLMap,
10
+ } from 'yaml';
11
+ import { isRecord } from './github-settings-parse';
12
+
13
+ const SAFE_KEY_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9_-]*$/u;
14
+ const RESERVED_KEY_SEGMENTS = new Set([
15
+ 'constructor',
16
+ 'prototype',
17
+ 'sops',
18
+ '__proto__',
19
+ ]);
20
+ export type SopsShapeFailure = {
21
+ readonly ok: false;
22
+ readonly kind:
23
+ | 'malformed-yaml'
24
+ | 'missing-sops-metadata'
25
+ | 'unsupported-shape';
26
+ readonly problem: string;
27
+ };
28
+ export type SopsStructureResult =
29
+ | {
30
+ readonly ok: true;
31
+ readonly keys: ReadonlyArray<string>;
32
+ readonly root: Readonly<Record<string, unknown>>;
33
+ }
34
+ | SopsShapeFailure;
35
+ export const isSafeSopsKeySegment = (segment: string): boolean =>
36
+ SAFE_KEY_SEGMENT.test(segment) && !RESERVED_KEY_SEGMENTS.has(segment);
37
+ export const parseSopsKeyPath = (
38
+ dottedPath: string,
39
+ ): ReadonlyArray<string> | null => {
40
+ const segments = dottedPath.split('.');
41
+ return segments.length > 0 && segments.every(isSafeSopsKeySegment)
42
+ ? segments
43
+ : null;
44
+ };
45
+ const isContained = (root: string, candidate: string): boolean => {
46
+ const fromRoot = relative(root, candidate);
47
+ return (
48
+ fromRoot !== '' &&
49
+ !isAbsolute(fromRoot) &&
50
+ fromRoot !== '..' &&
51
+ !fromRoot.startsWith(`..${sep}`)
52
+ );
53
+ };
54
+ const matchesKind = (stat: Stats, kind: 'directory' | 'file'): boolean =>
55
+ kind === 'file' ? stat.isFile() : stat.isDirectory();
56
+ export const isContainedSopsPath = (
57
+ consumer: string,
58
+ rel: string,
59
+ kind: 'directory' | 'file',
60
+ ): boolean => {
61
+ try {
62
+ if (
63
+ isAbsolute(rel) ||
64
+ rel.includes('\\') ||
65
+ lstatSync(consumer).isSymbolicLink()
66
+ ) {
67
+ return false;
68
+ }
69
+ const parts = rel.split('/');
70
+ if (
71
+ parts.length === 0 ||
72
+ parts.some((part) => ['', '.', '..'].includes(part))
73
+ ) {
74
+ return false;
75
+ }
76
+ const root = realpathSync(consumer);
77
+ let candidate = consumer;
78
+ for (const [index, part] of parts.entries()) {
79
+ candidate = join(candidate, part);
80
+ const stat = lstatSync(candidate);
81
+ const final = index === parts.length - 1;
82
+ const expectedKind = final ? kind : 'directory';
83
+ if (
84
+ stat.isSymbolicLink() ||
85
+ !isContained(root, realpathSync(candidate)) ||
86
+ !matchesKind(stat, expectedKind)
87
+ ) {
88
+ return false;
89
+ }
90
+ }
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ };
96
+ const keyName = (node: unknown): string | null =>
97
+ isScalar(node) && typeof node.value === 'string' && node.tag === undefined
98
+ ? node.value
99
+ : null;
100
+
101
+ const collectLeaves = (
102
+ node: unknown,
103
+ prefix: ReadonlyArray<string>,
104
+ keys: Array<string>,
105
+ ): string | null => {
106
+ if (isSeq(node)) {
107
+ return `arrays are not supported at ${prefix.join('.')}`;
108
+ }
109
+ if (isAlias(node)) {
110
+ return `aliases are not supported at ${prefix.join('.')}`;
111
+ }
112
+ if (!isMap(node)) {
113
+ keys.push(prefix.join('.'));
114
+ return null;
115
+ }
116
+ for (const pair of node.items) {
117
+ const key = keyName(pair.key);
118
+ if (key === null || !isSafeSopsKeySegment(key)) {
119
+ return `unsupported mapping key at ${prefix.join('.')}`;
120
+ }
121
+ const problem = collectLeaves(pair.value, [...prefix, key], keys);
122
+ if (problem !== null) {
123
+ return problem;
124
+ }
125
+ }
126
+ return null;
127
+ };
128
+ type RootInspection = {
129
+ readonly keys: ReadonlyArray<string>;
130
+ readonly hasMetadata: boolean;
131
+ readonly problem: string | null;
132
+ };
133
+ const inspectRoot = (root: YAMLMap): RootInspection => {
134
+ const keys: Array<string> = [];
135
+ let hasMetadata = false;
136
+ for (const pair of root.items) {
137
+ const key = keyName(pair.key);
138
+ if (key === null || (!isSafeSopsKeySegment(key) && key !== 'sops')) {
139
+ return {
140
+ keys,
141
+ hasMetadata,
142
+ problem: 'secrets document has an unsupported mapping key',
143
+ };
144
+ }
145
+ if (key === 'sops') {
146
+ hasMetadata = isMap(pair.value);
147
+ } else {
148
+ const problem = collectLeaves(pair.value, [key], keys);
149
+ if (problem !== null) {
150
+ return { keys, hasMetadata, problem };
151
+ }
152
+ }
153
+ }
154
+ return { keys, hasMetadata, problem: null };
155
+ };
156
+
157
+ export const inspectSopsStructure = (
158
+ text: string,
159
+ requireMetadata: boolean,
160
+ ): SopsStructureResult => {
161
+ const document = parseDocument(text);
162
+ if (document.errors.length > 0) {
163
+ return {
164
+ ok: false,
165
+ kind: 'malformed-yaml',
166
+ problem: 'secrets document is malformed YAML',
167
+ };
168
+ }
169
+ if (!isMap(document.contents)) {
170
+ return {
171
+ ok: false,
172
+ kind: 'unsupported-shape',
173
+ problem: 'secrets document root must be a mapping',
174
+ };
175
+ }
176
+ const inspected = inspectRoot(document.contents);
177
+ if (inspected.problem !== null) {
178
+ return {
179
+ ok: false,
180
+ kind: 'unsupported-shape',
181
+ problem: inspected.problem,
182
+ };
183
+ }
184
+ if (requireMetadata && !inspected.hasMetadata) {
185
+ return {
186
+ ok: false,
187
+ kind: 'missing-sops-metadata',
188
+ problem: 'secrets document has no SOPS metadata',
189
+ };
190
+ }
191
+ const root = document.toJS();
192
+ return isRecord(root)
193
+ ? { ok: true, keys: inspected.keys, root }
194
+ : {
195
+ ok: false,
196
+ kind: 'unsupported-shape',
197
+ problem: 'secrets document root must be a mapping',
198
+ };
199
+ };