Skip to content
published Visibility internal Owner erik@uvilo.com Approver _ Created _ Updated _

Security review — uvilo-mono — 2026-07-30

Summary

The most urgent issue is a set of production credentials committed to git history in a deleted .env.test file — Neon database URLs (including production), OpenAI API key, SendGrid key, Google OAuth secrets, Sentry tokens, and more. These must be rotated immediately; deleting the file did not remove them from the commit history. A close second is a command injection vulnerability in the audio transcription pipeline, where a user-controllable URL extension flows into a shell command via execSync. A HIGH-severity advisory in better-auth (the authentication library) is also unpatched. The Dockerfile runs as root.

Coverage

ScannerStatusFindings
Semgrep✅ Ran (p/security-audit, p/owasp-top-ten, p/secrets)27 results
Trivy (filesystem)✅ Ran (vuln, secret, misconfig)6 results
Trivy (SBOM)✅ Ran (CycloneDX)SBOM generated
Gitleaks✅ Ran (full git history)18 results
Checkov✅ Ran (IaC, Dockerfile, GitHub Actions)5 results
ZAP❌ Skipped — Docker not installed in this environment
Nuclei❌ Skipped — Nuclei not installed in this environment

Not examined: Live/DAST scanning was not performed (ZAP and Nuclei unavailable). A manual review of access control was conducted on the API auth middleware and several representative endpoints, but not a comprehensive endpoint-by-endpoint audit of all ~30 API routes. Android exported-activity findings were noted but not deeply investigated (the mobile apps are outside the primary attack surface for this review).

Findings

[CRITICAL] Production secrets committed to git history

  • Where: apps/uvilo-ai/.env.test — in commits 4b6e240c and 939a4efa (file later deleted, still in history)
  • Category: A03 Software Supply Chain Failures / A05 Injection (credential reuse)
  • What: An attacker with read access to the repo (or anyone if the repo becomes public) can extract live credentials for every external service the application uses. The committed file contains:
    • Neon database URLs for dev, preview, production, and unpooled connections — all with password 0pRivcuJqdG5
    • OpenAI API key (sk-proj-pqfmm...)
    • SendGrid API key (SG.BsDatiBZ...)
    • Google OAuth client secret (GOCSPX-M3znbg...)
    • Google API key (AIzaSyBOykk...)
    • Hugging Face token (hf_iCPruciy...)
    • OctoAI JWT access token
    • Sentry auth token (63e0eda2...)
    • Tavily API key (tvly-dev-2MqP0...)
    • Cloudinary API key + secret
    • NextAuth secret and RESET_TOKEN_SECRET
  • Why it’s real: Gitleaks scans full git history, and these secrets are in commits that are accessible via git show. The .env.test file is now in .gitignore and no longer tracked, but the commits remain. Per the triage reference: removing the file does not un-leak the credentials — the secret is in the history and must be rotated.
  • Fix:
    1. Rotate every credential listed above immediately. This is not optional — the credentials exist in git history and must be assumed compromised.
    2. After rotation, consider using git filter-repo or BFG Repo-Cleaner to purge the file from history (this rewrites all commit SHAs and requires force-push + re-clone for all collaborators).
    3. Verify .env.test and all .env* patterns are in .gitignore (already confirmed they are).

[HIGH] Command injection via ffmpeg in audio transcription

  • Where: packages/@erikdakoda/taxonomy/server/transcribeAudio.ts:119

  • Category: A05 Injection

  • What: An attacker who controls the audioUrl can inject arbitrary shell commands via the file extension. The URL is fetched from an RSS feed enclosure URL, the extension is extracted with path.extname(), and the resulting filename is interpolated into a shell command executed by execSync. Inside double quotes, $(...) command substitution is evaluated by the shell.

  • Why it’s real: The call chain is:

    1. Admin adds a podcast RSS feed URL via UploadPodcastButton/api/send-inngest-event (admin-only)
    2. indexPodcastJob.ts fetches the RSS feed and stores enclosureUrl in the database
    3. transcribePodEpisodeJob.ts reads podEpisode.enclosureUrl and passes it to transcribeAudio()
    4. transcribeAudio.ts extracts the file extension:
      const urlPath = new URL(audioUrl).pathname;
      const ext = path.extname(urlPath) || '.mp3';
      const inputFile = path.join(tempDir, `input${ext}`);
    5. The extension is interpolated into a shell command:
      const ffmpegCmd = `ffmpeg -i "${inputFile}" -f segment -segment_time ${chunkDuration} -c copy "${outputPattern}" -y`;
      execSync(ffmpegCmd, { stdio: 'pipe' });
    6. If the enclosure URL is https://evil.com/podcast.mp3$(id), then ext = .mp3$(id), and the shell evaluates $(id) inside the double quotes.

    This requires admin access to add the RSS feed, which lowers the severity from Critical to High. However, a compromised or malicious RSS feed could also change enclosure URLs after an admin has added a legitimate feed.

  • Fix: Avoid execSync with string interpolation. Use the array form execFileSync which does not invoke a shell:

    import { execFileSync } from 'child_process';
    
    execFileSync('ffmpeg', [
      '-i', inputFile,
      '-f', 'segment',
      '-segment_time', String(chunkDuration),
      '-c', 'copy',
      outputPattern,
      '-y',
    ], { stdio: 'pipe' });

    Additionally, sanitize the extension to allow only /\.[a-zA-Z0-9]{1,8}$/ and reject anything else.

[HIGH] Unpatched vulnerability in better-auth (authentication library)

  • Where: pnpm-lock.yamlbetter-auth@1.6.18
  • Category: A07 Authentication Failures
  • What: GHSA-qq9h-g4jm-xgf3 is a HIGH-severity advisory affecting better-auth versions before 1.6.22. The exact impact depends on the advisory details, but since this is the authentication library handling session management and OAuth flows, any vulnerability here could allow authentication bypass or session hijacking.
  • Why it’s real: better-auth is a direct runtime dependency used for all authentication. The installed version (1.6.18) is below the fixed version (1.6.22). This is not a transitive dev dependency — it’s on the critical path for every authenticated request.
  • Fix: Update better-auth to at least 1.6.22 (or latest stable):
    pnpm update better-auth --latest
    Review the advisory at https://github.com/advisories/GHSA-qq9h-g4jm-xgf3 for breaking changes before upgrading.

[HIGH] Dockerfile runs as root

  • Where: Dockerfile.forgentic (no USER instruction)
  • Category: A02 Security Misconfiguration
  • What: The container image has no USER instruction, so the process runs as root inside the container. If an attacker achieves code execution inside the container (e.g., via the command injection above), running as root gives them full container privileges.
  • Why it’s real: Confirmed by both Trivy (DS-0002) and Checkov (CKV_DOCKER_3). The Dockerfile builds a Next.js application and never switches to a non-root user.
  • Fix: Add a non-root user and switch to it:
    RUN groupadd -r appuser && useradd -r -g appuser appuser
    # ... COPY and build steps ...
    USER appuser

[MEDIUM] Font Awesome npm token committed in .npmrc

  • Where: .npmrc:3 — auth token 15518163-6241-446D-9E29-9EC40175304F (also in git history at commits fc8a86b1, 233c77d4, 0eecab06)
  • Category: A03 Software Supply Chain Failures
  • What: The Font Awesome Pro npm registry auth token is committed in plain text. Anyone with repo access can use it to consume the paid Font Awesome Pro license.
  • Why it’s real: The token is in the working tree .npmrc file (not just history) and is a real credential for a paid service. The repo is private, which limits exposure, but the token is still a committed secret.
  • Fix: Rotate the Font Awesome token. Replace the plaintext token in .npmrc with an environment variable reference:
    //npm.fontawesome.com/:_authToken=${FONTAWESOME_TOKEN}
    Set FONTAWESOME_TOKEN in the CI environment (GitHub Actions secrets) and local .env.local.

[MEDIUM] API keys stored in plaintext in the database

  • Where: packages/@erikdakoda/uvilo-mcp-server/api-handlers/GenerateUserApiTokenHandler.ts
  • Category: A04 Cryptographic Failures
  • What: The GenerateUserApiTokenHandler creates API keys using randomBytes(32).toString('hex') and stores the raw key directly in the ApiKey.key column. If the database is compromised, all API keys are immediately usable.
  • Why it’s real: Manual review finding. The key is stored unhashed — db.apiKey.create({ data: { ... key, ... } }) writes the raw token. Best practice is to store a hash (e.g., SHA-256) and compare hashes on lookup, returning the plaintext only once at creation time.
  • Fix: Store a hash of the key, not the key itself:
    import { createHash } from 'node:crypto';
    const key = randomBytes(32).toString('hex');
    const keyHash = createHash('sha256').update(key).digest('hex');
    await db.apiKey.create({ data: { ... keyHash, ... } });
    On lookup, hash the incoming token and compare against the stored hash.

[MEDIUM] GitHub Actions using mutable action tags (supply-chain risk)

  • Where: .github/workflows/ci.yml (8 steps), docker-forgentic-ghcr.yml (6 steps), e2e-full.yml (4 steps), neon-dev-backup.yml (1 step)
  • Category: A03 Software Supply Chain Failures
  • What: 19 GitHub Actions steps reference actions by mutable tag or branch (e.g., @v4, @main) rather than by commit SHA. If an action’s tag is repointed to a malicious commit, the attacker gains code execution in the CI pipeline with access to all repository secrets.
  • Why it’s real: This is the exact attack vector that affected trivy-action and others. Semgrep’s github-actions-mutable-action-tag rule flagged all 19 instances. The workflows handle SENTRY_AUTH_TOKEN, VERCEL_TOKEN, and other secrets.
  • Fix: Pin every third-party action to a full commit SHA:
    # Before
    - uses: actions/checkout@v4
    # After
    - uses: actions/checkout@692973e3d1605a4e6c39b15d4f2f10e8e5e53581 # v4.1.7

[MEDIUM] tar vulnerability (GHSA-r292-9mhp-454m)

  • Where: pnpm-lock.yamltar@7.5.19
  • Category: A03 Software Supply Chain Failures
  • What: Medium-severity advisory in the tar package. Fixed in 7.5.21.
  • Why it’s real: tar is a transitive dependency used in build tooling. The severity is medium, and the vulnerable path may not be reachable in production, but the fix is a trivial version bump.
  • Fix: pnpm update tar to 7.5.21 or later.

[MEDIUM] GitHub Actions workflows missing top-level permissions block

  • Where: .github/workflows/ci.yml, .github/workflows/e2e-full.yml
  • Category: A02 Security Misconfiguration
  • What: Both workflows define permissions at the job level but not at the top level. Without a top-level permissions block, the default token permissions apply, which may be broader than necessary depending on the repository/organization settings. Checkov flagged this as CKV2_GHA_1.
  • Why it’s real: The job-level permissions are properly scoped (contents: read, pull-requests: write), which limits the actual risk. But adding a top-level restrictive default is defence-in-depth.
  • Fix: Add a top-level permissions block to each workflow file:
    permissions:
      contents: read

[LOW] Dockerfile missing HEALTHCHECK instruction

  • Where: Dockerfile.forgentic
  • Category: A02 Security Misconfiguration
  • What: No HEALTHCHECK instruction in the Dockerfile. The container runtime cannot detect a hung process.
  • Why it’s real: Confirmed by both Trivy (DS-0026) and Checkov (CKV_DOCKER_2). This is a hardening issue, not an exploitable vulnerability.
  • Fix: Add a health check:
    HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
      CMD curl -f http://localhost:3000/api/health || exit 1

[LOW] No minimum release age configured for npm/pnpm

  • Where: .npmrc:1, pnpm-workspace.yaml:195
  • Category: A03 Software Supply Chain Failures
  • What: Neither .npmrc nor pnpm-workspace.yaml sets a minimum release age. Freshly published packages are resolved immediately, allowing typo-squatting or compromised-publisher attacks to take effect before they’re discovered.
  • Why it’s real: Semgrep flagged this. It’s a supply-chain hardening measure, not a direct vulnerability.
  • Fix: Add to .npmrc:
    min-release-age = 7
    And to pnpm-workspace.yaml:
    minimumReleaseAge: 10080  # 7 days in minutes

[LOW] pnpm missing trust policy and exotic subdeps block

  • Where: pnpm-workspace.yaml:195
  • Category: A03 Software Supply Chain Failures
  • What: pnpm-workspace.yaml does not set trustPolicy: no-downgrade (prevents malicious package updates from downgrading security settings) or blockExoticSubdeps: true (prevents transitive dependencies from being installed from untrusted sources).
  • Why it’s real: Supply-chain hardening measures flagged by Semgrep. Low severity because they require a specific attack pattern to exploit.
  • Fix: Add to pnpm-workspace.yaml:
    trustPolicy: no-downgrade
    blockExoticSubdeps: true

[LOW] @babel/core and body-parser CVEs

  • Where: pnpm-lock.yaml@babel/core@7.29.0, body-parser@2.2.2
  • Category: A03 Software Supply Chain Failures
  • What: LOW-severity CVEs in @babel/core (CVE-2026-49356, fixed in 7.29.6) and body-parser (CVE-2026-12590, fixed in 2.3.0).
  • Why it’s real: Both are low severity with no known reachable exploit path in this application. @babel/core is a build-time dependency; body-parser is used by Express/Next.js for request parsing.
  • Fix: pnpm update @babel/core body-parser when convenient.

[LOW] Android exported activities

  • Where: apps/forgentic/android/app/src/main/AndroidManifest.xml:12, apps/uvilo-ai/android/app/src/main/AndroidManifest.xml:12
  • Category: A06 Insecure Design
  • What: Both Android apps export activities, allowing any app on the device to launch them.
  • Why it’s real: Flagged by Semgrep. This is a mobile-specific concern and standard for apps that need deep linking, but exported activities should have intent filters or explicit permission requirements.
  • Fix: Review each exported activity and add android:exported="false" where deep linking is not needed, or add permission requirements.

Dismissed

  • TabSecurity.tsx “secrets” (Gitleaks findings 16–18): Three UUIDs in a React UI component (packages/@erikdakoda/user-ui/TabSecurity.tsx:49,55,61) — these are mock API key values displayed in a settings page demo, not real credentials.
  • Dockerfile “Basic Auth Credentials” (Checkov CKV_SECRET_4, Trivy): The postgresql://docker-build:docker-build@127.0.0.1:5432/docker_build URL in Dockerfile.forgentic:43 uses placeholder credentials (docker-build:docker-build) for the Docker build process only. These are not real credentials.
  • .cursor/mcp.json “secrets” (Gitleaks findings 1–2): The file uses environment variable references (${TAVILY_API_KEY}, ${env:UVILO_API_TOKEN}, ${env:GITHUB_PAT}), not actual secret values. Gitleaks’ generic-api-key rule matched the env var syntax.
  • Semgrep exported-activity findings: Kept as LOW above rather than dismissed — they are real findings but low severity for this application’s threat model.
  • Checkov CKV2_GHA_1 for ci.yml and e2e-full.yml: Kept as MEDIUM above — the job-level permissions are scoped, but the missing top-level block is a valid hardening recommendation.

Manual review notes

Access control

The authenticateExecution middleware (packages/@erikdakoda/execution/server/authenticateExecution.ts) is used by most API endpoints. It has a CRON_SECRET bypass: if the Authorization header matches Bearer ${process.env.CRON_SECRET}, the caller is returned as adminUser with full admin privileges. This is a shared secret that grants unauthenticated admin access to every endpoint using this middleware. If CRON_SECRET is weak, leaked, or committed to history (it was not found in the leaked .env.test), it represents a complete authentication bypass. Recommendation: ensure CRON_SECRET is a high-entropy value, never committed, and rotated periodically.

The isNonAdminAllowedEvent registry in inngestFunctionRegistry.ts is currently empty, meaning all Inngest job events require admin privileges. This is a conservative default — good.

The Composio webhook handler (ComposioWebhookHandler.ts) is intentionally unauthenticated (server-to-server), but verifies the payload via Composio’s signed webhook (triggers.parse with verifySecret: COMPOSIO_WEBHOOK_SECRET). This is correct — no issue.

Business logic

The seed-database.ts API endpoint is admin-only and seeds the database. It’s correctly gated behind authenticateExecution(req, res, JOB_SLUG, true). No issue, but it should be disabled or removed in production builds.

Failure modes

The transcribeAudio.ts command injection finding (see above) also has a failure-mode concern: execSync will throw on non-zero exit, and the catch block only checks if chunk files were created. A malicious payload that fails to produce chunks will throw, but a payload that produces chunks while also executing injected commands would succeed silently. Switching to execFileSync (the array form) eliminates the shell and thus both the injection and the silent-execution concern.

Coverage gaps

  • No DAST coverage: ZAP and Nuclei were not available in this environment. A live scan against preview.uvilo.ai could not be performed.
  • No comprehensive endpoint audit: ~30 API routes exist; this review checked the auth middleware and 5 representative endpoints. A full audit would examine each route’s authorization scope, particularly any endpoint that accepts an object ID and could be vulnerable to IDOR (cross-tenant access).
  • No authenticated DAST: Even with ZAP/Nuclei, an unauthenticated scan only covers the logged-out surface, which is a small fraction of the application.