diff --git a/package.json b/package.json index 7a1b16b..edcb4b4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@squeebot/core", - "version": "3.3.8", + "version": "3.4.0", "description": "Squeebot v3 core for the execution environment", "main": "lib/index.js", "types": "lib/index.d.ts", diff --git a/schema/repository.schema.json b/schema/repository.schema.json index 5a9b9de..9e7bd7b 100644 --- a/schema/repository.schema.json +++ b/schema/repository.schema.json @@ -25,6 +25,10 @@ "version": { "type": "string", "description": "Plugin version" + }, + "checksum": { + "type": "string", + "description": "Checksum of the plugin package" } }, "required": ["name", "version"] diff --git a/src/plugin/repository/manager.ts b/src/plugin/repository/manager.ts index 0b8f236..209ce82 100644 --- a/src/plugin/repository/manager.ts +++ b/src/plugin/repository/manager.ts @@ -11,6 +11,7 @@ import { IEnvironment } from '../../types'; import { PluginManager } from '../manager'; import { IPlugin, IPluginManifest } from '../plugin'; import { IRepoPluginDef, IRepository } from './repository'; +import { checkChecksum } from '../../util'; /** * Plugin repository manager @@ -22,25 +23,25 @@ export class RepositoryManager { /** * Determine if this repository provides a specific plugin - * @param repo Repository manifest - * @param mf Plugin manifest + * @param repository Repository manifest + * @param pluginManifest Plugin manifest * @returns Repository plugin definition */ public repoProvidesPlugin( - repo: IRepository, - mf: IPluginManifest + repository: IRepository, + pluginManifest: IPluginManifest ): IRepoPluginDef | undefined { - return repo.plugins.find(plugin => plugin.name === mf.name && repo.name === mf.repository); + return repository.plugins.find(plugin => plugin.name === pluginManifest.name && repository.name === pluginManifest.repository); } /** * Find repository providing plugin - * @param pname Plugin name + * @param pluginName Plugin name * @returns Repository manifest */ - public findRepoForPlugin(pname: string): IRepository | undefined { + public findRepoForPlugin(pluginName: string): IRepository | undefined { return Array.from(this.repositories.values()).find(repo => - repo.plugins.find(plugin => plugin.name === pname) !== undefined + repo.plugins.find(plugin => plugin.name === pluginName) !== undefined ); } @@ -80,6 +81,11 @@ export class RepositoryManager { throw new Error('Could not find a repository for a plugin named ' + name); } + const pluginInRepo = repo.plugins.find((entry) => entry.name === name); + if (!pluginInRepo) { + throw new Error(`Unexpected error: Plugin ${name} not found in repository ${repo.name} manifest, this shouldn't be possible!`); + } + const srcFile = name + '.plugin.tgz'; const pluginPath = repo.url + '/' + srcFile; const tempdir = await fs.mkdtemp(path.join(this.env.path, '.sbdl')); @@ -87,33 +93,47 @@ export class RepositoryManager { let manifest: IPluginManifest; try { + // Download plugin const save = await httpGET(pluginPath, {}, false, tempfile); - const extract = await tar.x({ + + // Check for checksum, if included in repository manifest + if (pluginInRepo.checksum) { + if (!await checkChecksum(save, pluginInRepo.checksum)) { + throw new Error(`Plugin ${name} package checksum validation FAILED! Please report this!`); + } + } + + // Extract plugin package + await tar.x({ file: tempfile, C: tempdir, }); + // Extraction failed const findByName = path.join(tempdir, name); if (!await fs.pathExists(findByName)) { throw new Error('Invalid file provided.'); } + // Find manifest const manifestFile = path.join(findByName, 'plugin.json'); if (!await fs.pathExists(manifestFile)) { throw new Error('manifest file does not exist.'); } + // Load manifest const loadManifest = await fs.readJSON(manifestFile); if (!loadManifest.name || !loadManifest.version) { throw new Error('Not a valid plugin manifest file.'); } manifest = loadManifest; - const fp = path.join(this.env.pluginsPath, manifest.name); - manifest.fullPath = fp; + const realPluginPath = path.join(this.env.pluginsPath, manifest.name); + manifest.fullPath = realPluginPath; manifest.repository = repo.name; + // Write local copy of manifest and copy plugin to the plugins path await fs.writeJSON(manifestFile, manifest); - await fs.copy(findByName, fp); + await fs.copy(findByName, realPluginPath); } catch (e) { await fs.remove(tempdir); throw e; @@ -205,23 +225,23 @@ export class RepositoryManager { /** * Uninstall a repository - * @param repo Repository name or manifest + * @param repository Repository name or manifest */ - public async uninstallRepository(repo: string | IRepository): Promise { - if (typeof repo === 'string') { - repo = this.getRepoByName(repo) as IRepository; - if (!repo) { + public async uninstallRepository(repository: string | IRepository): Promise { + if (typeof repository === 'string') { + repository = this.getRepoByName(repository) as IRepository; + if (!repository) { throw new Error('No such repository found!'); } } // Remove repository metadata file - const repoFile = path.join(this.env.repositoryPath, repo.name + '.json'); + const repoFile = path.join(this.env.repositoryPath, repository.name + '.json'); await fs.remove(repoFile); - this.repositories.delete(repo.name); + this.repositories.delete(repository.name); // Uninstall all plugins from this repository - for (const plugin of repo.plugins) { + for (const plugin of repository.plugins) { // Ignore non-installed plugins try { await this.uninstallPlugin(plugin.name); diff --git a/src/plugin/repository/repository.ts b/src/plugin/repository/repository.ts index 032293e..c71d67a 100644 --- a/src/plugin/repository/repository.ts +++ b/src/plugin/repository/repository.ts @@ -1,6 +1,7 @@ export interface IRepoPluginDef { name: string; version: string; + checksum?: string; } export interface IRepository { diff --git a/src/types/config.ts b/src/types/config.ts index 4b7791c..24604c6 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -51,7 +51,7 @@ export class Configuration { * @param from Internal use only * @returns Configuration value or default or null */ - public get(key: string, defval?: any, from?: any): any { + public get(key: string, defval?: T, from?: any): T { if (!from) { from = this.config; } @@ -63,24 +63,24 @@ export class Configuration { if (first != null) { return this.get(split.slice(1).join('.'), defval, first); } - return defval; + return defval as T; } // Array indexing if (key.indexOf('[') !== -1 && key.indexOf(']') !== -1) { const match = key.match(/\[(\d+)\]/i); - const realKey = key.substr(0, key.indexOf('[')); + const realKey = key.substring(0, key.indexOf('[')); if (match != null) { const index = parseInt(match[1], 10); if (from[realKey]) { return from[realKey][index]; } } - return defval; + return defval as T; } if (from[key] == null) { - return defval; + return defval as T; } return from[key]; @@ -108,7 +108,7 @@ export class Configuration { // Array indexing if (key.indexOf('[') !== -1 && key.indexOf(']') !== -1) { const match = key.match(/\[(\d+)\]/i); - const realKey = key.substr(0, key.indexOf('[')); + const realKey = key.substring(0, key.indexOf('[')); if (match != null) { const index = parseInt(match[1], 10); if (from[realKey]) { diff --git a/src/util/checksum.ts b/src/util/checksum.ts new file mode 100644 index 0000000..6d31d30 --- /dev/null +++ b/src/util/checksum.ts @@ -0,0 +1,24 @@ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; + +export const takeChecksum = (file: string): Promise => { + const hash = crypto.createHash('sha512'); + return new Promise((resolve, reject) => { + const readStream = fs.createReadStream(path.resolve(file)); + readStream.on('data', (chunk) => { + try { + hash.update(chunk); + } catch(e: unknown) { + reject(e); + } + }); + readStream.on('close', () => resolve(hash.digest('hex'))); + readStream.on('error', (err) => reject(err)); + }); +}; + +export const checkChecksum = async (file: string, checksum: string): Promise => { + const fileSum = await takeChecksum(file); + return crypto.timingSafeEqual(Buffer.from(fileSum), Buffer.from(checksum)); +}; diff --git a/src/util/index.ts b/src/util/index.ts index f119768..e6ababa 100644 --- a/src/util/index.ts +++ b/src/util/index.ts @@ -2,6 +2,7 @@ import path from 'path'; export { ScopedEventEmitter } from './events'; export { IProcessData, spawnProcess, execProcess } from './run'; +export * from './checksum'; /** * Load a Node.js module without caching it.