iri-shield 1.2.3 → 1.2.5
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/README.md +18 -3
- package/package.json +1 -1
- package/src/dashboard.js +169 -58
- package/src/index.js +1 -0
- package/src/mongodb-storage.js +168 -4
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# iri-shield
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/iri-shield)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
5
|
[](https://nodejs.org)
|
|
6
6
|
[](#explainable-risk-scoring)
|
|
@@ -402,10 +402,13 @@ dashboard: {
|
|
|
402
402
|
path: '/iri-shield',
|
|
403
403
|
username: process.env.SHIELD_ADMIN_USER,
|
|
404
404
|
password: process.env.SHIELD_ADMIN_PASSWORD,
|
|
405
|
+
sessionSecret: process.env.IRI_SHIELD_DASHBOARD_SECRET || process.env.SESSION_SECRET,
|
|
405
406
|
refreshMs: 60 * 1000
|
|
406
407
|
}
|
|
407
408
|
```
|
|
408
409
|
|
|
410
|
+
Set a stable `sessionSecret` on serverless hosts. Otherwise old deployments that used a random per-instance dashboard token could intermittently return `401 Unauthorized` from dashboard API calls after a cold start or instance switch.
|
|
411
|
+
|
|
409
412
|
### Production Configuration
|
|
410
413
|
|
|
411
414
|
```js
|
|
@@ -443,7 +446,8 @@ const shield = createShield({
|
|
|
443
446
|
enabled: true,
|
|
444
447
|
path: '/iri-shield',
|
|
445
448
|
username: process.env.SHIELD_ADMIN_USER,
|
|
446
|
-
password: process.env.SHIELD_ADMIN_PASSWORD
|
|
449
|
+
password: process.env.SHIELD_ADMIN_PASSWORD,
|
|
450
|
+
sessionSecret: process.env.IRI_SHIELD_DASHBOARD_SECRET || process.env.SESSION_SECRET || process.env.JWT_SECRET
|
|
447
451
|
}
|
|
448
452
|
});
|
|
449
453
|
|
|
@@ -476,6 +480,15 @@ storage: {
|
|
|
476
480
|
|
|
477
481
|
This avoids filesystem initialization errors and is suitable for testing the middleware, dashboard, detection rules, and API behaviour.
|
|
478
482
|
|
|
483
|
+
For persistent Vercel dashboard history, use MongoDB:
|
|
484
|
+
|
|
485
|
+
```js
|
|
486
|
+
storage: {
|
|
487
|
+
mode: 'mongodb',
|
|
488
|
+
mongoUrl: process.env.IRI_MONGO_URL
|
|
489
|
+
}
|
|
490
|
+
```
|
|
491
|
+
|
|
479
492
|
If you explicitly want SQLite for a short-lived demo, use `/tmp`:
|
|
480
493
|
|
|
481
494
|
```js
|
|
@@ -515,7 +528,7 @@ const shield = createShield({
|
|
|
515
528
|
trustProxy: true,
|
|
516
529
|
|
|
517
530
|
storage: {
|
|
518
|
-
mode: process.env.IRI_STORAGE_MODE || 'memory',
|
|
531
|
+
mode: process.env.IRI_STORAGE_MODE || (process.env.IRI_MONGO_URL ? 'mongodb' : 'memory'),
|
|
519
532
|
sqliteFile: process.env.IRI_SQLITE_FILE || '/tmp/iri-shield.sqlite',
|
|
520
533
|
mongoUrl: process.env.IRI_MONGO_URL
|
|
521
534
|
},
|
|
@@ -530,6 +543,7 @@ const shield = createShield({
|
|
|
530
543
|
path: '/iri-shield',
|
|
531
544
|
username: process.env.SHIELD_ADMIN_USER,
|
|
532
545
|
password: process.env.SHIELD_ADMIN_PASSWORD,
|
|
546
|
+
sessionSecret: process.env.IRI_SHIELD_DASHBOARD_SECRET || process.env.SESSION_SECRET || process.env.JWT_SECRET,
|
|
533
547
|
refreshMs: 60 * 1000
|
|
534
548
|
}
|
|
535
549
|
});
|
|
@@ -564,6 +578,7 @@ NODE_ENV=production
|
|
|
564
578
|
APP_NAME=my-api
|
|
565
579
|
SHIELD_ADMIN_USER=admin
|
|
566
580
|
SHIELD_ADMIN_PASSWORD=replace-with-a-strong-secret
|
|
581
|
+
IRI_SHIELD_DASHBOARD_SECRET=replace-with-a-long-random-session-secret
|
|
567
582
|
IRI_STORAGE_MODE=memory
|
|
568
583
|
```
|
|
569
584
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iri-shield",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.5",
|
|
4
4
|
"description": "Enterprise Express.js security middleware — explainable risk scoring, multi-signal identity profiling, behavioural anomaly detection, attack correlation, sensitive-data redaction, and dashboard monitoring.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iri-shield",
|
package/src/dashboard.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const express = require('express');
|
|
4
|
-
const {
|
|
4
|
+
const { createHmac, timingSafeEqual } = require('crypto');
|
|
5
5
|
|
|
6
6
|
function createDashboardRouter({ storage, config }) {
|
|
7
7
|
const router = express.Router();
|
|
@@ -46,60 +46,66 @@ function createDashboardRouter({ storage, config }) {
|
|
|
46
46
|
// API endpoints
|
|
47
47
|
// =========================================================================
|
|
48
48
|
|
|
49
|
-
router.
|
|
50
|
-
res.
|
|
49
|
+
router.use('/api/', (_req, res, next) => {
|
|
50
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
51
|
+
next();
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
router.get('/api/stats', asyncRoute(async (req, res) => {
|
|
55
|
+
const stats = await callStorage(storage, 'getStats');
|
|
56
|
+
res.json({ ...stats, config: publicConfig(config) });
|
|
57
|
+
}));
|
|
58
|
+
|
|
53
59
|
// Paginated events
|
|
54
|
-
router.get('/api/events', (req, res) => {
|
|
60
|
+
router.get('/api/events', asyncRoute(async (req, res) => {
|
|
55
61
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
56
62
|
const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
|
|
57
63
|
const riskLevel = req.query.riskLevel || null;
|
|
58
64
|
if (typeof storage.getEvents === 'function') {
|
|
59
|
-
res.json(storage
|
|
65
|
+
res.json(await callStorage(storage, 'getEvents', { page, perPage, riskLevel }));
|
|
60
66
|
} else {
|
|
61
67
|
const events = (storage.events || []).filter((e) => !riskLevel || e.riskLevel === riskLevel);
|
|
62
68
|
res.json(paginateArray(events, page, perPage));
|
|
63
69
|
}
|
|
64
|
-
});
|
|
70
|
+
}));
|
|
65
71
|
|
|
66
72
|
// Paginated clients
|
|
67
|
-
router.get('/api/clients', (req, res) => {
|
|
73
|
+
router.get('/api/clients', asyncRoute(async (req, res) => {
|
|
68
74
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
69
75
|
const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
|
|
70
76
|
if (typeof storage.getClients === 'function') {
|
|
71
|
-
res.json(storage
|
|
77
|
+
res.json(await callStorage(storage, 'getClients', { page, perPage }));
|
|
72
78
|
} else {
|
|
73
79
|
const clients = Array.from((storage.clients || new Map()).values()).sort(
|
|
74
80
|
(a, b) => b.requestCount - a.requestCount
|
|
75
81
|
);
|
|
76
82
|
res.json(paginateArray(clients, page, perPage));
|
|
77
83
|
}
|
|
78
|
-
});
|
|
84
|
+
}));
|
|
79
85
|
|
|
80
86
|
// Paginated blocked IPs with status filter
|
|
81
|
-
router.get('/api/blocked', (req, res) => {
|
|
87
|
+
router.get('/api/blocked', asyncRoute(async (req, res) => {
|
|
82
88
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
83
89
|
const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
|
|
84
90
|
const status = req.query.status || 'all';
|
|
85
91
|
if (typeof storage.getBlockedIps === 'function') {
|
|
86
|
-
res.json(storage
|
|
92
|
+
res.json(await callStorage(storage, 'getBlockedIps', { page, perPage, status }));
|
|
87
93
|
} else {
|
|
88
|
-
res.json(storage
|
|
94
|
+
res.json(await callStorage(storage, 'getBlockedIps', { page, perPage, status }));
|
|
89
95
|
}
|
|
90
|
-
});
|
|
96
|
+
}));
|
|
91
97
|
|
|
92
98
|
// Paginated alerts
|
|
93
|
-
router.get('/api/alerts', (req, res) => {
|
|
99
|
+
router.get('/api/alerts', asyncRoute(async (req, res) => {
|
|
94
100
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
95
101
|
const perPage = Math.min(100, Math.max(5, parseInt(req.query.perPage) || 20));
|
|
96
102
|
const dismissed = req.query.dismissed === 'true';
|
|
97
103
|
if (typeof storage.getAlerts === 'function') {
|
|
98
|
-
res.json(storage
|
|
104
|
+
res.json(await callStorage(storage, 'getAlerts', { page, perPage, dismissed }));
|
|
99
105
|
} else {
|
|
100
106
|
res.json(paginateArray([], page, perPage));
|
|
101
107
|
}
|
|
102
|
-
});
|
|
108
|
+
}));
|
|
103
109
|
|
|
104
110
|
// Unblock / Delete Block for an IP
|
|
105
111
|
router.post('/api/unblock', (req, res) => {
|
|
@@ -711,6 +717,27 @@ function formatThreatLabel(name) {
|
|
|
711
717
|
return cleaned.length > 12 ? cleaned.slice(0, 11) + '..' : cleaned;
|
|
712
718
|
}
|
|
713
719
|
|
|
720
|
+
async function apiJson(url, options) {
|
|
721
|
+
const res = await fetch(url, Object.assign({ cache: 'no-store' }, options || {}));
|
|
722
|
+
if (res.status === 401) {
|
|
723
|
+
window.location.href = BASE + '/login';
|
|
724
|
+
throw new Error('Dashboard session expired');
|
|
725
|
+
}
|
|
726
|
+
let data = {};
|
|
727
|
+
try { data = await res.json(); } catch (_) { /* non-json response */ }
|
|
728
|
+
if (!res.ok) throw new Error(data.error || ('Request failed with status ' + res.status));
|
|
729
|
+
return data;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function normalizePageData(pageData) {
|
|
733
|
+
const data = pageData && typeof pageData === 'object' ? pageData : {};
|
|
734
|
+
const total = Number.isFinite(Number(data.total)) ? Number(data.total) : 0;
|
|
735
|
+
const perPage = Number.isFinite(Number(data.perPage)) && Number(data.perPage) > 0 ? Number(data.perPage) : 20;
|
|
736
|
+
const totalPages = Math.max(1, Number.isFinite(Number(data.totalPages)) ? Number(data.totalPages) : Math.ceil(total / perPage) || 1);
|
|
737
|
+
const page = Math.max(1, Math.min(Number.isFinite(Number(data.page)) ? Number(data.page) : 1, totalPages));
|
|
738
|
+
return { ...data, data: Array.isArray(data.data) ? data.data : [], total, perPage, totalPages, page };
|
|
739
|
+
}
|
|
740
|
+
|
|
714
741
|
// -------------------------------------------------------------------------
|
|
715
742
|
// Navigation & Panel Memory
|
|
716
743
|
// -------------------------------------------------------------------------
|
|
@@ -826,13 +853,13 @@ async function doToggleBlock(ip, shouldBlock, reason = 'manual_action', duration
|
|
|
826
853
|
|
|
827
854
|
try {
|
|
828
855
|
if (shouldBlock) {
|
|
829
|
-
await
|
|
856
|
+
await apiJson(BASE + '/api/block', {
|
|
830
857
|
method: 'POST',
|
|
831
858
|
headers: { 'content-type': 'application/json' },
|
|
832
859
|
body: JSON.stringify({ ip, reason: reason || 'manual_action', durationMs })
|
|
833
860
|
});
|
|
834
861
|
} else {
|
|
835
|
-
await
|
|
862
|
+
await apiJson(BASE + '/api/unblock', {
|
|
836
863
|
method: 'POST',
|
|
837
864
|
headers: { 'content-type': 'application/json' },
|
|
838
865
|
body: JSON.stringify({ ip })
|
|
@@ -858,8 +885,7 @@ async function doToggleBlock(ip, shouldBlock, reason = 'manual_action', duration
|
|
|
858
885
|
|
|
859
886
|
async function loadStats() {
|
|
860
887
|
try {
|
|
861
|
-
const
|
|
862
|
-
const data = await res.json();
|
|
888
|
+
const data = await apiJson(BASE + '/api/stats');
|
|
863
889
|
|
|
864
890
|
refreshMs = Number(data.config?.dashboard?.refreshMs || refreshMs);
|
|
865
891
|
document.getElementById('refreshLabel').textContent = Math.round(refreshMs / 1000) + 's';
|
|
@@ -954,8 +980,7 @@ async function loadEvents(page) {
|
|
|
954
980
|
const risk = document.getElementById('eventRiskFilter')?.value || '';
|
|
955
981
|
try {
|
|
956
982
|
const url = BASE + '/api/events?page=' + currentPage.events + '&perPage=20' + (risk ? '&riskLevel=' + risk : '');
|
|
957
|
-
const
|
|
958
|
-
const data = await res.json();
|
|
983
|
+
const data = normalizePageData(await apiJson(url));
|
|
959
984
|
rawEventsData = data.data || [];
|
|
960
985
|
const tbody = document.getElementById('eventRows');
|
|
961
986
|
if (!rawEventsData.length) {
|
|
@@ -1103,8 +1128,7 @@ function toggleEventDetail(id) {
|
|
|
1103
1128
|
async function loadClients(page) {
|
|
1104
1129
|
currentPage.clients = page || currentPage.clients;
|
|
1105
1130
|
try {
|
|
1106
|
-
const
|
|
1107
|
-
const data = await res.json();
|
|
1131
|
+
const data = normalizePageData(await apiJson(BASE + '/api/clients?page=' + currentPage.clients + '&perPage=20'));
|
|
1108
1132
|
const tbody = document.getElementById('clientRows');
|
|
1109
1133
|
if (!data.data?.length) {
|
|
1110
1134
|
tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="9">No clients recorded</td></tr>';
|
|
@@ -1137,8 +1161,7 @@ async function loadBlocked(page) {
|
|
|
1137
1161
|
currentPage.blocked = page || currentPage.blocked;
|
|
1138
1162
|
const status = document.getElementById('blockedStatusFilter')?.value || 'all';
|
|
1139
1163
|
try {
|
|
1140
|
-
const
|
|
1141
|
-
const data = await res.json();
|
|
1164
|
+
const data = normalizePageData(await apiJson(BASE + '/api/blocked?page=' + currentPage.blocked + '&perPage=20&status=' + status));
|
|
1142
1165
|
const tbody = document.getElementById('blockedRows');
|
|
1143
1166
|
if (!data.data?.length) {
|
|
1144
1167
|
tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="8">No IP addresses recorded in blocklist</td></tr>';
|
|
@@ -1183,7 +1206,7 @@ async function loadBlocked(page) {
|
|
|
1183
1206
|
async function doDeleteBlock(ip) {
|
|
1184
1207
|
if (!confirm('Permanently remove IP ' + ip + ' from block history?')) return;
|
|
1185
1208
|
try {
|
|
1186
|
-
await
|
|
1209
|
+
await apiJson(BASE + '/api/unblock', {
|
|
1187
1210
|
method: 'POST',
|
|
1188
1211
|
headers: { 'content-type': 'application/json' },
|
|
1189
1212
|
body: JSON.stringify({ ip })
|
|
@@ -1202,8 +1225,7 @@ async function doDeleteBlock(ip) {
|
|
|
1202
1225
|
async function loadAlerts(page) {
|
|
1203
1226
|
currentPage.alerts = page || currentPage.alerts;
|
|
1204
1227
|
try {
|
|
1205
|
-
const
|
|
1206
|
-
const data = await res.json();
|
|
1228
|
+
const data = normalizePageData(await apiJson(BASE + '/api/alerts?page=' + currentPage.alerts + '&perPage=20'));
|
|
1207
1229
|
const tbody = document.getElementById('alertRows');
|
|
1208
1230
|
if (!data.data?.length) {
|
|
1209
1231
|
tbody.innerHTML = '<tr><td class="px-4 py-4 text-gray-400 text-center" colspan="8">No active alerts</td></tr>';
|
|
@@ -1231,7 +1253,7 @@ async function loadAlerts(page) {
|
|
|
1231
1253
|
|
|
1232
1254
|
async function doDismiss(clientId) {
|
|
1233
1255
|
try {
|
|
1234
|
-
await
|
|
1256
|
+
await apiJson(BASE + '/api/alerts/' + encodeURIComponent(clientId) + '/dismiss', { method: 'POST' });
|
|
1235
1257
|
loadAlerts(currentPage.alerts);
|
|
1236
1258
|
loadStats();
|
|
1237
1259
|
} catch(e) { console.error('Dismiss error', e); }
|
|
@@ -1261,8 +1283,7 @@ async function submitBlock() {
|
|
|
1261
1283
|
|
|
1262
1284
|
async function loadSettings() {
|
|
1263
1285
|
try {
|
|
1264
|
-
const
|
|
1265
|
-
const data = await res.json();
|
|
1286
|
+
const data = await apiJson(BASE + '/api/settings');
|
|
1266
1287
|
if (data.security) selectMode(data.security);
|
|
1267
1288
|
} catch(e) { console.error('Load settings error', e); }
|
|
1268
1289
|
}
|
|
@@ -1283,12 +1304,11 @@ document.getElementById('settingsForm').addEventListener('submit', async functio
|
|
|
1283
1304
|
if (modeEl) payload.security = modeEl.value;
|
|
1284
1305
|
|
|
1285
1306
|
try {
|
|
1286
|
-
const
|
|
1307
|
+
const updated = await apiJson(BASE + '/api/settings', {
|
|
1287
1308
|
method: 'POST',
|
|
1288
1309
|
headers: { 'content-type': 'application/json' },
|
|
1289
1310
|
body: JSON.stringify(payload)
|
|
1290
1311
|
});
|
|
1291
|
-
const updated = await res.json();
|
|
1292
1312
|
if (updated.security) selectMode(updated.security);
|
|
1293
1313
|
|
|
1294
1314
|
const msg = document.getElementById('settingsMsg');
|
|
@@ -1376,9 +1396,15 @@ function renderChart(rows) {
|
|
|
1376
1396
|
// -------------------------------------------------------------------------
|
|
1377
1397
|
|
|
1378
1398
|
function renderPagination(containerId, pageData, loadFn) {
|
|
1399
|
+
pageData = normalizePageData(pageData);
|
|
1379
1400
|
const container = document.getElementById(containerId);
|
|
1380
|
-
if (!container
|
|
1381
|
-
|
|
1401
|
+
if (!container) return;
|
|
1402
|
+
if (pageData.total === 0) {
|
|
1403
|
+
container.innerHTML = '<div class="text-xs text-gray-500 font-medium">Showing <strong>0</strong> records</div>';
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
if (pageData.totalPages <= 1) {
|
|
1407
|
+
container.innerHTML = \`<div class="text-xs text-gray-500 font-medium">Showing <strong>1-\${fmt.format(pageData.total)}</strong> of <strong>\${fmt.format(pageData.total)}</strong> records</div>\`;
|
|
1382
1408
|
return;
|
|
1383
1409
|
}
|
|
1384
1410
|
const { page, totalPages, total, perPage } = pageData;
|
|
@@ -1453,31 +1479,93 @@ function renderLogin(config, failed) {
|
|
|
1453
1479
|
<head>
|
|
1454
1480
|
<meta charset="utf-8" />
|
|
1455
1481
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1456
|
-
<title>iri-shield
|
|
1482
|
+
<title>iri-shield - Dashboard Login</title>
|
|
1457
1483
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
1484
|
+
<script>
|
|
1485
|
+
tailwind.config = {
|
|
1486
|
+
theme: {
|
|
1487
|
+
extend: {
|
|
1488
|
+
fontFamily: {
|
|
1489
|
+
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
|
1490
|
+
display: ['Syne', 'Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
|
1491
|
+
mono: ['JetBrains Mono', 'ui-monospace', 'SFMono-Regular', 'monospace']
|
|
1492
|
+
},
|
|
1493
|
+
colors: {
|
|
1494
|
+
paper: '#fbfbf8',
|
|
1495
|
+
line: '#e5e7eb',
|
|
1496
|
+
cobalt: '#d81d87',
|
|
1497
|
+
rose: '#be123c',
|
|
1498
|
+
pine: '#047857'
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
};
|
|
1503
|
+
</script>
|
|
1504
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
1505
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
1506
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&family=Syne:wght@600;700;800&display=swap" rel="stylesheet">
|
|
1458
1507
|
</head>
|
|
1459
|
-
<body class="min-h-screen bg-
|
|
1460
|
-
<main class="
|
|
1461
|
-
<
|
|
1462
|
-
<div class="
|
|
1463
|
-
<div class="
|
|
1464
|
-
<
|
|
1465
|
-
|
|
1466
|
-
|
|
1508
|
+
<body class="min-h-screen bg-paper text-slate-950 antialiased">
|
|
1509
|
+
<main class="grid min-h-screen grid-cols-1 lg:grid-cols-[0.58fr_0.42fr]">
|
|
1510
|
+
<section class="flex items-center border-b border-line bg-white px-6 py-12 lg:border-b-0 lg:border-r lg:px-12">
|
|
1511
|
+
<div class="mx-auto w-full max-w-3xl">
|
|
1512
|
+
<div class="mb-8 flex items-center gap-3">
|
|
1513
|
+
<div class="grid h-11 w-11 place-items-center rounded-md bg-cobalt text-sm font-black text-white shadow-sm">IS</div>
|
|
1514
|
+
<div>
|
|
1515
|
+
<p class="text-sm font-extrabold">iri-shield</p>
|
|
1516
|
+
<p class="text-[11px] font-bold uppercase tracking-[0.18em] text-slate-500">Security dashboard</p>
|
|
1517
|
+
</div>
|
|
1518
|
+
</div>
|
|
1519
|
+
<!-- <p class="mb-5 inline-flex rounded-full border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-bold uppercase tracking-[0.18em] text-cobalt">Research telemetry console</p> -->
|
|
1520
|
+
<h1 class="font-display text-5xl font-extrabold leading-[0.98] tracking-normal md:text-6xl">Monitor API threats with explainable decisions.</h1>
|
|
1521
|
+
<p class="mt-6 max-w-2xl text-lg leading-8 text-slate-600">Sign in to inspect alerts, event traces, identity drift, blocked IPs, storage health, and redaction activity generated by the Iri-Shield middleware.</p>
|
|
1522
|
+
<div class="mt-8 grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
1523
|
+
<div class="rounded-lg border border-line bg-slate-50 p-4">
|
|
1524
|
+
<p class="font-mono text-xs font-bold text-cobalt">01</p>
|
|
1525
|
+
<p class="mt-2 text-sm font-extrabold">Detect</p>
|
|
1526
|
+
<p class="mt-1 text-xs leading-5 text-slate-500">Rules, anomalies, and attack sequences.</p>
|
|
1527
|
+
</div>
|
|
1528
|
+
<div class="rounded-lg border border-line bg-slate-50 p-4">
|
|
1529
|
+
<p class="font-mono text-xs font-bold text-cobalt">02</p>
|
|
1530
|
+
<p class="mt-2 text-sm font-extrabold">Explain</p>
|
|
1531
|
+
<p class="mt-1 text-xs leading-5 text-slate-500">Risk scores with evidence breakdowns.</p>
|
|
1532
|
+
</div>
|
|
1533
|
+
<div class="rounded-lg border border-line bg-slate-50 p-4">
|
|
1534
|
+
<p class="font-mono text-xs font-bold text-cobalt">03</p>
|
|
1535
|
+
<p class="mt-2 text-sm font-extrabold">Mitigate</p>
|
|
1536
|
+
<p class="mt-1 text-xs leading-5 text-slate-500">Alerts, blocking, and redaction.</p>
|
|
1537
|
+
</div>
|
|
1467
1538
|
</div>
|
|
1468
|
-
<h1 class="text-2xl font-bold text-gray-900">iri-shield</h1>
|
|
1469
|
-
<p class="text-sm text-gray-500 mt-1">Enterprise Security Dashboard</p>
|
|
1470
1539
|
</div>
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
<
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1540
|
+
</section>
|
|
1541
|
+
|
|
1542
|
+
<section class="flex items-center px-6 py-12 lg:px-12">
|
|
1543
|
+
<div class="mx-auto w-full max-w-md">
|
|
1544
|
+
<div class="rounded-lg border border-line bg-white p-6 shadow-xl shadow-slate-200/60">
|
|
1545
|
+
<div class="mb-6">
|
|
1546
|
+
<p class="text-xs font-bold uppercase tracking-[0.18em] text-slate-500">Authenticated access</p>
|
|
1547
|
+
<h2 class="mt-2 font-display text-3xl font-bold tracking-normal">Dashboard sign in</h2>
|
|
1548
|
+
<p class="mt-2 text-sm leading-6 text-slate-600">Use the configured administrator credentials for ${escapeHtml(config.appName || 'iri-shield')}.</p>
|
|
1549
|
+
</div>
|
|
1550
|
+
${failed ? '<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm font-semibold text-red-700">Invalid username or password.</div>' : ''}
|
|
1551
|
+
<form method="post" action="${escapeHtml(config.dashboard?.path || '/iri-shield')}/login" class="space-y-4">
|
|
1552
|
+
<label class="block">
|
|
1553
|
+
<span class="mb-1 block text-sm font-bold text-slate-700">Username</span>
|
|
1554
|
+
<input class="w-full rounded-md border border-line bg-white px-3 py-2.5 text-sm outline-none transition focus:border-cobalt focus:ring-4 focus:ring-blue-100" id="username" name="username" autocomplete="username" required />
|
|
1555
|
+
</label>
|
|
1556
|
+
<label class="block">
|
|
1557
|
+
<span class="mb-1 block text-sm font-bold text-slate-700">Password</span>
|
|
1558
|
+
<input class="w-full rounded-md border border-line bg-white px-3 py-2.5 text-sm outline-none transition focus:border-cobalt focus:ring-4 focus:ring-blue-100" id="password" name="password" type="password" autocomplete="current-password" required />
|
|
1559
|
+
</label>
|
|
1560
|
+
<button class="w-full rounded-md bg-cobalt px-4 py-3 text-sm font-extrabold text-white shadow-sm transition hover:bg-blue-700" type="submit">Sign in to dashboard</button>
|
|
1561
|
+
</form>
|
|
1562
|
+
</div>
|
|
1563
|
+
<div class="mt-4 rounded-lg border border-dashed border-slate-300 bg-slate-50 p-4">
|
|
1564
|
+
<p class="text-xs font-bold uppercase tracking-[0.18em] text-slate-500">Media slot</p>
|
|
1565
|
+
<p class="mt-2 text-sm leading-6 text-slate-600">Optional future visual: a compact looping animation of alert cards entering the dashboard, risk scores rising, and sensitive fields being masked.</p>
|
|
1566
|
+
</div>
|
|
1567
|
+
</div>
|
|
1568
|
+
</section>
|
|
1481
1569
|
</main>
|
|
1482
1570
|
</body>
|
|
1483
1571
|
</html>`;
|
|
@@ -1561,7 +1649,15 @@ function iconAlert() { return '<svg class="w-5 h-5 text-blue-600" fill="none"
|
|
|
1561
1649
|
function dashboardAuth(options = {}) {
|
|
1562
1650
|
const username = options.username || 'admin';
|
|
1563
1651
|
const password = options.password || 'admin';
|
|
1564
|
-
const
|
|
1652
|
+
const sessionSecret =
|
|
1653
|
+
options.sessionSecret ||
|
|
1654
|
+
process.env.IRI_SHIELD_DASHBOARD_SECRET ||
|
|
1655
|
+
process.env.SESSION_SECRET ||
|
|
1656
|
+
process.env.JWT_SECRET ||
|
|
1657
|
+
`${username}:${password}`;
|
|
1658
|
+
const token = createHmac('sha256', String(sessionSecret))
|
|
1659
|
+
.update(`iri-shield-dashboard:${username}:${password}`)
|
|
1660
|
+
.digest('hex');
|
|
1565
1661
|
return {
|
|
1566
1662
|
token,
|
|
1567
1663
|
canLogin(inputUser, inputPass) {
|
|
@@ -1573,6 +1669,20 @@ function dashboardAuth(options = {}) {
|
|
|
1573
1669
|
};
|
|
1574
1670
|
}
|
|
1575
1671
|
|
|
1672
|
+
function asyncRoute(handler) {
|
|
1673
|
+
return function routeHandler(req, res, next) {
|
|
1674
|
+
Promise.resolve(handler(req, res, next)).catch(next);
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
async function callStorage(storage, method, ...args) {
|
|
1679
|
+
if (storage.ready && typeof storage.ready.then === 'function') {
|
|
1680
|
+
await storage.ready;
|
|
1681
|
+
}
|
|
1682
|
+
const result = storage[method](...args);
|
|
1683
|
+
return result && typeof result.then === 'function' ? await result : result;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1576
1686
|
function applyDashboardSettings(config, body) {
|
|
1577
1687
|
const numberFields = [
|
|
1578
1688
|
['rateLimit.max', 1, 100000],
|
|
@@ -1626,6 +1736,7 @@ function applyDashboardSettings(config, body) {
|
|
|
1626
1736
|
function publicConfig(config) {
|
|
1627
1737
|
const copy = JSON.parse(JSON.stringify(config));
|
|
1628
1738
|
if (copy.dashboard?.password) copy.dashboard.password = '';
|
|
1739
|
+
if (copy.dashboard?.sessionSecret) copy.dashboard.sessionSecret = '';
|
|
1629
1740
|
return copy;
|
|
1630
1741
|
}
|
|
1631
1742
|
|
package/src/index.js
CHANGED
|
@@ -651,6 +651,7 @@ function mergeInto(target, patch) {
|
|
|
651
651
|
function sanitizeConfig(config) {
|
|
652
652
|
const copy = JSON.parse(JSON.stringify(config));
|
|
653
653
|
if (copy.dashboard && copy.dashboard.password) copy.dashboard.password = '';
|
|
654
|
+
if (copy.dashboard && copy.dashboard.sessionSecret) copy.dashboard.sessionSecret = '';
|
|
654
655
|
return copy;
|
|
655
656
|
}
|
|
656
657
|
|
package/src/mongodb-storage.js
CHANGED
|
@@ -19,8 +19,8 @@ class MongoStorage extends MemoryStorage {
|
|
|
19
19
|
this.db = null;
|
|
20
20
|
this._cols = {};
|
|
21
21
|
|
|
22
|
-
// Start connection asynchronously — does not block constructor
|
|
23
|
-
this._connect();
|
|
22
|
+
// Start connection asynchronously — does not block constructor.
|
|
23
|
+
this.ready = this._connect();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
async _connect() {
|
|
@@ -47,8 +47,8 @@ class MongoStorage extends MemoryStorage {
|
|
|
47
47
|
// Initialize collections and TTL indexes
|
|
48
48
|
await this._initCollections();
|
|
49
49
|
|
|
50
|
-
//
|
|
51
|
-
await this.
|
|
50
|
+
// Warm the in-memory dashboard cache after cold starts.
|
|
51
|
+
await this._loadAllPersistentData();
|
|
52
52
|
|
|
53
53
|
console.log('[iri-shield] MongoDB connected:', this.mongoUrl);
|
|
54
54
|
} catch (err) {
|
|
@@ -86,6 +86,14 @@ class MongoStorage extends MemoryStorage {
|
|
|
86
86
|
this._cols = { requests, events, clients, blocks, alerts };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
async _loadAllPersistentData() {
|
|
90
|
+
await this._loadPersistentBlocks();
|
|
91
|
+
await this._loadPersistentClients();
|
|
92
|
+
await this._loadPersistentAlerts();
|
|
93
|
+
await this._loadPersistentEvents();
|
|
94
|
+
await this._loadPersistentRequests();
|
|
95
|
+
}
|
|
96
|
+
|
|
89
97
|
async _loadPersistentBlocks() {
|
|
90
98
|
if (!this.connected) return;
|
|
91
99
|
try {
|
|
@@ -104,6 +112,86 @@ class MongoStorage extends MemoryStorage {
|
|
|
104
112
|
} catch { /* ignore */ }
|
|
105
113
|
}
|
|
106
114
|
|
|
115
|
+
async _loadPersistentClients() {
|
|
116
|
+
if (!this.connected) return;
|
|
117
|
+
try {
|
|
118
|
+
const rows = await this._cols.clients.find({}).sort({ requestCount: -1 }).limit(this.maxClients).toArray();
|
|
119
|
+
for (const row of rows) {
|
|
120
|
+
if (row.clientId) this.clients.set(row.clientId, stripMongoId(row));
|
|
121
|
+
}
|
|
122
|
+
} catch { /* ignore */ }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async _loadPersistentAlerts() {
|
|
126
|
+
if (!this.connected) return;
|
|
127
|
+
try {
|
|
128
|
+
const rows = await this._cols.alerts.find({}).sort({ lastAlertAt: -1 }).limit(this.maxClients).toArray();
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
if (row.clientId) this.alerts.set(row.clientId, stripMongoId(row));
|
|
131
|
+
}
|
|
132
|
+
} catch { /* ignore */ }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async _loadPersistentEvents() {
|
|
136
|
+
if (!this.connected) return;
|
|
137
|
+
try {
|
|
138
|
+
const rows = await this._cols.events.find({}).sort({ timestamp: -1, createdAt: -1 }).limit(this.maxEvents).toArray();
|
|
139
|
+
this.events = rows.map(stripMongoId);
|
|
140
|
+
this.threatDistribution = new Map();
|
|
141
|
+
for (const event of this.events) {
|
|
142
|
+
const threat = event.threat || 'unknown';
|
|
143
|
+
this.threatDistribution.set(threat, (this.threatDistribution.get(threat) || 0) + 1);
|
|
144
|
+
}
|
|
145
|
+
} catch { /* ignore */ }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async _loadPersistentRequests() {
|
|
149
|
+
if (!this.connected) return;
|
|
150
|
+
try {
|
|
151
|
+
const rows = await this._cols.requests.find({}).sort({ createdAt: -1 }).limit(this.maxRequests).toArray();
|
|
152
|
+
this.requests = rows.map(stripMongoId);
|
|
153
|
+
const totals = await this._cols.requests.aggregate([
|
|
154
|
+
{
|
|
155
|
+
$group: {
|
|
156
|
+
_id: null,
|
|
157
|
+
totalRequests: { $sum: 1 },
|
|
158
|
+
blockedRequests: { $sum: { $cond: ['$blocked', 1, 0] } },
|
|
159
|
+
totalLatencyMs: { $sum: { $ifNull: ['$durationMs', 0] } }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
]).next();
|
|
163
|
+
this.totalRequests = totals?.totalRequests || this.requests.length;
|
|
164
|
+
this.blockedRequests = totals?.blockedRequests || 0;
|
|
165
|
+
this.totalLatencyMs = totals?.totalLatencyMs || 0;
|
|
166
|
+
|
|
167
|
+
const endpoints = await this._cols.requests.aggregate([
|
|
168
|
+
{
|
|
169
|
+
$group: {
|
|
170
|
+
_id: { method: '$method', endpoint: '$endpoint' },
|
|
171
|
+
count: { $sum: 1 },
|
|
172
|
+
errors: { $sum: { $cond: [{ $gte: ['$statusCode', 400] }, 1, 0] } },
|
|
173
|
+
avgLatencyMs: { $avg: '$durationMs' }
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
{ $sort: { count: -1 } },
|
|
177
|
+
{ $limit: 20 }
|
|
178
|
+
]).toArray();
|
|
179
|
+
|
|
180
|
+
this.endpointStats = new Map();
|
|
181
|
+
for (const row of endpoints) {
|
|
182
|
+
const method = row._id?.method || 'GET';
|
|
183
|
+
const endpoint = row._id?.endpoint || '/';
|
|
184
|
+
this.endpointStats.set(`${method} ${endpoint}`, {
|
|
185
|
+
method,
|
|
186
|
+
endpoint,
|
|
187
|
+
count: row.count || 0,
|
|
188
|
+
errors: row.errors || 0,
|
|
189
|
+
avgLatencyMs: row.avgLatencyMs || 0
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
} catch { /* ignore */ }
|
|
193
|
+
}
|
|
194
|
+
|
|
107
195
|
// ---------------------------------------------------------------------------
|
|
108
196
|
// Helpers
|
|
109
197
|
// ---------------------------------------------------------------------------
|
|
@@ -120,9 +208,11 @@ class MongoStorage extends MemoryStorage {
|
|
|
120
208
|
|
|
121
209
|
recordRequest(request) {
|
|
122
210
|
super.recordRequest(request);
|
|
211
|
+
const timestamp = new Date().toISOString();
|
|
123
212
|
this._safe(() =>
|
|
124
213
|
this._cols.requests.insertOne({
|
|
125
214
|
...request,
|
|
215
|
+
timestamp,
|
|
126
216
|
createdAt: new Date(),
|
|
127
217
|
blocked: Boolean(request.blocked)
|
|
128
218
|
})
|
|
@@ -220,6 +310,62 @@ class MongoStorage extends MemoryStorage {
|
|
|
220
310
|
return result;
|
|
221
311
|
}
|
|
222
312
|
|
|
313
|
+
async getEvents({ page = 1, perPage = 20, riskLevel = null } = {}) {
|
|
314
|
+
if (!this.connected) return super.getEvents({ page, perPage, riskLevel });
|
|
315
|
+
await this.ready;
|
|
316
|
+
if (!this.connected) return super.getEvents({ page, perPage, riskLevel });
|
|
317
|
+
try {
|
|
318
|
+
const filter = riskLevel ? { riskLevel } : {};
|
|
319
|
+
const total = await this._cols.events.countDocuments(filter);
|
|
320
|
+
const data = await this._cols.events
|
|
321
|
+
.find(filter)
|
|
322
|
+
.sort({ timestamp: -1, createdAt: -1 })
|
|
323
|
+
.skip((Math.max(1, page) - 1) * perPage)
|
|
324
|
+
.limit(perPage)
|
|
325
|
+
.toArray();
|
|
326
|
+
return paginateMongo(data, total, page, perPage);
|
|
327
|
+
} catch {
|
|
328
|
+
return super.getEvents({ page, perPage, riskLevel });
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
async getClients({ page = 1, perPage = 20 } = {}) {
|
|
333
|
+
if (!this.connected) return super.getClients({ page, perPage });
|
|
334
|
+
await this.ready;
|
|
335
|
+
if (!this.connected) return super.getClients({ page, perPage });
|
|
336
|
+
try {
|
|
337
|
+
const total = await this._cols.clients.countDocuments({});
|
|
338
|
+
const data = await this._cols.clients
|
|
339
|
+
.find({})
|
|
340
|
+
.sort({ requestCount: -1, lastSeenAt: -1 })
|
|
341
|
+
.skip((Math.max(1, page) - 1) * perPage)
|
|
342
|
+
.limit(perPage)
|
|
343
|
+
.toArray();
|
|
344
|
+
return paginateMongo(data, total, page, perPage);
|
|
345
|
+
} catch {
|
|
346
|
+
return super.getClients({ page, perPage });
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async getAlerts({ page = 1, perPage = 20, dismissed = false } = {}) {
|
|
351
|
+
if (!this.connected) return super.getAlerts({ page, perPage, dismissed });
|
|
352
|
+
await this.ready;
|
|
353
|
+
if (!this.connected) return super.getAlerts({ page, perPage, dismissed });
|
|
354
|
+
try {
|
|
355
|
+
const filter = { dismissed: Boolean(dismissed) };
|
|
356
|
+
const total = await this._cols.alerts.countDocuments(filter);
|
|
357
|
+
const data = await this._cols.alerts
|
|
358
|
+
.find(filter)
|
|
359
|
+
.sort({ lastAlertAt: -1, updatedAt: -1 })
|
|
360
|
+
.skip((Math.max(1, page) - 1) * perPage)
|
|
361
|
+
.limit(perPage)
|
|
362
|
+
.toArray();
|
|
363
|
+
return paginateMongo(data, total, page, perPage);
|
|
364
|
+
} catch {
|
|
365
|
+
return super.getAlerts({ page, perPage, dismissed });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
223
369
|
// ---------------------------------------------------------------------------
|
|
224
370
|
// Stats
|
|
225
371
|
// ---------------------------------------------------------------------------
|
|
@@ -235,3 +381,21 @@ class MongoStorage extends MemoryStorage {
|
|
|
235
381
|
}
|
|
236
382
|
|
|
237
383
|
module.exports = { MongoStorage };
|
|
384
|
+
|
|
385
|
+
function stripMongoId(row) {
|
|
386
|
+
if (!row) return row;
|
|
387
|
+
const { _id, ...rest } = row;
|
|
388
|
+
return rest;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function paginateMongo(rows, total, page, perPage) {
|
|
392
|
+
const totalPages = Math.ceil(total / perPage) || 1;
|
|
393
|
+
const safePage = Math.max(1, Math.min(page, totalPages));
|
|
394
|
+
return {
|
|
395
|
+
data: rows.map(stripMongoId),
|
|
396
|
+
total,
|
|
397
|
+
page: safePage,
|
|
398
|
+
perPage,
|
|
399
|
+
totalPages
|
|
400
|
+
};
|
|
401
|
+
}
|