Back to skill

Security audit

Strava Training Coach

Security checks for vulnerabilities and agentic risk

Overview

The skill mostly does what it advertises, but it should be reviewed before installation because it handles fitness account tokens and health-related data with some credential-scope and OAuth hardening weaknesses.

Review before installing if you are comfortable granting read access to Strava activities, sending derived training and possible health-related alerts to Discord or Slack, storing OAuth tokens locally, and optionally letting the skill read an existing Oura CLI token. Prefer installing only after the OAuth state handling and token-file creation are hardened, and treat its injury and recovery recommendations as general coaching information, not medical advice.

Vulnerability Patterns
  • Insecure Skill Coding PracticesFinds exploitable flaws such as hardcoded secrets or command injection
  • Skill Instruction HijackingAlters the agent's session goals or safety constraints when the skill loads
  • Agent Memory PoisoningWrites attacker-controlled rules into memory that affect later sessions
  • Remote Payload Retrieval and ExecutionFetches external code whose behavior can change after review
  • Embedded Malicious CodeShips malicious scripts inside the skill and executes them locally
Findings (2)

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/auth.py:43
Finding

OAuth Callback Does Not Validate a CSRF State Parameter

Content
View full analysis

Vulnerability Details

File Location: scripts/auth.py, lines 43-76
Vulnerability Type: OAuth login CSRF and account substitution
Risk Level: Medium

Vulnerable Code

python
class CallbackHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if '/callback' in self.path:
            # Parse authorization code
            query = urllib.parse.urlparse(self.path).query
            params = urllib.parse.parse_qs(query)
            
            if 'code' in params:
                self.server.auth_code = params['code'][0]
                self.send_response(200)
                self.end_headers()
                self.wfile.write(b'Authorization successful! You can close this window.')
            elif 'error' in params:
                self.server.error = params['error'][0]
                self.send_response(400)
                self.end_headers()
                self.wfile.write(f'Error: {params["error"][0]}'.encode())
        else:
            self.send_response(404)
            self.end_headers()
python
def get_authorization_url():
    """Build OAuth authorization URL"""
    scopes = 'read,activity:read'  # Read your activities
    params = {
        'client_id': CLIENT_ID,
        'redirect_uri': REDIRECT_URI,
        'response_type': 'code',
        'approval_prompt': 'force',
        'scope': scopes
    }
    return f"https://www.strava.com/oauth/authorize?{urllib.parse.urlencode(params)}"

Technical Analysis

The OAuth authorization request does not include a cryptographically random state parameter. The local callback handler consequently accepts any code parameter without verifying that the callback corresponds to the authorization flow initiated by the current process.

The state parameter binds an OAuth response to its initiating browser session and prevents login CSRF. Without this binding, a compatible authorization resp ...[truncated 1591 chars]

Remediation
View remediation

Remediation Suggestions

  1. Generate a fresh state value for every authorization attempt:

    python
    import secrets
    
    oauth_state = secrets.token_urlsafe(32)
    
  2. Add the value to the authorization request:

    python
    params = {
        'client_id': CLIENT_ID,
        'redirect_uri': REDIRECT_URI,
        'response_type': 'code',
        'approval_prompt': 'force',
        'scope': scopes,
        'state': oauth_state,
    }
    
  3. Retain the expected state only for the active process and reject callbacks that omit it or supply a different value. Use secrets.compare_digest for comparison.

  4. Exchange the authorization code only after successful state validation.

  5. Match the callback path exactly, such as parsed.path == '/callback'.

  6. Return an error and terminate the flow when state validation fails.

  7. Add timeout=30 to the token-exchange urlopen call so it matches the documented network timeout policy.

T09 · Insecure Skill Coding Practices

Warning
Location
scripts/auth.py:99
Finding

OAuth Token Files Are Permission-Restricted Only After Sensitive Data Is Written

Content
View full analysis

Vulnerability Details

File Locations:

  • scripts/auth.py, lines 99-103
  • scripts/coach_check.py, lines 274-277
  • scripts/weekly_report.py, lines 185-188

Vulnerability Type: Insecure sensitive-file creation and non-atomic credential replacement
Risk Level: Medium

Vulnerable Code

scripts/auth.py:

python
def save_tokens(token_data):
    """Save tokens to file with secure permissions"""
    os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True)
    with open(TOKEN_FILE, 'w') as f:
        json.dump(token_data, f, indent=2)
    os.chmod(TOKEN_FILE, 0o600)
    print(f"Tokens saved securely to {TOKEN_FILE}")

scripts/coach_check.py:

python
# Securely save new tokens
with open(TOKEN_FILE, 'w') as f:
    json.dump(new_tokens, f, indent=2)
os.chmod(TOKEN_FILE, 0o600)

scripts/weekly_report.py:

python
with open(TOKEN_FILE, 'w') as f:
    json.dump(new_tokens, f, indent=2)
os.chmod(TOKEN_FILE, 0o600)

Technical Analysis

Each location opens the token file using open(TOKEN_FILE, 'w'), writes OAuth credentials, closes the file, and only then changes its permissions to mode 0600.

For a newly created file, initial permissions are determined by the process umask. Under a permissive or misconfigured umask, the file can temporarily be readable by other local users while access and refresh tokens are already present. Applying chmod after the write does not protect the interval between creation and permission restriction.

The update is also non-atomic. A crash during serialization can leave a truncated token file. The code does not explicitly reject a symbolic-link destination, which weakens protection against path manipulation where an attacker already has sufficient access to the selected configuration directory.

Attack Path

  1. The Skill runs on a multi-user system with a permissive umask or an otherwise unsafe file-creation environment.

...[truncated 1409 chars]

Remediation
View remediation

Remediation Suggestions

  1. Create sensitive files with mode 0600 before writing any credential data. For example, use os.open with restrictive permissions:

    python
    flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
    if hasattr(os, "O_NOFOLLOW"):
        flags |= os.O_NOFOLLOW
    
    fd = os.open(TOKEN_FILE, flags, 0o600)
    with os.fdopen(fd, "w") as f:
        json.dump(token_data, f, indent=2)
        f.flush()
        os.fsync(f.fileno())
    
  2. Prefer an atomic replacement procedure:

    • Create a temporary file in the same configuration directory.
    • Set mode 0600 at creation time.
    • Write and flush the complete JSON document.
    • Call fsync.
    • Atomically replace the destination using os.replace.
  3. Reject symbolic-link destinations by using O_NOFOLLOW where supported and validating the destination with lstat.

  4. Verify that the configuration directory is owned by the current user and has mode 0700, including when it already exists.

  5. Apply the same helper function consistently in auth.py, coach_check.py, and weekly_report.py.

  6. Consider storing only fields required for refresh and API use rather than the complete OAuth response, reducing the amount of locally retained account information.

Vulnerability Patterns
  • Data ExfiltrationExternal Transmission, Env Variable Harvesting, File System Enumeration
  • Privilege EscalationExcessive Permissions, Sudo/Root Execution, Credential Access
  • Excessive AgencyUnrestricted Tool Access, Autonomous Decision Making, Scope Creep
  • Rogue AgentSelf-Modification, Session Persistence
  • Taint TrackingDirect Taint Flow, Variable-Mediated Taint Flow, Credential Exfiltration Chain
Findings (24)

Tainted flow: 'req' from os.environ.get (line 88, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/auth.py (reported line 95)May include surrounding context.

python
method='POST'
    )
    
    with urllib.request.urlopen(req) as response:
        return json.loads(response.read().decode())

def save_tokens(token_data):

Tainted flow: 'req' from os.environ.get (line 259, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/coach_check.py (reported line 267)May include surrounding context.

python
)
    
    try:
        with urllib.request.urlopen(req, timeout=30) as response:
            new_tokens = json.loads(response.read().decode())
            
            if not validate_token_data(new_tokens):

Tainted flow: 'req' from os.environ.get (line 259, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/coach_check.py (reported line 309)May include surrounding context.

python
)
    
    try:
        with urllib.request.urlopen(req, timeout=30) as response:
            new_tokens = json.loads(response.read().decode())
            
            if not validate_token_data(new_tokens):

Tainted flow: 'req' from os.environ.get (line 259, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/coach_check.py (reported line 663)May include surrounding context.

python
)
    
    try:
        with urllib.request.urlopen(req, timeout=30) as response:
            new_tokens = json.loads(response.read().decode())
            
            if not validate_token_data(new_tokens):

Tainted flow: 'req' from os.environ.get (line 259, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/coach_check.py (reported line 792)May include surrounding context.

python
)
    
    try:
        with urllib.request.urlopen(req, timeout=30):
            logger.info(f"Discord alert sent: {alert.get('type')}")
            return True
    except HTTPError as e:

Tainted flow: 'req' from os.environ.get (line 259, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/coach_check.py (reported line 841)May include surrounding context.

python
)
    
    try:
        with urllib.request.urlopen(req, timeout=30):
            logger.info(f"Discord alert sent: {alert.get('type')}")
            return True
    except HTTPError as e:

Tainted flow: 'req' from os.environ.get (line 171, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/weekly_report.py (reported line 179)May include surrounding context.

python
)

    try:
        with urllib.request.urlopen(req, timeout=30) as response:
            new_tokens = json.loads(response.read().decode())

            if not validate_token_data(new_tokens):

Tainted flow: 'req' from os.environ.get (line 171, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/weekly_report.py (reported line 244)May include surrounding context.

python
)

    try:
        with urllib.request.urlopen(req, timeout=30) as response:
            new_tokens = json.loads(response.read().decode())

            if not validate_token_data(new_tokens):

Tainted flow: 'req' from os.environ.get (line 171, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/weekly_report.py (reported line 469)May include surrounding context.

python
)

    try:
        with urllib.request.urlopen(req, timeout=30):
            logger.info("Weekly report sent to Discord")
            return True
    except HTTPError as e:

Tainted flow: 'req' from os.environ.get (line 171, credential/environment) → urllib.request.urlopen (network output)

Critical
Category
Data Flow
Confidence
90% confidence
Finding

Credentials or environment variables flow to a network sink. This is a high-confidence indicator of credential exfiltration.

Content

Scanner excerpt · scripts/weekly_report.py (reported line 544)May include surrounding context.

python
)

    try:
        with urllib.request.urlopen(req, timeout=30):
            logger.info("Weekly report sent to Discord")
            return True
    except HTTPError as e:

Credential Access

High
Category
Privilege Escalation
Confidence
70% confidence
Finding

Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Content

Scanner excerpt · scripts/auth.py (reported line 4)May include surrounding context.

python
#!/usr/bin/env python3
"""
Strava OAuth authentication flow
Gets access token and refresh token for API calls

Security:
- Tokens stored in XDG config dir with 0600 permissions

Credential Access

High
Category
Privilege Escalation
Confidence
70% confidence
Finding

Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Content

Scanner excerpt · scripts/auth.py (reported line 79)May include surrounding context.

python
#!/usr/bin/env python3
"""
Strava OAuth authentication flow
Gets access token and refresh token for API calls

Security:
- Tokens stored in XDG config dir with 0600 permissions

Credential Access

High
Category
Privilege Escalation
Confidence
70% confidence
Finding

Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Content

Scanner excerpt · scripts/auth.py (reported line 140)May include surrounding context.

python
#!/usr/bin/env python3
"""
Strava OAuth authentication flow
Gets access token and refresh token for API calls

Security:
- Tokens stored in XDG config dir with 0600 permissions

Credential Access

High
Category
Privilege Escalation
Confidence
70% confidence
Finding

Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Content

Scanner excerpt · scripts/coach_check.py (reported line 241)May include surrounding context.

python
#!/usr/bin/env python3
"""
Strava OAuth authentication flow
Gets access token and refresh token for API calls

Security:
- Tokens stored in XDG config dir with 0600 permissions

Credential Access

High
Category
Privilege Escalation
Confidence
70% confidence
Finding

Code accesses credential files (SSH keys, AWS credentials, etc.). This could indicate credential theft attempts.

Content

Scanner excerpt · scripts/weekly_report.py (reported line 153)May include surrounding context.

python
#!/usr/bin/env python3
"""
Strava OAuth authentication flow
Gets access token and refresh token for API calls

Security:
- Tokens stored in XDG config dir with 0600 permissions

Undeclared Tool Scope

Medium
Category
MCP Least Privilege
Confidence
94% confidence
Finding

The skill describes capabilities that require access to environment variables, filesystem, network, and shell execution, but it does not declare an explicit tool scope such as permissions or allowed-tools. This creates a mismatch between what the skill can do and what reviewers or runtime policy may expect, increasing the risk of over-privileged execution and accidental secret exposure or command/network misuse.

Content

No source excerpt is available for this finding.

Missing User Warnings

Medium
Category
Not specified by scanner
Confidence
96% confidence
Finding

This file provides detailed training and quasi-medical guidance about injury risk, overtraining, heart-rate zones, when to skip runs, illness-related exercise decisions, and recovery thresholds, but it does not clearly warn that the content is general educational information rather than individualized medical advice. In the context of an automated coaching skill that monitors users daily and sends alerts, users may reasonably rely on these recommendations as personalized health guidance, which can lead to unsafe decisions for people with underlying conditions, atypical physiology, or acute injuries.

Content

No source excerpt is available for this finding.

Session Persistence

Medium
Category
Rogue Agent
Confidence
60% confidence
Finding

Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Content

Scanner excerpt · scripts/coach_check.py (reported line 42)May include surrounding context.

python
STATE_FILE = os.path.join(CONFIG_DIR, 'coach_state.json')
LOG_FILE = os.path.join(CONFIG_DIR, 'coach.log')

# Create config dir if needed
os.makedirs(CONFIG_DIR, mode=0o700, exist_ok=True)

# User-configurable thresholds (with validation)

Session Persistence

Medium
Category
Rogue Agent
Confidence
60% confidence
Finding

Skill establishes unauthorized persistence across sessions via cron jobs, startup scripts, or state files. Session persistence allows an attacker to maintain access beyond the current interaction.

Content

Scanner excerpt · scripts/coach_check.py (reported line 174)May include surrounding context.

python
return cls()
    
    def save(self):
        """Save state to file securely"""
        try:
            with open(STATE_FILE, 'w') as f:
                json.dump(self.__dict__, f, indent=2, default=str)

Context-Inappropriate Capability

Medium
Category
Not specified by scanner
Confidence
89% confidence
Finding

The script reads an Oura access token from another tool's local config file without clear in-code consent or isolation boundaries. That creates a credential-scope crossover where enabling this skill grants it access to a separate application's bearer token, increasing the risk of unexpected data access and silent privacy impact.

Content

No source excerpt is available for this finding.

Missing User Warnings

Medium
Category
Not specified by scanner
Confidence
91% confidence
Finding

The skill silently accesses a locally stored Oura bearer token from another tool's config, which is a transparency and least-privilege problem. Even if the token is only used for intended API calls, users may not expect this skill to inherit credentials from unrelated software, making unauthorized data access more plausible in practice.

Content

No source excerpt is available for this finding.

External Transmission

Medium
Category
Data Exfiltration
Confidence
60% confidence
Finding

Data is being sent to an external URL. This could be legitimate telemetry or data exfiltration. Manual review is recommended.

Content

Scanner excerpt · scripts/coach_check.py (reported line 648)May include surrounding context.

python
logger.debug("Oura token not available")
        return None
    
    url = f'https://api.ouraring.com/v2/usercollection/{endpoint}'
    params = [f'start_date={start_date}']
    if end_date:
        params.append(f'end_date={end_date}')

Intent-Code Divergence

Low
Category
Not specified by scanner
Confidence
83% confidence
Finding

The top-level docstring advertises "Secure temp file handling," which implies temporary-file operations are part of the implementation. In reality, the script never creates temp files; it writes persistent files such as token, state, and log files under the config directory, so the documentation actively misdescribes what the code does.

Content

No source excerpt is available for this finding.

Natural-Language Policy Violations

Low
Category
Not specified by scanner
Confidence
88% confidence
Finding

User-facing notification strings such as "Training Coach Alert," "Recommendation," and Slack message text are fixed in English. The file does not offer any language selection or opt-in for locale, which violates the language/locale policy criterion for natural-language behavior.

Content

No source excerpt is available for this finding.

Static analysis

No suspicious patterns detected.