Skip to content

Security/audit 20260810 - #412

Merged
cyberjunky merged 10 commits into
masterfrom
security/audit-20260810
Aug 10, 2026
Merged

Security/audit 20260810#412
cyberjunky merged 10 commits into
masterfrom
security/audit-20260810

Conversation

@cyberjunky

@cyberjunky cyberjunky commented Aug 10, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • Bug Fixes

    • Improved protection of sensitive authentication, CAPTCHA, MFA, customer, username, and service-ticket data in errors, logs, requests, and generated test recordings.
    • Strengthened API path validation against encoded or backslash-based traversal attempts.
    • Rejected token paths containing symlinked parent directories.
    • Corrected Garmin request parameter handling without changing request behavior.
    • Improved exercise data parsing and detection of suspicious session data.
  • Tests

    • Added regression coverage for data redaction, path security, request parameters, and exercise parsing.

Report #4020: the traversal guard matched literal '..', '?' and '#' in the
raw path, but requests' requote_uri() decodes unreserved characters
(%2e -> .) after validation, so %2e%2e/%2e%2e/... slipped through and the
authenticated request went to a traversed path on the API host. Validate
the urllib.parse.unquote() form instead, and reject backslashes.

Also fixes call sites broken by the guard's '?' rejection, which embedded
query strings in the path instead of using params: get_gear_activities,
get_functional_threshold_power_range, and the lactate-threshold range
URLs.
Report #4018: the CAS service ticket must travel in a query string on the
ticket-consumption fallback (CAS protocol requirement), and requests
embeds the full URL in its exception text. That text reached the WARNING
log on strategy failure and the 'All login strategies exhausted' error
handed to callers — a credential leak precisely when users turn logging
up to file a bug report.

New _sanitize_exception_text() redacts all query-string values; applied
at the strategy-failure and 429 logs, the exhaustion raise, and the DI
token exchange fallback log.
Report #4021: sanitize_request() only scrubbed key=value form bodies, but
the credential-posting login strategies send JSON ({"username":...,
"password":...}), so account e-mail and password were recorded verbatim
when re-recording cassettes with real credentials. Parse request bodies
by structure and reuse sanitize_json() so request and response share one
deny-list.

Also: add username, mfaVerificationCode, serviceTicketId, service_ticket,
captchaToken and customerGuid to SENSITIVE_FIELDS; add service_ticket to
SENSITIVE_FORM_PARAMS (form-encoded DI token exchange); scrub
ticket/service_ticket from request.uri (ticket-consumption fallback).
Report #4023: token_file_path() only inspected the last two path
components, and O_NOFOLLOW on open() only covers the final component, so
a symlink planted higher in the tree (e.g. <home>/cfg/store ->
/attacker/dir with tokenstore <home>/cfg/store/sub/.garminconnect)
redirected dump()/load()/logout() into an attacker-controlled directory,
exposing di_refresh_token on write and allowing tokenstore substitution
on read. Walk token_path.parents instead of only the immediate parent.
Default ~/.garminconnect is unaffected (already fully covered).
Report #4024: the ROW regex combined re.S with an unanchored .*?, so an
element carrying both data-* attributes but no bare <span> captured the
next <span> anywhere later in the document — page chrome from the
authenticated session (display name, tokens) included — and render()
emitted it into the published exercises.py. Split into <li> elements
first and match within each; attribute-only elements are now skipped
instead of borrowing foreign text.

Also: main() refuses to write output whose names look like session data
(e-mail, JWT, URL, GUID), and the docstring now tells maintainers to copy
only the picker's <ul> and notes the output is published.
Two TestUrlConstruction tests asserted the old inline-query URL form; the
path-guard fix moves query parameters to the params kwarg. And the login
redaction test must set the garminconnect logger level explicitly because
demo.py sets it to CRITICAL at import time (via test_demo_security),
which starved caplog in full-suite runs.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Changes

The pull request separates Garmin query parameters, strengthens path and sensitive-data sanitization, and hardens exercise catalog parsing against malformed or session-derived input.

Garmin API request construction

Layer / File(s) Summary
Separate Garmin query parameters
garminconnect/__init__.py, tests/test_garmin_unit.py
Threshold and gear activity methods pass query values through connectapi parameters. Tests verify the parameter mappings.

Client security hardening

Layer / File(s) Summary
Sanitize authentication and request data
garminconnect/client.py, tests/conftest.py, tests/test_cassette_sanitization.py, tests/test_garmin_unit.py
Authentication logs and errors redact query values. Cassette sanitization covers JSON bodies, form bodies, and service-ticket URI values.
Validate token paths and API paths
garminconnect/client.py, tests/test_garmin_unit.py
Token-store validation checks all ancestors. API path validation decodes percent-encoded input and rejects traversal, semicolons, and backslashes while accepting valid dotted segments and a valid social profile path.

Exercise catalog input safety

Layer / File(s) Summary
Scope and validate exercise parsing
scripts/generate_exercises.py, tests/test_generate_exercises.py
Exercise rows are parsed within individual <li> elements. Suspicious names stop generation before output is written.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: tamcore

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 31.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title indicates a security audit but does not identify the primary changes, such as sanitization, path validation, or parser hardening. Replace the generic title with a concise summary of the main security changes, such as authentication-data sanitization and path-validation hardening.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch security/audit-20260810

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.

@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: 5

🤖 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 `@garminconnect/client.py`:
- Around line 196-204: Update every authentication DEBUG log at the cited
exception sites to pass the caught exception through _sanitize_exception_text()
instead of logging it directly, including the ticket-consumption request paths.
Preserve the existing log messages and exception handling while ensuring no raw
exception text is emitted.
- Around line 73-77: Replace the pre-validation loop around token_path with
descriptor-relative traversal that opens and retains each ancestor directory
using no-follow directory checks, then resolves the token file relative to the
final directory descriptor. Apply this protected traversal to both load() and
dump() (and logout() if it accesses the tokenstore), ensuring every directory
component is checked during the actual operation so replacement after validation
cannot redirect access.

In `@scripts/generate_exercises.py`:
- Around line 135-140: Update the suspect-entry guard in
scripts/generate_exercises.py:135-140 to report only non-sensitive diagnostics
such as the count or row indexes, never the extracted labels in suspect. Update
the related test in tests/test_generate_exercises.py:39-47 to capture the
SystemExit value and assert that “user@example.com” is absent from the error
text.
- Around line 32-34: Update the LI parsing in
scripts/generate_exercises.py:32-34 to use an HTML-aware parser or ensure
matches stop before a subsequent <li> start tag, preserving separate item
boundaries when </li> is omitted. Add the regression case in
tests/test_generate_exercises.py:32-36 with an attribute-only unclosed item
followed by an item containing user@example.com, and assert no combined row is
produced.
- Around line 45-50: Update the SUSPECT regular expression compilation to use
re.IGNORECASE so uppercase URL schemes and GUID characters are detected; add
regression cases covering HTTPS URLs and uppercase GUIDs while preserving the
existing safety-gate behavior.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 35d0c0f5-5eb5-4db2-86f5-0ce59773ce30

📥 Commits

Reviewing files that changed from the base of the PR and between a5ba3c9 and 662360c.

📒 Files selected for processing (7)
  • garminconnect/__init__.py
  • garminconnect/client.py
  • scripts/generate_exercises.py
  • tests/conftest.py
  • tests/test_cassette_sanitization.py
  • tests/test_garmin_unit.py
  • tests/test_generate_exercises.py

Comment thread garminconnect/client.py
Comment on lines +73 to +77
# Reject symlinks anywhere in the tokenstore ancestry (e.g.
# ~/.garminconnect -> /attacker/dir). O_NOFOLLOW on the final open()
# only covers the last component; an intermediate symlinked directory
# would still redirect load/dump/logout into an attacker-controlled tree.
for check_path in (token_path, *token_path.parents):

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.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

Prevent ancestor symlink replacement after validation.

The symlink checks complete before dump() and load() open or create files. A local attacker can replace a checked ancestor with a symlink after this loop. O_NOFOLLOW protects only the final file component. It does not protect replaced parent directories.

Use descriptor-relative operations with no-follow checks for every directory component. Apply the same protected path traversal to both reads and writes.

🤖 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 `@garminconnect/client.py` around lines 73 - 77, Replace the pre-validation
loop around token_path with descriptor-relative traversal that opens and retains
each ancestor directory using no-follow directory checks, then resolves the
token file relative to the final directory descriptor. Apply this protected
traversal to both load() and dump() (and logout() if it accesses the
tokenstore), ensuring every directory component is checked during the actual
operation so replacement after validation cannot redirect access.

Comment thread garminconnect/client.py
Comment thread scripts/generate_exercises.py Outdated
Comment thread scripts/generate_exercises.py
Comment thread scripts/generate_exercises.py Outdated
- Sanitize remaining auth-path exception logs (mobile/portal cffi 429 and
  failure debugs, token-validation, DI refresh, JWT_WEB refresh) through
  _sanitize_exception_text().
- generate_exercises: replace the element-splitting regex with an
  HTMLParser-based extractor (item boundaries hold even when </li> is
  omitted); SUSPECT regex is now case-insensitive (uppercase URL schemes
  and GUID hex); the refusal message reports row positions only, never
  the suspected labels.
- ruff format.

Skipped: descriptor-relative (dir_fd) tokenstore traversal. The ancestry
walk plus O_NOFOLLOW on the final component covers the reported model;
the residual TOCTOU window needs a local attacker racing a token write
mid-operation, which is out of proportion for this library.

@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: 3

🤖 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 `@garminconnect/client.py`:
- Around line 1631-1636: Update the decoded_path validation in _run_request so
safely encoded display-name content remains valid: check raw path input for
literal ? and # before decoding, and replace the broad ".." substring rejection
with segment-based traversal validation that rejects only actual dot-directory
segments. Preserve rejection of genuine path traversal while allowing encoded
names such as name?fragment and first..last.

In `@scripts/generate_exercises.py`:
- Around line 58-64: Update handle_data and the selected-span assembly flow to
accumulate every text fragment, including nested markup, instead of overwriting
self._cur[2]. Reassemble the buffered fragments with "/" before applying SUSPECT
filtering and flushing each row, ensuring complete labels such as URL prefixes
are evaluated and sensitive fragments are not emitted.
- Around line 88-92: Update parse to flush any remaining item after
parser.close(), ensuring a final exercise label without a closing </li> is
included; add a regression test covering omitted final </li>.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e3f98ea6-3ac9-4d46-9461-c1634aa70b64

📥 Commits

Reviewing files that changed from the base of the PR and between 662360c and a51330b.

📒 Files selected for processing (3)
  • garminconnect/client.py
  • scripts/generate_exercises.py
  • tests/test_generate_exercises.py

Comment thread garminconnect/client.py
Comment thread scripts/generate_exercises.py Outdated
Comment thread scripts/generate_exercises.py
- handle_data() overwrote the captured span text on every call instead of
  accumulating it, so a label split across nested tags (e.g. a stray
  https:// URL with part of it inside a <b>) lost its leading fragment
  and could slip past the SUSPECT safety filter.
- parser.close() does not synthesize a missing </li>, so the last item
  in the source HTML was silently dropped when its closing tag was
  omitted; flush explicitly after close().

Skipped: the client.py display-name path-validation false positive
(display names with literal ?, #, or .. get percent-encoded, then
rejected after decoding). Loosening that guard to check raw ?/# and
exact-segment ".." would reopen the "foo/%2e%2e;/bar" matrix-parameter
bypass the existing test suite guards against, for a false positive
that requires characters Garmin display names don't contain in
practice.

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tests/test_generate_exercises.py (1)

53-64: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Assert that refusal leaves the output file untouched.

This test checks only the SystemExit message. It would still pass if OUT.write_text() ran before the refusal. Point gen.OUT at a temporary path and assert that the path does not exist after gen.main() raises. (raw.githubusercontent.com)

🤖 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 `@tests/test_generate_exercises.py` around lines 53 - 64, Update
test_main_refuses_suspect_names to redirect gen.OUT to a temporary output path
before calling gen.main(), then assert that the path does not exist after
SystemExit is raised, while preserving the existing message assertions.
scripts/generate_exercises.py (1)

177-185: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report source row locations, not catalog indexes.

parse() deduplicates and sorts entries before main() computes suspect, so rows [... reports zero-based positions in the sorted catalog. If the message must point to input rows, preserve source indexes through parsing.

🤖 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 `@scripts/generate_exercises.py` around lines 177 - 185, Update parse() and the
main() suspect-reporting flow so each parsed exercise retains its original input
row index through deduplication and sorting. Use those preserved source indexes
in the refusal message’s rows list, while keeping the existing sensitive-name
detection and deduplication behavior unchanged.
🤖 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 `@scripts/generate_exercises.py`:
- Around line 59-64: Update the HTML parser’s span tracking to maintain nested
`<span>` depth rather than a boolean `_in_span`; initialize/reset that depth in
`__init__` and adjust span start/end handling so outer-span text continues
accumulating after an inner span closes. Preserve the existing `_cur[2]`
accumulation behavior for all text within the outer span.

---

Outside diff comments:
In `@scripts/generate_exercises.py`:
- Around line 177-185: Update parse() and the main() suspect-reporting flow so
each parsed exercise retains its original input row index through deduplication
and sorting. Use those preserved source indexes in the refusal message’s rows
list, while keeping the existing sensitive-name detection and deduplication
behavior unchanged.

In `@tests/test_generate_exercises.py`:
- Around line 53-64: Update test_main_refuses_suspect_names to redirect gen.OUT
to a temporary output path before calling gen.main(), then assert that the path
does not exist after SystemExit is raised, while preserving the existing message
assertions.
🪄 Autofix

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: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 10afb421-80be-4062-95ca-9f7a6b293bfb

📥 Commits

Reviewing files that changed from the base of the PR and between a51330b and 764ddfa.

📒 Files selected for processing (2)
  • scripts/generate_exercises.py
  • tests/test_generate_exercises.py

Comment thread scripts/generate_exercises.py
A plain in/out flag dropped back "out of span" on an inner </span>,
so <span><span>Back</span> Squat</span> lost the " Squat" that
followed the nested tag but was still inside the outer, selected
span. Track nesting depth instead so text keeps accumulating until
the matching outer </span>.
A quoted display name can legitimately contain a run of dots (e.g.
"first..last"); the old substring match rejected any decoded path
containing "..", even inside a single segment. Only reject a path
segment that is exactly ".." or "..;<matrix-params>" (a known
filter-bypass trick), so real traversal is still caught but a
legitimate name is not.

?/#/backslash checks are unchanged: they still run against the
decoded path, since callers only ever reach those characters raw or
via percent-encoding by embedding a query string in the path, which
the existing test suite already covers as a rejected case.
@cyberjunky
cyberjunky merged commit 02eab17 into master Aug 10, 2026
3 of 4 checks passed
@cyberjunky
cyberjunky deleted the security/audit-20260810 branch August 10, 2026 19:15

@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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
tests/test_garmin_unit.py (1)

241-242: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Verify the clean path in the weekly aggregation test.

These assertions verify two parameter values, but they do not verify the path passed to connectapi. A regression that embeds aggregation=weekly in the path could still pass. Assert the call count and the exact path, then keep the parameter assertions.

Proposed test update
+        mock.assert_called_once()
+        assert mock.call_args.args == (
+            "/biometric-service/stats/functionalThresholdPower/range/2025-06-01/2025-06-30",
+        )
         assert mock.call_args.kwargs["params"]["sport"] == "RUNNING"
         assert mock.call_args.kwargs["params"]["aggregation"] == "weekly"
🤖 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 `@tests/test_garmin_unit.py` around lines 241 - 242, Update the weekly
aggregation test around the existing mock call assertions to verify connectapi
was called exactly once with the clean expected path, then retain the sport and
aggregation parameter assertions. Use the path value already expected by the
test’s request setup rather than allowing aggregation to be embedded in the
path.
garminconnect/client.py (1)

193-206: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Sanitize all authentication failure outputs.

  • Do not log the full MFA response at client.py:1221.
  • Do not log raw DI response text at client.py:1329.
  • Catch fallback request errors at client.py:1258 and raise GarminConnectConnectionError(_sanitize_exception_text(err)) from None, because resume_login() can otherwise propagate a ticket-bearing exception.
🤖 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 `@garminconnect/client.py` around lines 193 - 206, Sanitize every
authentication failure output: in the MFA handling near the login flow, replace
full-response logging with a non-sensitive summary; in the DI response handling,
avoid logging raw response text; and in the fallback request path used by
resume_login(), catch request exceptions and raise GarminConnectConnectionError
using _sanitize_exception_text(err) from None so ticket-bearing details are
redacted.
🤖 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.

Outside diff comments:
In `@garminconnect/client.py`:
- Around line 193-206: Sanitize every authentication failure output: in the MFA
handling near the login flow, replace full-response logging with a non-sensitive
summary; in the DI response handling, avoid logging raw response text; and in
the fallback request path used by resume_login(), catch request exceptions and
raise GarminConnectConnectionError using _sanitize_exception_text(err) from None
so ticket-bearing details are redacted.

In `@tests/test_garmin_unit.py`:
- Around line 241-242: Update the weekly aggregation test around the existing
mock call assertions to verify connectapi was called exactly once with the clean
expected path, then retain the sport and aggregation parameter assertions. Use
the path value already expected by the test’s request setup rather than allowing
aggregation to be embedded in the path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 1991c350-f665-4373-8d53-6ab8e53d7337

📥 Commits

Reviewing files that changed from the base of the PR and between 999914c and de7bf51.

📒 Files selected for processing (2)
  • garminconnect/client.py
  • tests/test_garmin_unit.py

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