forked from wangziqi/gongxue-base
feat: add platform audit alert notifications
This commit is contained in:
@@ -1341,6 +1341,105 @@ async function testPlatformTenantOperationsAndAudit() {
|
||||
});
|
||||
assert.equal(invalidAuditAlertStatus.code, 'INVALID_ALERT_STATUS', 'audit alerts should not be reopened through status endpoint');
|
||||
|
||||
const notificationChannel = await request('/api/platform-admin/audit-notification-channels', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
channelCode: 'integration_platform_audit',
|
||||
name: '集成测试平台审计通知',
|
||||
provider: 'generic',
|
||||
webhookUrl: 'https://ops.example.test/platform-audit',
|
||||
secret: 'integration-platform-audit-notification-secret',
|
||||
minSeverity: 'medium',
|
||||
statusFilter: ['open'],
|
||||
actionPatterns: ['platform.tenant.*'],
|
||||
tenantIds: [tenantId],
|
||||
timeoutSec: 5,
|
||||
metadata: { owner: 'security' },
|
||||
},
|
||||
});
|
||||
assert.equal(notificationChannel.item?.channelCode, 'integration_platform_audit', 'platform admin should upsert audit notification channel');
|
||||
assert.equal(notificationChannel.item?.secretRef, 'app_private.platform_secrets:webhook:integration_platform_audit', 'channel should expose only platform secretRef');
|
||||
assert.equal(notificationChannel.item?.webhook?.host, 'ops.example.test', 'channel response should expose safe webhook host');
|
||||
assert.equal(notificationChannel.item?.webhookUrl, undefined, 'channel response must not expose raw webhook URL');
|
||||
assert.ok(!JSON.stringify(notificationChannel).includes('integration-platform-audit-notification-secret'), 'channel response must not leak webhook secret');
|
||||
|
||||
const notificationChannels = await request('/api/platform-admin/audit-notification-channels', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
query: { enabled: true, provider: 'generic', limit: 20 },
|
||||
});
|
||||
assert.ok(
|
||||
notificationChannels.items?.some(item => item.channelCode === 'integration_platform_audit'),
|
||||
'platform admin should list audit notification channels',
|
||||
);
|
||||
assert.ok(!JSON.stringify(notificationChannels).includes('integration-platform-audit-notification-secret'), 'channel list must not leak webhook secret');
|
||||
|
||||
const invalidNotificationChannel = await request('/api/platform-admin/audit-notification-channels', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
channelCode: 'bad_channel',
|
||||
name: 'bad channel',
|
||||
provider: 'generic',
|
||||
webhookUrl: 'ftp://ops.example.test/hook',
|
||||
},
|
||||
expectStatus: 400,
|
||||
});
|
||||
assert.equal(invalidNotificationChannel.code, 'INVALID_WEBHOOK_URL', 'audit notification channel should reject unsafe webhook URL');
|
||||
|
||||
const eventPool = new pg.Pool({ connectionString: process.env.DATABASE_URL || DEFAULT_DATABASE_URL });
|
||||
let platformNotificationEventId = '';
|
||||
try {
|
||||
const channelRow = await eventPool.query(
|
||||
"select id from public.platform_audit_notification_channels where channel_code = 'integration_platform_audit' limit 1",
|
||||
);
|
||||
const insertedEvent = await eventPool.query(
|
||||
`
|
||||
insert into public.platform_audit_notification_events (
|
||||
channel_id, alert_id, audit_log_id, provider, status, attempts,
|
||||
last_http_code, request_payload, last_response_summary
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, 'generic', 'sent', 1,
|
||||
200,
|
||||
'{"body":{"token":"must-not-leak","nested":{"password":"must-not-leak"}}}'::jsonb,
|
||||
'{"ok":true}'
|
||||
)
|
||||
on conflict (channel_id, alert_id)
|
||||
do update set status = excluded.status,
|
||||
attempts = excluded.attempts,
|
||||
request_payload = excluded.request_payload,
|
||||
updated_at = now()
|
||||
returning id
|
||||
`,
|
||||
[channelRow.rows[0].id, platformAlertId, statusAuditLog.id],
|
||||
);
|
||||
platformNotificationEventId = insertedEvent.rows[0].id;
|
||||
} finally {
|
||||
await eventPool.end();
|
||||
}
|
||||
|
||||
const notificationEvents = await request('/api/platform-admin/audit-notification-events', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
headers: adminHeaders,
|
||||
query: { alertId: platformAlertId, status: 'sent', limit: 20 },
|
||||
});
|
||||
assert.ok(
|
||||
notificationEvents.items?.some(item => item.id === platformNotificationEventId),
|
||||
'platform admin should list audit notification events',
|
||||
);
|
||||
const listedNotificationEvent = notificationEvents.items?.find(item => item.id === platformNotificationEventId);
|
||||
assert.equal(listedNotificationEvent?.requestPayload?.body?.token, '[REDACTED]', 'notification event list should redact token-like payload details');
|
||||
assert.equal(listedNotificationEvent?.requestPayload?.body?.nested?.password, '[REDACTED]', 'notification event list should redact nested password-like payload details');
|
||||
assert.ok(!JSON.stringify(notificationEvents).includes('must-not-leak'), 'notification event list must not leak sensitive details');
|
||||
|
||||
const candidates = await request('/api/platform-admin/invoices/subscription-candidates', {
|
||||
tenantId: false,
|
||||
userId: false,
|
||||
@@ -1555,6 +1654,20 @@ async function testPlatformTenantOperationsAndAudit() {
|
||||
});
|
||||
assert.equal(studentAuditAlertStatusDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not update platform audit alerts');
|
||||
|
||||
const studentAuditNotificationChannelDenied = await request('/api/platform-admin/audit-notification-channels', {
|
||||
tenantId: false,
|
||||
userId: USER_ID,
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(studentAuditNotificationChannelDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit notification channels');
|
||||
|
||||
const studentAuditNotificationEventDenied = await request('/api/platform-admin/audit-notification-events', {
|
||||
tenantId: false,
|
||||
userId: USER_ID,
|
||||
expectStatus: 403,
|
||||
});
|
||||
assert.equal(studentAuditNotificationEventDenied.code, 'PLATFORM_ADMIN_REQUIRED', 'student must not read platform audit notification events');
|
||||
|
||||
const studentReminderDenied = await request('/api/platform-admin/invoices/reminders', {
|
||||
tenantId: false,
|
||||
userId: USER_ID,
|
||||
|
||||
222
scripts/platform-audit-notification-worker-integration-test.js
Normal file
222
scripts/platform-audit-notification-worker-integration-test.js
Normal file
@@ -0,0 +1,222 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import pg from 'pg';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
|
||||
|
||||
const ids = {
|
||||
tenant: '00000000-0000-0000-0000-00000000ab01',
|
||||
auditLog: '00000000-0000-0000-0000-00000000ab02',
|
||||
alert: '00000000-0000-0000-0000-00000000ab03',
|
||||
};
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer();
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address();
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
server.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function startWebhookServer() {
|
||||
const port = await getFreePort();
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let raw = '';
|
||||
req.on('data', chunk => {
|
||||
raw += chunk.toString();
|
||||
});
|
||||
req.on('end', () => {
|
||||
requests.push({
|
||||
url: req.url,
|
||||
headers: req.headers,
|
||||
body: raw ? JSON.parse(raw) : {},
|
||||
});
|
||||
res.writeHead(200, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true }));
|
||||
});
|
||||
});
|
||||
await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}/platform-audit`,
|
||||
requests,
|
||||
close: () => new Promise(resolve => server.close(resolve)),
|
||||
};
|
||||
}
|
||||
|
||||
function runWorkerOnce() {
|
||||
const child = spawn(process.execPath, ['apps/worker/dist/apps/worker/src/index.js', '--once', '--job', 'platform-audit-notifications'], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
DATABASE_URL: databaseUrl,
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_BATCH_SIZE: '20',
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_REQUEST_TIMEOUT_MS: '5000',
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
let output = '';
|
||||
child.stdout.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
child.stderr.on('data', chunk => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
child.on('error', reject);
|
||||
child.on('exit', code => {
|
||||
try {
|
||||
assert.equal(code, 0, `worker should exit 0\n${output}`);
|
||||
assert.match(output, /platform-audit-notifications batch enqueued=\d+/, 'worker output should include notification summary');
|
||||
resolve(output);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function cleanup(pool) {
|
||||
await pool.query("delete from public.platform_audit_notification_events where alert_id = $1", [ids.alert]);
|
||||
await pool.query("delete from public.platform_audit_notification_channels where channel_code = 'worker_platform_audit_test'");
|
||||
await pool.query("delete from app_private.platform_secrets where secret_scope = 'webhook' and secret_key = 'worker_platform_audit_test'");
|
||||
await pool.query('delete from public.platform_audit_alerts where id = $1 or audit_log_id = $2', [ids.alert, ids.auditLog]);
|
||||
await pool.query('delete from public.audit_logs where id = $1 or tenant_id = $2', [ids.auditLog, ids.tenant]);
|
||||
await pool.query('delete from public.tenant_billing_profiles where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenant_domains where tenant_id = $1', [ids.tenant]);
|
||||
await pool.query('delete from public.tenants where id = $1', [ids.tenant]);
|
||||
}
|
||||
|
||||
async function seed(pool, webhookUrl) {
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.tenants (id, slug, name, legal_name, status, mode, billing_status, metadata)
|
||||
values ($1, 'platform-audit-notification-worker', '平台审计通知租户', '平台审计通知有限公司', 'active', 'saas', 'active', '{"source":"platform-audit-notification-worker-test"}'::jsonb)
|
||||
`,
|
||||
[ids.tenant],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.audit_logs (
|
||||
id, tenant_id, actor_user_id, action, target_type, target_id,
|
||||
details, ip_address, user_agent, created_at
|
||||
)
|
||||
values (
|
||||
$1, $2::uuid, null, 'platform.tenant.status_updated', 'tenant', $3,
|
||||
'{"status":"suspended","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
|
||||
'127.0.0.1', 'platform-audit-notification-worker-test', now()
|
||||
)
|
||||
`,
|
||||
[ids.auditLog, ids.tenant, ids.tenant],
|
||||
);
|
||||
const rule = await pool.query("select id from public.platform_audit_alert_rules where code = 'platform_tenant_status_changed' limit 1");
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.platform_audit_alerts (
|
||||
id, rule_id, audit_log_id, tenant_id, severity, status,
|
||||
action, target_type, target_id, title, summary, details,
|
||||
first_seen_at, last_seen_at
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4::uuid, 'high', 'open',
|
||||
'platform.tenant.status_updated', 'tenant', $4,
|
||||
'租户状态变更告警', 'platform audit notification integration alert',
|
||||
'{"source":"worker-test","apiKey":"must-not-leak","nested":{"password":"must-not-leak"}}'::jsonb,
|
||||
now(), now()
|
||||
)
|
||||
`,
|
||||
[ids.alert, rule.rows[0].id, ids.auditLog, ids.tenant],
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into app_private.platform_secrets (secret_scope, secret_key, secret_value, provider, last_rotated_at)
|
||||
values ('webhook', 'worker_platform_audit_test', 'platform-audit-notification-secret', 'generic', now())
|
||||
on conflict (secret_scope, secret_key)
|
||||
do update set secret_value = excluded.secret_value,
|
||||
provider = excluded.provider,
|
||||
last_rotated_at = now(),
|
||||
updated_at = now()
|
||||
`,
|
||||
);
|
||||
await pool.query(
|
||||
`
|
||||
insert into public.platform_audit_notification_channels (
|
||||
channel_code, name, enabled, provider, webhook_url, secret_ref,
|
||||
min_severity, status_filter, action_patterns, timeout_sec
|
||||
)
|
||||
values (
|
||||
'worker_platform_audit_test', 'Worker 平台审计通知', true, 'generic',
|
||||
$1, 'app_private.platform_secrets:webhook:worker_platform_audit_test',
|
||||
'medium', array['open']::text[], array['platform.tenant.*']::text[], 5
|
||||
)
|
||||
on conflict (channel_code)
|
||||
do update set enabled = excluded.enabled,
|
||||
provider = excluded.provider,
|
||||
webhook_url = excluded.webhook_url,
|
||||
secret_ref = excluded.secret_ref,
|
||||
min_severity = excluded.min_severity,
|
||||
status_filter = excluded.status_filter,
|
||||
action_patterns = excluded.action_patterns,
|
||||
timeout_sec = excluded.timeout_sec,
|
||||
updated_at = now()
|
||||
`,
|
||||
[webhookUrl],
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const webhook = await startWebhookServer();
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||
try {
|
||||
await cleanup(pool);
|
||||
await seed(pool, webhook.url);
|
||||
|
||||
const firstOutput = await runWorkerOnce();
|
||||
assert.match(firstOutput, /sent=1/, 'worker should send one platform audit notification');
|
||||
assert.equal(webhook.requests.length, 1, 'worker should call webhook exactly once');
|
||||
assert.equal(webhook.requests[0].body.event, 'platform.audit.alert', 'generic notification should use platform audit event name');
|
||||
assert.equal(webhook.requests[0].body.alert.id, ids.alert, 'webhook body should include alert id');
|
||||
assert.equal(webhook.requests[0].body.alert.details.apiKey, '[REDACTED]', 'webhook body should redact token-like keys');
|
||||
assert.equal(webhook.requests[0].body.alert.details.nested.password, '[REDACTED]', 'webhook body should redact nested password');
|
||||
assert.ok(!JSON.stringify(webhook.requests[0]).includes('must-not-leak'), 'webhook request must not leak sensitive values');
|
||||
assert.ok(!JSON.stringify(webhook.requests[0]).includes('platform-audit-notification-secret'), 'webhook request must not leak signing secret');
|
||||
|
||||
const events = await pool.query(
|
||||
`
|
||||
select status, attempts, last_http_code, sent_at, request_payload
|
||||
from public.platform_audit_notification_events
|
||||
where alert_id = $1
|
||||
limit 1
|
||||
`,
|
||||
[ids.alert],
|
||||
);
|
||||
assert.equal(events.rowCount, 1, 'worker should create one notification event');
|
||||
assert.equal(events.rows[0].status, 'sent', 'notification event should be sent');
|
||||
assert.equal(events.rows[0].attempts, 1, 'notification event should record one attempt');
|
||||
assert.equal(events.rows[0].last_http_code, 200, 'notification event should record HTTP 200');
|
||||
assert.ok(events.rows[0].sent_at, 'notification event should record sent_at');
|
||||
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('must-not-leak'), 'stored request payload should be redacted');
|
||||
assert.ok(!JSON.stringify(events.rows[0].request_payload).includes('platform-audit-notification-secret'), 'stored request payload should not leak secret');
|
||||
|
||||
const secondOutput = await runWorkerOnce();
|
||||
assert.match(secondOutput, /sent=0/, 'second worker run should not resend sent notification');
|
||||
assert.equal(webhook.requests.length, 1, 'worker should not duplicate sent notification');
|
||||
|
||||
console.log('Platform audit notification worker integration test complete.');
|
||||
} finally {
|
||||
await cleanup(pool).catch(() => {});
|
||||
await pool.end();
|
||||
await webhook.close();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -20,6 +20,7 @@ const safeBaseEnv = {
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT: 'https://scanner.gongxue100.com/api/scan',
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN: 's3cure-asset-scanner-token-2026-06-30-abcdef',
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN: 'false',
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'false',
|
||||
};
|
||||
|
||||
const safeApiEnv = {
|
||||
@@ -78,6 +79,17 @@ const unsafeWorkerScanner = runImport(workerConfigUrl, {
|
||||
assert.notEqual(unsafeWorkerScanner.status, 0, 'production worker config should require external scanner');
|
||||
assert.match(unsafeWorkerScanner.output, /WORKER_ASSET_SECURITY_SCANNER must include http/, 'worker config should require http scanner');
|
||||
|
||||
const unsafeWorkerPlatformAuditNotification = runImport(workerConfigUrl, {
|
||||
...safeBaseEnv,
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',
|
||||
});
|
||||
assert.notEqual(unsafeWorkerPlatformAuditNotification.status, 0, 'production worker config should reject platform audit notification localhost mode');
|
||||
assert.match(
|
||||
unsafeWorkerPlatformAuditNotification.output,
|
||||
/WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true/,
|
||||
'worker config should name unsafe platform audit notification localhost mode',
|
||||
);
|
||||
|
||||
const safeWorker = runImport(workerConfigUrl, safeBaseEnv);
|
||||
assert.equal(safeWorker.status, 0, `safe production worker config should load: ${safeWorker.output}`);
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuv
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TIMEOUT_MS=10000
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
WORKER_CRM_ALLOW_INSECURE_LOCALHOST=false
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
|
||||
WORKER_CRM_BATCH_SIZE=20
|
||||
WORKER_COMMERCE_BATCH_SIZE=20
|
||||
WORKER_ASSET_BATCH_SIZE=50
|
||||
@@ -115,6 +116,7 @@ WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=false
|
||||
`);
|
||||
|
||||
assert.notEqual(missingJwksIssuer.status, 0, 'JWKS readiness without issuer should fail');
|
||||
@@ -123,4 +125,36 @@ assert.ok(
|
||||
'JWKS readiness should block missing AUTH_JWT_ISSUER',
|
||||
);
|
||||
|
||||
const unsafePlatformAuditNotificationLocalhost = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com
|
||||
AUTH_SMS_PROVIDER=aliyun
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
|
||||
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
|
||||
ALLOW_LEGACY_AUTH_HEADERS=false
|
||||
ALLOW_PLATFORM_ADMIN_KEY=false
|
||||
PLATFORM_ADMIN_API_KEY=${strongSecretC}
|
||||
STORAGE_DEFAULT_PROVIDER=aliyun_oss
|
||||
STORAGE_DEFAULT_BUCKET=tiku-assets
|
||||
STORAGE_REQUIRE_TENANT_PREFIX=true
|
||||
ALIYUN_OSS_REGION=cn-hangzhou
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
|
||||
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST=true
|
||||
`);
|
||||
|
||||
assert.notEqual(unsafePlatformAuditNotificationLocalhost.status, 0, 'platform audit notification localhost readiness should fail');
|
||||
assert.ok(
|
||||
unsafePlatformAuditNotificationLocalhost.payload.checks?.some(item => item.id === 'env.worker_platform_audit_notification_insecure_localhost' && item.status === 'blocker'),
|
||||
'readiness should block platform audit notification localhost mode in production',
|
||||
);
|
||||
|
||||
console.log('[PASS] production readiness check script');
|
||||
|
||||
@@ -321,11 +321,17 @@ function validateEnv() {
|
||||
pass('env.asset_security_scan_fail_open', 'asset security scanning fails closed');
|
||||
}
|
||||
|
||||
if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) {
|
||||
block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production');
|
||||
} else {
|
||||
pass('env.worker_crm_insecure_localhost', 'CRM worker insecure localhost webhook mode is disabled');
|
||||
}
|
||||
if (envBool('WORKER_CRM_ALLOW_INSECURE_LOCALHOST', false)) {
|
||||
block('env.worker_crm_insecure_localhost', 'WORKER_CRM_ALLOW_INSECURE_LOCALHOST must be false in production');
|
||||
} else {
|
||||
pass('env.worker_crm_insecure_localhost', 'CRM worker insecure localhost webhook mode is disabled');
|
||||
}
|
||||
|
||||
if (envBool('WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST', false)) {
|
||||
block('env.worker_platform_audit_notification_insecure_localhost', 'WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST must be false in production');
|
||||
} else {
|
||||
pass('env.worker_platform_audit_notification_insecure_localhost', 'Platform audit notification worker insecure localhost webhook mode is disabled');
|
||||
}
|
||||
|
||||
const requiredPositiveNumbers = [
|
||||
'WORKER_CRM_BATCH_SIZE',
|
||||
@@ -419,6 +425,51 @@ async function validateDatabase() {
|
||||
pass('db.payment_provider_secrets', 'Active payment accounts have private secret rows');
|
||||
}
|
||||
|
||||
const unsafePlatformAuditNotificationRows = await pool.query(`
|
||||
select id, channel_code, provider, webhook_url
|
||||
from public.platform_audit_notification_channels
|
||||
where enabled = true
|
||||
and (
|
||||
webhook_url !~* '^https://'
|
||||
or webhook_url ~* '^https?://(localhost|127\\.0\\.0\\.1|\\[?::1\\]?)'
|
||||
)
|
||||
`);
|
||||
if (unsafePlatformAuditNotificationRows.rowCount > 0) {
|
||||
block('db.platform_audit_notification_webhooks', 'Enabled platform audit notification webhooks must use production HTTPS URLs', {
|
||||
count: unsafePlatformAuditNotificationRows.rowCount,
|
||||
samples: unsafePlatformAuditNotificationRows.rows.slice(0, 5).map(row => ({
|
||||
id: row.id,
|
||||
channelCode: row.channel_code,
|
||||
provider: row.provider,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
pass('db.platform_audit_notification_webhooks', 'Enabled platform audit notification webhooks use production HTTPS URLs');
|
||||
}
|
||||
|
||||
const missingPlatformAuditNotificationSecretRows = await pool.query(`
|
||||
select c.id, c.channel_code, c.provider, c.secret_ref
|
||||
from public.platform_audit_notification_channels c
|
||||
left join app_private.platform_secrets s
|
||||
on s.secret_scope = split_part(c.secret_ref, ':', 2)
|
||||
and s.secret_key = split_part(c.secret_ref, ':', 3)
|
||||
where c.enabled = true
|
||||
and c.provider in ('dingtalk', 'feishu')
|
||||
and (c.secret_ref is null or c.secret_ref !~ '^app_private\\.platform_secrets:' or s.id is null)
|
||||
`);
|
||||
if (missingPlatformAuditNotificationSecretRows.rowCount > 0) {
|
||||
block('db.platform_audit_notification_secrets', 'Signed platform audit notification channels require app_private.platform_secrets rows', {
|
||||
count: missingPlatformAuditNotificationSecretRows.rowCount,
|
||||
samples: missingPlatformAuditNotificationSecretRows.rows.slice(0, 5).map(row => ({
|
||||
id: row.id,
|
||||
channelCode: row.channel_code,
|
||||
provider: row.provider,
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
pass('db.platform_audit_notification_secrets', 'Signed platform audit notification channels have private secret rows');
|
||||
}
|
||||
|
||||
const unverifiedDomainRows = await pool.query(`
|
||||
select count(*)::int as count
|
||||
from public.tenant_domains
|
||||
|
||||
Reference in New Issue
Block a user