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
7 changes: 5 additions & 2 deletions .github/actions/build-contracts/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,14 @@ runs:
- name: Build contracts (modular)
shell: bash
working-directory: contracts
env:
INPUT_CONTRACTS: ${{ inputs.contracts }}
INPUT_FEATURES: ${{ inputs.features }}
run: |
set -euo pipefail

CONTRACTS="${{ inputs.contracts }}"
FEATURES="${{ inputs.features }}"
CONTRACTS="$INPUT_CONTRACTS"
FEATURES="$INPUT_FEATURES"
TARGET="wasm32v1-none"

# Builds are never masked. A failing contract must fail the job so a
Expand Down
9 changes: 6 additions & 3 deletions .github/workflows/api-changelog.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,17 @@ jobs:
# ── Push / manual mode: update the changelog file and commit ────────────
- name: Generate changelog and update file (push to main / manual)
if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
env:
INPUT_VERSION: ${{ github.event.inputs.version }}
INPUT_BREAKING_GATE: ${{ github.event.inputs.breaking_gate }}
run: |
VERSION_ARG=""
if [ -n "${{ github.event.inputs.version }}" ]; then
VERSION_ARG="--version ${{ github.event.inputs.version }}"
if [ -n "$INPUT_VERSION" ]; then
VERSION_ARG="--version $INPUT_VERSION"
fi

BREAKING_ARG=""
if [ "${{ github.event.inputs.breaking_gate }}" = "true" ]; then
if [ "$INPUT_BREAKING_GATE" = "true" ]; then
BREAKING_ARG="--breaking"
fi

Expand Down
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -462,11 +462,11 @@ jobs:
]
steps:
- name: Checkout code
uses: actions/checkout@v7
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Setup Node.js
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: ${{ env.NODE_VERSION }}

Expand Down
23 changes: 23 additions & 0 deletions .semgrep/semgrep-suppressions.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,26 @@ accepted_suppressions:
legitimate delivery as a duplicate.
reviewed_by: security review of PR #1703
expires: 2027-04-16

# backend/services/shared/poolMonitor.ts — SET statement_timeout
# PostgreSQL SET commands do not support parameterized values; the timeout
# is an integer-cast config field, not user input.
- rule_id: semgrep.subtrackr.sql.unparameterized-query
path: backend/services/shared/poolMonitor.ts
reason: >-
SET statement_timeout requires a numeric literal; parameterised queries
are unsupported for SET commands. Value is explicitly cast to a safe
integer from a validated config field (queryTimeoutMs), not user input.
reviewed_by: security fix in PR #1842
expires: 2027-10-19

# backend/shared/db/connectionPool.ts — SET statement_timeout
# Same as poolMonitor.ts above.
- rule_id: semgrep.subtrackr.sql.unparameterized-query
path: backend/shared/db/connectionPool.ts
reason: >-
SET statement_timeout requires a numeric literal; parameterised queries
are unsupported for SET commands. Value is explicitly cast to a safe
integer from a validated config field (statementTimeout), not user input.
reviewed_by: security fix in PR #1842
expires: 2027-10-19
27 changes: 26 additions & 1 deletion backend/analytics/jobs/mvRefreshJob.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ const DEFAULT_VIEWS: ViewConfig[] = [
{ name: 'ltv_mv', intervalMs: 86_400_000, lastRefreshedAt: null, isRefreshing: false },
];

/**
* Pre-built parameterised-safe SQL strings for REFRESH MATERIALIZED VIEW.
* REFRESH MATERIALIZED VIEW is DDL and does not support parameterised identifiers.
* We store one static SQL string per known view so that no runtime string
* interpolation ever appears in a query() call.
*/
const REFRESH_SQL: ReadonlyMap<string, string> = new Map([
['active_subscriptions_summary', 'REFRESH MATERIALIZED VIEW CONCURRENTLY active_subscriptions_summary'],
['subscriber_balance_mv', 'REFRESH MATERIALIZED VIEW CONCURRENTLY subscriber_balance_mv'],
['monthly_revenue_mv', 'REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue_mv'],
['churn_summary_mv', 'REFRESH MATERIALIZED VIEW CONCURRENTLY churn_summary_mv'],
['mrr_mv', 'REFRESH MATERIALIZED VIEW CONCURRENTLY mrr_mv'],
['cohort_retention_mv', 'REFRESH MATERIALIZED VIEW CONCURRENTLY cohort_retention_mv'],
['ltv_mv', 'REFRESH MATERIALIZED VIEW CONCURRENTLY ltv_mv'],
]);

export interface RefreshMetric {
viewName: string;
lastRefreshedAt: Date | null;
Expand Down Expand Up @@ -73,11 +89,20 @@ export class MVRefreshJob {
const view = this.views.find((v) => v.name === viewName);
if (!view || view.isRefreshing) return;

// Look up the pre-built static SQL string for this view.
// REFRESH MATERIALIZED VIEW is DDL with no parameterised-identifier support;
// using a static map ensures no runtime string interpolation reaches query().
const sql = REFRESH_SQL.get(viewName);
if (!sql) {
console.error(`[MVRefreshJob] No refresh SQL registered for view: ${viewName}`);
return;
}

view.isRefreshing = true;
const start = Date.now();

try {
await this.db.query(`REFRESH MATERIALIZED VIEW CONCURRENTLY ${viewName}`);
await this.db.query(sql);
view.lastRefreshedAt = new Date();
console.info(`[MVRefreshJob] Refreshed ${viewName} in ${Date.now() - start}ms`);
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion backend/pci/cardDataHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ export class CardDataHandler {

// Use createDecipheriv — note: in production this would use a HSM
const { createDecipheriv } = require('crypto');
const decipher = createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv);
const decipher = createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv, { authTagLength: 16 });
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return { success: true, pan: decrypted.toString('utf8') };
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export class ColumnEncryptionService {
const authTag = Buffer.from(encrypted.authTag, 'base64');
const ciphertext = Buffer.from(encrypted.ciphertext, 'base64');

const decipher = createDecipheriv('aes-256-gcm', dek, iv);
const decipher = createDecipheriv('aes-256-gcm', dek, iv, { authTagLength: 16 });
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);

Expand Down
2 changes: 1 addition & 1 deletion backend/services/shared/encryption/KmsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class KmsProvider implements IKmsProvider {

const [ciphertextB64, authTagB64] = encrypted.ciphertext.split(':');
const iv = randomBytes(16);
const decipher = createDecipheriv('aes-256-gcm', key.id.padEnd(32, '0').slice(0, 32), iv);
const decipher = createDecipheriv('aes-256-gcm', key.id.padEnd(32, '0').slice(0, 32), iv, { authTagLength: 16 });
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(ciphertextB64, 'base64')), decipher.final()]);
}
Expand Down
2 changes: 1 addition & 1 deletion backend/services/shared/encryption/VaultProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export class VaultProvider implements IKmsProvider {

const [ciphertextB64, authTagB64] = encrypted.ciphertext.split(':');
const iv = randomBytes(16);
const decipher = createDecipheriv('aes-256-gcm', key.slice(0, 32), iv);
const decipher = createDecipheriv('aes-256-gcm', key.slice(0, 32), iv, { authTagLength: 16 });
decipher.setAuthTag(Buffer.from(authTagB64, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(ciphertextB64, 'base64')), decipher.final()]);
}
Expand Down
11 changes: 9 additions & 2 deletions backend/services/shared/poolMonitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,16 @@ export class MonitoredPool implements Pool {
async connect(): Promise<PoolClient> {
const client = await this.inner.connect();

// Inject query timeout on new connections
// Inject query timeout on new connections.
// PostgreSQL SET does not accept parameterised values, but set_config() does.
// We use set_config('statement_timeout', $1, false) so the value is passed
// as a query parameter and never interpolated into SQL.
try {
await client.query(`SET statement_timeout = ${this.cfg.queryTimeoutMs}`);
const timeoutMs = Math.max(0, Math.trunc(Number(this.cfg.queryTimeoutMs)));
await client.query(
`SELECT pg_catalog.set_config('statement_timeout', $1, false)`,
[String(timeoutMs)],
);
} catch {
logger.warn(`[PoolMonitor:${this.cfg.name}] Could not set statement_timeout`);
}
Expand Down
9 changes: 8 additions & 1 deletion backend/shared/db/connectionPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,14 @@ export async function createPool(overrides: Partial<PoolConfig> = {}): Promise<P
(pool as Pool & { connect: () => Promise<PoolClient> }).connect = async () => {
const client = await origConnect();
try {
await client.query(`SET statement_timeout = ${config.statementTimeout ?? 30_000}`);
// PostgreSQL SET does not accept parameterised values, but set_config() does.
// We use set_config('statement_timeout', $1, false) so the value is passed
// as a query parameter and never interpolated into SQL.
const timeoutMs = Math.max(0, Math.trunc(Number(config.statementTimeout ?? 30_000)));
await client.query(
`SELECT pg_catalog.set_config('statement_timeout', $1, false)`,
[String(timeoutMs)],
);
} catch (err) {
console.warn('[DB Pool] Could not set statement_timeout:', err);
}
Expand Down
4 changes: 4 additions & 0 deletions docker/seed.Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,8 @@ RUN npm install --omit=dev --legacy-peer-deps && npm cache clean --force
COPY scripts/seed-local.js ./scripts/seed-local.js
COPY db/migrations/ ./db/migrations/

# Run as non-root user to reduce container attack surface
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

CMD ["node", "scripts/seed-local.js"]
4 changes: 4 additions & 0 deletions ml-service/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,9 @@ ENV ML_SERVICE_URL=http://localhost:8000
ENV FEATURE_PIPELINE_PATH=/app/services/feature-pipeline
ENV FEATURE_STORE_URL=redis://feature-store:6379/0

# Run as non-root user to reduce container attack surface
RUN useradd -m -u 1001 appuser
USER appuser

EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
4 changes: 4 additions & 0 deletions ml-service/onnx-serving/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ COPY onnx-serving/server.py ./server.py
ENV PYTHONUNBUFFERED=1
ENV MODEL_DIR=/app/models

# Run as non-root user to reduce container attack surface
RUN useradd -m -u 1001 appuser
USER appuser

EXPOSE 8000

CMD ["python", "server.py", "--port", "8000", "--model-dir", "/app/models"]
4 changes: 4 additions & 0 deletions services/feature-pipeline/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@ COPY services/feature-pipeline .

ENV FEATURE_STORE_URL=redis://feature-store:6379/0

# Run as non-root user to reduce container attack surface
RUN useradd -m -u 1001 appuser
USER appuser

EXPOSE 8010
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8010"]
4 changes: 4 additions & 0 deletions services/notification/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,9 @@ COPY --from=builder /app/services/notification/dist ./dist
COPY --from=builder /app/services/notification/package.json ./package.json
COPY --from=builder /app/node_modules ./node_modules

# Run as non-root user to reduce container attack surface
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

EXPOSE 3000
CMD ["node", "dist/index.js"]
Loading