Skip to content

feat: add comprehensive security hardening - #19

Merged
nazozokc merged 4 commits into
mainfrom
AI-agent
Jun 21, 2026
Merged

feat: add comprehensive security hardening#19
nazozokc merged 4 commits into
mainfrom
AI-agent

Conversation

@nazozokc

@nazozokc nazozokc commented Jun 21, 2026

Copy link
Copy Markdown
Owner

Summary

Comprehensive security hardening for subtrack across all risk categories.

Data-at-Rest (P1)

  • DB encryption: AES-256-GCM encryption via crypto.ts. Auto-generates key on first use (0o600). Supports SUBSC_CLI_DB_PASSPHRASE for passphrase-based key derivation via scrypt.
  • File permissions: DB files 0o600, directories 0o700, exclusive-create for backups (O_EXCL).
  • Backup integrity: SHA256 sidecar files (.sha256) with verification on restore.
  • Key backup warning: Logs warning on first key creation with recovery instructions.

Network (P1)

  • Fetch timeouts: 10s for FX API, 15s for pricing APIs via AbortController.
  • Prototype pollution prevention: safe-json.ts strips __proto__, constructor, prototype from parsed JSON.

Input Validation (P2)

  • CSV injection prevention: Prefixes =, +, -, @ with \t in CSV export.
  • Currency validation: Checks against known currency list.
  • CSV import size limit: 10MB max.
  • Error sanitization: No stack traces in error messages (use String(e) instead of e or template literal).

Concurrency & Integrity (P1-3, P2-2, P2-6)

  • File locking: O_EXCL lock file (.subtrack.lock with PID) prevents concurrent instances.
  • PRAGMA secure_delete = ON: Ensures deleted data is zeroed.
  • PRAGMA integrity_check: Validates DB integrity on startup.

Supply Chain

  • CI: Generates npm-shrinkwrap.json for reproducible installs. Uses npm publish for provenance attestation.
  • Audit script: lint:security runs pnpm audit --audit-level=high.

Signal Handling (P1-2)

  • SIGINT/SIGTERM handlers in index.ts call saveDb() before exit.

Directory Validation (P3-1)

  • SUBSC_CLI_DB_DIR rejects system directories (/, /etc, /dev, /proc, /sys, /tmp).

Testing

  • 203 tests passing (5 test files)
  • 12 new tests for crypto, db, and commands
  • All existing tests unaffected (in-memory __setDb bypasses file I/O changes)

Closes: subtrack-commit

Summary by CodeRabbit

  • New Features

    • Added encrypted backup support with --encrypt option
    • Added backup integrity verification using SHA256 hash validation
  • Security & Improvements

    • Enhanced CSV export to prevent injection attacks
    • Improved Markdown table formatting and escaping
    • Added request timeouts for network operations
    • Enforced CSV file size limits (10 MB)
    • Hardened file permissions for backups and encryption keys
    • Enhanced currency validation
    • Added prototype pollution protection in JSON parsing

@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@nazozokc, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 27 minutes and 36 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5440cd91-ea47-4d6c-9abe-aaa72693ec73

📥 Commits

Reviewing files that changed from the base of the PR and between 73af25a and c23b3ef.

📒 Files selected for processing (9)
  • .github/workflows/release.yml
  • subtrack/src/commands.test.ts
  • subtrack/src/commands.ts
  • subtrack/src/crypto.test.ts
  • subtrack/src/crypto.ts
  • subtrack/src/db.ts
  • subtrack/src/export.ts
  • subtrack/src/fx.ts
  • subtrack/src/import-csv.ts
📝 Walkthrough

Walkthrough

This PR adds AES-256-GCM encrypted backup support with a new crypto.ts module, SHA-256 sidecar hash verification for backup integrity, a filesystem lock and PRAGMA integrity_check for database safety, prototype-pollution-safe JSON parsing with fetch timeouts, CSV and Markdown injection prevention, CSV file-size limits, stricter currency validation, and release pipeline changes to generate npm-shrinkwrap.json and publish via npm.

Changes

Security Hardening

Layer / File(s) Summary
Safe JSON parsing and fetch timeouts
subtrack/src/safe-json.ts, subtrack/src/pricing.ts, subtrack/src/fx.ts
Adds safeJsonParse/safeResponseJson with stripProtoKeys to prevent prototype pollution. Updates pricing.ts and fx.ts to use AbortController-based timeouts (15 s and 10 s) and route response bodies through safeJsonParse.
AES-256-GCM crypto module
subtrack/src/crypto.ts, subtrack/src/crypto.test.ts
Introduces key management (passphrase-derived via scrypt or random persisted key at 0o600), encryptBuffer/decryptBuffer with random IV, isEncrypted SQLite-header detection, and hasEncryptionKey. Tests cover round-trip correctness, non-determinism, detection logic, key-file permissions, and passphrase-based key loading.
DB encryption, locking, and integrity check
subtrack/src/db.ts
Adds validateDbDir, a filesystem lock (.subtrack.lock) with process-exit/signal cleanup, encrypts on saveDb, decrypts on getDb, creates DB directory at 0o700, enables PRAGMA secure_delete, and runs PRAGMA integrity_check on startup.
Restore pipeline, .db.enc discovery, and backup hash sidecar
subtrack/src/db.ts, subtrack/src/db.test.ts
Expands backup discovery to .db.enc, reworks restoreDb to decrypt-first with fallback then decompress by extension or magic bytes, and persists restored DB encrypted at 0o600. Adds getBackupHashPath, writeBackupHash, verifyBackupHash with backward-compatible absent-sidecar behavior. Tests verify path naming, round-trip hash write/verify, tamper detection, and legacy backup compatibility.
Encrypted backup command and restore hash verification
subtrack/src/commands.ts, subtrack/src/index.ts
Extends handleBackup to accept options.encrypt, write .db.enc at 0o600, and record hash sidecar. Integrates verifyBackupHash in both non-interactive and interactive restore paths with warn-and-confirm on mismatch. Adds --encrypt/-e CLI flag and SIGINT/SIGTERM saveDb shutdown handlers.
CSV and Markdown injection prevention
subtrack/src/export.ts, subtrack/src/commands.test.ts, subtrack/src/commands.ts
Prefixes formula-like CSV leading characters with \t in escapeCsv. Adds escapeMdCell to escape pipe characters and strip newlines from exportMd cell values. Tests assert formula-prefix escaping and empty-field formatting. Error logging in handleAdd, handleExport, and handleTagRename converted to String(e).
Input validation: CSV size limit and currency allowlist
subtrack/src/import-csv.ts, subtrack/src/prompts.ts
Adds a 10 MB MAX_CSV_SIZE pre-check in importCsv using statSync. Tightens isValidCurrency to require membership in CURRENCY_CHOICES beyond the regex pattern.
Release pipeline: shrinkwrap and npm publish
.github/workflows/release.yml, subtrack/package.json
Adds a step to generate npm-shrinkwrap.json before publishing, switches publish command to npm publish --provenance --access public, and adds lint:security script (pnpm audit --audit-level=high).

Sequence Diagram(s)

sequenceDiagram
  rect rgba(173, 216, 230, 0.5)
    note over CLI,disk: Encrypted Backup Creation
    participant CLI as index.ts (--encrypt)
    participant handleBackup
    participant gzipSync
    participant encryptBuffer
    participant disk
    CLI->>handleBackup: destination, {encrypt: true}
    handleBackup->>gzipSync: compress SQLite export buffer
    gzipSync-->>handleBackup: compressed bytes
    handleBackup->>encryptBuffer: encrypt compressed bytes
    encryptBuffer-->>handleBackup: iv+tag+ciphertext
    handleBackup->>disk: openSync .db.enc (0o600, exclusive)
    handleBackup->>disk: writeBackupHash → .sha256 sidecar
  end
  rect rgba(144, 238, 144, 0.5)
    note over handleRestore,disk: Restore with Integrity Verification
    participant handleRestore
    participant verifyBackupHash
    participant decryptBuffer
    participant gunzipSync
    handleRestore->>verifyBackupHash: check .sha256 sidecar
    verifyBackupHash-->>handleRestore: match or mismatch
    handleRestore->>user: warn + confirm if mismatch
    handleRestore->>decryptBuffer: decrypt if isEncrypted
    decryptBuffer-->>handleRestore: compressed or raw bytes
    handleRestore->>gunzipSync: decompress if .gz or magic bytes
    handleRestore->>disk: write restored DB encrypted (0o600)
  end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • nazozokc/subtrack#6: The backup command introduced there is directly extended here with options.encrypt, SHA-256 sidecar hashing, and permission hardening in handleBackup and restoreDb.
  • nazozokc/subtrack#12: The export command and escapeCsv logic from that PR are the direct targets of the CSV injection prevention and Markdown escaping changes in this PR.
  • nazozokc/subtrack#16: Both PRs modify .github/workflows/release.yml to add or adjust pnpm audit --audit-level=high as a release gate.

Poem

🐇 A rabbit hops through encrypted fields,
Where lock files guard what the database yields.
No formula tricks shall pass through my CSV,
Each JSON is scrubbed of __proto__ debris.
With shrinkwrap and hashes and timeouts in place,
This bunny has hardened the whole codebase! 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: add comprehensive security hardening' directly summarizes the main change across the PR, which implements encryption, network timeouts, input validation, file locking, and other security measures.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch AI-agent

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🧹 Nitpick comments (2)
subtrack/src/crypto.ts (1)

77-85: ⚡ Quick win

Add minimum length validation before slicing ciphertext.

If ciphertext is shorter than IV_LENGTH + TAG_LENGTH (32 bytes), the subarray calls will produce empty or incomplete buffers, leading to cryptic errors from the crypto module. A guard would provide a clearer error message.

🛡️ Suggested validation
 export function decryptBuffer(ciphertext: Buffer): Buffer {
+  const minLength = IV_LENGTH + TAG_LENGTH
+  if (ciphertext.length < minLength) {
+    throw new Error(`Ciphertext too short: expected at least ${minLength} bytes`)
+  }
   const key = getOrCreateKey()
   const iv = ciphertext.subarray(0, IV_LENGTH)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@subtrack/src/crypto.ts` around lines 77 - 85, The decryptBuffer function does
not validate that the ciphertext parameter has the minimum required length
before slicing it with subarray calls. If ciphertext is shorter than IV_LENGTH +
TAG_LENGTH (32 bytes), the subarray operations will produce empty or incomplete
buffers, leading to cryptic errors from the crypto module. Add a guard clause at
the beginning of the decryptBuffer function that checks if ciphertext.length is
less than the required minimum (IV_LENGTH + TAG_LENGTH) and throws an Error with
a clear descriptive message indicating the minimum required length before
proceeding with the iv, tag, and data subarray operations.
subtrack/src/commands.ts (1)

7-8: Remove unused import: gunzipSync is imported but not used in this file.

gunzipSync is imported from node:zlib but only gzipSync is used in commands.ts (lines 238, 405). Decompression happens in db.ts's restoreDb function.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@subtrack/src/commands.ts` around lines 7 - 8, Remove the unused import
`gunzipSync` from the import statement at the top of the file where you import
from "node:zlib". Keep only `gzipSync` in that import since it is the only
function from that module that is actually used in commands.ts (gzipSync is used
in the compression operations on lines 238 and 405). The decompression operation
using gunzipSync occurs in a different file (db.ts) in the restoreDb function.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Around line 58-60: The "Audit dependencies" step is currently missing a
working-directory specification, causing it to audit the root lockfile instead
of the ./subtrack package. Add working-directory: ./subtrack to the audit step
to match the configuration of the other steps in this workflow (install, build,
shrinkwrap, publish) and ensure the audit correctly targets the subtrack package
being published.
- Around line 52-57: The workflow currently uses pnpm install --frozen-lockfile
to install dependencies during build and test, but then generates the
npm-shrinkwrap.json using npm install --package-lock-only, which can produce
different dependency resolution results. To fix this, either generate the
shrinkwrap directly from pnpm's lockfile instead of running npm install
--package-lock-only, or alternatively switch the entire workflow to use npm
consistently throughout for building, testing, and generating the shrinkwrap to
ensure the reproducibility guarantee is met with a single package manager's
resolution algorithm.

In `@subtrack/src/commands.test.ts`:
- Around line 375-396: The test "handleExport escapes CSV injection vectors in
name" is missing a test case for the tab character prefix that the production
regex includes as a dangerous CSV injection vector. Add a new writeSubscription
call with a name starting with a tab character (such as "\tTab"), and add
corresponding expect statements to verify that this tab-prefixed name is also
safely escaped with a backslash prefix in the output, consistent with how the
other dangerous prefixes (=, +, -, @) are being tested.

In `@subtrack/src/crypto.test.ts`:
- Around line 72-82: The test "key file is created with restricted permissions
(0o600)" is failing on Windows because Unix-style file permissions are not
enforced the same way on Windows systems. Skip this test when running on Windows
by checking if process.platform is "win32" at the start of the test function,
and either return early or use a conditional skip mechanism to prevent the test
from running on that platform. The rest of the test logic remains the same.

In `@subtrack/src/db.ts`:
- Around line 56-97: In the acquireLock function, when the EEXIST error is
caught (indicating another instance already holds the lock), the code currently
logs a warning but allows execution to continue, enabling concurrent access.
Replace the warning behavior with either throwing an error or calling
process.exit() to prevent the application from continuing when a lock already
exists. This ensures only one subtrack instance can run at a time and prevents
database corruption from concurrent access.

In `@subtrack/src/export.ts`:
- Around line 35-38: The escapeMdCell function currently escapes pipe characters
and newlines but does not handle carriage return characters, which can still
disrupt Markdown table formatting when processing CRLF-originated content.
Update the replace chain in escapeMdCell to also escape carriage return
characters (\r) to spaces, alongside the existing newline character handling,
ensuring both \r and \n are normalized in the same pass.

In `@subtrack/src/fx.ts`:
- Line 8: The exported function fetchFxRates is missing JSDoc documentation. Add
a JSDoc comment block above the fetchFxRates function declaration that documents
the function's purpose, return type, and any parameters. The JSDoc should
clearly describe what the function does, what it returns (Promise<FxRates>), and
provide relevant details for developers using this public API.
- Around line 12-20: Replace the `return res.json() as Promise<FxRates>`
statement in the fetchFxRates function with `return await
safeResponseJson<FxRates>(res)` to ensure response body parsing is awaited and
completes within the timeout window before the finally block clears the timer.
Additionally, add JSDoc documentation to the exported fetchFxRates function that
describes its purpose, parameters, and return type to meet public API
documentation standards.

In `@subtrack/src/import-csv.ts`:
- Around line 56-64: The statSync and readFileSync operations in the import-csv
file are not protected with error handling, which can cause the import command
to crash if file permissions are denied or if a race condition occurs during
file access. Wrap both the statSync(file) call and the readFileSync(file,
"utf-8") call in a try-catch block that catches any thrown errors and uses
consola.error to log the error message before returning, ensuring a controlled
CLI error response instead of an abrupt failure.

In `@subtrack/src/index.ts`:
- Around line 272-283: The signal handlers registered in db.ts during module
initialization (at lines 96-97) execute before the handleSignal function defined
here and call process.exit(0) before the saveDb() call in this file can run. To
fix this, either modify the signal handlers in db.ts to call saveDb() before
releaseLock() and process.exit(0), or consolidate all signal handling logic into
a single location in index.ts after CLI setup completes, removing the duplicate
handler registrations from db.ts. This ensures saveDb() is guaranteed to execute
during shutdown before the process terminates.

---

Nitpick comments:
In `@subtrack/src/commands.ts`:
- Around line 7-8: Remove the unused import `gunzipSync` from the import
statement at the top of the file where you import from "node:zlib". Keep only
`gzipSync` in that import since it is the only function from that module that is
actually used in commands.ts (gzipSync is used in the compression operations on
lines 238 and 405). The decompression operation using gunzipSync occurs in a
different file (db.ts) in the restoreDb function.

In `@subtrack/src/crypto.ts`:
- Around line 77-85: The decryptBuffer function does not validate that the
ciphertext parameter has the minimum required length before slicing it with
subarray calls. If ciphertext is shorter than IV_LENGTH + TAG_LENGTH (32 bytes),
the subarray operations will produce empty or incomplete buffers, leading to
cryptic errors from the crypto module. Add a guard clause at the beginning of
the decryptBuffer function that checks if ciphertext.length is less than the
required minimum (IV_LENGTH + TAG_LENGTH) and throws an Error with a clear
descriptive message indicating the minimum required length before proceeding
with the iv, tag, and data subarray operations.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c1e2e70f-d3e2-4e25-93f2-c0271c91de37

📥 Commits

Reviewing files that changed from the base of the PR and between 86f81c6 and 73af25a.

📒 Files selected for processing (15)
  • .github/workflows/release.yml
  • subtrack/package.json
  • subtrack/src/commands.test.ts
  • subtrack/src/commands.ts
  • subtrack/src/crypto.test.ts
  • subtrack/src/crypto.ts
  • subtrack/src/db.test.ts
  • subtrack/src/db.ts
  • subtrack/src/export.ts
  • subtrack/src/fx.ts
  • subtrack/src/import-csv.ts
  • subtrack/src/index.ts
  • subtrack/src/pricing.ts
  • subtrack/src/prompts.ts
  • subtrack/src/safe-json.ts

Comment thread .github/workflows/release.yml Outdated
Comment thread .github/workflows/release.yml
Comment thread subtrack/src/commands.test.ts
Comment thread subtrack/src/crypto.test.ts
Comment thread subtrack/src/db.ts Outdated
Comment thread subtrack/src/export.ts
Comment thread subtrack/src/fx.ts
Comment thread subtrack/src/fx.ts
Comment thread subtrack/src/import-csv.ts Outdated
Comment thread subtrack/src/index.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant