Commit 4629c44d by Dan Cech Committed by GitHub

Toolkit: implement plugin signing in grafana-toolkit (#27907)

* implement plugin signing in grafana-toolkit
parent ad0f0711
...@@ -61,6 +61,7 @@ With grafana-toolkit, we give you a CLI that addresses common tasks performed wh ...@@ -61,6 +61,7 @@ With grafana-toolkit, we give you a CLI that addresses common tasks performed wh
- `grafana-toolkit plugin:dev` - `grafana-toolkit plugin:dev`
- `grafana-toolkit plugin:test` - `grafana-toolkit plugin:test`
- `grafana-toolkit plugin:build` - `grafana-toolkit plugin:build`
- `grafana-toolkit plugin:sign`
### Create your plugin ### Create your plugin
...@@ -105,6 +106,19 @@ Available options: ...@@ -105,6 +106,19 @@ Available options:
- `--coverage` - Reports code coverage after the test step of the build. - `--coverage` - Reports code coverage after the test step of the build.
### Sign your plugin
`grafana-toolkit plugin:sign`
This command creates a signed MANIFEST.txt file which Grafana uses to validate the integrity of the plugin.
Available options:
- `--signatureType` - The [type of Signature](https://grafana.com/legal/plugins/) you are generating: `private`, `community` or `commercial`
- `--rootUrls` - For private signatures, a list of the Grafana instance URLs that the plugin will be used on
To generate a signature, you will need to sign up for a free account on https://grafana.com, create an API key with the Plugin Publisher role, and pass that in the `GRAFANA_API_KEY` environment variable.
## FAQ ## FAQ
### Which version of grafana-toolkit should I use? ### Which version of grafana-toolkit should I use?
......
...@@ -18,6 +18,7 @@ import { pluginUpdateTask } from './tasks/plugin.update'; ...@@ -18,6 +18,7 @@ import { pluginUpdateTask } from './tasks/plugin.update';
import { ciBuildPluginDocsTask, ciBuildPluginTask, ciPackagePluginTask, ciPluginReportTask } from './tasks/plugin.ci'; import { ciBuildPluginDocsTask, ciBuildPluginTask, ciPackagePluginTask, ciPluginReportTask } from './tasks/plugin.ci';
import { buildPackageTask } from './tasks/package.build'; import { buildPackageTask } from './tasks/package.build';
import { pluginCreateTask } from './tasks/plugin.create'; import { pluginCreateTask } from './tasks/plugin.create';
import { pluginSignTask } from './tasks/plugin.sign';
import { bundleManagedTask } from './tasks/plugin/bundle.managed'; import { bundleManagedTask } from './tasks/plugin/bundle.managed';
import { componentCreateTask } from './tasks/component.create'; import { componentCreateTask } from './tasks/component.create';
...@@ -180,6 +181,19 @@ export const run = (includeInternalScripts = false) => { ...@@ -180,6 +181,19 @@ export const run = (includeInternalScripts = false) => {
}); });
program program
.command('plugin:sign')
.option('--signatureType <type>', 'Signature Type')
.option('--rootUrls <urls...>', 'Root URLs')
.description('Create a plugin signature')
.action(async cmd => {
await execTask(pluginSignTask)({
signatureType: cmd.signatureType,
rootUrls: cmd.rootUrls,
silent: true,
});
});
program
.command('plugin:ci-build') .command('plugin:ci-build')
.option('--finish', 'move all results to the jobs folder', false) .option('--finish', 'move all results to the jobs folder', false)
.option('--maxJestWorkers <num>|<string>', 'Limit number of Jest workers spawned') .option('--maxJestWorkers <num>|<string>', 'Limit number of Jest workers spawned')
...@@ -200,11 +214,14 @@ export const run = (includeInternalScripts = false) => { ...@@ -200,11 +214,14 @@ export const run = (includeInternalScripts = false) => {
program program
.command('plugin:ci-package') .command('plugin:ci-package')
.option('--signing-admin', 'Use the admin API endpoint for signing the manifest.', false) .option('--signatureType <type>', 'Signature Type')
.option('--rootUrls <urls...>', 'Root URLs')
.option('--signing-admin', 'Use the admin API endpoint for signing the manifest. (deprecated)', false)
.description('Create a zip packages for the plugin') .description('Create a zip packages for the plugin')
.action(async cmd => { .action(async cmd => {
await execTask(ciPackagePluginTask)({ await execTask(ciPackagePluginTask)({
signingAdmin: cmd.signingAdmin, signatureType: cmd.signatureType,
rootUrls: cmd.rootUrls,
}); });
}); });
......
...@@ -8,6 +8,7 @@ import execa = require('execa'); ...@@ -8,6 +8,7 @@ import execa = require('execa');
import path = require('path'); import path = require('path');
import fs from 'fs-extra'; import fs from 'fs-extra';
import { getPackageDetails, getGrafanaVersions, readGitLog } from '../../plugins/utils'; import { getPackageDetails, getGrafanaVersions, readGitLog } from '../../plugins/utils';
import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest';
import { import {
getJobFolder, getJobFolder,
writeJobStats, writeJobStats,
...@@ -25,7 +26,8 @@ const rimraf = promisify(rimrafCallback); ...@@ -25,7 +26,8 @@ const rimraf = promisify(rimrafCallback);
export interface PluginCIOptions { export interface PluginCIOptions {
finish?: boolean; finish?: boolean;
upload?: boolean; upload?: boolean;
signingAdmin?: boolean; signatureType?: string;
rootUrls?: string[];
maxJestWorkers?: string; maxJestWorkers?: string;
} }
...@@ -107,7 +109,7 @@ export const ciBuildPluginDocsTask = new Task<PluginCIOptions>('Build Plugin Doc ...@@ -107,7 +109,7 @@ export const ciBuildPluginDocsTask = new Task<PluginCIOptions>('Build Plugin Doc
* 2. zip it into packages in `~/ci/packages` * 2. zip it into packages in `~/ci/packages`
* 3. prepare grafana environment in: `~/ci/grafana-test-env` * 3. prepare grafana environment in: `~/ci/grafana-test-env`
*/ */
const packagePluginRunner: TaskRunner<PluginCIOptions> = async ({ signingAdmin }) => { const packagePluginRunner: TaskRunner<PluginCIOptions> = async ({ signatureType, rootUrls }) => {
const start = Date.now(); const start = Date.now();
const ciDir = getCiFolder(); const ciDir = getCiFolder();
const packagesDir = path.resolve(ciDir, 'packages'); const packagesDir = path.resolve(ciDir, 'packages');
...@@ -163,11 +165,16 @@ const packagePluginRunner: TaskRunner<PluginCIOptions> = async ({ signingAdmin } ...@@ -163,11 +165,16 @@ const packagePluginRunner: TaskRunner<PluginCIOptions> = async ({ signingAdmin }
}); });
// Write a MANIFEST.txt file in the dist folder // Write a MANIFEST.txt file in the dist folder
// By using the --signing-admin flag the plugin doesn't need to be in the plugins database to be signed,
// however it requires an Admin API key.
try { try {
const grabplCommandFlags = signingAdmin ? ['build-plugin-manifest', '--signing-admin'] : ['build-plugin-manifest']; const manifest = await buildManifest(distContentDir);
await execa('grabpl', [...grabplCommandFlags, distContentDir]); if (signatureType) {
manifest.signatureType = signatureType;
}
if (rootUrls) {
manifest.rootUrls = rootUrls;
}
const signedManifest = await signManifest(manifest);
await saveManifest(distContentDir, signedManifest);
} catch (err) { } catch (err) {
console.warn(`Error signing manifest: ${distContentDir}`, err); console.warn(`Error signing manifest: ${distContentDir}`, err);
} }
......
import path from 'path';
import { buildManifest, signManifest, saveManifest } from '../../plugins/manifest';
import { Task, TaskRunner } from './task';
interface PluginSignOptions {
signatureType?: string;
rootUrls?: string[];
}
const pluginSignRunner: TaskRunner<PluginSignOptions> = async ({ signatureType, rootUrls }) => {
const distContentDir = path.resolve('dist');
try {
console.log('Building manifest...');
const manifest = await buildManifest(distContentDir);
// console.log(manifest);
console.log('Signing manifest...');
if (signatureType) {
manifest.signatureType = signatureType;
}
if (rootUrls) {
manifest.rootUrls = rootUrls;
}
const signedManifest = await signManifest(manifest);
// console.log(signedManifest);
console.log('Saving signed manifest...');
await saveManifest(distContentDir, signedManifest);
console.log('Signed successfully');
} catch (err) {
console.warn(err);
}
};
export const pluginSignTask = new Task<PluginSignOptions>('plugin:sign task', pluginSignRunner);
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import { ManifestInfo } from './types';
const MANIFEST_FILE = 'MANIFEST.txt';
async function* walk(dir: string, baseDir: string): AsyncGenerator<string, any, any> {
for await (const d of await (fs.promises as any).opendir(dir)) {
const entry = path.join(dir, d.name);
if (d.isDirectory()) {
yield* await walk(entry, baseDir);
} else if (d.isFile()) {
yield path.relative(baseDir, entry);
} else if (d.isSymbolicLink()) {
const realPath = fs.realpathSync(entry);
if (!realPath.startsWith(baseDir)) {
throw new Error(
`symbolic link ${path.relative(baseDir, entry)} targets a file outside of the base directory: ${baseDir}`
);
}
yield path.relative(baseDir, entry);
}
}
}
export async function buildManifest(dir: string): Promise<ManifestInfo> {
const pluginJson = JSON.parse(fs.readFileSync(path.join(dir, 'plugin.json'), { encoding: 'utf8' }));
const manifest = {
plugin: pluginJson.id,
version: pluginJson.info.version,
files: {},
} as ManifestInfo;
for await (const p of await walk(dir, dir)) {
if (p === MANIFEST_FILE) {
continue;
}
manifest.files[p] = crypto
.createHash('sha256')
.update(fs.readFileSync(path.join(dir, p)))
.digest('hex');
}
return manifest;
}
export async function signManifest(manifest: ManifestInfo): Promise<string> {
const GRAFANA_API_KEY = process.env.GRAFANA_API_KEY;
if (!GRAFANA_API_KEY) {
throw new Error('You must enter a GRAFANA_API_KEY to sign the plugin manifest');
}
const GRAFANA_COM_URL = process.env.GRAFANA_COM_URL || 'https://grafana.com/api';
const url = GRAFANA_COM_URL + '/plugins/ci/sign';
const axios = require('axios');
try {
const info = await axios.post(url, manifest, {
headers: { Authorization: 'Bearer ' + GRAFANA_API_KEY },
});
if (info.status !== 200) {
console.warn('Error: ', info);
throw new Error('Error signing manifest');
}
return info.data;
} catch (err) {
if ((err.response && err.response.data) || err.response.data.message) {
throw new Error('Error signing manifest: ' + err.response.data.message);
}
throw new Error('Error signing manifest: ' + err.message);
}
}
export async function saveManifest(dir: string, signedManifest: string): Promise<boolean> {
fs.writeFileSync(path.join(dir, MANIFEST_FILE), signedManifest);
return true;
}
...@@ -91,6 +91,10 @@ export interface GitLogInfo { ...@@ -91,6 +91,10 @@ export interface GitLogInfo {
export interface ManifestInfo { export interface ManifestInfo {
// time: number; << filled in by the server // time: number; << filled in by the server
// keyId: string; << filled in by the server // keyId: string; << filled in by the server
// signedByOrg: string; << filled in by the server
// signedByOrgName: string; << filled in by the server
signatureType?: string; // filled in by the server if not specified
rootUrls?: string[]; // for private signatures
plugin: string; plugin: string;
version: string; version: string;
files: Record<string, string>; files: Record<string, string>;
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment