diff --git a/.github/workflows/unused-dependencies.yml b/.github/workflows/unused-dependencies.yml index a401f7b50..b0230dfa7 100644 --- a/.github/workflows/unused-dependencies.yml +++ b/.github/workflows/unused-dependencies.yml @@ -21,7 +21,7 @@ jobs: node-version: '20.x' - name: 'Run depcheck' run: | - npx depcheck --skip-missing --ignores="tsx,@babel/*,@commitlint/*,eslint,eslint-*,husky,mocha,ts-mocha,ts-node,concurrently,nyc,prettier,typescript,tsconfig-paths,vite-tsconfig-paths" + npx depcheck --skip-missing --ignores="tsx,@babel/*,@commitlint/*,eslint,eslint-*,husky,mocha,ts-mocha,ts-node,concurrently,nyc,prettier,typescript,tsconfig-paths,vite-tsconfig-paths,jsonschema" echo $? if [[ $? == 1 ]]; then echo "Unused dependencies or devDependencies found" diff --git a/index.ts b/index.ts index 880ccfe02..ffc50b73a 100755 --- a/index.ts +++ b/index.ts @@ -2,8 +2,8 @@ /* eslint-disable max-len */ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; -import * as fs from 'fs'; -import { configFile, setConfigFile, validate } from './src/config/file'; +import { existsSync } from 'fs'; +import { configFile, setConfigFile, loadConfig } from './src/config/file'; import proxy from './src/proxy'; import service from './src/service'; @@ -13,37 +13,45 @@ const argv = yargs(hideBin(process.argv)) validate: { description: 'Check the proxy.config.json file in the current working directory for validation errors.', - required: false, alias: 'v', type: 'boolean', }, config: { description: 'Path to custom git-proxy configuration file.', - default: 'proxy.config.json', - required: false, alias: 'c', type: 'string', + default: 'proxy.config.json', }, }) .strict() .parseSync(); -setConfigFile(argv.c as string || ""); +setConfigFile(argv.config); -if (argv.v) { - if (!fs.existsSync(configFile)) { +if (argv.validate) { + if (!existsSync(configFile)) { console.error( - `Config file ${configFile} doesn't exist, nothing to validate! Did you forget -c/--config?`, + `✖ Config file ${configFile} doesn't exist, nothing to validate! Did you forget -c/--config?`, ); process.exit(1); } - validate(); - console.log(`${configFile} is valid`); - process.exit(0); + try { + loadConfig(); + console.log(`✔️ ${configFile} is valid`); + process.exit(0); + } catch (err: any) { + console.error('✖ Validation Error:', err.message); + process.exit(1); + } } -validate(); +try { + loadConfig(); +} catch (err: any) { + console.error('✖ Validation Error:', err.message); + process.exit(1); +} proxy.start(); service.start(); diff --git a/package-lock.json b/package-lock.json index 57cb15249..2a44462b2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,8 @@ "react-router-dom": "6.28.2", "simple-git": "^3.25.0", "uuid": "^11.0.0", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "zod": "^3.24.3" }, "bin": { "git-proxy": "index.js", @@ -14304,6 +14305,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz", + "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/git-proxy-cli": { "name": "@finos/git-proxy-cli", "version": "0.1.0", diff --git a/package.json b/package.json index 4fb28ca04..7199f3baa 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,8 @@ "react-router-dom": "6.28.2", "simple-git": "^3.25.0", "uuid": "^11.0.0", - "yargs": "^17.7.2" + "yargs": "^17.7.2", + "zod": "^3.24.3" }, "devDependencies": { "@babel/core": "^7.23.2", diff --git a/proxy.config.schema.ts b/proxy.config.schema.ts new file mode 100644 index 000000000..b0dfb46dd --- /dev/null +++ b/proxy.config.schema.ts @@ -0,0 +1,205 @@ +import { z } from 'zod'; + +const TempPasswordSchema = z.object({ + sendEmail: z.boolean().default(false), + emailConfig: z.record(z.unknown()).default({}), +}); + +const AuthorisedItemSchema = z.object({ + project: z.string(), + name: z.string(), + url: z.string().regex(/^(?:https?:\/\/.+\.git|git@[^:]+:[^/]+\/.+\.git)$/i, { + message: 'Must be a Git HTTPS URL (https://... .git) or SSH URL (git@...:... .git)', + }), +}); + +const FsSinkSchema = z.object({ + type: z.literal('fs'), + params: z.object({ filepath: z.string() }), + enabled: z.boolean().default(true), +}); + +const MongoSinkSchema = z.object({ + type: z.literal('mongo'), + connectionString: z.string(), + options: z.object({ + useNewUrlParser: z.boolean().default(true), + useUnifiedTopology: z.boolean().default(true), + tlsAllowInvalidCertificates: z.boolean().default(false), + ssl: z.boolean().default(false), + }), + enabled: z.boolean().default(false), +}); + +const SinkSchema = z.discriminatedUnion('type', [FsSinkSchema, MongoSinkSchema]); + +const ActiveDirectoryConfigSchema = z.object({ + url: z.string(), + baseDN: z.string(), + searchBase: z.string(), +}); + +const LocalAuthSchema = z.object({ + type: z.literal('local'), + enabled: z.boolean().default(true), +}); + +const ADAuthSchema = z.object({ + type: z.literal('ActiveDirectory'), + enabled: z.boolean().default(false), + adminGroup: z.string().default(''), + userGroup: z.string().default(''), + domain: z.string().default(''), + adConfig: ActiveDirectoryConfigSchema, +}); + +const AuthenticationSchema = z.discriminatedUnion('type', [LocalAuthSchema, ADAuthSchema]); + +const GithubApiSchema = z.object({ + baseUrl: z.string().url(), +}); + +const CommitEmailSchema = z.object({ + local: z.object({ block: z.string().default('') }), + domain: z.object({ allow: z.string().default('.*') }), +}); + +const CommitBlockSchema = z.object({ + literals: z.array(z.string()).default([]), + patterns: z.array(z.string()).default([]), +}); + +const CommitDiffSchema = z.object({ + block: z.object({ + literals: z.array(z.string()).default([]), + patterns: z.array(z.string()).default([]), + providers: z.record(z.unknown()).default({}), + }), +}); + +const AttestationQuestionSchema = z.object({ + label: z.string(), + tooltip: z.object({ + text: z.string(), + links: z.array(z.string()).default([]), + }), +}); + +export const RateLimitSchema = z + .object({ + windowMs: z.number({ description: 'Sliding window in milliseconds' }), + limit: z.number({ description: 'Maximum number of requests' }), + statusCode: z.number().optional().default(429), + message: z.string().optional().default('Too many requests'), + }) + .strict(); + +const FileConfigSourceSchema = z + .object({ + type: z.literal('file'), + enabled: z.boolean().default(false), + path: z.string(), + }) + .strict(); + +const HttpConfigSourceSchema = z + .object({ + type: z.literal('http'), + enabled: z.boolean().default(false), + url: z.string().url(), + headers: z.record(z.string()).default({}), + auth: z + .object({ + type: z.literal('bearer'), + token: z.string().default(''), + }) + .strict() + .default({ type: 'bearer', token: '' }), + }) + .strict(); + +const GitConfigSourceSchema = z + .object({ + type: z.literal('git'), + enabled: z.boolean().default(false), + repository: z.string(), + branch: z.string().default('main'), + path: z.string(), + auth: z + .object({ + type: z.literal('ssh'), + privateKeyPath: z.string(), + }) + .strict(), + }) + .strict(); + +const ConfigSourceSchema = z.discriminatedUnion('type', [ + FileConfigSourceSchema, + HttpConfigSourceSchema, + GitConfigSourceSchema, +]); + +export const ConfigurationSourcesSchema = z + .object({ + enabled: z.boolean(), + reloadIntervalSeconds: z.number().optional().default(60), + merge: z.boolean().optional().default(false), + sources: z.array(ConfigSourceSchema).default([]), + }) + .strict(); + +export const ConfigSchema = z + .object({ + proxyUrl: z.string().url().default('https://github.com'), + cookieSecret: z.string().default(''), + sessionMaxAgeHours: z.number().int().positive().default(12), + rateLimit: RateLimitSchema.default({ windowMs: 600000, limit: 150 }), + configurationSources: ConfigurationSourcesSchema.default({ + enabled: false, + reloadIntervalSeconds: 60, + merge: false, + sources: [], + }), + tempPassword: TempPasswordSchema.default({}), + authorisedList: z.array(AuthorisedItemSchema).default([]), + sink: z.array(SinkSchema).default([]), + authentication: z.array(AuthenticationSchema).default([{ type: 'local', enabled: true }]), + api: z + .object({ + github: GithubApiSchema, + }) + .default({ github: { baseUrl: 'https://api.github.com' } }), + commitConfig: z + .object({ + author: z.object({ email: CommitEmailSchema }), + message: z.object({ block: CommitBlockSchema }), + diff: CommitDiffSchema, + }) + .default({ + author: { email: { local: { block: '' }, domain: { allow: '.*' } } }, + message: { block: { literals: [], patterns: [] } }, + diff: { block: { literals: [], patterns: [], providers: {} } }, + }), + attestationConfig: z + .object({ + questions: z.array(AttestationQuestionSchema).default([]), + }) + .default({ questions: [] }), + domains: z.record(z.string(), z.string()).default({}), + privateOrganizations: z.array(z.string()).default([]), + urlShortener: z.string().default(''), + contactEmail: z.string().default(''), + csrfProtection: z.boolean().default(true), + plugins: z.array(z.unknown()).default([]), + tls: z + .object({ + enabled: z.boolean().default(false), + key: z.string().default(''), + cert: z.string().default(''), + }) + .default({}), + }) + .strict(); + +export type Config = z.infer; diff --git a/src/config/file.ts b/src/config/file.ts index e7aadcd46..7affe0d5a 100644 --- a/src/config/file.ts +++ b/src/config/file.ts @@ -1,27 +1,43 @@ import { readFileSync } from 'fs'; import { join } from 'path'; -import { validate as jsonSchemaValidate } from 'jsonschema'; +import { ConfigSchema, type Config } from '../../proxy.config.schema'; -export let configFile: string = join(process.cwd(), 'proxy.config.json'); +export let configFile: string = join(process.cwd(), 'config.proxy.json'); +export let config: Config; /** - * Set the config file path. - * @param {string} file - The path to the config file. + * Sets the path to the configuration file. + * + * @param {string} file - The path to the configuration file. + * @return {void} */ export function setConfigFile(file: string) { configFile = file; } /** - * Validate config file. - * @param {string} configFilePath - The path to the config file. - * @return {boolean} - Returns true if validation is successful. - * @throws Will throw an error if the validation fails. + * Loads and validates the configuration file using Zod. + * If validation succeeds, the parsed config is stored in the exported `config`. + * + * @return {Config} The validated and default-filled configuration object. + * @throws {ZodError} If validation fails. */ -export function validate(configFilePath: string = configFile!): boolean { - const config = JSON.parse(readFileSync(configFilePath, 'utf-8')); - const schemaPath = join(process.cwd(), 'config.schema.json'); - const schema = JSON.parse(readFileSync(schemaPath, 'utf-8')); - jsonSchemaValidate(config, schema, { required: true, throwError: true }); +export function loadConfig(): Config { + const raw = JSON.parse(readFileSync(configFile, 'utf-8')); + const parsed = ConfigSchema.parse(raw); + config = parsed; + return parsed; +} + +/** + * Validates a configuration file without mutating the exported `config`. + * + * @param {string} [filePath=configFile] - Path to the configuration file to validate. + * @return {boolean} Returns `true` if the file passes validation. + * @throws {ZodError} If validation fails. + */ +export function validate(filePath: string = configFile): boolean { + const raw = JSON.parse(readFileSync(filePath, 'utf-8')); + ConfigSchema.parse(raw); return true; }