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
| Scanner | Status | Findings |
|---|---|---|
| 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 commits4b6e240cand939a4efa(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
- Neon database URLs for dev, preview, production, and unpooled connections — all with password
- Why it’s real: Gitleaks scans full git history, and these secrets are in commits that are accessible via
git show. The.env.testfile is now in.gitignoreand 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:
- Rotate every credential listed above immediately. This is not optional — the credentials exist in git history and must be assumed compromised.
- After rotation, consider using
git filter-repoor BFG Repo-Cleaner to purge the file from history (this rewrites all commit SHAs and requires force-push + re-clone for all collaborators). - Verify
.env.testand 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
audioUrlcan inject arbitrary shell commands via the file extension. The URL is fetched from an RSS feed enclosure URL, the extension is extracted withpath.extname(), and the resulting filename is interpolated into a shell command executed byexecSync. Inside double quotes,$(...)command substitution is evaluated by the shell. -
Why it’s real: The call chain is:
- Admin adds a podcast RSS feed URL via
UploadPodcastButton→/api/send-inngest-event(admin-only) indexPodcastJob.tsfetches the RSS feed and storesenclosureUrlin the databasetranscribePodEpisodeJob.tsreadspodEpisode.enclosureUrland passes it totranscribeAudio()transcribeAudio.tsextracts the file extension:- The extension is interpolated into a shell command:
- If the enclosure URL is
https://evil.com/podcast.mp3$(id), thenext=.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.
- Admin adds a podcast RSS feed URL via
-
Fix: Avoid
execSyncwith string interpolation. Use the array formexecFileSyncwhich does not invoke a shell: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.yaml—better-auth@1.6.18 - Category: A07 Authentication Failures
- What: GHSA-qq9h-g4jm-xgf3 is a HIGH-severity advisory affecting
better-authversions 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-authis 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-authto at least 1.6.22 (or latest stable): 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(noUSERinstruction) - Category: A02 Security Misconfiguration
- What: The container image has no
USERinstruction, 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:
[MEDIUM] Font Awesome npm token committed in .npmrc
- Where:
.npmrc:3— auth token15518163-6241-446D-9E29-9EC40175304F(also in git history at commitsfc8a86b1,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
.npmrcfile (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
.npmrcwith an environment variable reference: SetFONTAWESOME_TOKENin 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
GenerateUserApiTokenHandlercreates API keys usingrandomBytes(32).toString('hex')and stores the raw key directly in theApiKey.keycolumn. 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: 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-actionand others. Semgrep’sgithub-actions-mutable-action-tagrule flagged all 19 instances. The workflows handleSENTRY_AUTH_TOKEN,VERCEL_TOKEN, and other secrets. - Fix: Pin every third-party action to a full commit SHA:
[MEDIUM] tar vulnerability (GHSA-r292-9mhp-454m)
- Where:
pnpm-lock.yaml—tar@7.5.19 - Category: A03 Software Supply Chain Failures
- What: Medium-severity advisory in the
tarpackage. Fixed in 7.5.21. - Why it’s real:
taris 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 tarto 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
permissionsat the job level but not at the top level. Without a top-levelpermissionsblock, 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
permissionsblock to each workflow file:
[LOW] Dockerfile missing HEALTHCHECK instruction
- Where:
Dockerfile.forgentic - Category: A02 Security Misconfiguration
- What: No
HEALTHCHECKinstruction 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:
[LOW] No minimum release age configured for npm/pnpm
- Where:
.npmrc:1,pnpm-workspace.yaml:195 - Category: A03 Software Supply Chain Failures
- What: Neither
.npmrcnorpnpm-workspace.yamlsets 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: And topnpm-workspace.yaml:
[LOW] pnpm missing trust policy and exotic subdeps block
- Where:
pnpm-workspace.yaml:195 - Category: A03 Software Supply Chain Failures
- What:
pnpm-workspace.yamldoes not settrustPolicy: no-downgrade(prevents malicious package updates from downgrading security settings) orblockExoticSubdeps: 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:
[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) andbody-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/coreis a build-time dependency;body-parseris used by Express/Next.js for request parsing. - Fix:
pnpm update @babel/core body-parserwhen 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_buildURL inDockerfile.forgentic:43uses 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-keyrule 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.aicould 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.