Initial Commit3

This commit is contained in:
2026-05-25 10:39:32 +08:00
parent 65df324fe3
commit 6fc2dc42aa
22 changed files with 21679 additions and 1 deletions

Submodule PatchProbe.Server deleted from 08eab2f7a2

7
PatchProbe.Server/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
node_modules/
patchprobe.db
patchprobe.db-shm
patchprobe.db-wal
.env
public/
logs/

108
PatchProbe.Server/README.md Normal file
View File

@@ -0,0 +1,108 @@
# PatchProbe Server
Node.js / Express server that receives scan payloads from enrolled PatchProbe devices and exposes a management API.
---
## First-run setup
### 1. Configure environment
```bash
cp config.example.env .env
# Edit .env — at minimum set PATCHPROBE_ADMIN_KEY_HASH (see below)
```
### 2. Generate an admin key
```bash
node server.js --gen-admin-key
# Prints a random key and its bcrypt hash.
# Paste the hash into PATCHPROBE_ADMIN_KEY_HASH in .env
# Store the key itself securely — it is only shown once.
```
### 3. Start the server
```bash
npm start # production
npm run dev # development (auto-reload, pretty logs)
```
On first start the SQLite database is created automatically at `DB_PATH` (default `./patchprobe.db`).
---
## Enrolling a device end-to-end
### Step 1 — Create an enrollment token (admin)
```bash
curl -X POST http://localhost:3000/api/admin/tokens \
-H "Authorization: Bearer <admin-key>" \
-H "Content-Type: application/json" \
-d '{"label": "lab-devices", "expiresInDays": 7, "maxUses": 10}'
# Response: { "token": "<64-char hex>", ... }
# The token value is only returned here — copy it now.
```
### Step 2 — Enroll the device (on the endpoint)
```bash
PatchProbe.exe enroll --server-url http://your-server:3000 --enrollment-key <token>
```
The client POSTs to `POST /api/enrollments`, receives a `deviceId`, and stores credentials encrypted on disk.
### Step 3 — Run a scan
```bash
PatchProbe.exe scan
# Collects evidence and POSTs signed payload to POST /api/scans
```
---
## Admin API reference
All `/api/admin/*` routes require `Authorization: Bearer <admin-key>`.
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/admin/tokens` | Create enrollment token (`label`, `expiresInDays?`, `maxUses?`) |
| `GET` | `/api/admin/tokens` | List tokens (value masked as `ab12****`) |
| `DELETE` | `/api/admin/tokens/:token` | Revoke a token |
| `GET` | `/api/admin/devices` | List all enrolled devices |
| `DELETE` | `/api/admin/devices/:id` | Revoke a device (blocks future scan uploads) |
| `GET` | `/api/admin/devices/:id/scans` | Scan summaries for a device |
---
## Device API (used by PatchProbe.exe — do not change)
| Method | Path | Auth |
|--------|------|------|
| `POST` | `/api/enrollments` | Enrollment token in body |
| `POST` | `/api/scans` | ECDSA device signature headers |
| `GET` | `/api/scans` | None |
| `GET` | `/api/scans/:id` | None |
| `DELETE` | `/api/scans/:id` | None |
---
## Smoke test
```bash
# Start server first (auth must be enabled, admin key configured)
node test-auth.js
```
---
## Architecture notes
- **Persistence**: SQLite via Node.js built-in `node:sqlite` (no native compilation). WAL mode, foreign keys enforced.
- **Device auth**: ECDSA-P256-SHA256 over `deviceId\ntimestamp\nbase64(sha256(rawBody))`. Raw body bytes are captured before JSON parsing so re-serialization cannot alter the signed content.
- **Admin auth**: bcrypt-hashed bearer key. bcrypt comparison is async (non-blocking).
- **Enrollment tokens**: constant-time comparison iterates all active tokens without early exit to prevent timing oracles.
- **Rate limiting**: 10 req/min on `/api/enrollments`, 60 req/min on `/api/scans`.

View File

@@ -0,0 +1,32 @@
# PatchProbe Server — environment configuration
# Copy to .env and fill in values before starting the server.
# ── HTTP ─────────────────────────────────────────────────────────────────────
PORT=3000
# ── Runtime ──────────────────────────────────────────────────────────────────
NODE_ENV=development
# ── Database ─────────────────────────────────────────────────────────────────
DB_PATH=./patchprobe.db
# ── Device authentication ─────────────────────────────────────────────────────
PATCHPROBE_AUTH_ENABLED=true
# ── Admin API key (bearer token for curl/API access) ─────────────────────────
# Generate with: node server.js --gen-admin-key
PATCHPROBE_ADMIN_KEY_HASH=
# ── Dashboard session ─────────────────────────────────────────────────────────
# Random 64-char hex string. Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Must be set in production or sessions will not survive server restarts.
PATCHPROBE_JWT_SECRET=
# ── WebAuthn passkeys ─────────────────────────────────────────────────────────
# rpId: hostname only (no port, no protocol) — must match the domain serving the dashboard.
# Use 'localhost' in development.
PATCHPROBE_RP_ID=localhost
# origin: full origin the browser uses to reach this server (protocol + host + port).
# Must match exactly what appears in the browser URL bar.
PATCHPROBE_ORIGIN=http://localhost:3000

View File

@@ -0,0 +1,6 @@
{
"authEnabled": true,
"enrollmentKeys": [
"change-this-to-a-strong-random-key"
]
}

1660
PatchProbe.Server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"name": "patchprobe-server",
"version": "2.0.0",
"description": "PatchProbe scan ingestion and management server",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js",
"gen-admin-key": "node server.js --gen-admin-key"
},
"dependencies": {
"@simplewebauthn/server": "^13.0.0",
"bcryptjs": "^2.4.3",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-rate-limit": "^7.3.1",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"pino": "^9.3.2",
"pino-pretty": "^11.2.2"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
{
"collector": {
"machineName": "TEST-HOST",
"collectedAt": "2026-05-22T12:00:00Z",
"ranAsAdministrator": true
},
"windowsUpdate": {
"applicableUpdates": [
{
"title": "KB12345"
}
]
},
"pendingReboot": {
"anyPending": false
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

176
PatchProbe.Server/server.js Normal file
View File

@@ -0,0 +1,176 @@
'use strict';
// ---------------------------------------------------------------------------
// --gen-admin-key utility
// ---------------------------------------------------------------------------
if (process.argv.includes('--gen-admin-key')) {
const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const key = crypto.randomBytes(32).toString('hex');
const hash = bcrypt.hashSync(key, 12);
console.log('\nGenerated admin key (store securely — shown only once):');
console.log(' Key: ' + key);
console.log(' Hash: ' + hash);
console.log('\nAdd to your .env file:');
console.log(' PATCHPROBE_ADMIN_KEY_HASH=' + hash + '\n');
process.exit(0);
}
// ---------------------------------------------------------------------------
// Server startup
// ---------------------------------------------------------------------------
const fs = require('fs');
const path = require('path');
const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const cookieParser = require('cookie-parser');
const config = require('./src/config');
const logger = require('./src/logger');
const { initDb, closeDb } = require('./src/db');
const authRouter = require('./src/routes/auth');
const enrollmentRouter = require('./src/routes/enrollments');
const scansRouter = require('./src/routes/scans');
const adminRouter = require('./src/routes/admin');
initDb();
const app = express();
// ---------------------------------------------------------------------------
// Security headers
// Relax CSP slightly so the built React SPA can load scripts/styles from self
// ---------------------------------------------------------------------------
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"], // Vite injects a small inline style in dev
imgSrc: ["'self'", 'data:'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
upgradeInsecureRequests: config.nodeEnv === 'production' ? [] : null,
},
},
crossOriginEmbedderPolicy: false,
}));
// ---------------------------------------------------------------------------
// Rate limiting
// ---------------------------------------------------------------------------
app.use('/api/enrollments', rateLimit({
windowMs: 60_000, max: 10,
standardHeaders: true, legacyHeaders: false,
message: { error: 'Too many enrollment requests — try again in a minute' },
}));
app.use('/api/scans', rateLimit({
windowMs: 60_000, max: 60,
standardHeaders: true, legacyHeaders: false,
message: { error: 'Too many scan requests — try again in a minute' },
}));
app.use('/api/auth', rateLimit({
windowMs: 60_000, max: 20,
standardHeaders: true, legacyHeaders: false,
message: { error: 'Too many auth requests — try again in a minute' },
}));
// ---------------------------------------------------------------------------
// Body parsing + cookie parsing
// raw body capture must come before cookie-parser to avoid interference
// ---------------------------------------------------------------------------
app.use(express.json({
limit: '50mb',
verify: (req, _res, buf) => { req.rawBody = buf; },
}));
app.use(cookieParser());
// ---------------------------------------------------------------------------
// Static files (built SPA)
// ---------------------------------------------------------------------------
app.use(express.static(path.join(__dirname, 'public')));
// ---------------------------------------------------------------------------
// API routes
// ---------------------------------------------------------------------------
app.use('/api/auth', authRouter);
app.use('/api/enrollments', enrollmentRouter);
app.use('/api/scans', scansRouter);
app.use('/api/admin', adminRouter);
// API 404
app.use('/api/', (req, res) => {
res.status(404).json({ error: 'Not found' });
});
// ---------------------------------------------------------------------------
// SPA catch-all — serve index.html for all non-API GET requests
// React Router handles client-side routing
// ---------------------------------------------------------------------------
app.get('*', (req, res) => {
const indexPath = path.join(__dirname, 'public', 'index.html');
if (fs.existsSync(indexPath)) {
res.sendFile(indexPath);
} else {
res.status(503).send(
'Dashboard not built — run: cd PatchProbe.Dashboard && npm install && npm run build',
);
}
});
// ---------------------------------------------------------------------------
// Error handler
// ---------------------------------------------------------------------------
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
if (err.type === 'entity.parse.failed') {
return res.status(400).json({ error: 'Invalid JSON in request body' });
}
logger.error({ err, method: req.method, path: req.path }, 'Unhandled error');
res.status(err.status ?? 500).json({ error: err.message ?? 'Internal server error' });
});
// ---------------------------------------------------------------------------
// Startup
// ---------------------------------------------------------------------------
const server = app.listen(config.port, '0.0.0.0', () => {
logger.info({ port: config.port, auth: config.authEnabled, env: config.nodeEnv }, 'PatchProbe Server started');
if (!config.adminKeyHash) {
logger.warn('PATCHPROBE_ADMIN_KEY_HASH not set — run: node server.js --gen-admin-key');
}
if (!process.env.PATCHPROBE_JWT_SECRET) {
logger.warn('PATCHPROBE_JWT_SECRET not set — sessions will not survive server restarts');
}
});
// ---------------------------------------------------------------------------
// Graceful shutdown
// ---------------------------------------------------------------------------
function shutdown(signal) {
logger.info({ signal }, 'Shutdown signal received');
server.close(() => {
closeDb();
logger.info('Server stopped cleanly');
process.exit(0);
});
setTimeout(() => { logger.error('Forced shutdown'); process.exit(1); }, 10_000).unref();
}
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

View File

@@ -0,0 +1,20 @@
'use strict';
const crypto = require('crypto');
require('dotenv').config();
const config = {
port: parseInt(process.env.PORT ?? '3000', 10),
nodeEnv: process.env.NODE_ENV ?? 'development',
dbPath: process.env.DB_PATH ?? './patchprobe.db',
authEnabled: (process.env.PATCHPROBE_AUTH_ENABLED ?? 'true') === 'true',
adminKeyHash: process.env.PATCHPROBE_ADMIN_KEY_HASH ?? null,
// WebAuthn — rpId must be a plain hostname (no port, no protocol)
rpId: process.env.PATCHPROBE_RP_ID ?? 'localhost',
// WebAuthn — full origin that the browser will use to reach this server
origin: process.env.PATCHPROBE_ORIGIN ?? 'http://localhost:3000',
// JWT — auto-generated if unset; set in production so sessions survive restarts
jwtSecret: process.env.PATCHPROBE_JWT_SECRET ?? crypto.randomBytes(32).toString('hex'),
};
module.exports = config;

126
PatchProbe.Server/src/db.js Normal file
View File

@@ -0,0 +1,126 @@
'use strict';
const { DatabaseSync } = require('node:sqlite');
const config = require('./config');
const logger = require('./logger');
let db;
function getVersion() {
try {
const row = db.prepare('SELECT MAX(version) AS v FROM schema_version').get();
return row?.v ?? 0;
} catch {
return 0;
}
}
function migrate() {
const current = getVersion();
if (current < 1) {
logger.info('Applying DB migration v1');
try {
db.exec('BEGIN');
db.exec('CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)');
db.exec(`
CREATE TABLE IF NOT EXISTS devices (
device_id TEXT PRIMARY KEY,
machine_name TEXT NOT NULL,
device_fingerprint TEXT UNIQUE,
public_key_spki TEXT NOT NULL,
enrolled_at TEXT NOT NULL,
last_seen_at TEXT,
revoked_at TEXT
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS scans (
id TEXT PRIMARY KEY,
device_id TEXT REFERENCES devices(device_id),
machine_name TEXT,
collected_at TEXT,
ran_as_administrator INTEGER,
applicable_update_count INTEGER,
pending_reboot INTEGER,
raw_json TEXT NOT NULL,
received_at TEXT NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS enrollment_tokens (
token TEXT PRIMARY KEY,
label TEXT NOT NULL,
created_at TEXT NOT NULL,
expires_at TEXT,
used_count INTEGER NOT NULL DEFAULT 0,
max_uses INTEGER,
revoked_at TEXT
)
`);
db.prepare('DELETE FROM schema_version').run();
db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(1);
db.exec('COMMIT');
} catch (err) {
try { db.exec('ROLLBACK'); } catch { /* ignore */ }
throw err;
}
}
if (current < 2) {
logger.info('Applying DB migration v2');
try {
db.exec('BEGIN');
db.exec(`
CREATE TABLE IF NOT EXISTS admin_users (
user_id TEXT PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
display_name TEXT NOT NULL,
created_at TEXT NOT NULL
)
`);
db.exec(`
CREATE TABLE IF NOT EXISTS admin_credentials (
credential_id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES admin_users(user_id),
public_key TEXT NOT NULL,
counter INTEGER NOT NULL DEFAULT 0,
device_type TEXT,
backed_up INTEGER NOT NULL DEFAULT 0,
transports TEXT,
created_at TEXT NOT NULL,
last_used_at TEXT
)
`);
db.prepare('DELETE FROM schema_version').run();
db.prepare('INSERT INTO schema_version (version) VALUES (?)').run(2);
db.exec('COMMIT');
} catch (err) {
try { db.exec('ROLLBACK'); } catch { /* ignore */ }
throw err;
}
}
}
function initDb() {
db = new DatabaseSync(config.dbPath);
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
migrate();
logger.info({ dbPath: config.dbPath }, 'Database ready');
return db;
}
function getDb() {
if (!db) throw new Error('Database not initialized — call initDb() first');
return db;
}
function closeDb() {
if (db) {
db.close();
db = null;
}
}
module.exports = { initDb, getDb, closeDb };

View File

@@ -0,0 +1,18 @@
'use strict';
const pino = require('pino');
const config = require('./config');
const logger = pino(
config.nodeEnv === 'production'
? { level: 'info' }
: {
level: 'debug',
transport: {
target: 'pino-pretty',
options: { colorize: true },
},
}
);
module.exports = logger;

View File

@@ -0,0 +1,70 @@
'use strict';
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const config = require('../config');
const logger = require('../logger');
const SESSION_COOKIE = 'pp_session';
// Bearer key check (bcrypt) — for curl / API access
function requireAdmin(req, res, next) {
const authHeader = req.headers['authorization'] ?? '';
const key = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
if (!key) {
return res.status(401).json({ error: 'Admin authentication required (Authorization: Bearer <key>)' });
}
if (!config.adminKeyHash) {
logger.error('Admin endpoint called but PATCHPROBE_ADMIN_KEY_HASH is not configured');
return res.status(503).json({ error: 'Admin authentication not configured — run: node server.js --gen-admin-key' });
}
bcrypt.compare(key, config.adminKeyHash, (err, match) => {
if (err) {
logger.error({ err }, 'bcrypt compare error during admin auth');
return res.status(500).json({ error: 'Internal server error' });
}
if (!match) {
logger.warn({ reason: 'invalid_key' }, 'Admin auth failure');
return res.status(401).json({ error: 'Invalid admin key' });
}
next();
});
}
// JWT session check — for browser / dashboard access
function requireSession(req, res, next) {
const token = req.cookies?.[SESSION_COOKIE];
if (!token) {
return res.status(401).json({ error: 'Not authenticated' });
}
try {
req.adminSession = jwt.verify(token, config.jwtSecret);
next();
} catch {
res.clearCookie(SESSION_COOKIE, { path: '/' });
return res.status(401).json({ error: 'Session expired — please log in again' });
}
}
// Session cookie OR bearer key — used by all /api/admin/* routes
function requireAdminAccess(req, res, next) {
const token = req.cookies?.[SESSION_COOKIE];
if (token) {
try {
req.adminSession = jwt.verify(token, config.jwtSecret);
return next();
} catch {
res.clearCookie(SESSION_COOKIE, { path: '/' });
}
}
const authHeader = req.headers['authorization'] ?? '';
if (authHeader.startsWith('Bearer ')) {
return requireAdmin(req, res, next);
}
return res.status(401).json({ error: 'Authentication required' });
}
module.exports = { requireAdmin, requireSession, requireAdminAccess };

View File

@@ -0,0 +1,74 @@
'use strict';
const crypto = require('crypto');
const config = require('../config');
const { getDb } = require('../db');
const logger = require('../logger');
function requireAuth(req, res, next) {
if (!config.authEnabled) return next();
const deviceId = req.headers['x-device-id'];
const timestamp = req.headers['x-timestamp'];
const signature = req.headers['x-signature'];
if (!deviceId || !timestamp || !signature) {
logger.warn({ reason: 'missing_headers' }, 'Device auth failure');
return res.status(401).json({ error: 'Missing authentication headers (X-Device-Id, X-Timestamp, X-Signature)' });
}
const reqTime = parseInt(timestamp, 10);
if (isNaN(reqTime) || Math.abs(Date.now() - reqTime) > 5 * 60 * 1000) {
logger.warn({ deviceId, reason: 'timestamp_expired' }, 'Device auth failure');
return res.status(401).json({ error: 'Request timestamp is expired or invalid' });
}
const db = getDb();
const device = db.prepare('SELECT * FROM devices WHERE device_id = ?').get(deviceId);
if (!device) {
logger.warn({ deviceId, reason: 'unknown_device' }, 'Device auth failure');
return res.status(401).json({ error: 'Unknown device' });
}
if (device.revoked_at) {
logger.warn({ deviceId, reason: 'device_revoked' }, 'Device auth failure');
return res.status(401).json({ error: 'Device has been revoked' });
}
// Reconstruct the signed message: deviceId LF timestamp LF base64(sha256(rawBody))
const bodyHash = crypto.createHash('sha256').update(req.rawBody ?? '').digest('base64');
const message = `${deviceId}\n${timestamp}\n${bodyHash}`;
let valid = false;
try {
const publicKey = crypto.createPublicKey({
key: Buffer.from(device.public_key_spki, 'base64'),
format: 'der',
type: 'spki',
});
const verifier = crypto.createVerify('SHA256');
verifier.update(message);
// .NET ECDsa.SignData() produces IEEE P1363 (raw r‖s), not DER
valid = verifier.verify(
{ key: publicKey, dsaEncoding: 'ieee-p1363' },
Buffer.from(signature, 'base64'),
);
} catch {
logger.warn({ deviceId, reason: 'sig_error' }, 'Device auth failure');
return res.status(401).json({ error: 'Signature verification error' });
}
if (!valid) {
logger.warn({ deviceId, reason: 'invalid_sig' }, 'Device auth failure');
return res.status(401).json({ error: 'Invalid signature' });
}
db.prepare('UPDATE devices SET last_seen_at = ? WHERE device_id = ?')
.run(new Date().toISOString(), deviceId);
req.device = device;
next();
}
module.exports = { requireAuth };

View File

@@ -0,0 +1,155 @@
'use strict';
const express = require('express');
const crypto = require('crypto');
const { getDb } = require('../db');
const { requireAdminAccess } = require('../middleware/adminAuth');
const logger = require('../logger');
const router = express.Router();
router.use(requireAdminAccess);
// ---------------------------------------------------------------------------
// Enrollment tokens
// ---------------------------------------------------------------------------
// POST /api/admin/tokens — create an enrollment token
router.post('/tokens', (req, res) => {
const { label, expiresInDays, maxUses } = req.body ?? {};
if (typeof label !== 'string' || label.trim().length === 0 || label.length > 255) {
return res.status(400).json({ error: 'label is required (string, max 255 chars)' });
}
if (expiresInDays !== undefined && (!Number.isInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > 3650)) {
return res.status(400).json({ error: 'expiresInDays must be a positive integer (max 3650)' });
}
if (maxUses !== undefined && (!Number.isInteger(maxUses) || maxUses < 1)) {
return res.status(400).json({ error: 'maxUses must be a positive integer' });
}
const token = crypto.randomBytes(32).toString('hex');
const now = new Date().toISOString();
const expiresAt = expiresInDays
? new Date(Date.now() + expiresInDays * 86_400_000).toISOString()
: null;
const db = getDb();
db.prepare(
'INSERT INTO enrollment_tokens (token, label, created_at, expires_at, max_uses) VALUES (?, ?, ?, ?, ?)'
).run(token, label.trim(), now, expiresAt, maxUses ?? null);
logger.info({ label: label.trim(), expiresInDays, maxUses }, 'Enrollment token created');
// Token value only returned once — on creation
res.status(201).json({
token,
label: label.trim(),
createdAt: now,
expiresAt,
maxUses: maxUses ?? null,
});
});
// GET /api/admin/tokens — list tokens (masked)
router.get('/tokens', (req, res) => {
const db = getDb();
const tokens = db.prepare(
'SELECT rowid, token, label, created_at, expires_at, used_count, max_uses, revoked_at FROM enrollment_tokens ORDER BY created_at DESC'
).all();
const now = new Date();
res.json(tokens.map(t => ({
id: t.rowid,
tokenMasked: `${t.token.slice(0, 4)}****`,
label: t.label,
createdAt: t.created_at,
expiresAt: t.expires_at,
usedCount: t.used_count,
maxUses: t.max_uses,
revokedAt: t.revoked_at,
active: !t.revoked_at
&& (!t.expires_at || new Date(t.expires_at) > now)
&& (t.max_uses === null || t.used_count < t.max_uses),
})));
});
// DELETE /api/admin/tokens/:id — revoke a token by rowid
router.delete('/tokens/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isInteger(id) || id < 1) {
return res.status(400).json({ error: 'Invalid token id' });
}
const db = getDb();
const row = db.prepare('SELECT token FROM enrollment_tokens WHERE rowid = ?').get(id);
if (!row) return res.status(404).json({ error: 'Token not found' });
if (row.revoked_at) return res.status(409).json({ error: 'Token already revoked' });
db.prepare('UPDATE enrollment_tokens SET revoked_at = ? WHERE rowid = ?')
.run(new Date().toISOString(), id);
logger.info({ tokenId: id, tokenMasked: row.token.slice(0, 4) + '****' }, 'Enrollment token revoked');
res.status(204).end();
});
// ---------------------------------------------------------------------------
// Devices
// ---------------------------------------------------------------------------
// GET /api/admin/devices — list all devices
router.get('/devices', (req, res) => {
const db = getDb();
const devices = db.prepare(
'SELECT device_id, machine_name, device_fingerprint, enrolled_at, last_seen_at, revoked_at FROM devices ORDER BY enrolled_at DESC'
).all();
res.json(devices.map(d => ({
id: d.device_id,
machineName: d.machine_name,
deviceFingerprint: d.device_fingerprint,
enrolledAt: d.enrolled_at,
lastSeenAt: d.last_seen_at,
revoked: !!d.revoked_at,
revokedAt: d.revoked_at,
})));
});
// DELETE /api/admin/devices/:id — revoke a device
router.delete('/devices/:id', (req, res) => {
const db = getDb();
const device = db.prepare('SELECT device_id FROM devices WHERE device_id = ?').get(req.params.id);
if (!device) return res.status(404).json({ error: 'Device not found' });
db.prepare('UPDATE devices SET revoked_at = ? WHERE device_id = ?')
.run(new Date().toISOString(), req.params.id);
logger.info({ deviceId: req.params.id }, 'Device revoked');
res.status(204).end();
});
// GET /api/admin/devices/:id/scans — scan summaries for one device
router.get('/devices/:id/scans', (req, res) => {
const db = getDb();
const device = db.prepare('SELECT device_id FROM devices WHERE device_id = ?').get(req.params.id);
if (!device) return res.status(404).json({ error: 'Device not found' });
const scans = db.prepare(`
SELECT id, machine_name, collected_at, ran_as_administrator, applicable_update_count, pending_reboot, received_at
FROM scans
WHERE device_id = ?
ORDER BY received_at DESC
`).all(req.params.id);
res.json(scans.map(s => ({
id: s.id,
machineName: s.machine_name,
collectedAt: s.collected_at,
receivedAt: s.received_at,
ranAsAdministrator: s.ran_as_administrator === 1,
applicableUpdateCount: s.applicable_update_count,
pendingReboot: s.pending_reboot === 1,
})));
});
module.exports = router;

View File

@@ -0,0 +1,350 @@
'use strict';
const express = require('express');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} = require('@simplewebauthn/server');
const config = require('../config');
const { getDb } = require('../db');
const { requireAdminAccess, requireSession } = require('../middleware/adminAuth');
const logger = require('../logger');
const router = express.Router();
// ---------------------------------------------------------------------------
// In-memory challenge store (TTL 2 minutes, single-server only)
// ---------------------------------------------------------------------------
const pending = new Map();
function storeEntry(data) {
const token = crypto.randomBytes(32).toString('hex');
pending.set(token, { ...data, expiresAt: Date.now() + 120_000 });
return token;
}
function consumeEntry(token) {
if (!token) return null;
const entry = pending.get(token);
pending.delete(token);
if (!entry || entry.expiresAt < Date.now()) return null;
return entry;
}
setInterval(() => {
const now = Date.now();
for (const [k, v] of pending) if (v.expiresAt < now) pending.delete(k);
}, 60_000).unref();
// ---------------------------------------------------------------------------
// Cookie helpers
// ---------------------------------------------------------------------------
const SESSION_COOKIE = 'pp_session';
const CEREMONY_COOKIE = 'pp_ceremony';
const BASE_OPTS = {
httpOnly: true,
sameSite: 'strict',
secure: config.nodeEnv === 'production',
path: '/',
};
function setSession(res, user) {
const token = jwt.sign(
{ sub: user.user_id, username: user.username, displayName: user.display_name },
config.jwtSecret,
{ expiresIn: '8h' },
);
res.cookie(SESSION_COOKIE, token, { ...BASE_OPTS, maxAge: 8 * 60 * 60 * 1000 });
return token;
}
// ---------------------------------------------------------------------------
// Bootstrap guard: allow if no admin users exist yet, else require admin access
// ---------------------------------------------------------------------------
function requireAdminOrBootstrap(req, res, next) {
const db = getDb();
const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get();
if (n === 0) return next();
return requireAdminAccess(req, res, next);
}
// ---------------------------------------------------------------------------
// Status — lets the login page know if first-time setup is needed
// ---------------------------------------------------------------------------
router.get('/status', (req, res) => {
const db = getDb();
const { n } = db.prepare('SELECT COUNT(*) AS n FROM admin_users').get();
res.json({ hasUsers: n > 0 });
});
// ---------------------------------------------------------------------------
// Session
// ---------------------------------------------------------------------------
router.get('/me', (req, res) => {
const token = req.cookies?.[SESSION_COOKIE];
if (!token) return res.status(401).json({ error: 'Not authenticated' });
try {
const p = jwt.verify(token, config.jwtSecret);
res.json({ userId: p.sub, username: p.username, displayName: p.displayName });
} catch {
res.clearCookie(SESSION_COOKIE, { path: '/' });
res.status(401).json({ error: 'Session expired' });
}
});
router.post('/logout', (req, res) => {
res.clearCookie(SESSION_COOKIE, { path: '/' });
res.json({ ok: true });
});
// ---------------------------------------------------------------------------
// Registration
// ---------------------------------------------------------------------------
router.post('/register/begin', requireAdminOrBootstrap, async (req, res, next) => {
try {
const { username, displayName } = req.body ?? {};
if (typeof username !== 'string' || !username.trim() || username.length > 64) {
return res.status(400).json({ error: 'username required (max 64 chars)' });
}
if (typeof displayName !== 'string' || !displayName.trim() || displayName.length > 128) {
return res.status(400).json({ error: 'displayName required (max 128 chars)' });
}
const db = getDb();
const cleanName = username.trim().toLowerCase();
const cleanDisplay = displayName.trim();
const userId = crypto.randomUUID();
// Exclude credentials already registered for this username
const existingCreds = db.prepare(`
SELECT ac.credential_id, ac.transports
FROM admin_credentials ac
JOIN admin_users au ON au.user_id = ac.user_id
WHERE au.username = ?
`).all(cleanName);
const options = await generateRegistrationOptions({
rpName: 'PatchProbe',
rpID: config.rpId,
userID: Buffer.from(userId),
userName: cleanName,
userDisplayName: cleanDisplay,
attestation: 'none',
authenticatorSelection: { residentKey: 'required', userVerification: 'required' },
excludeCredentials: existingCreds.map(c => ({
id: c.credential_id,
transports: JSON.parse(c.transports ?? '[]'),
})),
timeout: 60_000,
});
const ceremonyToken = storeEntry({
challenge: options.challenge,
pendingUser: { userId, username: cleanName, displayName: cleanDisplay },
});
res.cookie(CEREMONY_COOKIE, ceremonyToken, { ...BASE_OPTS, maxAge: 120_000 });
logger.info({ username: cleanName }, 'Passkey registration ceremony started');
res.json(options);
} catch (err) {
next(err);
}
});
router.post('/register/finish', async (req, res, next) => {
try {
const entry = consumeEntry(req.cookies?.[CEREMONY_COOKIE]);
res.clearCookie(CEREMONY_COOKIE, { path: '/' });
if (!entry?.challenge || !entry?.pendingUser) {
return res.status(400).json({ error: 'Registration ceremony expired or not started' });
}
const { challenge, pendingUser } = entry;
let verification;
try {
verification = await verifyRegistrationResponse({
response: req.body,
expectedChallenge: challenge,
expectedOrigin: config.origin,
expectedRPID: config.rpId,
requireUserVerification: true,
});
} catch (err) {
logger.warn({ err: err.message, username: pendingUser.username }, 'Passkey registration verification failed');
return res.status(400).json({ error: 'Passkey verification failed' });
}
if (!verification.verified || !verification.registrationInfo) {
return res.status(400).json({ error: 'Passkey verification failed' });
}
const { credential } = verification.registrationInfo;
const db = getDb();
const now = new Date().toISOString();
// Upsert user (handles both new registration and additional passkeys)
const existingUser = db.prepare('SELECT user_id FROM admin_users WHERE username = ?').get(pendingUser.username);
const userId = existingUser?.user_id ?? pendingUser.userId;
if (!existingUser) {
db.prepare(
'INSERT INTO admin_users (user_id, username, display_name, created_at) VALUES (?, ?, ?, ?)',
).run(userId, pendingUser.username, pendingUser.displayName, now);
}
db.prepare(`
INSERT OR REPLACE INTO admin_credentials
(credential_id, user_id, public_key, counter, device_type, backed_up, transports, created_at, last_used_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
credential.id,
userId,
Buffer.from(credential.publicKey).toString('base64'),
credential.counter,
credential.deviceType ?? null,
credential.backedUp ? 1 : 0,
JSON.stringify(credential.transports ?? []),
now,
now,
);
const user = db.prepare('SELECT * FROM admin_users WHERE user_id = ?').get(userId);
setSession(res, user);
logger.info({ userId, username: pendingUser.username }, 'Passkey registered');
res.json({ ok: true, username: user.username, displayName: user.display_name });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
router.post('/login/begin', async (req, res, next) => {
try {
const db = getDb();
const credCount = db.prepare('SELECT COUNT(*) AS n FROM admin_credentials').get().n;
if (credCount === 0) {
return res.status(404).json({ error: 'No passkeys registered — complete first-time setup' });
}
// No allowCredentials → browser picks from all passkeys registered for this RP
const options = await generateAuthenticationOptions({
rpID: config.rpId,
userVerification: 'required',
timeout: 60_000,
});
const ceremonyToken = storeEntry({ challenge: options.challenge });
res.cookie(CEREMONY_COOKIE, ceremonyToken, { ...BASE_OPTS, maxAge: 120_000 });
res.json(options);
} catch (err) {
next(err);
}
});
router.post('/login/finish', async (req, res, next) => {
try {
const entry = consumeEntry(req.cookies?.[CEREMONY_COOKIE]);
res.clearCookie(CEREMONY_COOKIE, { path: '/' });
if (!entry?.challenge) {
return res.status(400).json({ error: 'Authentication ceremony expired or not started' });
}
const credentialId = req.body?.id;
const db = getDb();
const storedCred = db.prepare('SELECT * FROM admin_credentials WHERE credential_id = ?').get(credentialId);
if (!storedCred) {
return res.status(401).json({ error: 'Passkey not recognized' });
}
let verification;
try {
verification = await verifyAuthenticationResponse({
response: req.body,
expectedChallenge: entry.challenge,
expectedOrigin: config.origin,
expectedRPID: config.rpId,
credential: {
id: storedCred.credential_id,
publicKey: Buffer.from(storedCred.public_key, 'base64'),
counter: storedCred.counter,
transports: JSON.parse(storedCred.transports ?? '[]'),
},
requireUserVerification: true,
});
} catch (err) {
logger.warn({ err: err.message }, 'Passkey authentication verification failed');
return res.status(401).json({ error: 'Passkey verification failed' });
}
if (!verification.verified) {
return res.status(401).json({ error: 'Passkey verification failed' });
}
const { newCounter } = verification.authenticationInfo;
const now = new Date().toISOString();
db.prepare('UPDATE admin_credentials SET counter = ?, last_used_at = ? WHERE credential_id = ?')
.run(newCounter, now, storedCred.credential_id);
const user = db.prepare('SELECT * FROM admin_users WHERE user_id = ?').get(storedCred.user_id);
setSession(res, user);
logger.info({ userId: user.user_id, username: user.username }, 'Passkey login successful');
res.json({ ok: true, username: user.username, displayName: user.display_name });
} catch (err) {
next(err);
}
});
// ---------------------------------------------------------------------------
// Passkey management (session required — bearer key has no user context)
// ---------------------------------------------------------------------------
router.get('/passkeys', requireSession, (req, res) => {
const db = getDb();
const passkeys = db.prepare(
'SELECT credential_id, device_type, backed_up, transports, created_at, last_used_at FROM admin_credentials WHERE user_id = ?',
).all(req.adminSession.sub);
res.json(passkeys.map(p => ({
id: p.credential_id,
idMasked: `${p.credential_id.slice(0, 8)}`,
deviceType: p.device_type,
backedUp: p.backed_up === 1,
transports: JSON.parse(p.transports ?? '[]'),
createdAt: p.created_at,
lastUsedAt: p.last_used_at,
})));
});
router.delete('/passkeys/:id', requireSession, (req, res) => {
const db = getDb();
const userId = req.adminSession.sub;
const count = db.prepare('SELECT COUNT(*) AS n FROM admin_credentials WHERE user_id = ?').get(userId).n;
if (count <= 1) {
return res.status(400).json({ error: 'Cannot remove your only passkey' });
}
const result = db.prepare('DELETE FROM admin_credentials WHERE credential_id = ? AND user_id = ?')
.run(req.params.id, userId);
if (result.changes === 0) return res.status(404).json({ error: 'Passkey not found' });
logger.info({ userId, credentialId: req.params.id.slice(0, 8) }, 'Passkey removed');
res.status(204).end();
});
module.exports = router;

View File

@@ -0,0 +1,158 @@
'use strict';
const express = require('express');
const crypto = require('crypto');
const config = require('../config');
const { getDb } = require('../db');
const { requireAdmin } = require('../middleware/adminAuth');
const logger = require('../logger');
const router = express.Router();
// ---------------------------------------------------------------------------
// Input validation
// ---------------------------------------------------------------------------
function validateEnrollBody(body) {
const { machineName, publicKeySpki, deviceFingerprint } = body ?? {};
if (typeof machineName !== 'string' || machineName.trim().length === 0 || machineName.length > 255) {
return 'machineName is required (string, max 255 chars)';
}
if (typeof publicKeySpki !== 'string' || publicKeySpki.length === 0 || publicKeySpki.length > 4096) {
return 'publicKeySpki is required (base64 DER SPKI string)';
}
if (deviceFingerprint !== undefined &&
(typeof deviceFingerprint !== 'string' || deviceFingerprint.length > 255)) {
return 'deviceFingerprint must be a string (max 255 chars)';
}
return null;
}
// ---------------------------------------------------------------------------
// POST /api/enrollments
// ---------------------------------------------------------------------------
router.post('/', (req, res) => {
const { enrollmentKey, machineName, deviceFingerprint, publicKeySpki } = req.body ?? {};
const validationError = validateEnrollBody(req.body);
if (validationError) {
return res.status(400).json({ error: validationError });
}
const db = getDb();
let matchedToken = null;
if (config.authEnabled) {
if (!enrollmentKey || typeof enrollmentKey !== 'string') {
logger.warn({ machineName, reason: 'missing_token' }, 'Enrollment rejected');
return res.status(403).json({ error: 'Invalid or missing enrollment key' });
}
const tokens = db.prepare(
'SELECT token, expires_at, max_uses, used_count FROM enrollment_tokens WHERE revoked_at IS NULL'
).all();
const providedBuf = Buffer.from(enrollmentKey);
// Compare against every token without short-circuiting to avoid timing oracle
for (const t of tokens) {
const storedBuf = Buffer.from(t.token);
const maxLen = Math.max(providedBuf.length, storedBuf.length);
const a = Buffer.alloc(maxLen);
const b = Buffer.alloc(maxLen);
providedBuf.copy(a);
storedBuf.copy(b);
if (crypto.timingSafeEqual(a, b)) {
matchedToken = t;
// Continue loop — do not break early
}
}
if (!matchedToken) {
logger.warn({ machineName, reason: 'invalid_token' }, 'Enrollment rejected');
return res.status(403).json({ error: 'Invalid or missing enrollment key' });
}
if (matchedToken.expires_at && new Date(matchedToken.expires_at) < new Date()) {
logger.warn({ machineName, reason: 'token_expired' }, 'Enrollment rejected');
return res.status(403).json({ error: 'Enrollment token has expired' });
}
if (matchedToken.max_uses !== null && matchedToken.used_count >= matchedToken.max_uses) {
logger.warn({ machineName, reason: 'token_max_uses' }, 'Enrollment rejected');
return res.status(403).json({ error: 'Enrollment token usage limit reached' });
}
}
// Validate the public key before storing anything
try {
const key = crypto.createPublicKey({
key: Buffer.from(publicKeySpki, 'base64'),
format: 'der',
type: 'spki',
});
if (key.asymmetricKeyType !== 'ec') {
throw new Error('Not an EC key');
}
} catch {
return res.status(400).json({ error: 'Invalid public key — must be ECDSA P-256 SubjectPublicKeyInfo (DER, base64)' });
}
const sanitizedName = machineName.trim().slice(0, 255);
const sanitizedFp = deviceFingerprint?.trim().slice(0, 255) ?? null;
const now = new Date().toISOString();
// Re-enrollment: same fingerprint → rotate public key, preserve deviceId
if (sanitizedFp) {
const existing = db.prepare('SELECT device_id FROM devices WHERE device_fingerprint = ?').get(sanitizedFp);
if (existing) {
db.prepare(
'UPDATE devices SET public_key_spki = ?, machine_name = ?, last_seen_at = ? WHERE device_id = ?'
).run(publicKeySpki, sanitizedName, now, existing.device_id);
if (matchedToken) {
db.prepare('UPDATE enrollment_tokens SET used_count = used_count + 1 WHERE token = ?')
.run(matchedToken.token);
}
logger.info({ deviceId: existing.device_id, machineName: sanitizedName }, 'Device re-enrolled');
return res.status(200).json({ deviceId: existing.device_id, message: 'Re-enrollment successful' });
}
}
// New device
const deviceId = crypto.randomUUID();
db.prepare(
'INSERT INTO devices (device_id, machine_name, device_fingerprint, public_key_spki, enrolled_at) VALUES (?, ?, ?, ?, ?)'
).run(deviceId, sanitizedName, sanitizedFp, publicKeySpki, now);
if (matchedToken) {
db.prepare('UPDATE enrollment_tokens SET used_count = used_count + 1 WHERE token = ?')
.run(matchedToken.token);
}
logger.info({ deviceId, machineName: sanitizedName }, 'Device enrolled');
res.status(201).json({ deviceId, message: 'Enrollment successful' });
});
// ---------------------------------------------------------------------------
// GET /api/enrollments — admin-protected device list (backward-compat route)
// ---------------------------------------------------------------------------
router.get('/', requireAdmin, (req, res) => {
const db = getDb();
const devices = db.prepare(
'SELECT device_id, machine_name, device_fingerprint, enrolled_at, last_seen_at, revoked_at FROM devices ORDER BY enrolled_at DESC'
).all();
res.json(devices.map(d => ({
deviceId: d.device_id,
machineName: d.machine_name,
deviceFingerprint: d.device_fingerprint,
enrolledAt: d.enrolled_at,
lastSeenAt: d.last_seen_at,
revoked: !!d.revoked_at,
})));
});
module.exports = router;

View File

@@ -0,0 +1,102 @@
'use strict';
const express = require('express');
const crypto = require('crypto');
const { getDb } = require('../db');
const { requireAuth } = require('../middleware/auth');
const logger = require('../logger');
const router = express.Router();
// ---------------------------------------------------------------------------
// POST /api/scans — ingest a scan payload from an enrolled device
// ---------------------------------------------------------------------------
router.post('/', requireAuth, (req, res) => {
const payload = req.body;
if (typeof payload !== 'object' || payload === null) {
return res.status(400).json({ error: 'Request body must be a JSON object' });
}
if (typeof payload.collector?.machineName !== 'string' || payload.collector.machineName.trim().length === 0) {
return res.status(400).json({ error: 'Invalid payload: missing collector.machineName' });
}
const db = getDb();
const id = crypto.randomUUID();
const now = new Date().toISOString();
const machineName = payload.collector.machineName.trim().slice(0, 255);
const deviceId = req.device?.device_id ?? null;
let collectedAt = null;
try {
collectedAt = payload.collector.collectedAt
? new Date(payload.collector.collectedAt).toISOString()
: null;
} catch { /* leave null */ }
const ranAsAdmin = payload.collector.ranAsAdministrator ? 1 : 0;
const applicableUpdateCount = Array.isArray(payload.windowsUpdate?.applicableUpdates)
? payload.windowsUpdate.applicableUpdates.length
: 0;
const pendingReboot = payload.pendingReboot?.anyPending ? 1 : 0;
db.prepare(`
INSERT INTO scans
(id, device_id, machine_name, collected_at, ran_as_administrator, applicable_update_count, pending_reboot, raw_json, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, deviceId, machineName, collectedAt, ranAsAdmin, applicableUpdateCount, pendingReboot, JSON.stringify(payload), now);
logger.info({ id, machineName, deviceId }, 'Scan ingested');
res.status(201).json({ id });
});
// ---------------------------------------------------------------------------
// GET /api/scans — list scan summaries
// ---------------------------------------------------------------------------
router.get('/', (req, res) => {
const db = getDb();
const rows = db.prepare(`
SELECT id, device_id, machine_name, collected_at, ran_as_administrator,
applicable_update_count, pending_reboot, received_at
FROM scans
ORDER BY received_at DESC
`).all();
res.json(rows.map(s => ({
id: s.id,
deviceId: s.device_id,
machineName: s.machine_name,
collectedAt: s.collected_at,
receivedAt: s.received_at,
ranAsAdministrator: s.ran_as_administrator === 1,
applicableUpdateCount: s.applicable_update_count,
pendingReboot: s.pending_reboot === 1,
})));
});
// ---------------------------------------------------------------------------
// GET /api/scans/:id — return full scan payload
// ---------------------------------------------------------------------------
router.get('/:id', (req, res) => {
const db = getDb();
const scan = db.prepare('SELECT raw_json FROM scans WHERE id = ?').get(req.params.id);
if (!scan) return res.status(404).json({ error: 'Not found' });
// Send raw stored JSON without re-parsing to preserve exact byte representation
res.set('Content-Type', 'application/json').send(scan.raw_json);
});
// ---------------------------------------------------------------------------
// DELETE /api/scans/:id
// ---------------------------------------------------------------------------
router.delete('/:id', (req, res) => {
const db = getDb();
const result = db.prepare('DELETE FROM scans WHERE id = ?').run(req.params.id);
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
res.status(204).end();
});
module.exports = router;