@openpalm/lib 0.11.0-beta.10 → 0.11.0-beta.13
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 +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-user-env.test.ts +113 -0
- package/src/control-plane/akm-user-env.ts +144 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +90 -31
- package/src/control-plane/compose-args.ts +119 -9
- package/src/control-plane/config-persistence.ts +87 -133
- package/src/control-plane/core-assets.test.ts +9 -9
- package/src/control-plane/core-assets.ts +24 -8
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +1 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +94 -102
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +36 -34
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/paths.ts +62 -42
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +97 -88
- package/src/control-plane/registry.ts +142 -110
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +7 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +60 -0
- package/src/control-plane/secrets-files.ts +66 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +60 -40
- package/src/control-plane/setup.ts +36 -31
- package/src/control-plane/skeleton-guardrail.test.ts +64 -55
- package/src/control-plane/spec-to-env.test.ts +22 -17
- package/src/control-plane/spec-to-env.ts +7 -2
- package/src/control-plane/stack-spec.test.ts +10 -0
- package/src/control-plane/stack-spec.ts +28 -1
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.ts +60 -58
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +47 -15
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
|
2
|
+
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
auditComposeSecrets,
|
|
7
|
+
auditFileBasedSecrets,
|
|
8
|
+
auditSecretFilesystem,
|
|
9
|
+
auditStackEnv,
|
|
10
|
+
isSecretLikeKey,
|
|
11
|
+
} from './secret-audit.js';
|
|
12
|
+
|
|
13
|
+
let tempDir: string;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tempDir = mkdtempSync(join(tmpdir(), 'secret-audit-test-'));
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('isSecretLikeKey', () => {
|
|
24
|
+
it('detects secret-like keys but allows file indirection keys', () => {
|
|
25
|
+
expect(isSecretLikeKey('OPENAI_API_KEY')).toBe(true);
|
|
26
|
+
expect(isSecretLikeKey('OP_UI_LOGIN_PASSWORD')).toBe(true);
|
|
27
|
+
expect(isSecretLikeKey('CHANNEL_CHAT_SECRET')).toBe(true);
|
|
28
|
+
expect(isSecretLikeKey('OPENAI_API_KEY_FILE')).toBe(false);
|
|
29
|
+
expect(isSecretLikeKey('OP_IMAGE_TAG')).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('auditStackEnv', () => {
|
|
34
|
+
it('rejects secret-like keys in stack.env', () => {
|
|
35
|
+
const issues = auditStackEnv({
|
|
36
|
+
OP_HOME: '/home/me/.openpalm',
|
|
37
|
+
OP_IMAGE_TAG: 'latest',
|
|
38
|
+
OPENAI_API_KEY: 'sk-test',
|
|
39
|
+
OP_UI_LOGIN_PASSWORD: 'secret',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(issues.map((entry) => entry.code)).toEqual([
|
|
43
|
+
'stack-env-secret-key',
|
|
44
|
+
'stack-env-secret-key',
|
|
45
|
+
]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('accepts non-secret runtime configuration', () => {
|
|
49
|
+
expect(auditStackEnv({
|
|
50
|
+
OP_HOME: '/home/me/.openpalm',
|
|
51
|
+
OP_UID: '1000',
|
|
52
|
+
OP_GID: '1000',
|
|
53
|
+
OP_ASSISTANT_PORT: '3800',
|
|
54
|
+
OPENAI_BASE_URL: 'http://localhost:11434/v1',
|
|
55
|
+
})).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('auditComposeSecrets', () => {
|
|
60
|
+
it('rejects service env_file and direct secret-like environment values', () => {
|
|
61
|
+
const issues = auditComposeSecrets(`
|
|
62
|
+
services:
|
|
63
|
+
guardian:
|
|
64
|
+
env_file:
|
|
65
|
+
- ./service.env
|
|
66
|
+
environment:
|
|
67
|
+
OPENCODE_SERVER_PASSWORD: secret
|
|
68
|
+
`);
|
|
69
|
+
|
|
70
|
+
expect(issues.map((entry) => entry.code)).toEqual([
|
|
71
|
+
'compose-service-env-file',
|
|
72
|
+
'compose-secret-env-var',
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('accepts *_FILE environment variables and in-boundary secret grants', () => {
|
|
77
|
+
const issues = auditComposeSecrets({
|
|
78
|
+
services: {
|
|
79
|
+
assistant: {
|
|
80
|
+
environment: {
|
|
81
|
+
OPENAI_API_KEY_FILE: '/run/secrets/provider_openai_api_key',
|
|
82
|
+
},
|
|
83
|
+
secrets: ['provider_openai_api_key'],
|
|
84
|
+
},
|
|
85
|
+
guardian: {
|
|
86
|
+
environment: ['GUARDIAN_CHANNEL_SECRET_FILE=/run/secrets/guardian_channel_secret'],
|
|
87
|
+
secrets: [{ source: 'guardian_channel_secret' }, { source: 'channel_chat_hmac' }],
|
|
88
|
+
},
|
|
89
|
+
chat: {
|
|
90
|
+
image: 'openpalm/channel:latest',
|
|
91
|
+
secrets: ['channel_chat_hmac'],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(issues).toEqual([]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('rejects cross-boundary secret grants', () => {
|
|
100
|
+
const issues = auditComposeSecrets({
|
|
101
|
+
services: {
|
|
102
|
+
assistant: { secrets: ['guardian_channel_secret'] },
|
|
103
|
+
chat: { image: 'openpalm/channel:latest', secrets: ['channel_slack_hmac'] },
|
|
104
|
+
guardian: { secrets: ['admin_session_key'] },
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
expect(issues.map((entry) => entry.code)).toEqual([
|
|
109
|
+
'compose-secret-boundary',
|
|
110
|
+
'compose-secret-boundary',
|
|
111
|
+
'compose-secret-boundary',
|
|
112
|
+
]);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('auditSecretFilesystem', () => {
|
|
117
|
+
it('requires a 0700 secrets directory and 0600 secret files', () => {
|
|
118
|
+
const secretsDir = join(tempDir, 'config', 'stack', 'secrets');
|
|
119
|
+
mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
|
|
120
|
+
chmodSync(secretsDir, 0o700);
|
|
121
|
+
const secretPath = join(secretsDir, 'provider_openai_api_key');
|
|
122
|
+
writeFileSync(secretPath, 'sk-test\n', { mode: 0o600 });
|
|
123
|
+
chmodSync(secretPath, 0o600);
|
|
124
|
+
|
|
125
|
+
expect(auditSecretFilesystem(secretsDir)).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('reports unsafe directory and file permissions', () => {
|
|
129
|
+
const secretsDir = join(tempDir, 'secrets');
|
|
130
|
+
mkdirSync(secretsDir, { recursive: true, mode: 0o755 });
|
|
131
|
+
chmodSync(secretsDir, 0o755);
|
|
132
|
+
const secretPath = join(secretsDir, 'admin_session_key');
|
|
133
|
+
writeFileSync(secretPath, 'secret\n', { mode: 0o644 });
|
|
134
|
+
chmodSync(secretPath, 0o644);
|
|
135
|
+
|
|
136
|
+
expect(auditSecretFilesystem(secretsDir).map((entry) => entry.code)).toEqual([
|
|
137
|
+
'secrets-dir-mode',
|
|
138
|
+
'secret-file-mode',
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe('auditFileBasedSecrets', () => {
|
|
144
|
+
it('combines stack env, compose, and filesystem checks', () => {
|
|
145
|
+
const secretsDir = join(tempDir, 'secrets');
|
|
146
|
+
mkdirSync(secretsDir, { recursive: true, mode: 0o700 });
|
|
147
|
+
chmodSync(secretsDir, 0o700);
|
|
148
|
+
writeFileSync(join(secretsDir, 'provider_openai_api_key'), 'sk-test\n', { mode: 0o600 });
|
|
149
|
+
|
|
150
|
+
const result = auditFileBasedSecrets({
|
|
151
|
+
stackEnvContent: 'OP_HOME=/tmp/openpalm\nOPENAI_API_KEY=bad\n',
|
|
152
|
+
composeConfig: 'services:\n assistant:\n environment:\n OPENAI_API_KEY_FILE: /run/secrets/provider_openai_api_key\n',
|
|
153
|
+
secretsDir,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
expect(result.ok).toBe(false);
|
|
157
|
+
expect(result.issues.map((entry) => entry.code)).toEqual(['stack-env-secret-key']);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { parse as parseYaml } from 'yaml';
|
|
4
|
+
import { parseEnvContent, parseEnvFile } from './env.js';
|
|
5
|
+
|
|
6
|
+
export type SecretAuditSeverity = 'error' | 'warning';
|
|
7
|
+
|
|
8
|
+
export type SecretAuditIssue = {
|
|
9
|
+
severity: SecretAuditSeverity;
|
|
10
|
+
code: string;
|
|
11
|
+
message: string;
|
|
12
|
+
path?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type SecretAuditResult = {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
issues: SecretAuditIssue[];
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export type SecretAuditOptions = {
|
|
21
|
+
stackEnvPath?: string;
|
|
22
|
+
stackEnvContent?: string;
|
|
23
|
+
composeConfig?: string | unknown;
|
|
24
|
+
secretsDir?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type ComposeService = {
|
|
28
|
+
image?: unknown;
|
|
29
|
+
env_file?: unknown;
|
|
30
|
+
environment?: unknown;
|
|
31
|
+
secrets?: unknown;
|
|
32
|
+
networks?: unknown;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type ComposeConfig = {
|
|
36
|
+
services?: Record<string, ComposeService>;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const SECRET_FILE_MODE = 0o600;
|
|
40
|
+
const SECRET_DIR_MODE = 0o700;
|
|
41
|
+
|
|
42
|
+
const NON_SECRET_STACK_KEYS = new Set([
|
|
43
|
+
'COMPOSE_PROJECT_NAME',
|
|
44
|
+
'OP_PROJECT_NAME',
|
|
45
|
+
'OP_HOME',
|
|
46
|
+
'OP_UID',
|
|
47
|
+
'OP_GID',
|
|
48
|
+
'OP_IMAGE_NAMESPACE',
|
|
49
|
+
'OP_IMAGE_TAG',
|
|
50
|
+
'OP_SETUP_COMPLETE',
|
|
51
|
+
'OP_ASSISTANT_BIND_ADDRESS',
|
|
52
|
+
'OP_ASSISTANT_PORT',
|
|
53
|
+
'OP_ASSISTANT_SSH_BIND_ADDRESS',
|
|
54
|
+
'OP_ASSISTANT_SSH_PORT',
|
|
55
|
+
'OPENCODE_ENABLE_SSH',
|
|
56
|
+
'OP_CHAT_BIND_ADDRESS',
|
|
57
|
+
'OP_CHAT_PORT',
|
|
58
|
+
'OP_API_BIND_ADDRESS',
|
|
59
|
+
'OP_API_PORT',
|
|
60
|
+
'OP_VOICE_BIND_ADDRESS',
|
|
61
|
+
'OP_VOICE_PORT',
|
|
62
|
+
'OP_OLLAMA_BIND_ADDRESS',
|
|
63
|
+
'OP_VOICE_PROFILE',
|
|
64
|
+
'OP_OLLAMA_PROFILE',
|
|
65
|
+
'OP_HOST_UI_PORT',
|
|
66
|
+
'OP_OWNER_NAME',
|
|
67
|
+
'OP_OWNER_EMAIL',
|
|
68
|
+
'OPENAI_BASE_URL',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
function issue(code: string, message: string, path?: string): SecretAuditIssue {
|
|
72
|
+
return { severity: 'error', code, message, path };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isSecretLikeKey(key: string): boolean {
|
|
76
|
+
const normalized = key.toUpperCase();
|
|
77
|
+
if (normalized.endsWith('_FILE')) return false;
|
|
78
|
+
return /(^|_)(SECRET|TOKEN|PASSWORD|PASS|API_KEY|PRIVATE_KEY|CREDENTIAL|CREDENTIALS)(_|$)/.test(normalized);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseComposeConfig(input: string | unknown): ComposeConfig {
|
|
82
|
+
if (typeof input === 'string') {
|
|
83
|
+
const parsed = parseYaml(input) as unknown;
|
|
84
|
+
return isRecord(parsed) ? parsed as ComposeConfig : {};
|
|
85
|
+
}
|
|
86
|
+
return isRecord(input) ? input as ComposeConfig : {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
90
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function environmentEntries(environment: unknown): Array<[string, unknown]> {
|
|
94
|
+
if (Array.isArray(environment)) {
|
|
95
|
+
return environment.flatMap((entry) => {
|
|
96
|
+
if (typeof entry !== 'string') return [];
|
|
97
|
+
const eq = entry.indexOf('=');
|
|
98
|
+
return eq > 0 ? [[entry.slice(0, eq), entry.slice(eq + 1)] as [string, string]] : [[entry, '']];
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
if (isRecord(environment)) return Object.entries(environment);
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function serviceSecrets(secrets: unknown): string[] {
|
|
106
|
+
if (!Array.isArray(secrets)) return [];
|
|
107
|
+
return secrets.flatMap((entry) => {
|
|
108
|
+
if (typeof entry === 'string') return [entry];
|
|
109
|
+
if (!isRecord(entry)) return [];
|
|
110
|
+
const source = entry.source ?? entry.target;
|
|
111
|
+
return typeof source === 'string' ? [source] : [];
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function serviceNetworks(networks: unknown): string[] {
|
|
116
|
+
if (Array.isArray(networks)) return networks.filter((entry): entry is string => typeof entry === 'string');
|
|
117
|
+
if (isRecord(networks)) return Object.keys(networks);
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function normalizedSecretName(name: string): string {
|
|
122
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isChannelService(name: string, service: ComposeService): boolean {
|
|
126
|
+
const normalized = normalizedSecretName(name);
|
|
127
|
+
if (normalized.startsWith('channel_')) return true;
|
|
128
|
+
const image = typeof service.image === 'string' ? service.image.toLowerCase() : '';
|
|
129
|
+
if (image.includes('/channel') || image.endsWith(':channel') || image.includes('openpalm/channel')) return true;
|
|
130
|
+
return serviceNetworks(service.networks).includes('channel_lan') && name !== 'guardian';
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function allowedSecretForService(serviceName: string, service: ComposeService, secretName: string): boolean {
|
|
134
|
+
const serviceId = normalizedSecretName(serviceName.replace(/^channel[-_]/i, ''));
|
|
135
|
+
const secretId = normalizedSecretName(secretName);
|
|
136
|
+
|
|
137
|
+
if (serviceName === 'assistant') {
|
|
138
|
+
return /^(assistant|opencode|provider|llm|embedding|akm|user)_/.test(secretId);
|
|
139
|
+
}
|
|
140
|
+
if (serviceName === 'guardian') {
|
|
141
|
+
return secretId.startsWith('guardian_') || secretId.startsWith('channel_');
|
|
142
|
+
}
|
|
143
|
+
if (serviceName === 'admin') {
|
|
144
|
+
return /^(admin|ui|openpalm)_/.test(secretId);
|
|
145
|
+
}
|
|
146
|
+
if (isChannelService(serviceName, service)) {
|
|
147
|
+
return secretId.startsWith(`channel_${serviceId}_`) || secretId.startsWith(`${serviceId}_`);
|
|
148
|
+
}
|
|
149
|
+
return secretId.startsWith(`${serviceId}_`);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function auditStackEnv(env: Record<string, string>, label = 'stack.env'): SecretAuditIssue[] {
|
|
153
|
+
const issues: SecretAuditIssue[] = [];
|
|
154
|
+
for (const key of Object.keys(env)) {
|
|
155
|
+
if (NON_SECRET_STACK_KEYS.has(key)) continue;
|
|
156
|
+
if (isSecretLikeKey(key)) {
|
|
157
|
+
issues.push(issue(
|
|
158
|
+
'stack-env-secret-key',
|
|
159
|
+
`${label} must not contain secret-like key ${key}; store it as a file under knowledge/secrets and expose ${key}_FILE instead.`,
|
|
160
|
+
`${label}:${key}`,
|
|
161
|
+
));
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return issues;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function auditComposeSecrets(composeConfig: string | unknown): SecretAuditIssue[] {
|
|
168
|
+
const compose = parseComposeConfig(composeConfig);
|
|
169
|
+
const issues: SecretAuditIssue[] = [];
|
|
170
|
+
for (const [serviceName, service] of Object.entries(compose.services ?? {})) {
|
|
171
|
+
if (service.env_file !== undefined) {
|
|
172
|
+
issues.push(issue(
|
|
173
|
+
'compose-service-env-file',
|
|
174
|
+
`service ${serviceName} must not use env_file; pass non-secrets explicitly and use Docker secrets for secret values.`,
|
|
175
|
+
`services.${serviceName}.env_file`,
|
|
176
|
+
));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
for (const [key] of environmentEntries(service.environment)) {
|
|
180
|
+
if (isSecretLikeKey(key)) {
|
|
181
|
+
issues.push(issue(
|
|
182
|
+
'compose-secret-env-var',
|
|
183
|
+
`service ${serviceName} environment key ${key} is secret-like; expose only ${key}_FILE.`,
|
|
184
|
+
`services.${serviceName}.environment.${key}`,
|
|
185
|
+
));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
for (const secretName of serviceSecrets(service.secrets)) {
|
|
190
|
+
if (!allowedSecretForService(serviceName, service, secretName)) {
|
|
191
|
+
issues.push(issue(
|
|
192
|
+
'compose-secret-boundary',
|
|
193
|
+
`service ${serviceName} is not allowed to mount secret ${secretName}.`,
|
|
194
|
+
`services.${serviceName}.secrets`,
|
|
195
|
+
));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return issues;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function auditSecretFilesystem(secretsDir: string): SecretAuditIssue[] {
|
|
203
|
+
const issues: SecretAuditIssue[] = [];
|
|
204
|
+
if (!existsSync(secretsDir)) {
|
|
205
|
+
issues.push(issue('secrets-dir-missing', `secrets directory does not exist: ${secretsDir}`, secretsDir));
|
|
206
|
+
return issues;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const dirStat = statSync(secretsDir);
|
|
210
|
+
if (!dirStat.isDirectory()) {
|
|
211
|
+
issues.push(issue('secrets-dir-not-directory', `secrets path is not a directory: ${secretsDir}`, secretsDir));
|
|
212
|
+
return issues;
|
|
213
|
+
}
|
|
214
|
+
if ((dirStat.mode & 0o777) !== SECRET_DIR_MODE) {
|
|
215
|
+
issues.push(issue('secrets-dir-mode', `secrets directory must be 0700, got ${formatMode(dirStat.mode)}.`, secretsDir));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const entry of readdirSync(secretsDir, { withFileTypes: true })) {
|
|
219
|
+
const path = join(secretsDir, entry.name);
|
|
220
|
+
if (entry.isDirectory()) {
|
|
221
|
+
issues.push(...auditSecretFilesystem(path));
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
if (!entry.isFile()) continue;
|
|
225
|
+
const fileStat = statSync(path);
|
|
226
|
+
if ((fileStat.mode & 0o777) !== SECRET_FILE_MODE) {
|
|
227
|
+
issues.push(issue('secret-file-mode', `secret file must be 0600, got ${formatMode(fileStat.mode)}.`, path));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return issues;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function formatMode(mode: number): string {
|
|
234
|
+
return `0${(mode & 0o777).toString(8)}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function auditFileBasedSecrets(options: SecretAuditOptions): SecretAuditResult {
|
|
238
|
+
const issues: SecretAuditIssue[] = [];
|
|
239
|
+
|
|
240
|
+
if (options.stackEnvContent !== undefined) {
|
|
241
|
+
issues.push(...auditStackEnv(parseEnvContent(options.stackEnvContent), 'stack.env'));
|
|
242
|
+
} else if (options.stackEnvPath) {
|
|
243
|
+
issues.push(...auditStackEnv(parseEnvFile(options.stackEnvPath), options.stackEnvPath));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (options.composeConfig !== undefined) {
|
|
247
|
+
issues.push(...auditComposeSecrets(options.composeConfig));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (options.secretsDir) {
|
|
251
|
+
issues.push(...auditSecretFilesystem(options.secretsDir));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return { ok: issues.length === 0, issues };
|
|
255
|
+
}
|
|
@@ -62,7 +62,7 @@ type SecretIndexFile = {
|
|
|
62
62
|
};
|
|
63
63
|
|
|
64
64
|
function secretIndexPath(state: ControlPlaneState): string {
|
|
65
|
-
return `${state.
|
|
65
|
+
return `${state.dataDir}/secrets/plaintext-index.json`;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function normalizeIndexedKey(key: string): string {
|
|
@@ -144,7 +144,7 @@ export function readPlaintextSecretIndex(state: ControlPlaneState): SecretIndexF
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
export function writePlaintextSecretIndex(state: ControlPlaneState, index: SecretIndexFile): void {
|
|
147
|
-
const dir = `${state.
|
|
147
|
+
const dir = `${state.dataDir}/secrets`;
|
|
148
148
|
mkdirSync(dir, { recursive: true });
|
|
149
149
|
writeFileSync(secretIndexPath(state), JSON.stringify(index, null, 2) + '\n');
|
|
150
150
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { mkdtempSync, mkdirSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { buildEnvFiles } from './config-persistence.js';
|
|
6
|
+
import { assertNoSecretLikeStackEnvKeys, patchSecretsEnvFile } from './secrets.js';
|
|
7
|
+
import { listSecretNames, readSecret, resolveSecretsDir, secretPath, writeSecret } from './secrets-files.js';
|
|
8
|
+
import type { ControlPlaneState } from './types.js';
|
|
9
|
+
|
|
10
|
+
function tempStackDir(): string {
|
|
11
|
+
return mkdtempSync(join(tmpdir(), 'openpalm-secrets-files-'));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('file-based control-plane secrets', () => {
|
|
15
|
+
it('creates the secrets directory and files with private permissions', () => {
|
|
16
|
+
const stackDir = tempStackDir();
|
|
17
|
+
|
|
18
|
+
writeSecret(stackDir, 'channel_chat_secret', 'value');
|
|
19
|
+
|
|
20
|
+
expect(resolveSecretsDir(stackDir)).toBe(join(stackDir, 'knowledge', 'secrets'));
|
|
21
|
+
expect(statSync(resolveSecretsDir(stackDir)).mode & 0o777).toBe(0o700);
|
|
22
|
+
expect(statSync(secretPath(stackDir, 'channel_chat_secret')).mode & 0o777).toBe(0o600);
|
|
23
|
+
expect(readSecret(stackDir, 'channel_chat_secret')).toBe('value');
|
|
24
|
+
expect(listSecretNames(stackDir)).toEqual(['channel_chat_secret']);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('rejects invalid secret names', () => {
|
|
28
|
+
const stackDir = tempStackDir();
|
|
29
|
+
|
|
30
|
+
expect(() => writeSecret(stackDir, 'CHANNEL_CHAT_SECRET', 'value')).toThrow(/Invalid secret name/);
|
|
31
|
+
expect(() => writeSecret(stackDir, 'channel-chat-secret', 'value')).toThrow(/Invalid secret name/);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('rejects secret-like stack.env keys', () => {
|
|
35
|
+
expect(() => assertNoSecretLikeStackEnvKeys({ OPENAI_API_KEY: 'sk-test' })).toThrow(/OPENAI_API_KEY/);
|
|
36
|
+
expect(() => assertNoSecretLikeStackEnvKeys({ OP_OWNER_NAME: 'Ada' })).not.toThrow();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('does not include file-based secrets in compose env files', () => {
|
|
40
|
+
const stackDir = tempStackDir();
|
|
41
|
+
const stashDir = join(stackDir, 'knowledge');
|
|
42
|
+
const stackEnv = join(stashDir, 'env', 'stack.env');
|
|
43
|
+
mkdirSync(join(stashDir, 'env'), { recursive: true });
|
|
44
|
+
writeFileSync(stackEnv, 'OP_HOME=/tmp/openpalm\n');
|
|
45
|
+
writeSecret(stackDir, 'channel_chat_secret', 'value');
|
|
46
|
+
const state = { stackDir, stashDir } as ControlPlaneState;
|
|
47
|
+
|
|
48
|
+
expect(buildEnvFiles(state)).toEqual([stackEnv]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('routes secret patches to lower-case secret files instead of stack.env', () => {
|
|
52
|
+
const stackDir = tempStackDir();
|
|
53
|
+
writeFileSync(join(stackDir, 'stack.env'), 'OP_SETUP_COMPLETE=false\n');
|
|
54
|
+
|
|
55
|
+
patchSecretsEnvFile(stackDir, { OP_UI_LOGIN_PASSWORD: 'pw', OP_IMAGE_TAG: 'latest' });
|
|
56
|
+
|
|
57
|
+
expect(readSecret(stackDir, 'op_ui_login_password')).toBe('pw\n');
|
|
58
|
+
expect(listSecretNames(stackDir)).toContain('op_ui_login_password');
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, basename } from 'node:path';
|
|
3
|
+
|
|
4
|
+
const SECRET_NAME_RE = /^[a-z0-9][a-z0-9_]{0,80}$/;
|
|
5
|
+
const SECRETS_DIR_MODE = 0o700;
|
|
6
|
+
const SECRET_FILE_MODE = 0o600;
|
|
7
|
+
|
|
8
|
+
export function validateSecretName(name: string): void {
|
|
9
|
+
if (!SECRET_NAME_RE.test(name)) throw new Error(`Invalid secret name: ${name}`);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function resolveHomeDirFromStackDir(stackDir: string): string {
|
|
13
|
+
const parentDir = dirname(stackDir);
|
|
14
|
+
if (basename(stackDir) === 'stack' && basename(parentDir) === 'config') {
|
|
15
|
+
return dirname(parentDir);
|
|
16
|
+
}
|
|
17
|
+
return stackDir;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function resolveSecretsDir(stackDir: string): string {
|
|
21
|
+
const dir = join(resolveHomeDirFromStackDir(stackDir), 'knowledge', 'secrets');
|
|
22
|
+
mkdirSync(dir, { recursive: true, mode: SECRETS_DIR_MODE });
|
|
23
|
+
chmodSync(dir, SECRETS_DIR_MODE);
|
|
24
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
25
|
+
if (entry.isFile()) chmodSync(join(dir, entry.name), SECRET_FILE_MODE);
|
|
26
|
+
}
|
|
27
|
+
return dir;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function secretPath(stackDir: string, name: string): string {
|
|
31
|
+
validateSecretName(name);
|
|
32
|
+
return join(resolveSecretsDir(stackDir), name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readSecret(stackDir: string, name: string): string | null {
|
|
36
|
+
const path = secretPath(stackDir, name);
|
|
37
|
+
if (!existsSync(path)) return null;
|
|
38
|
+
chmodSync(path, SECRET_FILE_MODE);
|
|
39
|
+
return readFileSync(path, 'utf-8');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function writeSecret(stackDir: string, name: string, value: string): void {
|
|
43
|
+
const path = secretPath(stackDir, name);
|
|
44
|
+
writeFileSync(path, value, { mode: SECRET_FILE_MODE });
|
|
45
|
+
chmodSync(path, SECRET_FILE_MODE);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function ensureSecret(stackDir: string, name: string, valueFactory: () => string): string {
|
|
49
|
+
const existing = readSecret(stackDir, name);
|
|
50
|
+
if (existing !== null) return existing;
|
|
51
|
+
const value = valueFactory();
|
|
52
|
+
writeSecret(stackDir, name, value);
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function removeSecret(stackDir: string, name: string): void {
|
|
57
|
+
rmSync(secretPath(stackDir, name), { force: true });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function listSecretNames(stackDir: string): string[] {
|
|
61
|
+
const dir = resolveSecretsDir(stackDir);
|
|
62
|
+
return readdirSync(dir, { withFileTypes: true })
|
|
63
|
+
.filter((entry) => entry.isFile() && SECRET_NAME_RE.test(entry.name))
|
|
64
|
+
.map((entry) => entry.name)
|
|
65
|
+
.sort();
|
|
66
|
+
}
|