@certynix/mcp 1.0.0
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/CHANGELOG.md +49 -0
- package/README.md +209 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +119 -0
- package/dist/prompts/audit-report.d.ts +6 -0
- package/dist/prompts/audit-report.js +117 -0
- package/dist/prompts/security-review.d.ts +6 -0
- package/dist/prompts/security-review.js +95 -0
- package/dist/resources/alerts-active.d.ts +7 -0
- package/dist/resources/alerts-active.js +43 -0
- package/dist/resources/asset.d.ts +7 -0
- package/dist/resources/asset.js +61 -0
- package/dist/resources/organization-info.d.ts +8 -0
- package/dist/resources/organization-info.js +44 -0
- package/dist/resources/trust-score.d.ts +7 -0
- package/dist/resources/trust-score.js +46 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.js +67 -0
- package/dist/tools/create-api-key.d.ts +3 -0
- package/dist/tools/create-api-key.js +37 -0
- package/dist/tools/create-webhook.d.ts +3 -0
- package/dist/tools/create-webhook.js +45 -0
- package/dist/tools/delete-asset.d.ts +3 -0
- package/dist/tools/delete-asset.js +48 -0
- package/dist/tools/get-asset.d.ts +3 -0
- package/dist/tools/get-asset.js +41 -0
- package/dist/tools/get-trust-score.d.ts +3 -0
- package/dist/tools/get-trust-score.js +43 -0
- package/dist/tools/list-alerts.d.ts +3 -0
- package/dist/tools/list-alerts.js +55 -0
- package/dist/tools/list-api-keys.d.ts +3 -0
- package/dist/tools/list-api-keys.js +51 -0
- package/dist/tools/list-assets.d.ts +3 -0
- package/dist/tools/list-assets.js +68 -0
- package/dist/tools/list-audit-logs.d.ts +3 -0
- package/dist/tools/list-audit-logs.js +62 -0
- package/dist/tools/list-webhooks.d.ts +3 -0
- package/dist/tools/list-webhooks.js +51 -0
- package/dist/tools/register-asset.d.ts +3 -0
- package/dist/tools/register-asset.js +87 -0
- package/dist/tools/revoke-api-key.d.ts +3 -0
- package/dist/tools/revoke-api-key.js +48 -0
- package/dist/tools/verify-asset.d.ts +3 -0
- package/dist/tools/verify-asset.js +53 -0
- package/dist/tools/verify-webhook-signature.d.ts +7 -0
- package/dist/tools/verify-webhook-signature.js +178 -0
- package/dist/utils/format.d.ts +10 -0
- package/dist/utils/format.js +18 -0
- package/dist/utils/mask.d.ts +14 -0
- package/dist/utils/mask.js +42 -0
- package/eslint.config.mjs +27 -0
- package/package.json +38 -0
- package/src/index.ts +149 -0
- package/src/prompts/audit-report.ts +126 -0
- package/src/prompts/security-review.ts +102 -0
- package/src/resources/alerts-active.ts +56 -0
- package/src/resources/asset.ts +79 -0
- package/src/resources/organization-info.ts +60 -0
- package/src/resources/trust-score.ts +59 -0
- package/src/server.ts +81 -0
- package/src/tools/create-api-key.ts +52 -0
- package/src/tools/create-webhook.ts +63 -0
- package/src/tools/delete-asset.ts +63 -0
- package/src/tools/get-asset.ts +53 -0
- package/src/tools/get-trust-score.ts +57 -0
- package/src/tools/list-alerts.ts +69 -0
- package/src/tools/list-api-keys.ts +63 -0
- package/src/tools/list-assets.ts +80 -0
- package/src/tools/list-audit-logs.ts +76 -0
- package/src/tools/list-webhooks.ts +63 -0
- package/src/tools/register-asset.ts +103 -0
- package/src/tools/revoke-api-key.ts +65 -0
- package/src/tools/verify-asset.ts +66 -0
- package/src/tools/verify-webhook-signature.ts +232 -0
- package/src/utils/format.ts +20 -0
- package/src/utils/mask.ts +41 -0
- package/tests/unit/tools/register-asset.test.ts +190 -0
- package/tests/unit/tools/verify-webhook-signature.test.ts +250 -0
- package/tests/unit/utils/mask.test.ts +129 -0
- package/tsconfig.json +17 -0
- package/vitest.config.ts +19 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { CertynixClient } from '@certynix/sdk';
|
|
4
|
+
import { formatError } from '../utils/format.js';
|
|
5
|
+
|
|
6
|
+
export function registerListAuditLogsTool(server: McpServer, client: CertynixClient): void {
|
|
7
|
+
server.tool(
|
|
8
|
+
'list_audit_logs',
|
|
9
|
+
'Lista o histórico completo de ações auditadas na organização: registros de assets, criação de API Keys, mudanças de plano, alertas, e eventos de segurança.',
|
|
10
|
+
{
|
|
11
|
+
limit: z
|
|
12
|
+
.number()
|
|
13
|
+
.int()
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(100)
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('Número de registros por página (máximo 100, padrão 20).'),
|
|
18
|
+
cursor: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Cursor de paginação retornado pela chamada anterior.'),
|
|
22
|
+
action: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe(
|
|
26
|
+
'Filtrar por tipo de ação (ex: asset.created, api_key.created, asset.deleted).',
|
|
27
|
+
),
|
|
28
|
+
created_after: z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe('Retornar apenas logs criados após esta data (ISO 8601).'),
|
|
32
|
+
created_before: z
|
|
33
|
+
.string()
|
|
34
|
+
.optional()
|
|
35
|
+
.describe('Retornar apenas logs criados antes desta data (ISO 8601).'),
|
|
36
|
+
},
|
|
37
|
+
async (params) => {
|
|
38
|
+
try {
|
|
39
|
+
const page = await client.auditLogs.listPage({
|
|
40
|
+
...(params.limit !== undefined ? { limit: params.limit } : {}),
|
|
41
|
+
...(params.cursor !== undefined ? { cursor: params.cursor } : {}),
|
|
42
|
+
...(params.action !== undefined ? { action: params.action } : {}),
|
|
43
|
+
...(params.created_after !== undefined ? { createdAfter: params.created_after } : {}),
|
|
44
|
+
...(params.created_before !== undefined
|
|
45
|
+
? { createdBefore: params.created_before }
|
|
46
|
+
: {}),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
content: [
|
|
51
|
+
{
|
|
52
|
+
type: 'text' as const,
|
|
53
|
+
text: JSON.stringify(
|
|
54
|
+
{
|
|
55
|
+
data: page.data,
|
|
56
|
+
pagination: {
|
|
57
|
+
has_more: page.pagination.has_more,
|
|
58
|
+
next_cursor: page.pagination.next_cursor,
|
|
59
|
+
},
|
|
60
|
+
total_returned: page.data.length,
|
|
61
|
+
},
|
|
62
|
+
null,
|
|
63
|
+
2,
|
|
64
|
+
),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
} catch (err) {
|
|
69
|
+
return {
|
|
70
|
+
content: [{ type: 'text' as const, text: formatError(err) }],
|
|
71
|
+
isError: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { CertynixClient } from '@certynix/sdk';
|
|
4
|
+
import { formatError } from '../utils/format.js';
|
|
5
|
+
|
|
6
|
+
export function registerListWebhooksTool(server: McpServer, client: CertynixClient): void {
|
|
7
|
+
server.tool(
|
|
8
|
+
'list_webhooks',
|
|
9
|
+
'Lista os endpoints de webhook configurados na organização. O signing_secret nunca é retornado em listagens — apenas o ID e URL são exibidos.',
|
|
10
|
+
{
|
|
11
|
+
limit: z
|
|
12
|
+
.number()
|
|
13
|
+
.int()
|
|
14
|
+
.min(1)
|
|
15
|
+
.max(100)
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('Número de webhooks por página (máximo 100, padrão 20).'),
|
|
18
|
+
cursor: z
|
|
19
|
+
.string()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Cursor de paginação retornado pela chamada anterior.'),
|
|
22
|
+
},
|
|
23
|
+
async (params) => {
|
|
24
|
+
try {
|
|
25
|
+
const page = await client.webhooks.listPage({
|
|
26
|
+
...(params.limit !== undefined ? { limit: params.limit } : {}),
|
|
27
|
+
...(params.cursor !== undefined ? { cursor: params.cursor } : {}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
content: [
|
|
32
|
+
{
|
|
33
|
+
type: 'text' as const,
|
|
34
|
+
text: JSON.stringify(
|
|
35
|
+
{
|
|
36
|
+
data: page.data.map((webhook: (typeof page.data)[number]) => ({
|
|
37
|
+
id: webhook.id,
|
|
38
|
+
url: webhook.url,
|
|
39
|
+
events: webhook.events,
|
|
40
|
+
// signing_secret NUNCA retornado em listagens
|
|
41
|
+
created_at: webhook.createdAt,
|
|
42
|
+
})),
|
|
43
|
+
pagination: {
|
|
44
|
+
has_more: page.pagination.has_more,
|
|
45
|
+
next_cursor: page.pagination.next_cursor,
|
|
46
|
+
},
|
|
47
|
+
total_returned: page.data.length,
|
|
48
|
+
},
|
|
49
|
+
null,
|
|
50
|
+
2,
|
|
51
|
+
),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: 'text' as const, text: formatError(err) }],
|
|
58
|
+
isError: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { CertynixClient } from '@certynix/sdk';
|
|
4
|
+
import { formatError } from '../utils/format.js';
|
|
5
|
+
|
|
6
|
+
export function registerRegisterAssetTool(server: McpServer, client: CertynixClient): void {
|
|
7
|
+
server.tool(
|
|
8
|
+
'register_asset',
|
|
9
|
+
'Registra um ativo digital na Certynix. Aceita hash SHA-256, URL pública ou conteúdo de arquivo em base64. Retorna o ID do asset, o hash criptográfico e o URL de verificação pública.',
|
|
10
|
+
{
|
|
11
|
+
hash_sha256: z
|
|
12
|
+
.string()
|
|
13
|
+
.regex(/^[a-fA-F0-9]{64}$/)
|
|
14
|
+
.optional()
|
|
15
|
+
.describe(
|
|
16
|
+
'Hash SHA-256 do arquivo em formato hexadecimal (64 caracteres). Use quando já calculou o hash localmente.',
|
|
17
|
+
),
|
|
18
|
+
url: z
|
|
19
|
+
.string()
|
|
20
|
+
.url()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe('URL pública do ativo para calcular e registrar o hash.'),
|
|
23
|
+
file_base64: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('Conteúdo do arquivo em base64 (máximo 10MB). Use quando tiver acesso direto ao arquivo.'),
|
|
27
|
+
filename: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe('Nome original do arquivo (ex: contrato-2024.pdf). Usado para referência — não afeta o hash.'),
|
|
31
|
+
mime_type: z
|
|
32
|
+
.string()
|
|
33
|
+
.optional()
|
|
34
|
+
.describe('Tipo MIME do arquivo (ex: application/pdf, image/png).'),
|
|
35
|
+
},
|
|
36
|
+
async (params) => {
|
|
37
|
+
try {
|
|
38
|
+
if (!params.hash_sha256 && !params.url && !params.file_base64) {
|
|
39
|
+
return {
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: 'text' as const,
|
|
43
|
+
text: 'Error: one of hash_sha256, url, or file_base64 is required',
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
isError: true,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type RegisterInput = Parameters<typeof client.assets.register>[0];
|
|
51
|
+
let input: RegisterInput;
|
|
52
|
+
|
|
53
|
+
if (params.hash_sha256) {
|
|
54
|
+
input = {
|
|
55
|
+
hash_sha256: params.hash_sha256,
|
|
56
|
+
...(params.filename !== undefined ? { filename: params.filename } : {}),
|
|
57
|
+
...(params.mime_type !== undefined ? { mime_type: params.mime_type } : {}),
|
|
58
|
+
} as RegisterInput;
|
|
59
|
+
} else if (params.url) {
|
|
60
|
+
input = {
|
|
61
|
+
url: params.url,
|
|
62
|
+
...(params.filename !== undefined ? { filename: params.filename } : {}),
|
|
63
|
+
} as RegisterInput;
|
|
64
|
+
} else {
|
|
65
|
+
const buffer = Buffer.from(params.file_base64 as string, 'base64');
|
|
66
|
+
input = {
|
|
67
|
+
file: buffer,
|
|
68
|
+
...(params.filename !== undefined ? { filename: params.filename } : {}),
|
|
69
|
+
...(params.mime_type !== undefined ? { mime_type: params.mime_type } : {}),
|
|
70
|
+
} as RegisterInput;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const asset = await client.assets.register(input);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
content: [
|
|
77
|
+
{
|
|
78
|
+
type: 'text' as const,
|
|
79
|
+
text: JSON.stringify(
|
|
80
|
+
{
|
|
81
|
+
id: asset.id,
|
|
82
|
+
hash: asset.hash,
|
|
83
|
+
status: asset.status,
|
|
84
|
+
trust_score: asset.trustScore,
|
|
85
|
+
verification_url: `https://certynix.com/verify/${asset.id}`,
|
|
86
|
+
is_first_registrant: asset.isFirstRegistrant,
|
|
87
|
+
created_at: asset.createdAt,
|
|
88
|
+
},
|
|
89
|
+
null,
|
|
90
|
+
2,
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
} catch (err) {
|
|
96
|
+
return {
|
|
97
|
+
content: [{ type: 'text' as const, text: formatError(err) }],
|
|
98
|
+
isError: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { CertynixClient } from '@certynix/sdk';
|
|
4
|
+
import { formatError } from '../utils/format.js';
|
|
5
|
+
|
|
6
|
+
export function registerRevokeApiKeyTool(server: McpServer, client: CertynixClient): void {
|
|
7
|
+
server.tool(
|
|
8
|
+
'revoke_api_key',
|
|
9
|
+
'Revoga uma API Key imediatamente. Todas as integrações que usam esta chave param de funcionar instantaneamente. Esta ação é irreversível — requer confirmação explícita com confirm: true.',
|
|
10
|
+
{
|
|
11
|
+
api_key_id: z
|
|
12
|
+
.string()
|
|
13
|
+
.min(1)
|
|
14
|
+
.describe('ID da API Key a ser revogada (retornado pelo list_api_keys).'),
|
|
15
|
+
confirm: z
|
|
16
|
+
.literal(true)
|
|
17
|
+
.describe(
|
|
18
|
+
'Deve ser true para confirmar a revogação. Esta ação é irreversível e interrompe imediatamente todas as integrações que usam esta chave.',
|
|
19
|
+
),
|
|
20
|
+
},
|
|
21
|
+
async (params) => {
|
|
22
|
+
try {
|
|
23
|
+
// Verificação de segurança adicional além do schema
|
|
24
|
+
if (params.confirm !== true) {
|
|
25
|
+
return {
|
|
26
|
+
content: [
|
|
27
|
+
{
|
|
28
|
+
type: 'text' as const,
|
|
29
|
+
text: 'Error: confirm must be true to revoke an API Key. This action is irreversible and will immediately break all integrations using this key.',
|
|
30
|
+
},
|
|
31
|
+
],
|
|
32
|
+
isError: true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await client.apiKeys.revoke(params.api_key_id);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
content: [
|
|
40
|
+
{
|
|
41
|
+
type: 'text' as const,
|
|
42
|
+
text: JSON.stringify(
|
|
43
|
+
{
|
|
44
|
+
success: true,
|
|
45
|
+
api_key_id: params.api_key_id,
|
|
46
|
+
message:
|
|
47
|
+
'API Key revogada com sucesso. Todas as integrações que usavam esta chave pararam de funcionar imediatamente.',
|
|
48
|
+
warning:
|
|
49
|
+
'Esta ação é irreversível. Para reativar o acesso, crie uma nova API Key e atualize todas as integrações.',
|
|
50
|
+
},
|
|
51
|
+
null,
|
|
52
|
+
2,
|
|
53
|
+
),
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
} catch (err) {
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: 'text' as const, text: formatError(err) }],
|
|
60
|
+
isError: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import type { CertynixClient } from '@certynix/sdk';
|
|
4
|
+
import { formatError } from '../utils/format.js';
|
|
5
|
+
|
|
6
|
+
export function registerVerifyAssetTool(server: McpServer, client: CertynixClient): void {
|
|
7
|
+
server.tool(
|
|
8
|
+
'verify_asset',
|
|
9
|
+
'Verifica publicamente se um ativo digital é genuíno. Retorna quem certificou o ativo e quando. Esta operação não requer autenticação e não consome quota do plano.',
|
|
10
|
+
{
|
|
11
|
+
hash_sha256: z
|
|
12
|
+
.string()
|
|
13
|
+
.regex(/^[a-fA-F0-9]{64}$/)
|
|
14
|
+
.optional()
|
|
15
|
+
.describe('Hash SHA-256 para verificar (formato hexadecimal, 64 caracteres).'),
|
|
16
|
+
asset_id: z
|
|
17
|
+
.string()
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('ID do asset retornado pelo register_asset.'),
|
|
20
|
+
},
|
|
21
|
+
async (params) => {
|
|
22
|
+
try {
|
|
23
|
+
if (!params.hash_sha256 && !params.asset_id) {
|
|
24
|
+
return {
|
|
25
|
+
content: [
|
|
26
|
+
{
|
|
27
|
+
type: 'text' as const,
|
|
28
|
+
text: 'Error: one of hash_sha256 or asset_id is required',
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
isError: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const result = params.hash_sha256
|
|
36
|
+
? await client.verify.byHash(params.hash_sha256)
|
|
37
|
+
: await client.verify.byAssetId(params.asset_id as string);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: 'text' as const,
|
|
43
|
+
text: JSON.stringify(
|
|
44
|
+
{
|
|
45
|
+
match: result.match,
|
|
46
|
+
hash: result.hash,
|
|
47
|
+
certifiers: result.certifiers,
|
|
48
|
+
summary: result.match
|
|
49
|
+
? `Asset verificado. Certificado por ${result.certifiers.length} organização(ões).`
|
|
50
|
+
: 'Asset não encontrado na Certynix.',
|
|
51
|
+
},
|
|
52
|
+
null,
|
|
53
|
+
2,
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
} catch (err) {
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: 'text' as const, text: formatError(err) }],
|
|
61
|
+
isError: true,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Tolerância máxima de timestamp para prevenir replay attacks.
|
|
7
|
+
* Conforme especificação: 5 minutos (300 segundos).
|
|
8
|
+
*/
|
|
9
|
+
const MAX_TIMESTAMP_AGE_SECONDS = 300;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Verifica assinatura de webhook localmente via HMAC-SHA256.
|
|
13
|
+
* NÃO realiza chamada à API — operação 100% local.
|
|
14
|
+
* NUNCA loga o webhook_secret.
|
|
15
|
+
*/
|
|
16
|
+
export function registerVerifyWebhookSignatureTool(server: McpServer): void {
|
|
17
|
+
server.tool(
|
|
18
|
+
'verify_webhook_signature',
|
|
19
|
+
'Valida se um delivery de webhook é genuíno verificando a assinatura HMAC-SHA256. Operação local — não realiza chamada à API. Use para garantir que o evento veio da Certynix e não foi adulterado. Proteção anti-replay: timestamps com mais de 5 minutos são rejeitados.',
|
|
20
|
+
{
|
|
21
|
+
payload: z
|
|
22
|
+
.string()
|
|
23
|
+
.min(1)
|
|
24
|
+
.describe('Body raw do webhook recebido (string exata, sem parsing JSON prévio).'),
|
|
25
|
+
signature: z
|
|
26
|
+
.string()
|
|
27
|
+
.min(1)
|
|
28
|
+
.describe(
|
|
29
|
+
'Valor do header X-Certynix-Signature recebido (formato esperado: t=timestamp,v1=hash).',
|
|
30
|
+
),
|
|
31
|
+
webhook_secret: z
|
|
32
|
+
.string()
|
|
33
|
+
.min(1)
|
|
34
|
+
.describe(
|
|
35
|
+
'Signing secret do webhook (obtido ao criar o webhook via create_webhook). Este valor nunca aparecerá no output.',
|
|
36
|
+
),
|
|
37
|
+
},
|
|
38
|
+
(params) => {
|
|
39
|
+
// NUNCA logar webhook_secret — nem em debug, nem em erro
|
|
40
|
+
try {
|
|
41
|
+
const signatureHeader = params.signature;
|
|
42
|
+
|
|
43
|
+
// Extrair timestamp e assinatura do header
|
|
44
|
+
// Formato: t=1706745600,v1=abc123def456...
|
|
45
|
+
const parts = signatureHeader.split(',');
|
|
46
|
+
let timestamp: string | undefined;
|
|
47
|
+
let receivedSignature: string | undefined;
|
|
48
|
+
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
if (part.startsWith('t=')) {
|
|
51
|
+
timestamp = part.slice(2);
|
|
52
|
+
} else if (part.startsWith('v1=')) {
|
|
53
|
+
receivedSignature = part.slice(3);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!timestamp || !receivedSignature) {
|
|
58
|
+
return {
|
|
59
|
+
content: [
|
|
60
|
+
{
|
|
61
|
+
type: 'text' as const,
|
|
62
|
+
text: JSON.stringify(
|
|
63
|
+
{
|
|
64
|
+
valid: false,
|
|
65
|
+
error:
|
|
66
|
+
"Invalid signature header format. Expected: t=<timestamp>,v1=<hash>. Got: " +
|
|
67
|
+
signatureHeader.substring(0, 50),
|
|
68
|
+
},
|
|
69
|
+
null,
|
|
70
|
+
2,
|
|
71
|
+
),
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Validar que o timestamp é numérico
|
|
78
|
+
const timestampNumber = parseInt(timestamp, 10);
|
|
79
|
+
if (isNaN(timestampNumber)) {
|
|
80
|
+
return {
|
|
81
|
+
content: [
|
|
82
|
+
{
|
|
83
|
+
type: 'text' as const,
|
|
84
|
+
text: JSON.stringify(
|
|
85
|
+
{
|
|
86
|
+
valid: false,
|
|
87
|
+
error: 'Invalid timestamp in signature header: not a number.',
|
|
88
|
+
},
|
|
89
|
+
null,
|
|
90
|
+
2,
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Verificar anti-replay: timestamp não pode ser mais antigo que 5 minutos
|
|
98
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
99
|
+
const ageSeconds = nowSeconds - timestampNumber;
|
|
100
|
+
|
|
101
|
+
if (ageSeconds > MAX_TIMESTAMP_AGE_SECONDS) {
|
|
102
|
+
return {
|
|
103
|
+
content: [
|
|
104
|
+
{
|
|
105
|
+
type: 'text' as const,
|
|
106
|
+
text: JSON.stringify(
|
|
107
|
+
{
|
|
108
|
+
valid: false,
|
|
109
|
+
error: `Replay attack detected: timestamp is ${ageSeconds} seconds old (maximum allowed: ${MAX_TIMESTAMP_AGE_SECONDS} seconds).`,
|
|
110
|
+
timestamp_age_seconds: ageSeconds,
|
|
111
|
+
},
|
|
112
|
+
null,
|
|
113
|
+
2,
|
|
114
|
+
),
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (ageSeconds < -60) {
|
|
121
|
+
return {
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: 'text' as const,
|
|
125
|
+
text: JSON.stringify(
|
|
126
|
+
{
|
|
127
|
+
valid: false,
|
|
128
|
+
error:
|
|
129
|
+
'Invalid timestamp: timestamp is in the future. Possible clock skew or tampered request.',
|
|
130
|
+
},
|
|
131
|
+
null,
|
|
132
|
+
2,
|
|
133
|
+
),
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Calcular HMAC-SHA256
|
|
140
|
+
// Algoritmo: HMAC(signing_secret, timestamp + "." + raw_body)
|
|
141
|
+
const signedPayload = `${timestamp}.${params.payload}`;
|
|
142
|
+
const expectedHmac = createHmac('sha256', params.webhook_secret)
|
|
143
|
+
.update(signedPayload, 'utf8')
|
|
144
|
+
.digest('hex');
|
|
145
|
+
|
|
146
|
+
// Comparação constant-time para prevenir timing attacks
|
|
147
|
+
let signaturesMatch = false;
|
|
148
|
+
try {
|
|
149
|
+
const expectedBuffer = Buffer.from(expectedHmac, 'hex');
|
|
150
|
+
const receivedBuffer = Buffer.from(receivedSignature, 'hex');
|
|
151
|
+
|
|
152
|
+
if (expectedBuffer.length === receivedBuffer.length) {
|
|
153
|
+
signaturesMatch = timingSafeEqual(expectedBuffer, receivedBuffer);
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
// Buffer com hex inválido — assinatura malformada
|
|
157
|
+
signaturesMatch = false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!signaturesMatch) {
|
|
161
|
+
return {
|
|
162
|
+
content: [
|
|
163
|
+
{
|
|
164
|
+
type: 'text' as const,
|
|
165
|
+
text: JSON.stringify(
|
|
166
|
+
{
|
|
167
|
+
valid: false,
|
|
168
|
+
error:
|
|
169
|
+
'Invalid signature: HMAC-SHA256 mismatch. The webhook payload may have been tampered with, or the wrong signing secret was used.',
|
|
170
|
+
},
|
|
171
|
+
null,
|
|
172
|
+
2,
|
|
173
|
+
),
|
|
174
|
+
},
|
|
175
|
+
],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Extrair tipo de evento do payload (sem logar o conteúdo completo)
|
|
180
|
+
let eventType: string | undefined;
|
|
181
|
+
try {
|
|
182
|
+
const parsed = JSON.parse(params.payload) as Record<string, unknown>;
|
|
183
|
+
if (typeof parsed['type'] === 'string') {
|
|
184
|
+
eventType = parsed['type'];
|
|
185
|
+
}
|
|
186
|
+
} catch {
|
|
187
|
+
// Payload não é JSON válido — ainda pode ser válido dependendo do uso
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
content: [
|
|
192
|
+
{
|
|
193
|
+
type: 'text' as const,
|
|
194
|
+
text: JSON.stringify(
|
|
195
|
+
{
|
|
196
|
+
valid: true,
|
|
197
|
+
timestamp: timestampNumber,
|
|
198
|
+
timestamp_age_seconds: ageSeconds,
|
|
199
|
+
...(eventType !== undefined ? { event_type: eventType } : {}),
|
|
200
|
+
message:
|
|
201
|
+
'Assinatura válida. O webhook é genuíno e foi enviado pela Certynix.',
|
|
202
|
+
},
|
|
203
|
+
null,
|
|
204
|
+
2,
|
|
205
|
+
),
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
} catch (err) {
|
|
210
|
+
// NUNCA incluir webhook_secret em mensagens de erro
|
|
211
|
+
return {
|
|
212
|
+
content: [
|
|
213
|
+
{
|
|
214
|
+
type: 'text' as const,
|
|
215
|
+
text: JSON.stringify(
|
|
216
|
+
{
|
|
217
|
+
valid: false,
|
|
218
|
+
error:
|
|
219
|
+
err instanceof Error
|
|
220
|
+
? `Verification failed: ${err.message}`
|
|
221
|
+
: 'Verification failed: unknown error',
|
|
222
|
+
},
|
|
223
|
+
null,
|
|
224
|
+
2,
|
|
225
|
+
),
|
|
226
|
+
},
|
|
227
|
+
],
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { sanitizeForOutput } from './mask.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Formata um asset para exibição no output de uma tool call.
|
|
5
|
+
* Remove campos sensíveis antes de serializar.
|
|
6
|
+
*/
|
|
7
|
+
export function formatAssetOutput(asset: unknown): string {
|
|
8
|
+
return JSON.stringify(sanitizeForOutput(asset), null, 2);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Formata um erro para mensagem legível no output de uma tool call.
|
|
13
|
+
* Nunca inclui stack trace ou detalhes internos.
|
|
14
|
+
*/
|
|
15
|
+
export function formatError(err: unknown): string {
|
|
16
|
+
if (err instanceof Error) {
|
|
17
|
+
return `Error: ${err.message}`;
|
|
18
|
+
}
|
|
19
|
+
return `Unknown error: ${String(err)}`;
|
|
20
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Máscara de campos sensíveis para logs e outputs do MCP Server.
|
|
3
|
+
* NUNCA expor API Keys, secrets ou tokens em qualquer output.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mascara uma API Key para exibição em logs.
|
|
8
|
+
* Formato de saída: cnx_live_sk_*** (primeiros 12 caracteres + ***)
|
|
9
|
+
*/
|
|
10
|
+
export function maskApiKey(key: string): string {
|
|
11
|
+
if (!key.startsWith('cnx_')) return '***';
|
|
12
|
+
return key.substring(0, 12) + '***';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Remove campos sensíveis recursivamente de um objeto antes de qualquer output.
|
|
17
|
+
* Campos sensíveis nunca devem aparecer em respostas de tool calls.
|
|
18
|
+
*/
|
|
19
|
+
export function sanitizeForOutput(obj: unknown): unknown {
|
|
20
|
+
if (typeof obj !== 'object' || obj === null) return obj;
|
|
21
|
+
if (Array.isArray(obj)) return obj.map(sanitizeForOutput);
|
|
22
|
+
|
|
23
|
+
const result: Record<string, unknown> = {};
|
|
24
|
+
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
25
|
+
const sensitiveKeys = [
|
|
26
|
+
'apiKey',
|
|
27
|
+
'api_key',
|
|
28
|
+
'secret',
|
|
29
|
+
'token',
|
|
30
|
+
'password',
|
|
31
|
+
'signing_secret',
|
|
32
|
+
'webhook_secret',
|
|
33
|
+
];
|
|
34
|
+
if (sensitiveKeys.some((k) => key.toLowerCase().includes(k.toLowerCase()))) {
|
|
35
|
+
result[key] = '***';
|
|
36
|
+
} else {
|
|
37
|
+
result[key] = sanitizeForOutput(value);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|