Merge pull request #17024 from Rbillon59/feat/badges/enable-safe-public-access

Enable safe public access for Badges
This commit is contained in:
Johan Haals
2023-05-04 10:08:02 +02:00
committed by GitHub
18 changed files with 1196 additions and 149 deletions
+10
View File
@@ -0,0 +1,10 @@
---
'@backstage/plugin-badges': patch
'@backstage/plugin-badges-backend': minor
---
Fixing badges-backend plugin to get a token from the TokenManager instead of parsing the request header. Hence, it's now possible to disable the authMiddleware for the badges-backend plugin to expose publicly the badges.
Implementing an obfuscation feature to protect an open badges endpoint from being enumerated. The feature is disabled by default and the change is compatible with the previous version.
**BREAKING**: `createRouter` now require that `tokenManager`, `logger`, `identityApi` and `database`, are passed in as options.
+3
View File
@@ -28,5 +28,8 @@ export default async function createPlugin(
config: env.config,
discovery: env.discovery,
badgeFactories: createDefaultBadgeFactories(),
tokenManager: env.tokenManager,
logger: env.logger,
identity: env.identity,
});
}
+36
View File
@@ -36,6 +36,9 @@ export default async function createPlugin(
config: env.config,
discovery: env.discovery,
badgeFactories: createDefaultBadgeFactories(),
tokenManager: env.tokenManager,
logger: env.logger,
identity: env.identity,
});
}
```
@@ -112,11 +115,29 @@ export const createMyCustomBadgeFactories = (): BadgeFactories => ({
});
```
### Badge obfuscation
When you enable the obfuscation feature, the badges backend will obfuscate the entity names in the badge link. It's useful when you want your badges to be visible to the public, but you don't want to expose the entity names and also to protect your entity names from being enumerated.
To enable the obfuscation you need to activate the `obfuscation` feature in the `app-config.yaml`:
```yaml
app:
badges:
obfuscate: true
```
:warning: **Warning**: The only endpoint to be publicly available is the `/entity/:entityUuid/:badgeId` endpoint. The other endpoints are meant for trusted internal users and should not be publicly exposed.
> Note that you cannot use env vars to set the `obfuscate` value. It must be a boolean value and env vars are always strings.
## API
The badges backend api exposes two main endpoints for entity badges. The
`/badges` prefix is arbitrary, and the default for the example backend.
### If obfuscation is disabled (default or apps.badges.obfuscate: false)
- `/badges/entity/:namespace/:kind/:name/badge-specs` List all defined badges
for a particular entity, in json format. See
[BadgeSpec](https://github.com/backstage/backstage/tree/master/plugins/badges/src/api/types.ts)
@@ -126,6 +147,21 @@ The badges backend api exposes two main endpoints for entity badges. The
an SVG image. If the `accept` request header prefers `application/json` the
badge spec as JSON will be returned instead of the image.
### If obfuscation is enabled (apps.badges.obfuscate: true)
- `/badges/entity/:namespace/:kind/:name/obfuscated` Get the obfuscated `entity url`.
> Note that endpoint have a embedded authMiddleware to authenticate the user requesting this endpoint. _It meant to be called from the frontend plugin._
- `/badges/entity/:entityUuid/:badgeId` Get the entity badge as an SVG image. If
the `accept` request header prefers `application/json` the badge spec as JSON
will be returned instead of the image.
- `/badge/entity/:entityUuid/badge-specs` List all defined badges for a
particular entity, in json format. See
[BadgeSpec](https://github.com/backstage/backstage/tree/master/plugins/badges/src/api/types.ts)
from the frontend plugin for a type declaration.
## Links
- [Frontend part of the plugin](https://github.com/backstage/backstage/tree/master/plugins/badges)
+32
View File
@@ -7,7 +7,10 @@ import { CatalogApi } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { Entity } from '@backstage/catalog-model';
import express from 'express';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { Logger } from 'winston';
import { PluginEndpointDiscovery } from '@backstage/backend-common';
import { TokenManager } from '@backstage/backend-common';
// @public (undocumented)
export interface Badge {
@@ -78,6 +81,27 @@ export type BadgeSpec = {
markdown: string;
};
// @public
export interface BadgesStore {
// (undocumented)
getBadgeFromUuid(uuid: string): Promise<
| {
name: string;
namespace: string;
kind: string;
}
| undefined
>;
// (undocumented)
getBadgeUuid(
name: string,
namespace: string,
kind: string,
): Promise<{
uuid: string;
}>;
}
// @public (undocumented)
export type BadgeStyle = (typeof BADGE_STYLES)[number];
@@ -107,10 +131,18 @@ export interface RouterOptions {
// (undocumented)
badgeFactories?: BadgeFactories;
// (undocumented)
badgeStore?: BadgesStore;
// (undocumented)
catalog?: CatalogApi;
// (undocumented)
config: Config;
// (undocumented)
discovery: PluginEndpointDiscovery;
// (undocumented)
identity: IdentityApi;
// (undocumented)
logger: Logger;
// (undocumented)
tokenManager: TokenManager;
}
```
@@ -0,0 +1,33 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
exports.up = async function up(knex) {
await knex.schema.createTable('badges', table => {
table.string('kind').notNullable();
table.string('namespace').notNullable();
table.string('name').notNullable();
table.string('uuid').unique().notNullable();
table.index(['uuid'], 'badges_uuid_index');
table.primary(['kind', 'namespace', 'name']);
});
};
exports.down = async function down(knex) {
await knex.schema.alterTable('badges', table => {
table.dropIndex('', 'badges_uuid_index');
});
await knex.schema.dropTable('badges');
};
+12 -2
View File
@@ -38,18 +38,28 @@
"@backstage/catalog-model": "workspace:^",
"@backstage/config": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/plugin-auth-node": "workspace:^",
"@types/express": "^4.17.6",
"badge-maker": "^3.3.0",
"cors": "^2.8.5",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"knex": "^2.4.2",
"lodash": "^4.17.21",
"supertest": "^6.3.3",
"uuid": "^9.0.0",
"winston": "^3.2.1",
"yn": "^4.0.0"
},
"devDependencies": {
"@backstage/backend-test-utils": "workspace:^",
"@backstage/catalog-client": "workspace:^",
"@backstage/cli": "workspace:^",
"@types/supertest": "^2.0.8",
"supertest": "^6.1.3"
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@types/node": "*",
"cross-fetch": "^3.1.5"
},
"files": [
"dist"
@@ -0,0 +1,110 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DatabaseBadgesStore } from './badgesStore';
import { TestDatabaseId, TestDatabases } from '@backstage/backend-test-utils';
import { Knex } from 'knex';
import { Entity } from '@backstage/catalog-model';
describe('DatabaseBadgesStore', () => {
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
},
};
const databases = TestDatabases.create();
async function createDatabaseBadgesStore(databaseId: TestDatabaseId) {
const knex = await databases.init(databaseId);
return {
knex,
badgeStore: await DatabaseBadgesStore.create({
database: { getClient: async () => knex },
}),
};
}
describe.each(databases.eachSupportedId())('%p', databaseId => {
let knex: Knex;
let badgeStore: DatabaseBadgesStore;
beforeEach(async () => {
({ knex, badgeStore } = await createDatabaseBadgesStore(databaseId));
});
it('createABadge if not existing in DB', async () => {
const uuid = await badgeStore.getBadgeUuid(
entity.metadata.name,
entity.metadata.namespace || 'default',
entity.kind,
);
expect(uuid.uuid).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
const storedBadge = await badgeStore.getBadgeFromUuid(uuid.uuid);
expect(storedBadge?.kind).toEqual(entity.kind);
expect(storedBadge?.name).toEqual(entity.metadata.name);
expect(storedBadge?.namespace).toEqual(
entity.metadata.namespace || 'default',
);
});
it('getBadge if badge already exist in DB', async () => {
await knex('badges').truncate();
await knex('badges').insert([
{
uuid: 'uuid1',
name: 'test',
namespace: 'default',
kind: 'component',
},
]);
const storedEntity = await badgeStore.getBadgeFromUuid('uuid1');
expect(storedEntity).toEqual({
name: 'test',
namespace: 'default',
kind: 'component',
});
});
it('getBadgeUuid if badge exist in DB', async () => {
await knex('badges').truncate();
await knex('badges').insert([
{
uuid: 'uuid1',
name: 'test',
namespace: 'default',
kind: 'component',
},
]);
const storedUuid = await badgeStore.getBadgeUuid(
'test',
'default',
'component',
);
expect(storedUuid).toEqual({ uuid: 'uuid1' });
});
});
});
@@ -0,0 +1,110 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
PluginDatabaseManager,
resolvePackagePath,
} from '@backstage/backend-common';
import { Knex } from 'knex';
import { isNil } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
/**
* internal
* @public
*/
export interface BadgesStore {
getBadgeUuid(
name: string,
namespace: string,
kind: string,
): Promise<{ uuid: string }>;
getBadgeFromUuid(
uuid: string,
): Promise<{ name: string; namespace: string; kind: string } | undefined>;
}
const migrationsDir = resolvePackagePath(
'@backstage/plugin-badges-backend', // Package name
'migrations', // Migrations directory
);
/**
* DatabaseBadgesStore
* @internal
*/
export class DatabaseBadgesStore implements BadgesStore {
private constructor(private readonly db: Knex) {}
static async create({
database,
skipMigrations,
}: {
database: PluginDatabaseManager;
skipMigrations?: boolean;
}): Promise<DatabaseBadgesStore> {
const client = await database.getClient();
if (!database.migrations?.skip && !skipMigrations) {
await client.migrate.latest({
directory: migrationsDir,
});
}
return new DatabaseBadgesStore(client);
}
async getBadgeFromUuid(
uuid: string,
): Promise<{ name: string; namespace: string; kind: string } | undefined> {
const result = await this.db('badges')
.select('namespace', 'name', 'kind')
.where({ uuid: uuid })
.first();
return result;
}
async getBadgeUuid(
name: string,
namespace: string,
kind: string,
): Promise<{ uuid: string }> {
const result = await this.db('badges')
.select('uuid')
.where({ name: name, namespace: namespace, kind: kind })
.first();
let uuid = result?.uuid;
if (isNil(uuid)) {
uuid = uuidv4();
await this.db('badges')
.insert({
uuid: uuid,
name: name,
namespace: namespace,
kind: kind,
})
.onConflict(['name', 'namespace', 'kind'])
.ignore();
}
return { uuid };
}
}
+1
View File
@@ -24,3 +24,4 @@ export * from './badges';
export * from './lib';
export * from './service/router';
export * from './types';
export * from './database/badgesStore';
@@ -0,0 +1,302 @@
/*
* Copyright 2021 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import express from 'express';
import request from 'supertest';
import {
getVoidLogger,
PluginEndpointDiscovery,
ServerTokenManager,
SingleHostDiscovery,
} from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import type { Entity } from '@backstage/catalog-model';
import { Config, ConfigReader } from '@backstage/config';
import { createRouter } from './router';
import { BadgeBuilder } from '../lib';
import {
BackstageIdentityResponse,
IdentityApiGetIdentityRequest,
} from '@backstage/plugin-auth-node';
import { BadgesStore } from '../database/badgesStore';
describe('createRouter', () => {
let app: express.Express;
const badgeBuilder: jest.Mocked<BadgeBuilder> = {
getBadges: jest.fn(),
createBadgeJson: jest.fn(),
createBadgeSvg: jest.fn(),
};
const catalog = {
addLocation: jest.fn(),
getEntities: jest.fn(),
getEntityByRef: jest.fn(),
getLocationByRef: jest.fn(),
getLocationById: jest.fn(),
removeLocationById: jest.fn(),
removeEntityByUid: jest.fn(),
refreshEntity: jest.fn(),
getEntityAncestors: jest.fn(),
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const getIdentity = jest
.fn()
.mockImplementation(
async ({
request: _request,
}: IdentityApiGetIdentityRequest): Promise<
BackstageIdentityResponse | undefined
> => {
return {
identity: {
userEntityRef: 'user:default/guest',
ownershipEntityRefs: [],
type: 'user',
},
token: 'token',
};
},
);
const config: Config = new ConfigReader({
backend: {
baseUrl: 'http://127.0.0.1',
listen: {
port: 7007,
},
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
app: {
badges: {
obfuscate: true,
},
},
});
let discovery: PluginEndpointDiscovery;
const entity: Entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test',
},
};
const entities: Entity[] = [
entity,
{
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'test-2',
},
},
];
const badge = {
id: 'test-badge',
badge: {
label: 'test',
message: 'badge',
},
url: '/...',
markdown: '[![...](...)]',
};
const badgeEntity = {
name: 'test',
namespace: 'default',
kind: 'component',
};
const badgeStore: jest.Mocked<BadgesStore> = {
getBadgeUuid: jest.fn().mockImplementation(async () => {
return { uuid: 'uuid1' };
}),
getBadgeFromUuid: jest.fn().mockImplementation(async () => {
return badgeEntity;
}),
};
beforeAll(async () => {
discovery = SingleHostDiscovery.fromConfig(config);
const tokenManager = ServerTokenManager.noop();
const router = await createRouter({
badgeBuilder,
catalog: catalog as Partial<CatalogApi> as CatalogApi,
config,
discovery,
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
});
app = express().use(router);
});
beforeEach(() => {
jest.clearAllMocks();
});
it('works with provided badgeStore', async () => {
const tokenManager = ServerTokenManager.noop();
const router = await createRouter({
badgeBuilder,
catalog: catalog as Partial<CatalogApi> as CatalogApi,
config,
discovery,
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
badgeStore: badgeStore,
});
expect(router).toBeDefined();
});
describe('GET /entity/:namespace/:kind/:name/badge-specs', () => {
it('does not returns all badge specs for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const response = await request(app).get(
'/entity/default/component/test/badge-specs',
);
expect(response.status).toEqual(404);
});
});
describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => {
it('does not returns badge for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
const image = '<svg>...</svg>';
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
const response = await request(app).get(
'/entity/default/component/test/badge/test-badge',
);
expect(response.status).toEqual(404);
});
it('does not returns badge spec for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const url = '/entity/default/component/test/badge/test-badge?format=json';
const response = await request(app).get(url);
expect(response.status).toEqual(404);
});
});
describe('GET /entity/:namespace/:kind/:name/obfuscated', () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
catalog.getEntities.mockResolvedValueOnce({ items: entities });
it('returns obfuscated 401 if no auth', async () => {
const obfuscatedEntity = await request(app).get(
'/entity/default/component/test/obfuscated',
);
expect(obfuscatedEntity.status).toEqual(401);
});
it('returns obfuscated entity and badges', async () => {
const obfuscatedEntity = await request(app)
.get('/entity/default/component/test/obfuscated')
.set('Authorization', 'Bearer fakeToken');
expect(obfuscatedEntity.status).toEqual(200);
expect(obfuscatedEntity.body.uuid).toMatch(
new RegExp(
'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$',
),
);
const uuid = obfuscatedEntity.body.uuid;
const url = `/entity/${uuid}/test-badge?format=json`;
let response = await request(app).get(url);
expect(response.status).toEqual(200);
expect(response.body).toEqual(badge);
catalog.getEntityByRef.mockResolvedValueOnce(entity);
catalog.getEntities.mockResolvedValueOnce({ items: entities });
const image = '<svg>...</svg>';
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
response = await request(app).get(`/entity/${uuid}/test-badge`);
expect(response.status).toEqual(200);
expect(response.body).toEqual(Buffer.from(image));
catalog.getEntities.mockResolvedValueOnce({ items: entities });
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
response = await request(app).get(`/entity/${uuid}/badge-specs`);
expect(response.status).toEqual(200);
expect(response.body).toEqual([badge]);
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeJson).toHaveBeenCalledTimes(2);
});
describe('Errors', () => {
it('returns 404 for unknown entity uuid', async () => {
badgeStore.getBadgeFromUuid.mockResolvedValue(undefined);
catalog.getEntityByRef.mockResolvedValueOnce(entity);
catalog.getEntities.mockResolvedValueOnce({ items: entities });
badgeBuilder.getBadges.mockResolvedValueOnce([{ id: badge.id }]);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
async function testUrl(url: string) {
const response = await request(app).get(url);
expect(response.status).toEqual(404);
expect(response.body).toEqual({
error: {
message: expect.any(String),
name: 'NotFoundError',
},
request: {
method: 'GET',
url,
},
response: {
statusCode: 404,
},
});
}
await testUrl(
'/entity/3a5f91c1e66519be5394c37a8ba69cfsf3087b7c322c600e7497dc9d517353e5bed/badge-specs',
);
await testUrl(
'/entity/3a5f91c1e66519be5394c37a8ba69c3087b7csfsf322c600e7497dc9d517353e5bed/test-badge',
);
});
});
});
});
+129 -59
View File
@@ -17,7 +17,9 @@
import express from 'express';
import request from 'supertest';
import {
getVoidLogger,
PluginEndpointDiscovery,
ServerTokenManager,
SingleHostDiscovery,
} from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
@@ -25,10 +27,16 @@ import type { Entity } from '@backstage/catalog-model';
import { Config, ConfigReader } from '@backstage/config';
import { createRouter } from './router';
import { BadgeBuilder } from '../lib';
import {
BackstageIdentityResponse,
IdentityApiGetIdentityRequest,
} from '@backstage/plugin-auth-node';
import { BadgesStore } from '../database/badgesStore';
describe('createRouter', () => {
let app: express.Express;
let badgeBuilder: jest.Mocked<BadgeBuilder>;
const catalog = {
addLocation: jest.fn(),
getEntities: jest.fn(),
@@ -42,12 +50,15 @@ describe('createRouter', () => {
getEntityFacets: jest.fn(),
validateEntity: jest.fn(),
};
const getIdentity = jest.fn();
let config: Config;
let discovery: PluginEndpointDiscovery;
const entity: Entity = {
apiVersion: 'v1',
kind: 'service',
kind: 'component',
metadata: {
name: 'test',
},
@@ -63,6 +74,11 @@ describe('createRouter', () => {
markdown: '[![...](...)]',
};
const badgeStore: jest.Mocked<BadgesStore> = {
getBadgeFromUuid: jest.fn(),
getBadgeUuid: jest.fn(),
};
beforeAll(async () => {
badgeBuilder = {
getBadges: jest.fn(),
@@ -73,15 +89,40 @@ describe('createRouter', () => {
backend: {
baseUrl: 'http://127.0.0.1',
listen: { port: 7007 },
database: {
client: 'better-sqlite3',
connection: ':memory:',
},
},
});
discovery = SingleHostDiscovery.fromConfig(config);
getIdentity.mockImplementation(
async ({
request: _request,
}: IdentityApiGetIdentityRequest): Promise<
BackstageIdentityResponse | undefined
> => {
return {
identity: {
userEntityRef: 'user:default/guest',
ownershipEntityRefs: [],
type: 'user',
},
token: 'token',
};
},
);
discovery = SingleHostDiscovery.fromConfig(config);
const tokenManager = ServerTokenManager.noop();
const router = await createRouter({
badgeBuilder,
catalog: catalog as Partial<CatalogApi> as CatalogApi,
config,
discovery,
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
});
app = express().use(router);
});
@@ -90,12 +131,17 @@ describe('createRouter', () => {
jest.resetAllMocks();
});
it('works', async () => {
it('works with badgeStore', async () => {
const tokenManager = ServerTokenManager.noop();
const router = await createRouter({
badgeBuilder,
catalog: catalog as Partial<CatalogApi> as CatalogApi,
config,
discovery,
tokenManager,
logger: getVoidLogger(),
identity: { getIdentity },
badgeStore: badgeStore,
});
expect(router).toBeDefined();
});
@@ -108,7 +154,7 @@ describe('createRouter', () => {
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const response = await request(app).get(
'/entity/default/service/test/badge-specs',
'/entity/default/component/test/badge-specs',
);
expect(response.status).toEqual(200);
@@ -118,10 +164,10 @@ describe('createRouter', () => {
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
{
namespace: 'default',
kind: 'service',
kind: 'component',
name: 'test',
},
{ token: undefined },
{ token: '' },
);
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(1);
@@ -130,46 +176,7 @@ describe('createRouter', () => {
badgeInfo: { id: badge.id },
context: {
badgeUrl: expect.stringMatching(
/http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/,
),
config,
entity,
},
});
});
});
describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => {
it('returns badge for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
const image = '<svg>...</svg>';
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
const response = await request(app).get(
'/entity/default/service/test/badge/test-badge',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(Buffer.from(image));
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
{
namespace: 'default',
kind: 'service',
name: 'test',
},
{ token: undefined },
);
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({
badgeInfo: { id: badge.id },
context: {
badgeUrl: expect.stringMatching(
/http:\/\/127.0.0.1\/api\/badges\/entity\/default\/service\/test\/badge\/test-badge/,
/http:\/\/127.0.0.1\/api\/badges\/entity\/default\/component\/test\/badge\/test-badge/,
),
config,
entity,
@@ -177,27 +184,90 @@ describe('createRouter', () => {
});
});
it('returns badge spec for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
describe('GET /entity/:namespace/:kind/:name/badge/test-badge', () => {
it('returns badge for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
const url = '/entity/default/service/test/badge/test-badge?format=json';
const response = await request(app).get(url);
const image = '<svg>...</svg>';
badgeBuilder.createBadgeSvg.mockResolvedValueOnce(image);
expect(response.status).toEqual(200);
expect(response.body).toEqual(badge);
const response = await request(app).get(
'/entity/default/component/test/badge/test-badge',
);
expect(response.status).toEqual(200);
expect(response.body).toEqual(Buffer.from(image));
expect(catalog.getEntityByRef).toHaveBeenCalledTimes(1);
expect(catalog.getEntityByRef).toHaveBeenCalledWith(
{
namespace: 'default',
kind: 'component',
name: 'test',
},
{ token: '' },
);
expect(badgeBuilder.getBadges).toHaveBeenCalledTimes(0);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledTimes(1);
expect(badgeBuilder.createBadgeSvg).toHaveBeenCalledWith({
badgeInfo: { id: badge.id },
context: {
badgeUrl: expect.stringMatching(
/http:\/\/127.0.0.1\/api\/badges\/entity\/default\/component\/test\/badge\/test-badge/,
),
config,
entity,
},
});
});
it('returns badge spec for entity', async () => {
catalog.getEntityByRef.mockResolvedValueOnce(entity);
badgeBuilder.createBadgeJson.mockResolvedValueOnce(badge);
const url =
'/entity/default/component/test/badge/test-badge?format=json';
const response = await request(app).get(url);
expect(response.status).toEqual(200);
expect(response.body).toEqual(badge);
});
});
});
describe('Errors', () => {
it('returns 404 for unknown entities', async () => {
describe('Errors', () => {
it('returns 404 for unknown entities', async () => {
catalog.getEntityByRef.mockResolvedValue(undefined);
async function testUrl(url: string) {
const response = await request(app).get(url);
expect(response.status).toEqual(404);
expect(response.body).toEqual({
error: {
message: 'No component entity in default named "missing"',
name: 'NotFoundError',
},
request: {
method: 'GET',
url,
},
response: {
statusCode: 404,
},
});
}
await testUrl('/entity/default/component/missing/badge-specs');
await testUrl('/entity/default/component/missing/badge/test-badge');
});
});
it('returns 404 for uuid entities', async () => {
catalog.getEntityByRef.mockResolvedValue(undefined);
async function testUrl(url: string) {
const response = await request(app).get(url);
expect(response.status).toEqual(404);
expect(response.body).toEqual({
error: {
message: 'No service entity in default named "missing"',
message: 'No component entity in default named "missing"',
name: 'NotFoundError',
},
request: {
@@ -209,8 +279,8 @@ describe('createRouter', () => {
},
});
}
await testUrl('/entity/default/service/missing/badge-specs');
await testUrl('/entity/default/service/missing/badge/test-badge');
await testUrl('/entity/default/component/missing/badge-specs');
await testUrl('/entity/default/component/missing/badge/test-badge');
});
});
});
+227 -33
View File
@@ -17,14 +17,21 @@
import express from 'express';
import Router from 'express-promise-router';
import {
DatabaseManager,
errorHandler,
PluginEndpointDiscovery,
TokenManager,
} from '@backstage/backend-common';
import { CatalogApi, CatalogClient } from '@backstage/catalog-client';
import { Config } from '@backstage/config';
import { NotFoundError } from '@backstage/errors';
import { AuthenticationError, NotFoundError } from '@backstage/errors';
import { BadgeBuilder, DefaultBadgeBuilder } from '../lib/BadgeBuilder';
import { BadgeContext, BadgeFactories } from '../types';
import { isNil } from 'lodash';
import { Logger } from 'winston';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
import { BadgesStore, DatabaseBadgesStore } from '../database/badgesStore';
/** @public */
export interface RouterOptions {
@@ -33,6 +40,10 @@ export interface RouterOptions {
catalog?: CatalogApi;
config: Config;
discovery: PluginEndpointDiscovery;
tokenManager: TokenManager;
logger: Logger;
identity: IdentityApi;
badgeStore?: BadgesStore;
}
/** @public */
@@ -46,13 +57,214 @@ export async function createRouter(
new DefaultBadgeBuilder(options.badgeFactories || {});
const router = Router();
const { config, logger, tokenManager, discovery, identity } = options;
const baseUrl = await discovery.getExternalBaseUrl('badges');
if (config.getOptionalBoolean('app.badges.obfuscate')) {
return obfuscatedRoute(
router,
catalog,
badgeBuilder,
tokenManager,
logger,
options,
config,
identity,
baseUrl,
);
}
return nonObfuscatedRoute(
router,
catalog,
badgeBuilder,
tokenManager,
config,
baseUrl,
);
}
async function obfuscatedRoute(
router: express.Router,
catalog: CatalogApi,
badgeBuilder: BadgeBuilder,
tokenManager: TokenManager,
logger: Logger,
options: RouterOptions,
config: Config,
identity: IdentityApi,
baseUrl: string,
) {
logger.info('Badges obfuscation is enabled');
const store = options.badgeStore
? options.badgeStore
: await DatabaseBadgesStore.create({
database: await DatabaseManager.fromConfig(config).forPlugin('badges'),
});
router.get('/entity/:entityUuid/badge-specs', async (req, res) => {
const { entityUuid } = req.params;
// Retrieve the badge info from the database
const badgeInfos = await store.getBadgeFromUuid(entityUuid);
if (isNil(badgeInfos)) {
throw new NotFoundError(`No badge found for entity uuid "${entityUuid}"`);
}
// If a mapping is found, map name, namespace and kind
const name = badgeInfos.name;
const namespace = badgeInfos.namespace;
const kind = badgeInfos.kind;
const token = await tokenManager.getToken();
// Query the catalog with the name, namespace, kind to get the entity informations
const entity = await catalog.getEntityByRef(
{
namespace,
kind,
name,
},
token,
);
if (isNil(entity)) {
throw new NotFoundError(
`No ${kind} entity in ${namespace} named "${name}"`,
);
}
// Create the badge specs
const specs = [];
for (const badgeInfo of await badgeBuilder.getBadges()) {
const context: BadgeContext = {
badgeUrl: `${baseUrl}/entity/${entityUuid}/${badgeInfo.id}`,
config: config,
entity,
};
const badge = await badgeBuilder.createBadgeJson({
badgeInfo,
context,
});
specs.push(badge);
}
res.status(200).json(specs);
});
router.get('/entity/:entityUuid/:badgeId', async (req, res) => {
const { entityUuid, badgeId } = req.params;
// Retrieve the badge info from the database
const badgeInfo = await store.getBadgeFromUuid(entityUuid);
if (isNil(badgeInfo)) {
throw new NotFoundError(`No badge found for entity uuid "${entityUuid}"`);
}
// If a mapping is found, map name, namespace and kind
const name = badgeInfo.name;
const namespace = badgeInfo.namespace;
const kind = badgeInfo.kind;
const token = await tokenManager.getToken();
const entity = await catalog.getEntityByRef(
{
namespace,
kind,
name,
},
token,
);
if (isNil(entity)) {
throw new NotFoundError(
`No ${kind} entity in ${namespace} named "${name}"`,
res.sendStatus(404),
);
}
let format =
req.accepts(['image/svg+xml', 'application/json']) || 'image/svg+xml';
if (req.query.format === 'json') {
format = 'application/json';
}
const badgeOptions = {
badgeInfo: { id: badgeId },
context: {
badgeUrl: `${baseUrl}/entity/${entityUuid}/${badgeId}`,
config: config,
entity,
},
};
let data: string;
if (format === 'application/json') {
data = JSON.stringify(
await badgeBuilder.createBadgeJson(badgeOptions),
null,
2,
);
} else {
data = await badgeBuilder.createBadgeSvg(badgeOptions);
}
res.setHeader('Content-Type', format);
res.status(200).send(data);
});
router.get(
'/entity/:namespace/:kind/:name/obfuscated',
function authenticate(req, _res, next) {
const token =
getBearerTokenFromAuthorizationHeader(req.headers.authorization) ||
(req.cookies?.token as string | undefined);
if (!token) {
throw new AuthenticationError('Unauthorized');
}
try {
req.user = identity.getIdentity({ request: req });
next();
} catch (error) {
tokenManager.authenticate(token.toString());
next(error);
}
},
async (req, res) => {
const { namespace, kind, name } = req.params;
const storedEntityUuid: { uuid: string } | undefined =
await store.getBadgeUuid(name, namespace, kind);
if (isNil(storedEntityUuid)) {
throw new NotFoundError(
`No uuid found for entity "${namespace}/${kind}/${name}"`,
);
}
return res.status(200).json(storedEntityUuid);
},
);
router.use(errorHandler());
return router;
}
async function nonObfuscatedRoute(
router: express.Router,
catalog: CatalogApi,
badgeBuilder: BadgeBuilder,
tokenManager: TokenManager,
config: Config,
baseUrl: string,
) {
router.get('/entity/:namespace/:kind/:name/badge-specs', async (req, res) => {
const token = await tokenManager.getToken();
const { namespace, kind, name } = req.params;
const entity = await catalog.getEntityByRef(
{ namespace, kind, name },
{
token: getBearerToken(req.headers.authorization),
},
token,
);
if (!entity) {
throw new NotFoundError(
@@ -62,19 +274,17 @@ export async function createRouter(
const specs = [];
for (const badgeInfo of await badgeBuilder.getBadges()) {
const badgeId = badgeInfo.id;
const context: BadgeContext = {
badgeUrl: await getBadgeUrl(
namespace,
kind,
name,
badgeInfo.id,
options,
),
config: options.config,
badgeUrl: `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`,
config: config,
entity,
};
const badge = await badgeBuilder.createBadgeJson({ badgeInfo, context });
const badge = await badgeBuilder.createBadgeJson({
badgeInfo,
context,
});
specs.push(badge);
}
@@ -85,11 +295,10 @@ export async function createRouter(
'/entity/:namespace/:kind/:name/badge/:badgeId',
async (req, res) => {
const { namespace, kind, name, badgeId } = req.params;
const token = await tokenManager.getToken();
const entity = await catalog.getEntityByRef(
{ namespace, kind, name },
{
token: getBearerToken(req.headers.authorization),
},
token,
);
if (!entity) {
throw new NotFoundError(
@@ -106,8 +315,8 @@ export async function createRouter(
const badgeOptions = {
badgeInfo: { id: badgeId },
context: {
badgeUrl: await getBadgeUrl(namespace, kind, name, badgeId, options),
config: options.config,
badgeUrl: `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`,
config: config,
entity,
},
};
@@ -132,18 +341,3 @@ export async function createRouter(
return router;
}
async function getBadgeUrl(
namespace: string,
kind: string,
name: string,
badgeId: string,
options: RouterOptions,
): Promise<string> {
const baseUrl = await options.discovery.getExternalBaseUrl('badges');
return `${baseUrl}/entity/${namespace}/${kind}/${name}/badge/${badgeId}`;
}
function getBearerToken(header?: string): string | undefined {
return header?.match(/Bearer\s+(\S+)/i)?.[1];
}
@@ -19,9 +19,14 @@ import { Logger } from 'winston';
import {
createServiceBuilder,
loadBackendConfig,
ServerTokenManager,
SingleHostDiscovery,
useHotMemoize,
} from '@backstage/backend-common';
import { createRouter } from './router';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { DatabaseBadgesStore } from '../database/badgesStore';
import Knex from 'knex';
export interface ServerOptions {
port: number;
@@ -35,10 +40,40 @@ export async function startStandaloneServer(
const logger = options.logger.child({ service: 'badges-backend' });
const config = await loadBackendConfig({ logger, argv: process.argv });
const discovery = SingleHostDiscovery.fromConfig(config);
const database = useHotMemoize(module, () => {
return Knex({
client: 'better-sqlite3',
connection: ':memory:',
useNullAsDefault: true,
});
});
logger.debug('Creating application...');
const router = await createRouter({ config, discovery });
const tokenManager = ServerTokenManager.noop();
const identity: IdentityApi = {
async getIdentity({ request }) {
const token = request.headers.authorization?.split(' ')[1];
return {
identity: {
type: 'user',
ownershipEntityRefs: [],
userEntityRef: token || 'user:default/john_doe',
},
token: token || 'no-token',
};
},
};
const router = await createRouter({
config,
discovery,
tokenManager,
logger,
identity,
badgeStore: await DatabaseBadgesStore.create({
database: { getClient: async () => database },
}),
});
let service = createServiceBuilder(module)
.setPort(options.port)
+45
View File
@@ -18,6 +18,51 @@ This will popup a badges dialog showing all available badges for that entity lik
![Badges Dialog](./doc/badges-dialog.png)
##  Badge obfuscation
The badges plugin supports obfuscating the badge URL to prevent it from being enumerated if the badges are used in a public context (like in Github repositories).
To enable obfuscation, set the `obfuscate` option to `true` in the `app.badges` section of your `app-config.yaml`:
```yaml
app:
badges:
obfuscate: true
```
Please note that if you have already set badges in your repositories and you activate the obfuscation you will need to update the badges in your repositories to use the new obfuscated URLs.
Please note that the backend part needs to be configured to support obfuscation. See the [backend plugin documentation](../badges-backend/README.md) for more details.
Also, you need to allow your frontend to access the configuration see <https://backstage.io/docs/conf/defining/#visibility> :
Example implementation would be in : `packages/app/src/config.d.ts`
```typescript
export interface Config {
app: {
... some code
badges: {
/**
* badges obfuscate
* @visibility frontend
*/
obfuscate?: string;
};
};
}
```
then include in the `packages/app/package.json` :
```json
"files": [
"dist",
"config.d.ts"
],
"configSchema": "config.d.ts",
```
## Sample Badges
Here are some samples of badges for the `artists-lookup` service in the Demo Backstage site:
+2 -7
View File
@@ -50,14 +50,9 @@
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/dom": "^8.0.0",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/user-event": "^14.0.0",
"@testing-library/jest-dom": "^5.16.5",
"@types/node": "^16.11.26",
"@types/react": "^16.13.1 || ^17.0.0",
"cross-fetch": "^3.1.5",
"msw": "^1.0.0"
"cross-fetch": "^3.1.5"
},
"files": [
"dist"
+65 -16
View File
@@ -18,31 +18,55 @@ import { generatePath } from 'react-router-dom';
import { ResponseError } from '@backstage/errors';
import { Entity, DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { BadgesApi, BadgeSpec } from './types';
import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api';
import { ConfigApi, DiscoveryApi, FetchApi } from '@backstage/core-plugin-api';
export class BadgesClient implements BadgesApi {
private readonly discoveryApi: DiscoveryApi;
private readonly identityApi: IdentityApi;
private readonly fetchApi: FetchApi;
private readonly configApi: ConfigApi;
constructor(options: {
discoveryApi: DiscoveryApi;
identityApi: IdentityApi;
fetchApi: FetchApi;
configApi: ConfigApi;
}) {
this.discoveryApi = options.discoveryApi;
this.identityApi = options.identityApi;
this.fetchApi = options.fetchApi;
this.configApi = options.configApi;
}
static fromConfig(options: {
fetchApi: FetchApi;
discoveryApi: DiscoveryApi;
configApi: ConfigApi;
}) {
return new BadgesClient(options);
}
public async getEntityBadgeSpecs(entity: Entity): Promise<BadgeSpec[]> {
const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity);
const { token } = await this.identityApi.getCredentials();
const response = await fetch(entityBadgeSpecsUrl, {
headers: token
? {
Authorization: `Bearer ${token}`,
}
: undefined,
});
// Check if obfuscation is enabled in the configuration
const obfuscate = this.configApi.getOptionalBoolean('app.badges.obfuscate');
if (obfuscate) {
const entityUuidUrl = await this.getEntityUuidUrl(entity);
const entityUuid = await this.getEntityUuid(entityUuidUrl).then(data => {
return data.uuid;
});
const entityUuidBadgeSpecsUrl = await this.getEntityUuidBadgeSpecsUrl(
entityUuid,
);
const response = await this.fetchApi.fetch(entityUuidBadgeSpecsUrl);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
return await response.json();
}
// If obfuscation is disabled, get the badge specs directly as the previous implementation
const entityBadgeSpecsUrl = await this.getEntityBadgeSpecsUrl(entity);
const response = await this.fetchApi.fetch(entityBadgeSpecsUrl);
if (!response.ok) {
throw await ResponseError.fromResponse(response);
}
@@ -50,14 +74,39 @@ export class BadgesClient implements BadgesApi {
return await response.json();
}
private async getEntityUuidUrl(entity: Entity): Promise<string> {
const routeParams = this.getEntityRouteParams(entity);
const path = generatePath(`:namespace/:kind/:name`, routeParams);
const baseUrl = await this.discoveryApi.getBaseUrl('badges');
const obfuscatedEntityUrl = `${baseUrl}/entity/${path}/obfuscated`;
return obfuscatedEntityUrl;
}
private async getEntityUuid(entityUuidUrl: string): Promise<any> {
const responseEntityUuid = await this.fetchApi.fetch(entityUuidUrl);
if (!responseEntityUuid.ok) {
throw await ResponseError.fromResponse(responseEntityUuid);
}
return await responseEntityUuid.json();
}
private async getEntityUuidBadgeSpecsUrl(entityUuid: {
uuid: string;
}): Promise<string> {
const baseUrl = await this.discoveryApi.getBaseUrl('badges');
return `${baseUrl}/entity/${entityUuid}/badge-specs`;
}
private async getEntityBadgeSpecsUrl(entity: Entity): Promise<string> {
const routeParams = this.getEntityRouteParams(entity);
const path = generatePath(`:namespace/:kind/:name`, routeParams);
return `${await this.discoveryApi.getBaseUrl(
'badges',
)}/entity/${path}/badge-specs`;
const baseUrl = await this.discoveryApi.getBaseUrl('badges');
return `${baseUrl}/entity/${path}/badge-specs`;
}
// This function is used to generate the route parameters using the entity kind, namespace and name
private getEntityRouteParams(entity: Entity) {
return {
kind: entity.kind.toLocaleLowerCase('en-US'),
+13 -4
View File
@@ -15,11 +15,12 @@
*/
import { badgesApiRef, BadgesClient } from './api';
import {
configApiRef,
createApiFactory,
createComponentExtension,
createPlugin,
fetchApiRef,
discoveryApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
/** @public */
@@ -28,9 +29,17 @@ export const badgesPlugin = createPlugin({
apis: [
createApiFactory({
api: badgesApiRef,
deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef },
factory: ({ discoveryApi, identityApi }) =>
new BadgesClient({ discoveryApi, identityApi }),
deps: {
fetchApi: fetchApiRef,
discoveryApi: discoveryApiRef,
configApi: configApiRef,
},
factory: ({ fetchApi, discoveryApi, configApi }) =>
new BadgesClient({
fetchApi,
discoveryApi,
configApi,
}),
}),
],
});
+30 -27
View File
@@ -4986,18 +4986,27 @@ __metadata:
resolution: "@backstage/plugin-badges-backend@workspace:plugins/badges-backend"
dependencies:
"@backstage/backend-common": "workspace:^"
"@backstage/backend-test-utils": "workspace:^"
"@backstage/catalog-client": "workspace:^"
"@backstage/catalog-model": "workspace:^"
"@backstage/cli": "workspace:^"
"@backstage/config": "workspace:^"
"@backstage/core-app-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/errors": "workspace:^"
"@backstage/plugin-auth-node": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@types/express": ^4.17.6
"@types/supertest": ^2.0.8
"@types/node": "*"
badge-maker: ^3.3.0
cors: ^2.8.5
cross-fetch: ^3.1.5
express: ^4.17.1
express-promise-router: ^4.1.0
supertest: ^6.1.3
knex: ^2.4.2
lodash: ^4.17.21
supertest: ^6.3.3
uuid: ^9.0.0
winston: ^3.2.1
yn: ^4.0.0
languageName: unknown
@@ -5020,14 +5029,9 @@ __metadata:
"@material-ui/core": ^4.12.2
"@material-ui/icons": ^4.9.1
"@material-ui/lab": 4.0.0-alpha.61
"@testing-library/dom": ^8.0.0
"@testing-library/jest-dom": ^5.10.1
"@testing-library/react": ^12.1.3
"@testing-library/user-event": ^14.0.0
"@testing-library/jest-dom": ^5.16.5
"@types/node": ^16.11.26
"@types/react": ^16.13.1 || ^17.0.0
cross-fetch: ^3.1.5
msw: ^1.0.0
react-use: ^17.2.4
peerDependencies:
react: ^16.13.1 || ^17.0.0
@@ -15002,7 +15006,7 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/jest-dom@npm:^5.10.1, @testing-library/jest-dom@npm:^5.16.4":
"@testing-library/jest-dom@npm:^5.10.1, @testing-library/jest-dom@npm:^5.16.4, @testing-library/jest-dom@npm:^5.16.5":
version: 5.16.5
resolution: "@testing-library/jest-dom@npm:5.16.5"
dependencies:
@@ -20662,7 +20666,7 @@ __metadata:
languageName: node
linkType: hard
"cookiejar@npm:^2.1.3":
"cookiejar@npm:^2.1.4":
version: 2.1.4
resolution: "cookiejar@npm:2.1.4"
checksum: c4442111963077dc0e5672359956d6556a195d31cbb35b528356ce5f184922b99ac48245ac05ed86cf993f7df157c56da10ab3efdadfed79778a0d9b1b092d5b
@@ -24667,7 +24671,7 @@ __metadata:
languageName: node
linkType: hard
"formidable@npm:^2.0.1":
"formidable@npm:^2.1.2":
version: 2.1.2
resolution: "formidable@npm:2.1.2"
dependencies:
@@ -28975,7 +28979,7 @@ __metadata:
languageName: node
linkType: hard
"knex@npm:^2.0.0":
"knex@npm:^2.0.0, knex@npm:^2.4.2":
version: 2.4.2
resolution: "knex@npm:2.4.2"
dependencies:
@@ -34267,7 +34271,7 @@ __metadata:
languageName: node
linkType: hard
"qs@npm:6.11.0, qs@npm:^6.10.1, qs@npm:^6.10.2, qs@npm:^6.10.3, qs@npm:^6.11.0, qs@npm:^6.9.1, qs@npm:^6.9.4, qs@npm:^6.9.6":
"qs@npm:6.11.0, qs@npm:^6.10.1, qs@npm:^6.10.2, qs@npm:^6.11.0, qs@npm:^6.9.1, qs@npm:^6.9.4, qs@npm:^6.9.6":
version: 6.11.0
resolution: "qs@npm:6.11.0"
dependencies:
@@ -37879,32 +37883,31 @@ __metadata:
languageName: node
linkType: hard
"superagent@npm:^8.0.0":
version: 8.0.0
resolution: "superagent@npm:8.0.0"
"superagent@npm:^8.0.5":
version: 8.0.9
resolution: "superagent@npm:8.0.9"
dependencies:
component-emitter: ^1.3.0
cookiejar: ^2.1.3
cookiejar: ^2.1.4
debug: ^4.3.4
fast-safe-stringify: ^2.1.1
form-data: ^4.0.0
formidable: ^2.0.1
formidable: ^2.1.2
methods: ^1.1.2
mime: 2.6.0
qs: ^6.10.3
readable-stream: ^3.6.0
semver: ^7.3.7
checksum: 14343e59327eafd85fa230acb876017079d5efcecc72a56566abc0f965220bb460af2e070dddecd9e2856410b2d2b318d81d9cc1d342aa5922da93c29a295dd7
qs: ^6.11.0
semver: ^7.3.8
checksum: 5d00cdc7ceb5570663da80604965750e6b1b8d7d7442b7791e285c62bcd8d578a8ead0242a2426432b59a255fb42eb3a196d636157538a1392e7b6c5f1624810
languageName: node
linkType: hard
"supertest@npm:^6.1.3, supertest@npm:^6.1.6, supertest@npm:^6.2.4":
version: 6.2.4
resolution: "supertest@npm:6.2.4"
"supertest@npm:^6.1.3, supertest@npm:^6.1.6, supertest@npm:^6.2.4, supertest@npm:^6.3.3":
version: 6.3.3
resolution: "supertest@npm:6.3.3"
dependencies:
methods: ^1.1.2
superagent: ^8.0.0
checksum: f2ddc4f3ba467a5c4036dd4aad41351e4b60eb13c39ecf5233ccd2ebb425504073b2b7036c973a70c7047f5c6bc1b9fef096b7bbff114d357cbe80654441db23
superagent: ^8.0.5
checksum: 38239e517f7ba62b7a139a79c5c48d55f8d67b5ff4b6e51d5b07732ca8bbc4a28ffa1b10916fbb403dd013a054dbf028edc5850057d9a43aecbff439d494673e
languageName: node
linkType: hard