App Security Assessment Report

Target /Users/dmitryshishov/Desktop/Gymmate/gymmate-app Generated 2026-09-08 13:51 UTC Domains Web Application Mobile Application LLM Application
Hybrid SAST + DAST Enabled
22SAST hits76%
5DAST hits17%
2Correlated7%
BA
89
Safety score
0 = critical · 100 = safe
Overall grade B
OWASP Smart Contract Top 10 : 2026
Reference docs OWASP-Web-Top-10-2025.md, OWASP-Mobile-Top-10-2024.md, OWASP-LLM-Top-10-2026.md
Files inspected 373 (105 source files)
Static analysis Semgrep 1.172.0
Dynamic analysis DAST active (nuclei, auth-probe, mobsfscan, garak)
LLM triage Claude claude-sonnet-5
Live targets web: http://127.0.0.1:8000, mobile-api: http://127.0.0.1:8000, llm: http://127.0.0.1:8000/intake/start

Executive Summary

Gymmate is a cross-platform fitness application combining a FastAPI/Python backend, a React Native/Expo mobile client with native Swift components, and an emerging LLM-driven feature set, giving it a broad attack surface spanning web, mobile, and AI domains. The confirmed findings skew toward configuration and identity weaknesses rather than deep code-level injection flaws: authentication is undermined by unverified self-registration issuing live sessions (A07 — Authentication Failures), the backend suffers from multiple instances of A02 — Security Misconfiguration including root-privileged containers, permissive CORS, and unauthenticated exposure of interactive API documentation, and there is a gap in A09 — Security Logging & Alerting Failures around login events. Mobile-side issues are comparatively minor, involving supply-chain hygiene (M2) and an ATS cleartext exception scoped to local networking (M8). No critical-severity or injection-class findings were confirmed in this pass, suggesting the application's core data-handling logic is relatively sound, but the accumulation of misconfiguration and authentication gaps materially lowers the bar for account takeover and unauthorized backend access. The overall risk profile is moderate: individually the findings are containable, but several compound (weak registration plus poor logging plus exposed docs) into a more exploitable chain than any single issue implies.

Most urgent risks

  • HIGH F-001: Open self-registration issues a live session with no email verification

Strategic recommendation

The programme should prioritize closing the identity and configuration gaps before expanding LLM or mobile feature surface, since these are the areas most reachable by unauthenticated attackers and cheapest to remediate: enforce email/phone verification before session issuance, lock down CORS to named origins, drop container privileges to a non-root user, and gate or remove public access to API documentation endpoints. In parallel, establish baseline security logging and alerting for authentication events so that account-takeover attempts and misconfiguration abuse are detectable rather than silent. Mobile supply-chain and ATS findings are lower urgency and can be folded into routine dependency-hardening and release-build review cycles. As the LLM components mature, the team should get ahead of risk by threat-modeling prompt construction, tool invocation, and data exposure paths now, before agentic capabilities are added, so that the same misconfiguration-driven pattern seen on the web side does not repeat in the AI surface. A recurring configuration-as-code review and a lightweight secure-SDLC checkpoint for new endpoints would address the root cause common to most findings here: settings and defaults left unreviewed rather than deliberate design choices.

Scoring: risk score = 10 × critical + 5 × high + 2 × medium + 0.5 × low, capped at 100 (0 = no risk, 100 = catastrophic — higher is worse). Grades: A ≤ 10, B ≤ 25, C ≤ 40, D ≤ 60, otherwise F. Any CRITICAL finding is graded no better than D; any HIGH no better than B.

Web Application coverage— detectable 10/10 (100%)
OWASP category SAST DAST Found
A01Broken Access Control ✓ ✓ –
A02Security Misconfiguration ✓ ✓ ✓
A03Software Supply Chain Failures ✓ ✓ –
A04Cryptographic Failures ✓ – –
A05Injection ✓ ✓ –
A06Insecure Design ✓ – –
A07Authentication Failures ✓ ✓ ✓
A08Software or Data Integrity Failures ✓ ✓ –
A09Security Logging & Alerting Failures ✓ – ✓
A10Mishandling of Exceptional Conditions ✓ – –
Mobile Application coverage— detectable 9/10 (90%)
OWASP category SAST DAST Found
M1Improper Credential Usage ✓ ✓ –
M2Inadequate Supply Chain Security – – ✓
M3Insecure Authentication / Authorization ✓ ✓ –
M4Insufficient Input / Output Validation ✓ ✓ –
M5Insecure Communication ✓ ✓ –
M6Inadequate Privacy Controls ✓ ✓ –
M7Insufficient Binary Protections ✓ ✓ –
M8Security Misconfiguration ✓ ✓ ✓
M9Insecure Data Storage ✓ ✓ –
M10Insufficient Cryptography ✓ – –
LLM Application coverage— detectable 8/10 (80%)
OWASP category SAST DAST Found
LLM01Prompt Injection ✓ ✓ –
LLM02Sensitive Information Disclosure ✓ ✓ –
LLM03Excessive Agency ✓ – –
LLM04Supply Chain ✓ ✓ –
LLM05Data and Model Poisoning – – –
LLM06Unbounded Consumption ✓ – –
LLM07Misinformation – ✓ –
LLM08Hidden Context Exposure ✓ – –
LLM09Vector and Embedding Weaknesses – – –
LLM10Improper Output Handling ✓ ✓ –

Domain Breakdown

Domain Critical High Medium Low Total
Web Application (OWASP Top 10:2025) 0 1 2 2 5
Mobile Application (OWASP Mobile Top 10 2024) 0 0 0 2 2
LLM / AI Application (OWASP Top 10 for LLM Applications 2026) 0 0 0 0 0

Findings by OWASP category

OWASP category Findings
A02 — Security Misconfiguration 3
A07 — Authentication Failures 1
A09 — Security Logging & Alerting Failures 1
M2 — Inadequate Supply Chain Security 1
M8 — Security Misconfiguration 1

Findings Summary

ID Severity OWASP category Location
F-001 HIGH A07Authentication Failures backend/auth.py:147
F-002 MEDIUM A02Security Misconfiguration Dockerfile:40
F-003 MEDIUM A02Security Misconfiguration backend/server.py:41
F-004 LOW M2Inadequate Supply Chain Security mobile/.npmrc:1
F-005 LOW M8Security Misconfiguration mobile/ios/GymmateDev/Info.plist:1
F-006 LOW A09Security Logging & Alerting Failures backend/auth.py:154
F-007 LOW A02Security Misconfiguration backend/server.py:35

Detailed Findings

F-001: Open self-registration issues a live session with no email verification

Severity HIGH
OWASP category A07Authentication Failures (Web Application (OWASP Top 10:2025))
Vulnerable file backend/auth.py
Line(s) 147–151
Source Correlated (Semgrep + auth-probe)
Detection SAST + DAST (correlated)
Confidence HIGH

Vulnerable code

@router.post("/register")
def register(credentials: Credentials):
    email = check(credentials)
    body = call("/signup", {"email": email, "password": credentials.password})
    return session_from(body)

Root cause

register() only validates email shape/password length client-side-equivalent (check()) and then forwards straight to Supabase /signup; when the project's email confirmation is off, session_from() returns a fully usable access_token immediately, with no rate limiting or verification step in front of it — confirmed live by an auth-probe that posted random throwaway credentials and got back a valid, immediately-usable JWT and refresh token.

Impact

An attacker can script unlimited account creation with disposable addresses, each minting a fresh authenticated session in one request; combined with usage.py's per-account $5 LLM budget, this lets an attacker cheaply and repeatedly re-roll free assistant/plan-generation credit, and more generally enables spam signups and credential-stuffing infrastructure with no friction.

Remediation

Enable Supabase's 'Confirm email' setting so /signup never returns tokens for an unverified address, add IP/email-based rate limiting on /auth/register, and consider CAPTCHA or another anti-automation control on the endpoint per the doc's guidance on abuse of high-value flows.

from time import time
from collections import defaultdict
from fastapi import HTTPException, Request

_attempts = defaultdict(list)
RATE_WINDOW = 3600
RATE_MAX = 5

def rate_limit(key: str):
    now = time()
    _attempts[key] = [t for t in _attempts[key] if now - t < RATE_WINDOW]
    if len(_attempts[key]) >= RATE_MAX:
        raise HTTPException(status_code=429, detail="too many signups, try again later")
    _attempts[key].append(now)


@router.post("/register")
def register(credentials: Credentials, request: Request):
    rate_limit(request.client.host)
    email = check(credentials)
    body = call("/signup", {"email": email, "password": credentials.password})
    user = body.get("user") or {}
    if not user.get("email_confirmed_at") and not body.get("access_token"):
        return {"session": False, "user": account(user)}
    return session_from(body)
POST of freshly-generated random credentials to /auth/register returned HTTP 200 with a valid access_token, refresh_token and 3600s expiry immediately — no email confirmation step was enforced by the deployment under test.

F-002: Backend container runs as root (no USER directive)

Severity MEDIUM
OWASP category A02Security Misconfiguration (Web Application (OWASP Top 10:2025))
Vulnerable file Dockerfile
Line(s) 40
Source Semgrep rule dockerfile.security.missing-user.missing-user
Detection SAST (static)
Confidence HIGH

Vulnerable code

CMD ["sh", "-c", "exec uvicorn server:app --app-dir backend --host 0.0.0.0 --port ${PORT}"]

Root cause

The image never creates or switches to a non-root user, so uvicorn and the whole FastAPI process (which holds the Anthropic, Supabase, OpenAI and APNs credentials from .env) run as root inside the container — an insecure default left in place, matching the doc's 'a default left in place... a debug setting shipped' pattern.

Impact

If any other flaw in the stack (dependency RCE, deserialization, path traversal in the static file mounts) is ever exploited, the attacker inherits root inside the container immediately, simplifying container breakout and giving unrestricted access to every mounted secret and file (exercise-images, web-setup, templates, .cache).

Remediation

Create a dedicated unprivileged user in the image, chown the app directory to it, and add a USER directive before the CMD; keep pip installs in the root-owned build layer only.

RUN addgroup --system app && adduser --system --ingroup app app \
    && chown -R app:app /app
USER app
CMD ["sh", "-c", "exec uvicorn server:app --app-dir backend --host 0.0.0.0 --port ${PORT}"]
Not dynamically confirmed (static finding only).

F-003: Wildcard CORS policy on the FastAPI backend

Severity MEDIUM
OWASP category A02Security Misconfiguration (Web Application (OWASP Top 10:2025))
Vulnerable file backend/server.py
Line(s) 41–46
Source Semgrep rule python.fastapi.security.wildcard-cors.wildcard-cors
Detection SAST (static)
Confidence MEDIUM

Vulnerable code

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

Root cause

allow_origins, allow_methods and allow_headers are all wildcarded on every route (including /auth/, /account/, /chat/*), matching the doc's bullet on 'a permissive Access-Control-Allow-Origin: * on authenticated APIs' — any web origin can issue cross-origin requests and read the JSON responses.

Impact

Any third-party page that can obtain or guess a bearer token (e.g. through an XSS elsewhere, a malicious browser extension, or a compromised dependency in the web preview) can call every account, chat and usage endpoint cross-origin without the browser blocking the response; it also removes any origin-based defence-in-depth for the web-setup client.

Remediation

Replace the wildcard with an explicit allow-list of known origins (the Expo web preview host, the production web-setup origin), restrict allow_methods/allow_headers to what is actually used, and leave allow_credentials off since it already is.

import os

ALLOWED_ORIGINS = [o.strip() for o in os.environ.get("ALLOWED_ORIGINS", "").split(",") if o.strip()]

app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS or ["http://localhost:8081"],
    allow_credentials=False,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type", "X-Trial-Id"],
)
Not dynamically confirmed (static finding only).

F-004: Mobile package manager config lacks minimum release-age protection

Severity LOW
OWASP category M2Inadequate Supply Chain Security (Mobile Application (OWASP Mobile Top 10 2024))
Vulnerable file mobile/.npmrc
Line(s) 1
Source Semgrep rule package_managers.npm.npm-missing-minimum-release-age.npm-missing-minimum-release-age
Detection SAST (static)
Confidence MEDIUM

Vulnerable code

legacy-peer-deps=true

Root cause

The project's .npmrc sets no min-release-age, so npm install can resolve a package version within minutes of publication — the same floating/unpinned-resolution pattern the doc warns about ('Floating version ranges... that silently pick up a compromised release').

Impact

A compromised maintainer account or hijacked package could publish a malicious version that CI or a developer machine installs immediately, before the community or registry has time to flag and pull it, and that code would then ship inside the signed mobile release artifact.

Remediation

Add min-release-age=7 to mobile/.npmrc; pin exact dependency versions and commit lockfiles; verify checksums; run dependency scanning in CI; vet new SDKs before adoption.

legacy-peer-deps=true
min-release-age=7
Not dynamically confirmed (static finding only).

F-005: App Transport Security exception permits local-network cleartext traffic

Severity LOW
OWASP category M8Security Misconfiguration (Mobile Application (OWASP Mobile Top 10 2024))
Vulnerable file mobile/ios/GymmateDev/Info.plist
Line(s) 1
Source Dynamic analysis (mobsfscan)
Detection DAST (dynamic)
Confidence MEDIUM

Vulnerable code

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <false/>
    <key>NSAllowsLocalNetworking</key>
    <true/>
</dict>

Root cause

NSAllowsLocalNetworking is enabled so the dev client can reach the Metro bundler and a LAN-hosted backend over plain HTTP, which mobsfscan flags as a Network Security Config / ATS exception; NSAllowsArbitraryLoads is correctly left false so this is scoped to local networks only, and it is confined to the GymmateDev target rather than the store build.

Impact

On a shared or hostile local network (public Wi-Fi, a compromised router), traffic between the dev client and the LAN backend could be intercepted or modified, but this is limited to development builds — the comment in app.config.js states production relies on HTTPS with no such exception.

Remediation

Confirm the production Info.plist carries no NSAllowsLocalNetworking/NSAllowsArbitraryLoads exception, keep this exception restricted to the development bundle identifier only, and prefer scoping any future ATS exceptions to specific domains via NSExceptionDomains rather than a blanket local-networking allowance.

<key>NSAppTransportSecurity</key>
<dict>
  <key>NSAllowsArbitraryLoads</key>
  <false/>
  <!-- Keep NSAllowsLocalNetworking only in the Debug/GymmateDev configuration;
       ensure it is absent from the release Info.plist entirely. -->
  <key>NSAllowsLocalNetworking</key>
  <true/>
</dict>
mobsfscan statically confirmed the NSAllowsLocalNetworking=true ATS exception in the shipped Info.plist for the development target.

F-006: Login endpoint performs no authentication event logging

Severity LOW
OWASP category A09Security Logging & Alerting Failures (Web Application (OWASP Top 10:2025))
Vulnerable file backend/auth.py
Line(s) 154–162
Source Semgrep rule web.logging.python-auth-events-not-logged-hint
Detection SAST (static)
Confidence LOW

Vulnerable code

@router.post("/login")
def login(credentials: Credentials):
    email = check(credentials)
    body = call(
        "/token",
        {"email": email, "password": credentials.password},
        params={"grant_type": "password"},
    )
    return session_from(body)

Root cause

The login handler forwards credentials to the identity provider and returns a session but never records the attempt or its outcome, matching the doc's bullet that auditable events like logins are 'not logged at all'.

Impact

Credential-stuffing bursts, brute-force attempts, and successful account takeovers leave no application-level trace, so detection depends entirely on the upstream IdP's own logging, if any exists.

Remediation

Log every login attempt with outcome (success/failure), user identifier, and request metadata; ship logs off-host to a central store; define alert thresholds for failure spikes; never log raw passwords; add rate limiting on repeated failures.

@router.post("/login")
def login(credentials: Credentials):
    email = check(credentials)
    try:
        body = call(
            "/token",
            {"email": email, "password": credentials.password},
            params={"grant_type": "password"},
        )
    except AuthError:
        logger.warning("login_failed", extra={"email": email})
        raise
    logger.info("login_succeeded", extra={"email": email})
    return session_from(body)
Not dynamically confirmed (static finding only).

F-007: FastAPI interactive docs and OpenAPI schema exposed with no authentication

Severity LOW
OWASP category A02Security Misconfiguration (Web Application (OWASP Top 10:2025))
Vulnerable file backend/server.py
Line(s) 35
Source Correlated (Semgrep + nuclei)
Detection SAST + DAST (correlated)
Confidence MEDIUM

Vulnerable code

app = FastAPI()

Root cause

FastAPI() is instantiated with its defaults, so /docs, /redoc and /openapi.json are all served publicly with no auth guard or docs_url=None override — an unnecessary developer feature left enabled in a production-facing deployment, and dynamically confirmed reachable at /docs, /redoc and /openapi.json.

Impact

Any unauthenticated visitor can enumerate the complete API surface (every route, request/response schema, including the auth, account, usage and voice endpoints), materially speeding up reconnaissance for further attacks such as the open-registration and CORS issues above.

Remediation

Disable docs_url/redoc_url/openapi_url outside of development (e.g. gated on an ENV variable), or place them behind an internal-only network path/auth dependency if they must remain available for API consumers.

import os

app = FastAPI(
    docs_url="/docs" if os.environ.get("ENV") == "development" else None,
    redoc_url="/redoc" if os.environ.get("ENV") == "development" else None,
    openapi_url="/openapi.json" if os.environ.get("ENV") == "development" else None,
)
Nuclei confirmed all three unauthenticated endpoints live: /docs returned the Swagger UI page, /openapi.json returned the full 21KB API schema, and /redoc returned the ReDoc viewer — all with plain unauthenticated GET requests.

Dismissed Static-Analysis Findings

Rule
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:708
innerHTML is assigned an empty string literal to clear the week-switch container before repopulation; no untrusted data reaches the sink.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:715
innerHTML is assigned a static, hard-coded HTML literal with no interpolated variables; the actual week/label text is set afterwards via textContent.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:729
innerHTML is assigned an empty string literal to clear the plan.week container before rendering; no untrusted data involved.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:763
innerHTML is assigned a static template literal containing only fixed markup (span classes); no user-controlled data is interpolated into the string.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:954
innerHTML is assigned a fixed, hard-coded string with no interpolated variables or user input.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:968
innerHTML is assigned an empty string literal to clear the focus panel; subsequent content is built with textContent, not innerHTML.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:1168
innerHTML is assigned an empty string literal to clear the chat log after a reset call; no untrusted data reaches the sink.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:1238
innerHTML is assigned an empty string literal to clear the intake log; transcript entries are subsequently rendered via the say() helper which the codebase uses with textContent, not raw HTML.
web.crypto.hardcoded-secret-key-js Location: mobile/store/auth.ts:7
KEY_TOKENS is an AsyncStorage key name ('gymmate.auth'), not a cryptographic secret or signing key; no key material is embedded here. False positive on a string-literal-heuristic rule.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:231
innerHTML is assigned a fixed, hard-coded markup string with no interpolated data; nothing attacker-controlled reaches it.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:250
Static literal markup (three empty tags) with no user input; not exploitable.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:264
innerHTML is set to the empty string to clear the log before rendering with textContent/appendChild elsewhere; no injection surface.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:371
innerHTML is set to the empty string to clear a container in an error handler; no untrusted data assigned.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:538
innerHTML is set to the empty string before rebuilding the strip's children; no untrusted data assigned.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:560
innerHTML is a fixed static template of empty divs; actual label/value strings are written afterward via textContent, which auto-escapes.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:577
innerHTML is a fixed static template for one empty div; the label is written afterward via textContent.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:605
innerHTML is set to the empty string to clear the toggles container before re-render; no untrusted data assigned.
web.injection.js-dom-xss-innerhtml Location: web-setup/app.js:693
innerHTML is set to the empty string to clear the week skeleton before appending generated placeholder nodes; no untrusted data assigned.

Scan Metadata

Languages detected typescript, python, swift, sql, javascript, c, html
Semgrep rulesets web.yaml, mobile.yaml, llm.yaml, owasp-top-ten, secrets, typescript, python, swift
Semgrep results 22 raw
Context files reviewed 53
Project type hybrid
DAST status active
DAST tools ran nuclei, auth-probe, mobsfscan, garak
DAST findings (raw) 5
Correlation candidates 1
DAST artifacts /Users/dmitryshishov/Desktop/Gymmate/gymmate-app/dast_artifacts
LLM model claude-sonnet-5
Token usage input 164935, output 32753, cache read 0, cache write 94255

Analyst notes and warnings

Analyst notes

  • Context source files were not re-supplied in this batch (marked as already reviewed earlier), so contextual review here is limited to what the Semgrep snippets and the single correlation candidate (C-001) reveal; F-002 is reported on that basis with reduced confidence since the full body of the /auth/register handler (lines ~140-150) was not visible, only its tail (line 151). The correlation candidate references a DAST record 'D-001' for a Nuclei auth-bypass probe, but no DAST findings were included in this batch, so detection is left as 'sast' rather than 'both'/'correlated' pending that evidence. All eight innerHTML Semgrep hits in web-setup/app.js were reviewed against surrounding code and are consistent false positives: the codebase's pattern is to clear a container with .innerHTML = "" or set static markup, then populate actual dynamic content with textContent, which is safe against DOM XSS.
  • Scope covered: backend FastAPI app (server.py, auth.py, assistant.py, usage.py, voice.py, apns.py, postgrest.py), the mobile Expo/React Native app and its native Swift lock-screen voice feature, the web-setup static client, and the two OWASP-relevant DAST batches (auth-probe, mobsfscan, nuclei). All 9 web.injection.js-dom-xss-innerhtml hits were manually verified against the surrounding snippet and are false positives — either fixed literal markup or clear-then-rebuild patterns; none interpolate untrusted data into innerHTML. The hardcoded-secret-key-js hit is a storage-key string constant, not a secret. Three DAST-only findings (swagger-api, openapi, redoc-api-docs) were merged into one A02 finding since they are one root cause (FastAPI default docs left enabled). The open-registration DAST finding was merged with the corresponding backend/auth.py register() handler per the supplied correlation candidate; note the semgrep rule id referenced in that candidate (web.logging.python-auth-events-not-logged-hint) was not present in the supplied Semgrep batch, so the merge is based on direct source review of auth.py rather than a specific semgrep hit. Not independently verified: whether Supabase 'Confirm email' is actually off in the production project (the DAST probe suggests it is, against whatever environment was under test) — recommend confirming project auth settings directly. The prompts.py assistant system prompt, tool definitions (tools.py) and library.py catalog constraints were reviewed for LLM01/LLM03/LLM08 risk and appear reasonably well-scoped (closed movement catalog, tool-mediated state changes, no secrets embedded in the system prompt), so no LLM findings are reported from this batch; a second pass with the tools.py source and the remainder of assistant.py/prompts.py (truncated in the provided context) is recommended to fully assess excessive-agency and hidden-context-exposure risk in the immersive voice/tool-calling path.

Warnings

  • mobsfscan: suppressed 11 parse warning(s) in vendored/third-party files (node_modules, Pods, ...).
  • Skipped 2 non-loopback backend API host(s) extracted from mobile config (getmentor.tech, gymmate-app-hjwm.onrender.com); pass --allow-remote to scan them.