Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/codeql/codeql-config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: TransTrack CodeQL configuration

# Why this config exists
# ----------------------
# CodeQL's `js/clear-text-logging` rule (correctly) errs on the side of
# caution and flags any path where a variable whose name matches a
# password-shaped pattern (e.g. `PW`, `password`, `secret`, `token`) can
# flow into a `console.error` / `console.log`. Several of our top-level
# devtool/test scripts construct deliberately-fake credentials with
# names like `PW = 'Smoke-Test-Pw-' + Date.now()` and then `console.error(e)`
# the resulting error envelope on failure. These scripts:
#
# * are NOT shipped in the Electron binary or the Fastify server image;
# * are NOT executed in production;
# * never receive real PHI / real credentials;
# * exist solely so a developer or CI runner can drive a fresh stack
# end-to-end against a throwaway database.
#
# We therefore exclude them from CodeQL analysis. Production code paths
# under `electron/`, `server/`, and `src/` continue to be analysed
# normally (CodeQL's default behaviour scans everything that isn't
# explicitly ignored, so omitting a `paths:` allow-list is intentional).
#
# To re-include a script for analysis (e.g. if it ever starts touching
# real credentials), remove it from `paths-ignore` below.

paths-ignore:
# E2E smoke / sandbox harnesses. Not shipped, never run in production.
- 'scripts/smoke-test.mjs'
- 'scripts/epic-sandbox-test.mjs'
# Standard noise we never want analysed
- '**/node_modules'
- 'dist'
- 'dist-electron'
- 'release'
- '**/__snapshots__'
4 changes: 4 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ jobs:
with:
languages: javascript-typescript
build-mode: none
# See .github/codeql/codeql-config.yml for path filters and
# rationale (notably: dev-only smoke/sandbox scripts that
# construct fake credentials are intentionally excluded).
config-file: ./.github/codeql/codeql-config.yml

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
Expand Down
30 changes: 27 additions & 3 deletions electron/database/init.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -681,21 +681,45 @@ async function seedDefaultData(defaultOrgId) {
);

// First-launch banner — printed in every environment, not just dev, so a
// production operator installing the MSI/DMG can see the token once.
// production operator installing the MSI/DMG can see WHERE to retrieve
// the one-time setup token. The token value itself is NOT echoed to
// stdout by default — operators must read it from the protected
// setup-token file (mode 0o600) that we wrote above. This protects
// against accidental capture by terminal recorders, screen-shares,
// CI logs, and journalctl.
//
// For locked-down environments where the file cannot be written
// (read-only userData on a kiosk) we still need to surface the token
// somewhere; in that case it is emitted on stderr only when the
// operator explicitly opts in via TRANSTRACK_ECHO_SETUP_TOKEN=1, and
// we print a loud reminder that they must rotate it after first use.
//
// Closes CodeQL alerts js/clear-text-logging at this site.
const echoTokenOptIn = process.env.TRANSTRACK_ECHO_SETUP_TOKEN === '1';
console.log('');
console.log('================================================================');
console.log(' TransTrack — first-launch administrator setup');
console.log('================================================================');
console.log(' Account : admin@transtrack.local');
console.log(' Source : ' + passwordSource);
if (setupTokenFilePath) {
console.log(' Token : ' + defaultPassword);
console.log(' Token : (written to setup-token file; not echoed)');
console.log(' File : ' + setupTokenFilePath);
console.log(' (mode 0o600 on POSIX; ACL inherited on Windows)');
} else if (envPassword) {
console.log(' Token : (supplied by env; not echoed)');
} else if (echoTokenOptIn) {
console.error(
' Token : (echoed because TRANSTRACK_ECHO_SETUP_TOKEN=1 is set; ROTATE AFTER FIRST USE)'
);
// Use stderr separately so the actual token is never on the same
// stream as the banner and is easier to scrub from logs.
process.stderr.write(' Token : ' + defaultPassword + '\n');
} else {
console.log(' Token : ' + defaultPassword);
console.log(' Token : (suppressed — set TRANSTRACK_ECHO_SETUP_TOKEN=1 to print)');
console.log(' (no setup-token file could be written; rerun with the env var');
console.log(' above OR with TRANSTRACK_DEFAULT_PASSWORD=<your-token> to set it');
console.log(' yourself, then rotate at first sign-in.)');
}
console.log(' Must change password on first sign-in: yes');
console.log('================================================================');
Expand Down
22 changes: 22 additions & 0 deletions electron/database/migrations.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,28 @@ const MIGRATIONS = [
`);
},
},
{
version: 10,
name: 'add_siem_verify_tls',
description:
'Per-destination TLS certificate verification toggle (TT-R026, default ON). ' +
'Closes CodeQL js/disabling-certificate-validation: TLS forwarders now ' +
'verify peer certificates by default; disabling requires an explicit ' +
'admin opt-in per destination (e.g. for self-signed dev SIEMs).',
// No rollback — making the column NOT NULL DEFAULT 1 is forward-only.
rollbackSql: null,
up(db) {
const cols = db
.prepare("PRAGMA table_info(siem_destinations)")
.all()
.map((c) => c.name);
if (!cols.includes('verify_tls')) {
db.exec(
'ALTER TABLE siem_destinations ADD COLUMN verify_tls INTEGER NOT NULL DEFAULT 1'
);
}
},
},
];

/**
Expand Down
15 changes: 12 additions & 3 deletions electron/services/mfa.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -147,13 +147,22 @@ function decryptSecret(stored) {
// ---------------- Backup codes ----------------

function generateBackupCodes(count = BACKUP_CODE_COUNT) {
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // exclude ambiguous chars
// Charset is intentionally 32 chars (exact power of two) so that
// crypto.randomInt(0, charset.length) is uniformly distributed without
// any modulo-bias workaround, AND ambiguous characters (0/O, 1/I/L) are
// excluded so users can transcribe codes from a printout.
const charset = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const codes = [];
for (let i = 0; i < count; i++) {
const buf = crypto.randomBytes(BACKUP_CODE_LENGTH);
let s = '';
for (let j = 0; j < BACKUP_CODE_LENGTH; j++) {
s += charset[buf[j] % charset.length];
// crypto.randomInt(min, max) is rejection-sampling under the hood
// and returns a uniformly-distributed integer in [min, max).
// This eliminates the modulo-bias pattern that CodeQL's
// js/biased-cryptographic-random rule (correctly) warns about for
// generic charset.length values, and is also robust if anyone ever
// changes the charset to a non-power-of-two length in the future.
s += charset[crypto.randomInt(0, charset.length)];
}
codes.push(s.slice(0, 5) + '-' + s.slice(5));
}
Expand Down
61 changes: 53 additions & 8 deletions electron/services/siemForwarder.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function getDestination(id, orgId) {
}

function createDestination({ orgId, name, host, port, protocol = 'udp', format = 'cef',
enabled = true, severityFilter = 'all', createdBy }) {
enabled = true, severityFilter = 'all', createdBy, verifyTls = true }) {
if (!orgId) throw new Error('orgId required');
if (!name) throw new Error('name required');
if (!host) throw new Error('host required');
Expand All @@ -54,19 +54,26 @@ function createDestination({ orgId, name, host, port, protocol = 'udp', format =
getDatabase().prepare(`
INSERT INTO siem_destinations (
id, org_id, name, host, port, protocol, format, enabled, severity_filter,
created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`).run(id, orgId, name, host, port, protocol, format, enabled ? 1 : 0, severityFilter, createdBy ?? null);
verify_tls, created_by, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
`).run(
id, orgId, name, host, port, protocol, format,
enabled ? 1 : 0, severityFilter, verifyTls ? 1 : 0,
createdBy ?? null
);
return getDestination(id, orgId);
}

function updateDestination({ id, orgId, fields }) {
const allowed = ['name', 'host', 'port', 'protocol', 'format', 'enabled', 'severity_filter'];
const allowed = [
'name', 'host', 'port', 'protocol', 'format',
'enabled', 'severity_filter', 'verify_tls',
];
const sets = []; const params = [];
for (const k of Object.keys(fields || {})) {
if (allowed.includes(k)) {
let v = fields[k];
if (k === 'enabled') v = v ? 1 : 0;
if (k === 'enabled' || k === 'verify_tls') v = v ? 1 : 0;
sets.push(`${k} = ?`); params.push(v);
}
}
Expand Down Expand Up @@ -129,13 +136,30 @@ function toJson(record) {
});
}

// RFC 5424 §6.3.3: inside a structured-data PARAM-VALUE the only chars that
// must be escaped are '"', '\', and ']'. Each must be preceded by a single
// backslash. Order matters: escape backslash FIRST so we don't double-escape
// the backslashes we're about to introduce.
function escapeSdParamValue(value) {
return String(value ?? '')
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/]/g, '\\]')
.replace(/[\r\n]+/g, ' ');
}

function toRfc5424(record) {
const pri = 14; // facility=user (1), severity=informational (6) → 1*8+6=14
const ts = new Date(record.created_at).toISOString();
const app = 'transtrack';
const procid = process.pid;
const msgid = String(record.action || 'audit').slice(0, 32);
const sd = `[transtrack@53914 org="${(record.org_id || '').replace(/"/g, '\\"')}" user="${(record.user_email || '').replace(/"/g, '\\"')}" entity="${(record.entity_type || '')}" id="${(record.entity_id || '')}"]`;
const sd =
'[transtrack@53914' +
` org="${escapeSdParamValue(record.org_id)}"` +
` user="${escapeSdParamValue(record.user_email)}"` +
` entity="${escapeSdParamValue(record.entity_type)}"` +
` id="${escapeSdParamValue(record.entity_id)}"]`;
const msg = String(record.details || '').replace(/[\r\n]+/g, ' ');
return `<${pri}>1 ${ts} ${HOSTNAME} ${app} ${procid} ${msgid} ${sd} ${msg}`;
}
Expand Down Expand Up @@ -198,7 +222,28 @@ function ensureSocket(dest, st) {
sock.on('close', () => { st.socket = null; });
st.socket = sock;
} else if (dest.protocol === 'tls') {
const sock = tls.connect({ host: dest.host, port: dest.port, rejectUnauthorized: false });
// Default: verify peer certificate (rejectUnauthorized = true). The
// operator may explicitly opt out per destination by setting
// verify_tls = 0 (e.g. for an internal SIEM with a self-signed cert
// during pilot bring-up). When opted out, we annotate the failure log
// so the operator is reminded the destination is unverified.
// Closes CodeQL alert js/disabling-certificate-validation.
const verify = dest.verify_tls === undefined || dest.verify_tls === null
? true
: Number(dest.verify_tls) === 1;
const sock = tls.connect({
host: dest.host,
port: dest.port,
rejectUnauthorized: verify,
servername: dest.host,
minVersion: 'TLSv1.2',
});
if (!verify) {
recordFailure(
dest.id,
'WARNING: TLS peer-certificate verification disabled by operator (verify_tls=0).'
);
}
sock.on('error', (err) => { recordFailure(dest.id, err.message); try { sock.destroy(); } catch {} st.socket = null; });
sock.on('close', () => { st.socket = null; });
st.socket = sock;
Expand Down
Loading
Loading