├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ └── bug_report.md ├── stale.yml └── workflows │ ├── codeql-analysis.yml │ └── docker-ghcr.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── check_and_gen.py ├── config.py ├── docker └── download_chromedriver.py ├── docker_check_and_gen.sh ├── docker_hunt.sh ├── ghunt.py ├── lib ├── __init__.py ├── banner.py ├── calendar.py ├── gmaps.py ├── listener.py ├── metadata.py ├── modwall.py ├── os_detect.py ├── photos.py ├── search.py ├── utils.py └── youtube.py ├── modules ├── doc.py ├── email.py ├── gaia.py └── youtube.py ├── profile_pics └── .keep ├── requirements.txt └── resources └── .gitkeep /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: mxrch 2 | custom: https://www.blockchain.com/btc/address/362MrYHLLMzWBbvBG7K5yzF18ouxMZeNge 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **System (please complete the following information):** 27 | - OS 28 | - Python version 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 30 5 | 6 | # Number of days of inactivity before an Issue or Pull Request with the stale label is closed. 7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 8 | daysUntilClose: 21 9 | 10 | # Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled) 11 | onlyLabels: [] 12 | 13 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 14 | exemptLabels: 15 | - pinned 16 | - security 17 | - bug 18 | - keep 19 | 20 | # Set to true to ignore issues in a project (defaults to false) 21 | exemptProjects: false 22 | 23 | # Set to true to ignore issues in a milestone (defaults to false) 24 | exemptMilestones: false 25 | 26 | # Set to true to ignore issues with an assignee (defaults to false) 27 | exemptAssignees: false 28 | 29 | # Label to use when marking as stale 30 | staleLabel: stale 31 | 32 | # Comment to post when removing the stale label. 33 | # unmarkComment: > 34 | # Your comment here. 35 | 36 | # Comment to post when closing a stale Issue or Pull Request. 37 | # closeComment: > 38 | # Your comment here. 39 | 40 | # Limit the number of actions per hour, from 1-30. Default is 30 41 | limitPerRun: 30 42 | 43 | # Limit to only `issues` or `pulls` 44 | # only: issues 45 | 46 | # Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': 47 | pulls: 48 | daysUntilStale: 60 49 | markComment: > 50 | This pull request has been automatically marked as stale because it has not had 51 | activity on the last 60 days. It will be closed in 7 days if no further activity occurs. Thank you 52 | for your contributions. 53 | 54 | issues: 55 | markComment: > 56 | This issue has been automatically marked as stale because it has not had 57 | recent activity on the last 18 days. It will be closed in 6 days if no further activity occurs. 58 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | #Don't ask me what any of this means, this file was generated with Github's web-GUI 2 | 3 | # For most projects, this workflow file will not need changing; you simply need 4 | # to commit it to your repository. 5 | # 6 | # You may wish to alter this file to override the set of languages analyzed, 7 | # or to provide custom queries or build logic. 8 | name: "CodeQL" 9 | 10 | on: 11 | push: 12 | branches: [master] 13 | pull_request: 14 | # The branches below must be a subset of the branches above 15 | branches: [master] 16 | 17 | jobs: 18 | analyze: 19 | name: Analyze 20 | runs-on: ubuntu-latest 21 | 22 | strategy: 23 | fail-fast: false 24 | matrix: 25 | # Override automatic language detection by changing the below list 26 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 27 | language: ['python'] 28 | # Learn more... 29 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v2 34 | with: 35 | # We must fetch at least the immediate parents so that if this is 36 | # a pull request then we can checkout the head. 37 | fetch-depth: 2 38 | 39 | # If this run was triggered by a pull request event, then checkout 40 | # the head of the pull request instead of the merge commit. 41 | - run: git checkout HEAD^2 42 | if: ${{ github.event_name == 'pull_request' }} 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.github/workflows/docker-ghcr.yml: -------------------------------------------------------------------------------- 1 | name: 'Build & Push Image' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | name: 'Build' 12 | runs-on: ubuntu-latest 13 | env: 14 | IMAGE_NAME: ghunt 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@v1 21 | 22 | - name: Login to GitHub Container Registry 23 | uses: docker/login-action@v1 24 | with: 25 | registry: ghcr.io 26 | username: ${{ github.repository_owner }} 27 | password: ${{ secrets.GHCR_TOKEN }} 28 | 29 | - name: Build and push 30 | uses: docker/build-push-action@v2 31 | with: 32 | context: . 33 | push: ${{ GitHub.event_name != 'pull_request' }} 34 | tags: ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:latest 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | lib/__pycache__/ 3 | modules/__pycache__/ 4 | profile_pics/*.jpg 5 | resources/ 6 | chromedriver 7 | chromedriver.exe 8 | data.txt 9 | login.py 10 | .DS_Store 11 | debug.log -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8.6-slim-buster 2 | 3 | ARG UID=1000 4 | ARG GID=1000 5 | 6 | WORKDIR /usr/src/app 7 | 8 | RUN groupadd -o -g ${GID} -r app && adduser --system --home /home/app --ingroup app --uid ${UID} app && \ 9 | chown -R app:app /usr/src/app && \ 10 | apt-get update && \ 11 | apt-get install -y curl unzip gnupg && \ 12 | curl -sS -o - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - && \ 13 | echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google-chrome.list && \ 14 | apt-get update && \ 15 | apt-get install -y google-chrome-stable && \ 16 | rm -rf /var/lib/apt/lists/* 17 | 18 | COPY --chown=app:app requirements.txt docker/download_chromedriver.py ./ 19 | 20 | RUN python3 -m pip install --no-cache-dir -r requirements.txt && \ 21 | python3 download_chromedriver.py && chown -R app:app /usr/src/app 22 | 23 | COPY --chown=app:app . . 24 | 25 | USER app 26 | 27 | ENTRYPOINT [ "python3" ] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![screenshot](https://files.catbox.moe/8a5nzs.png) 2 | 3 | ![Python minimum version](https://img.shields.io/badge/Python-3.7%2B-brightgreen) 4 | 5 | ![Docker Cloud Build Status](https://img.shields.io/docker/cloud/build/mxrch/ghunt) ![GitHub Workflow Status](https://img.shields.io/github/workflow/status/mxrch/ghunt/CodeQL?label=CodeQL) 6 | # Description 7 | GHunt is a modulable OSINT tool designed to evolve over the years, and incorporates many techniques to investigate Google accounts, or objects.\ 8 | It currently has **email**, **document**, **youtube** and **gaia** modules. 9 | 10 | ## What can GHunt find ? 11 | 12 | 🗺️ **Email** module: 13 | - Owner's name 14 | - Gaia ID 15 | - Last time the profile was edited 16 | - Profile picture (+ detect custom picture) 17 | - If the account is a Hangouts Bot 18 | - Activated Google services (YouTube, Photos, Maps, News360, Hangouts, etc.) 19 | - Possible YouTube channel 20 | - Possible other usernames 21 | - Google Maps reviews (M) 22 | - Possible physical location (M) 23 | - Events from Google Calendar (C) 24 | - Organizations (work & education) (A) 25 | - Contact emails (A) 26 | - Contact phones (A) 27 | - Addresses (A) 28 | - ~~Public photos (P)~~ 29 | - ~~Phones models (P)~~ 30 | - ~~Phones firmwares (P)~~ 31 | - ~~Installed softwares (P)~~ 32 | 33 | 🗺️ **Document** module: 34 | - Owner's name 35 | - Owner's Gaia ID 36 | - Owner's profile picture (+ detect custom picture) 37 | - Creation date 38 | - Last time the document was edited 39 | - Public permissions 40 | - Your permissions 41 | 42 | 🗺️ **Youtube** module: 43 | - Owner's Gaia ID (through Wayback Machine) 44 | - Detect if the email is visible 45 | - Country 46 | - Description 47 | - Total views 48 | - Joined date 49 | - Primary links (social networks) 50 | - All infos accessible by the Gaia module 51 | 52 | 🗺️ **Gaia** module: 53 | - Owner's name 54 | - Profile picture (+ detect custom picture) 55 | - Possible YouTube channel 56 | - Possible other usernames 57 | - Google Maps reviews (M) 58 | - Possible physical location (M) 59 | - Organizations (work & education) (A) 60 | - Contact emails (A) 61 | - Contact phones (A) 62 | - Addresses (A) 63 | 64 | The features marked with a **(P)** require the target account to have the default setting of `Allow the people you share content with to download your photos and videos` on the Google AlbumArchive, or if the target has ever used Picasa linked to their Google account.\ 65 | More info [here](https://github.com/mxrch/GHunt#%EF%B8%8F-protecting-yourself). 66 | 67 | Those marked with a **(M)** require the Google Maps reviews of the target to be public (they are by default). 68 | 69 | Those marked with a **(C)** require user to have Google Calendar set on public (default it is closed). 70 | 71 | Those marked with a **(A)** require user to have the additional info set [on profile](https://myaccount.google.com/profile) with privacy option "Anyone" enabled. 72 | 73 | # Screenshots 74 |

75 | 76 |

77 | 78 | ## 📰 Latest news 79 | - **02/10/2020** : Since a few days ago, Google returns a 404 when we try to access someone's Google Photos public albums, we can only access it if we have a link to one of their albums.\ 80 | Either this is a bug and this will be fixed, either it's a protection that we need to find how to bypass. 81 | - **03/10/2020** : Successfully bypassed. 🕺 (commit 01dc016)\ 82 | It requires the "Profile photos" album to be public (it is by default) 83 | - **20/10/2020** : Google WebArchive now returns a 404 even when coming from the "Profile photos" album, so **the photos scraping is temporary (or permanently) disabled.** (commit e762543) 84 | - **25/11/2020** : Google now removes the name from the Google Maps profile if the user has 0 reviews (or contributions, even private). I did not find a bypass for the moment, so **all the help in the research of a bypass is appreciated.** 85 | - **20/03/2021** : Successfully bypassed. 🕺 (commit b3b01bc) 86 | 87 | # Installation 88 | 89 | ## Manual installation 90 | - Make sure you have Python 3.7+ installed. (I developed it with Python 3.8.1) 91 | - Some Python modules are required which are contained in `requirements.txt` and will be installed below. 92 | 93 | ### 1. Chromedriver & Google Chrome 94 | This project uses Selenium and automatically downloads the correct driver for your Chrome version. \ 95 | ⚠️ So just make sure to have Google Chrome installed. 96 | 97 | ### 2. Cloning 98 | Open your terminal, and execute the following commands : 99 | ```bash 100 | git clone https://github.com/mxrch/ghunt 101 | cd ghunt 102 | ``` 103 | 104 | ### 3. Requirements 105 | In the GHunt folder, run: 106 | ```bash 107 | python3 -m pip install -r requirements.txt 108 | ``` 109 | Adapt the command to your operating system if needed. 110 | 111 | ## Docker 112 | The Docker image is automatically built and pushed to Dockerhub after each push on this repo.\ 113 | You can pull the Docker image with: 114 | 115 | ``` 116 | docker pull ghcr.io/mxrch/ghunt 117 | ``` 118 | 119 | Then, you can use the `docker_check_and_gen.sh` and `docker_hunt.sh` to invoke GHunt through Docker, or you can use these commants : 120 | 121 | ``` 122 | docker run -v ghunt-resources:/usr/src/app/resources -ti ghcr.io/mxrch/ghunt check_and_gen.py 123 | docker run -v ghunt-resources:/usr/src/app/resources -ti ghcr.io/mxrch/ghunt ghunt.py 124 | ``` 125 | 126 | # Usage 127 | For the first run and sometime after, you'll need to check the validity of your cookies.\ 128 | To do this, run `check_and_gen.py`. \ 129 | If you don't have cookies stored (ex: first launch), you will be asked for the required cookies. If they are valid, it will generate the Authentication token and the Google Docs & Hangouts tokens. 130 | 131 | Then, you can run the tool like this: 132 | ```bash 133 | python3 ghunt.py email larry@google.com 134 | ``` 135 | ```bash 136 | python3 ghunt.py doc https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms 137 | ``` 138 | 139 | ⚠️ I suggest you make an empty account just for this or use an account where you never login because depending on your browser/location, re-logging in into the Google Account used for the cookies can deauthorize them. 140 | 141 | # Where I get these cookies ? 142 | 143 | ## Auto (faster) 144 | You can download the GHunt Companion extension that will automate the cookies extraction in 1-click !\ 145 | \ 146 | [![Firefox](https://ffp4g1ylyit3jdyti1hqcvtb-wpengine.netdna-ssl.com/addons/files/2015/11/get-the-addon.png)](https://addons.mozilla.org/fr/firefox/addon/ghunt-companion/)   [![Chrome](https://storage.googleapis.com/web-dev-uploads/image/WlD8wC6g8khYWPJUsQceQkhXSlv1/UV4C4ybeBTsZt43U4xis.png)](https://chrome.google.com/webstore/detail/ghunt-companion/dpdcofblfbmmnikcbmmiakkclocadjab)   [![Edge](https://user-images.githubusercontent.com/11660256/111323589-4f4c7c00-866a-11eb-80ff-da7de777d7c0.png)](https://microsoftedge.microsoft.com/addons/detail/ghunt-companion/jhgmpcigklnbjglpipnbnjhdncoihhdj) 147 | 148 | You just need to launch the check_and_gen.py file and choose the extraction mode you want to use, between putting GHunt in listening mode, or copy/paste the encoded cookies in base64. 149 | 150 | ## Manual 151 | 1. Be logged-in to myaccount.google.com 152 | 2. After that, open the Dev Tools window and navigate to the Network tab\ 153 | If you don't know how to open it, just right-click anywhere and click "Inspect Element". 154 | 3. Go to myaccount.google.com, and in the browser requests, select the GET on "accounts.google.com" that gives a 302 redirect 155 | 4. Then you'll find every cookie you need in the "cookies" section. 156 | 157 | ![cookies](https://files.catbox.moe/15j8pg.png) 158 | 159 | # 🛡️ Protecting yourself 160 | Regarding the collection of metadata from your Google Photos account: 161 | 162 | Given that Google shows **"X require access"** on [your Google Account Dashboard](https://myaccount.google.com/intro/dashboard), you might imagine that you had to explicitly authorize another account in order for it to access your pictures; but this is not the case.\ 163 | Any account can access your AlbumArchive (by default): 164 | 165 | ![account-dashboard](https://files.catbox.moe/ufqc9g.jpg) 166 | 167 | Here's how to check and fix the fact that you're vulnerable (which you most likely are):\ 168 | Go to https://get.google.com/albumarchive/ while logged in with your Google account. You will be **automatically** redirected to your correct albumarchive URL (`https://get.google.com/albumarchive/YOUR-GOOGLE-ID-HERE`). After that, click the three dots on the top left corner, and click on **setting** 169 | 170 | ![three-dots-setting](https://files.catbox.moe/ru6kci.jpg) 171 | 172 | Then, uncheck the only option there: 173 | 174 | ![setting](https://files.catbox.moe/b8879l.jpg) 175 | 176 | 177 | On another note, the target account will **also** be vulnerable if they have ever used **Picasa** linked to their Google account in any way, shape or form. For more details on this, read PinkDev1's comment on [issue #10](https://github.com/mxrch/GHunt/issues/10).\ 178 | For now, the only (known) solution to this is to delete the Picasa albums from your AlbumArchive. 179 | 180 | # Thanks 181 | This tool is based on [Sector's research on Google IDs](https://sector035.nl/articles/getting-a-grasp-on-google-ids) and completed by my own as well.\ 182 | If I have the motivation to write a blog post about it, I'll add the link here ! 183 | - Palenath (for the name bypass) 184 | -------------------------------------------------------------------------------- /check_and_gen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from lib import modwall; modwall.check() # We check the requirements 4 | 5 | import json 6 | from time import time 7 | from os.path import isfile 8 | from pathlib import Path 9 | from ssl import SSLError 10 | import base64 11 | from copy import deepcopy 12 | 13 | import httpx 14 | from seleniumwire import webdriver 15 | from selenium.common.exceptions import TimeoutException as SE_TimeoutExepction 16 | from bs4 import BeautifulSoup as bs 17 | 18 | import config 19 | from lib.utils import * 20 | from lib import listener 21 | 22 | 23 | # We change the current working directory to allow using GHunt from anywhere 24 | os.chdir(Path(__file__).parents[0]) 25 | 26 | def get_saved_cookies(): 27 | ''' returns cookie cache if exists ''' 28 | if isfile(config.data_path): 29 | try: 30 | with open(config.data_path, 'r') as f: 31 | out = json.loads(f.read()) 32 | cookies = out["cookies"] 33 | print("[+] Detected stored cookies, checking it") 34 | return cookies 35 | except Exception: 36 | print("[-] Stored cookies are corrupted\n") 37 | return False 38 | print("[-] No stored cookies found\n") 39 | return False 40 | 41 | 42 | def get_authorization_source(cookies): 43 | ''' returns html source of hangouts page if user authorized ''' 44 | req = httpx.get("https://docs.google.com/document/u/0/?usp=direct_url", 45 | cookies=cookies, headers=config.headers) 46 | 47 | if req.status_code == 200: 48 | req2 = httpx.get("https://hangouts.google.com", cookies=cookies, 49 | headers=config.headers) 50 | if "myaccount.google.com" in req2.text: 51 | return req.text 52 | return None 53 | 54 | 55 | def save_tokens(hangouts_auth, gdoc_token, hangouts_token, internal_token, internal_auth, cac_key, cookies, osid): 56 | ''' save tokens to file ''' 57 | output = { 58 | "hangouts_auth": hangouts_auth, "internal_auth": internal_auth, 59 | "keys": {"gdoc": gdoc_token, "hangouts": hangouts_token, "internal": internal_token, "clientauthconfig": cac_key}, 60 | "cookies": cookies, 61 | "osids": { 62 | "cloudconsole": osid 63 | } 64 | } 65 | with open(config.data_path, 'w') as f: 66 | f.write(json.dumps(output)) 67 | 68 | 69 | def get_hangouts_tokens(driver, cookies, tmprinter): 70 | ''' gets auth and hangouts token ''' 71 | 72 | tmprinter.out("Setting cookies...") 73 | driver.get("https://hangouts.google.com/robots.txt") 74 | for k, v in cookies.items(): 75 | driver.add_cookie({'name': k, 'value': v}) 76 | 77 | tmprinter.out("Fetching Hangouts homepage...") 78 | driver.get("https://hangouts.google.com") 79 | 80 | tmprinter.out("Waiting for the /v2/people/me/blockedPeople request, it " 81 | "can takes a few minutes...") 82 | try: 83 | req = driver.wait_for_request('/v2/people/me/blockedPeople', timeout=config.browser_waiting_timeout) 84 | tmprinter.out("Request found !") 85 | driver.close() 86 | tmprinter.out("") 87 | except SE_TimeoutExepction: 88 | tmprinter.out("") 89 | exit("\n[!] Selenium TimeoutException has occured. Please check your internet connection, proxies, vpns, et cetera.") 90 | 91 | 92 | hangouts_auth = req.headers["Authorization"] 93 | hangouts_token = req.url.split("key=")[1] 94 | 95 | return (hangouts_auth, hangouts_token) 96 | 97 | def drive_interceptor(request): 98 | global internal_auth, internal_token 99 | 100 | if request.url.endswith(('.woff2', '.css', '.png', '.jpeg', '.svg', '.gif')): 101 | request.abort() 102 | elif request.path != "/drive/my-drive" and "Accept" in request.headers and \ 103 | any([x in request.headers["Accept"] for x in ["image", "font-woff"]]): 104 | request.abort() 105 | if "authorization" in request.headers and "_" in request.headers["authorization"] and \ 106 | request.headers["authorization"]: 107 | internal_auth = request.headers["authorization"] 108 | 109 | def get_internal_tokens(driver, cookies, tmprinter): 110 | """ Extract the mysterious token used for Internal People API 111 | and some Drive requests, with the Authorization header""" 112 | 113 | global internal_auth, internal_token 114 | 115 | internal_auth = "" 116 | 117 | tmprinter.out("Setting cookies...") 118 | driver.get("https://drive.google.com/robots.txt") 119 | for k, v in cookies.items(): 120 | driver.add_cookie({'name': k, 'value': v}) 121 | 122 | start = time() 123 | 124 | tmprinter.out("Fetching Drive homepage...") 125 | driver.request_interceptor = drive_interceptor 126 | driver.get("https://drive.google.com/drive/my-drive") 127 | 128 | body = driver.page_source 129 | internal_token = body.split("appsitemsuggest-pa")[1].split(",")[3].strip('"') 130 | 131 | tmprinter.out(f"Waiting for the authorization header, it " 132 | "can takes a few minutes...") 133 | 134 | while True: 135 | if internal_auth and internal_token: 136 | tmprinter.clear() 137 | break 138 | elif time() - start > config.browser_waiting_timeout: 139 | tmprinter.clear() 140 | exit("[-] Timeout while fetching the Internal tokens.\nPlease increase the timeout in config.py or try again.") 141 | 142 | del driver.request_interceptor 143 | 144 | return internal_auth, internal_token 145 | 146 | def gen_osid(cookies, domain, service): 147 | req = httpx.get(f"https://accounts.google.com/ServiceLogin?service={service}&osid=1&continue=https://{domain}/&followup=https://{domain}/&authuser=0", 148 | cookies=cookies, headers=config.headers) 149 | 150 | body = bs(req.text, 'html.parser') 151 | 152 | params = {x.attrs["name"]:x.attrs["value"] for x in body.find_all("input", {"type":"hidden"})} 153 | 154 | headers = {**config.headers, **{"Content-Type": "application/x-www-form-urlencoded"}} 155 | req = httpx.post(f"https://{domain}/accounts/SetOSID", cookies=cookies, data=params, headers=headers) 156 | 157 | osid_header = [x for x in req.headers["set-cookie"].split(", ") if x.startswith("OSID")] 158 | if not osid_header: 159 | exit("[-] No OSID header detected, exiting...") 160 | 161 | osid = osid_header[0].split("OSID=")[1].split(";")[0] 162 | 163 | return osid 164 | 165 | def get_clientauthconfig_key(cookies): 166 | """ Extract the Client Auth Config API token.""" 167 | 168 | req = httpx.get("https://console.cloud.google.com", 169 | cookies=cookies, headers=config.headers) 170 | 171 | if req.status_code == 200 and "pantheon_apiKey" in req.text: 172 | cac_key = req.text.split('pantheon_apiKey\\x22:')[1].split(",")[0].strip('\\x22') 173 | return cac_key 174 | exit("[-] I can't find the Client Auth Config API...") 175 | 176 | def check_cookies(cookies): 177 | wanted = ["authuser", "continue", "osidt", "ifkv"] 178 | 179 | req = httpx.get(f"https://accounts.google.com/ServiceLogin?service=cloudconsole&osid=1&continue=https://console.cloud.google.com/&followup=https://console.cloud.google.com/&authuser=0", 180 | cookies=cookies, headers=config.headers) 181 | 182 | body = bs(req.text, 'html.parser') 183 | 184 | params = [x.attrs["name"] for x in body.find_all("input", {"type":"hidden"})] 185 | for param in wanted: 186 | if param not in params: 187 | return False 188 | 189 | return True 190 | 191 | def getting_cookies(cookies): 192 | choices = ("You can facilitate configuring GHunt by using the GHunt Companion extension on Firefox, Chrome, Edge and Opera here :\n" 193 | "=> https://github.com/mxrch/ghunt_companion\n\n" 194 | "[1] (Companion) Put GHunt on listening mode (currently not compatible with docker)\n" 195 | "[2] (Companion) Paste base64-encoded cookies\n" 196 | "[3] Enter manually all cookies\n\n" 197 | "Choice => ") 198 | 199 | choice = input(choices) 200 | if choice not in ["1","2","3"]: 201 | exit("Please choose a valid choice. Exiting...") 202 | 203 | if choice == "1": 204 | received_cookies = listener.run() 205 | cookies = json.loads(base64.b64decode(received_cookies)) 206 | 207 | elif choice == "2": 208 | received_cookies = input("Paste the cookies here => ") 209 | cookies = json.loads(base64.b64decode(received_cookies)) 210 | 211 | elif choice == "3": 212 | for name in cookies.keys(): 213 | if not cookies[name]: 214 | cookies[name] = input(f"{name} => ").strip().strip('\"') 215 | 216 | return cookies 217 | 218 | if __name__ == '__main__': 219 | 220 | driverpath = get_driverpath() 221 | cookies_from_file = get_saved_cookies() 222 | 223 | tmprinter = TMPrinter() 224 | 225 | cookies = {"SID": "", "SSID": "", "APISID": "", "SAPISID": "", "HSID": "", "LSID": "", "__Secure-3PSID": "", "CONSENT": config.default_consent_cookie, "PREF": config.default_pref_cookie} 226 | 227 | new_cookies_entered = False 228 | 229 | if not cookies_from_file: 230 | cookies = getting_cookies(cookies) 231 | new_cookies_entered = True 232 | else: 233 | # in case user wants to enter new cookies (example: for new account) 234 | html = get_authorization_source(cookies_from_file) 235 | valid_cookies = check_cookies(cookies_from_file) 236 | valid = False 237 | if html and valid_cookies: 238 | print("[+] The cookies seems valid !") 239 | valid = True 240 | else: 241 | print("[-] Seems like the cookies are invalid.") 242 | new_gen_inp = input("\nDo you want to enter new browser cookies from accounts.google.com ? (Y/n) ").lower() 243 | if new_gen_inp == "y": 244 | cookies = getting_cookies(cookies) 245 | new_cookies_entered = True 246 | 247 | elif not valid: 248 | exit("Please put valid cookies. Exiting...") 249 | 250 | 251 | # Validate cookies 252 | if new_cookies_entered or not cookies_from_file: 253 | html = get_authorization_source(cookies) 254 | if html: 255 | print("\n[+] The cookies seems valid !") 256 | else: 257 | exit("\n[-] Seems like the cookies are invalid, try regenerating them.") 258 | 259 | if not new_cookies_entered: 260 | cookies = cookies_from_file 261 | choice = input("Do you want to generate new tokens ? (Y/n) ").lower() 262 | if choice != "y": 263 | exit() 264 | 265 | # Start the extraction process 266 | 267 | # We first initialize the browser driver 268 | chrome_options = get_chrome_options_args(config.headless) 269 | options = { 270 | 'connection_timeout': None # Never timeout, otherwise it floods errors 271 | } 272 | 273 | tmprinter.out("Starting browser...") 274 | driver = webdriver.Chrome( 275 | executable_path=driverpath, seleniumwire_options=options, 276 | options=chrome_options 277 | ) 278 | driver.header_overrides = config.headers 279 | 280 | print("Extracting the tokens...\n") 281 | # Extracting Google Docs token 282 | trigger = '\"token\":\"' 283 | if trigger not in html: 284 | exit("[-] I can't find the Google Docs token in the source code...\n") 285 | else: 286 | gdoc_token = html.split(trigger)[1][:100].split('"')[0] 287 | print("Google Docs Token => {}".format(gdoc_token)) 288 | 289 | print("Generating OSID for the Cloud Console...") 290 | osid = gen_osid(cookies, "console.cloud.google.com", "cloudconsole") 291 | cookies_with_osid = deepcopy(cookies) 292 | cookies_with_osid["OSID"] = osid 293 | # Extracting Internal People API tokens 294 | internal_auth, internal_token = get_internal_tokens(driver, cookies_with_osid, tmprinter) 295 | print(f"Internal APIs Token => {internal_token}") 296 | print(f"Internal APIs Authorization => {internal_auth}") 297 | 298 | # Extracting Hangouts tokens 299 | auth_token, hangouts_token = get_hangouts_tokens(driver, cookies_with_osid, tmprinter) 300 | print(f"Hangouts Authorization => {auth_token}") 301 | print(f"Hangouts Token => {hangouts_token}") 302 | 303 | cac_key = get_clientauthconfig_key(cookies_with_osid) 304 | print(f"Client Auth Config API Key => {cac_key}") 305 | 306 | save_tokens(auth_token, gdoc_token, hangouts_token, internal_token, internal_auth, cac_key, cookies, osid) 307 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | regexs = { 2 | "albums": r'href=\"\.\/albumarchive\/\d*?\/album\/(.*?)\" jsaction.*?>(?:<.*?>){5}(.*?)<\/div><.*?>(\d*?) ', 3 | "photos": r'\],\"(https:\/\/lh\d\.googleusercontent\.com\/.*?)\",\[\"\d{21}\"(?:.*?,){16}\"(.*?)\"', 4 | "review_loc_by_id": r'{}\",.*?\[\[null,null,(.*?),(.*?)\]', 5 | "gplus": r"plus\.google\.com\/\d*\"" 6 | } 7 | 8 | headers = { 9 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; rv:68.0) Gecko/20100101 Firefox/68.0', 10 | 'Connection': 'Keep-Alive' 11 | } 12 | 13 | headless = True # if True, it doesn't show the browser while scraping GMaps reviews 14 | ytb_hunt_always = True # if True, search the Youtube channel everytime 15 | gmaps_radius = 30 # in km. The radius distance to create groups of gmaps reviews. 16 | gdocs_public_doc = "1jaEEHZL32t1RUN5WuZEnFpqiEPf_APYKrRBG9LhLdvE" # The public Google Doc to use it as an endpoint, to use Google's Search. 17 | data_path = "resources/data.txt" 18 | browser_waiting_timeout = 120 19 | 20 | # Profile pictures options 21 | write_profile_pic = True 22 | profile_pics_dir = "profile_pics" 23 | 24 | # Cookies 25 | # if True, it will uses the Google Account cookies to request the services, 26 | # and gonna be able to read your personal informations 27 | gmaps_cookies = False 28 | calendar_cookies = False 29 | default_consent_cookie = "YES+FR.fr+V10+BX" 30 | default_pref_cookie = "tz=Europe.Paris&f6=40000000&hl=en" # To set the lang settings to english -------------------------------------------------------------------------------- /docker/download_chromedriver.py: -------------------------------------------------------------------------------- 1 | from webdriver_manager.chrome import ChromeDriverManager 2 | 3 | ChromeDriverManager(path="/usr/src/app").install() 4 | print('ChromeDriver download was successful.') 5 | -------------------------------------------------------------------------------- /docker_check_and_gen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker run -v ghunt-resources:/usr/src/app/resources -ti ghcr.io/mxrch/ghunt check_and_gen.py 3 | -------------------------------------------------------------------------------- /docker_hunt.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | docker run -v ghunt-resources:/usr/src/app/resources -ti ghcr.io/mxrch/ghunt ghunt.py $1 $2 -------------------------------------------------------------------------------- /ghunt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from lib import modwall; modwall.check() # We check the requirements 4 | 5 | import sys 6 | import os 7 | from pathlib import Path 8 | 9 | from lib import modwall 10 | from lib.utils import * 11 | from modules.doc import doc_hunt 12 | from modules.email import email_hunt 13 | from modules.gaia import gaia_hunt 14 | from modules.youtube import youtube_hunt 15 | 16 | 17 | if __name__ == "__main__": 18 | 19 | # We change the current working directory to allow using GHunt from anywhere 20 | os.chdir(Path(__file__).parents[0]) 21 | 22 | modules = ["email", "doc", "gaia", "youtube"] 23 | 24 | if len(sys.argv) <= 1 or sys.argv[1].lower() not in modules: 25 | print("Please choose a module.\n") 26 | print("Available modules :") 27 | for module in modules: 28 | print(f"- {module}") 29 | exit() 30 | 31 | module = sys.argv[1].lower() 32 | if len(sys.argv) >= 3: 33 | data = sys.argv[2] 34 | else: 35 | data = None 36 | 37 | if module == "email": 38 | email_hunt(data) 39 | elif module == "doc": 40 | doc_hunt(data) 41 | elif module == "gaia": 42 | gaia_hunt(data) 43 | elif module == "youtube": 44 | youtube_hunt(data) -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxoj/GHunt/b41322364294850678f4b80f6a5cf0ee36a5dc54/lib/__init__.py -------------------------------------------------------------------------------- /lib/banner.py: -------------------------------------------------------------------------------- 1 | from colorama import init, Fore, Back, Style 2 | 3 | def banner(): 4 | init() 5 | 6 | banner = """ 7 | """ + Fore.RED + """ .d8888b. """ + Fore.BLUE + """888 888""" + Fore.RED + """ 888 8 | """ + Fore.RED + """d88P Y88b """ + Fore.BLUE + """888 888""" + Fore.RED + """ 888 9 | """ + Fore.YELLOW + """888 """ + Fore.RED + """888 """ + Fore.BLUE + """888 888""" + Fore.RED + """ 888 10 | """ + Fore.YELLOW + """888 """ + Fore.BLUE + """8888888888""" + Fore.GREEN + """ 888 888""" + Fore.YELLOW + """ 88888b. """ + Fore.RED + """ 888888 11 | """ + Fore.YELLOW + """888 """ + Fore.BLUE + """88888 """ + Fore.BLUE + """888 888""" + Fore.GREEN + """ 888 888""" + Fore.YELLOW + """ 888 "88b""" + Fore.RED + """ 888 12 | """ + Fore.YELLOW + """888 """ + Fore.BLUE + """888 """ + Fore.BLUE + """888 888""" + Fore.GREEN + """ 888 888""" + Fore.YELLOW + """ 888 888""" + Fore.RED + """ 888 13 | """ + Fore.GREEN + """Y88b d88P """ + Fore.BLUE + """888 888""" + Fore.GREEN + """ Y88b 888""" + Fore.YELLOW + """ 888 888""" + Fore.RED + """ Y88b. 14 | """ + Fore.GREEN + """ "Y8888P88 """ + Fore.BLUE + """888 888""" + Fore.GREEN + """ "Y88888""" + Fore.YELLOW + """ 888 888""" + Fore.RED + """ "Y888 15 | """ + Fore.RESET 16 | 17 | print(banner) 18 | 19 | -------------------------------------------------------------------------------- /lib/calendar.py: -------------------------------------------------------------------------------- 1 | import httpx 2 | from dateutil.relativedelta import relativedelta 3 | from beautifultable import BeautifulTable 4 | from termcolor import colored 5 | 6 | import time 7 | import json 8 | from datetime import datetime, timezone 9 | from urllib.parse import urlencode 10 | 11 | 12 | # assembling the json request url endpoint 13 | def assemble_api_req(calendarId, singleEvents, maxAttendees, maxResults, sanitizeHtml, timeMin, API_key, email): 14 | base_url = f"https://clients6.google.com/calendar/v3/calendars/{email}/events?" 15 | params = { 16 | "calendarId": calendarId, 17 | "singleEvents": singleEvents, 18 | "maxAttendees": maxAttendees, 19 | "maxResults": maxResults, 20 | "timeMin": timeMin, 21 | "key": API_key 22 | } 23 | base_url += urlencode(params, doseq=True) 24 | return base_url 25 | 26 | # from iso to datetime object in utc 27 | def get_datetime_utc(date_str): 28 | date = datetime.fromisoformat(date_str) 29 | margin = date.utcoffset() 30 | return date.replace(tzinfo=timezone.utc) - margin 31 | 32 | # main method of calendar.py 33 | def fetch(email, client, config): 34 | if not config.calendar_cookies: 35 | cookies = {"CONSENT": config.default_consent_cookie} 36 | client.cookies = cookies 37 | url_endpoint = f"https://calendar.google.com/calendar/u/0/embed?src={email}" 38 | print("\nGoogle Calendar : " + url_endpoint) 39 | req = client.get(url_endpoint + "&hl=en") 40 | source = req.text 41 | try: 42 | # parsing parameters from source code 43 | calendarId = source.split('title\":\"')[1].split('\"')[0] 44 | singleEvents = "true" 45 | maxAttendees = 1 46 | maxResults = 250 47 | sanitizeHtml = "true" 48 | timeMin = datetime.strptime(source.split('preloadStart\":\"')[1].split('\"')[0], '%Y%m%d').replace(tzinfo=timezone.utc).isoformat() 49 | API_key = source.split('developerKey\":\"')[1].split('\"')[0] 50 | except IndexError: 51 | return False 52 | 53 | json_calendar_endpoint = assemble_api_req(calendarId, singleEvents, maxAttendees, maxResults, sanitizeHtml, timeMin, API_key, email) 54 | req = client.get(json_calendar_endpoint) 55 | data = json.loads(req.text) 56 | events = [] 57 | try: 58 | for item in data["items"]: 59 | title = item["summary"] 60 | start = get_datetime_utc(item["start"]["dateTime"]) 61 | end = get_datetime_utc(item["end"]["dateTime"]) 62 | 63 | events.append({"title": title, "start": start, "end": end}) 64 | except KeyError: 65 | return False 66 | 67 | return {"status": "available", "events": events} 68 | 69 | def out(events): 70 | limit = 5 71 | now = datetime.utcnow().replace(tzinfo=timezone.utc) 72 | after = [date for date in events if date["start"] >= now][:limit] 73 | before = [date for date in events if date["start"] <= now][:limit] 74 | print(f"\n=> The {'next' if after else 'last'} {len(after) if after else len(before)} event{'s' if (len(after) > 1) or (not after and len(before) > 1) else ''} :") 75 | target = after if after else before 76 | 77 | table = BeautifulTable() 78 | table.set_style(BeautifulTable.STYLE_GRID) 79 | table.columns.header = [colored(x, attrs=['bold']) for x in ["Name", "Datetime (UTC)", "Duration"]] 80 | for event in target: 81 | title = event["title"] 82 | duration = relativedelta(event["end"], event["start"]) 83 | if duration.days or duration.hours or duration.minutes: 84 | duration = (f"{(str(duration.days) + ' day' + ('s' if duration.days > 1 else '')) if duration.days else ''} " 85 | f"{(str(duration.hours) + ' hour' + ('s' if duration.hours > 1 else '')) if duration.hours else ''} " 86 | f"{(str(duration.minutes) + ' minute' + ('s' if duration.minutes > 1 else '')) if duration.minutes else ''}").strip() 87 | else: 88 | duration = "?" 89 | date = event["start"].strftime("%Y/%m/%d %H:%M:%S") 90 | table.rows.append([title, date, duration]) 91 | print(table) -------------------------------------------------------------------------------- /lib/gmaps.py: -------------------------------------------------------------------------------- 1 | import hashlib 2 | import re 3 | import time 4 | from datetime import datetime 5 | 6 | from dateutil.relativedelta import relativedelta 7 | from geopy import distance 8 | from geopy.geocoders import Nominatim 9 | from selenium.webdriver.common.by import By 10 | from selenium.webdriver.support import expected_conditions as EC 11 | from selenium.webdriver.support.ui import WebDriverWait 12 | from seleniumwire import webdriver 13 | from webdriver_manager.chrome import ChromeDriverManager 14 | 15 | from lib.utils import * 16 | 17 | 18 | def scrape(gaiaID, client, cookies, config, headers, regex_rev_by_id, is_headless): 19 | def get_datetime(datepublished): 20 | if datepublished.split()[0] == "a": 21 | nb = 1 22 | else: 23 | nb = int(datepublished.split()[0]) 24 | if "minute" in datepublished: 25 | delta = relativedelta(minutes=nb) 26 | elif "hour" in datepublished: 27 | delta = relativedelta(hours=nb) 28 | elif "day" in datepublished: 29 | delta = relativedelta(days=nb) 30 | elif "week" in datepublished: 31 | delta = relativedelta(weeks=nb) 32 | elif "month" in datepublished: 33 | delta = relativedelta(months=nb) 34 | elif "year" in datepublished: 35 | delta = relativedelta(years=nb) 36 | else: 37 | delta = relativedelta() 38 | return (datetime.today() - delta).replace(microsecond=0, second=0) 39 | 40 | tmprinter = TMPrinter() 41 | 42 | base_url = f"https://www.google.com/maps/contrib/{gaiaID}/reviews?hl=en" 43 | print(f"\nGoogle Maps : {base_url.replace('?hl=en', '')}") 44 | 45 | tmprinter.out("Initial request...") 46 | 47 | req = client.get(base_url) 48 | source = req.text 49 | 50 | data = source.split(';window.APP_INITIALIZATION_STATE=')[1].split(';window.APP_FLAGS')[0].replace("\\", "") 51 | 52 | if "/maps/reviews/data" not in data: 53 | tmprinter.out("") 54 | print("[-] No reviews") 55 | return False 56 | 57 | chrome_options = get_chrome_options_args(is_headless) 58 | options = { 59 | 'connection_timeout': None # Never timeout, otherwise it floods errors 60 | } 61 | 62 | tmprinter.out("Starting browser...") 63 | 64 | driverpath = get_driverpath() 65 | driver = webdriver.Chrome(executable_path=driverpath, seleniumwire_options=options, options=chrome_options) 66 | driver.header_overrides = headers 67 | wait = WebDriverWait(driver, 15) 68 | 69 | tmprinter.out("Setting cookies...") 70 | driver.get("https://www.google.com/robots.txt") 71 | 72 | if not config.gmaps_cookies: 73 | cookies = {"CONSENT": config.default_consent_cookie} 74 | for k, v in cookies.items(): 75 | driver.add_cookie({'name': k, 'value': v}) 76 | 77 | tmprinter.out("Fetching reviews page...") 78 | driver.get(base_url) 79 | 80 | wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, 'div.section-scrollbox'))) 81 | scrollbox = driver.find_element(By.CSS_SELECTOR, 'div.section-scrollbox') 82 | 83 | tab_info = scrollbox.find_element(By.TAG_NAME, "div") 84 | if tab_info and tab_info.text: 85 | scroll_max = sum([int(x) for x in tab_info.text.split() if x.isdigit()]) 86 | else: 87 | return False 88 | 89 | tmprinter.clear() 90 | print(f"[+] {scroll_max} reviews found !") 91 | 92 | timeout = scroll_max * 1.25 93 | timeout_start = time.time() 94 | reviews_elements = driver.find_elements_by_xpath('//div[@data-review-id][@aria-label]') 95 | tmprinter.out(f"Fetching reviews... ({len(reviews_elements)}/{scroll_max})") 96 | while len(reviews_elements) < scroll_max: 97 | driver.execute_script("arguments[0].scrollTop = arguments[0].scrollHeight", scrollbox) 98 | reviews_elements = driver.find_elements_by_xpath('//div[@data-review-id][@aria-label]') 99 | tmprinter.out(f"Fetching reviews... ({len(reviews_elements)}/{scroll_max})") 100 | if time.time() > timeout_start + timeout: 101 | tmprinter.out(f"Timeout while fetching reviews !") 102 | break 103 | 104 | tmprinter.out("Fetching internal requests history...") 105 | requests = [r.url for r in driver.requests if "locationhistory" in r.url] 106 | tmprinter.out(f"Fetching internal requests... (0/{len(requests)})") 107 | for nb, load in enumerate(requests): 108 | req = client.get(load) 109 | data += req.text.replace('\n', '') 110 | tmprinter.out(f"Fetching internal requests... ({nb + 1}/{len(requests)})") 111 | 112 | tmprinter.out(f"Fetching reviews location... (0/{len(reviews_elements)})") 113 | reviews = [] 114 | rating = 0 115 | for nb, review in enumerate(reviews_elements): 116 | id = review.get_attribute("data-review-id") 117 | location = re.compile(regex_rev_by_id.format(id)).findall(data)[0] 118 | try: 119 | stars = review.find_element(By.CSS_SELECTOR, 'span[aria-label$="stars "]') 120 | except Exception: 121 | stars = review.find_element(By.CSS_SELECTOR, 'span[aria-label$="star "]') 122 | rating += int(stars.get_attribute("aria-label").strip().split()[0]) 123 | date = get_datetime(stars.find_element(By.XPATH, "following-sibling::span").text) 124 | reviews.append({"location": location, "date": date}) 125 | tmprinter.out(f"Fetching reviews location... ({nb + 1}/{len(reviews_elements)})") 126 | 127 | rating_avg = rating / len(reviews) 128 | tmprinter.clear() 129 | print(f"[+] Average rating : {int(rating_avg) if int(rating_avg) / round(rating_avg, 1) == 1 else round(rating_avg, 1)}/5 stars !") 130 | # 4.9 => 4.9, 5.0 => 5, we don't show the 0 131 | return reviews 132 | 133 | 134 | def avg_location(locs): 135 | latitude = [] 136 | longitude = [] 137 | for loc in locs: 138 | latitude.append(float(loc[0])) 139 | longitude.append(float(loc[1])) 140 | 141 | latitude = sum(latitude) / len(latitude) 142 | longitude = sum(longitude) / len(longitude) 143 | return latitude, longitude 144 | 145 | 146 | def translate_confidence(percents): 147 | if percents >= 100: 148 | return "Extremely high" 149 | elif percents >= 80: 150 | return "Very high" 151 | elif percents >= 60: 152 | return "Little high" 153 | elif percents >= 40: 154 | return "Okay" 155 | elif percents >= 20: 156 | return "Low" 157 | elif percents >= 10: 158 | return "Very low" 159 | else: 160 | return "Extremely low" 161 | 162 | 163 | def get_confidence(geolocator, data, gmaps_radius): 164 | tmprinter = TMPrinter() 165 | radius = gmaps_radius 166 | 167 | locations = {} 168 | tmprinter.out(f"Calculation of the distance of each review...") 169 | for nb, review in enumerate(data): 170 | hash = hashlib.md5(str(review).encode()).hexdigest() 171 | if hash not in locations: 172 | locations[hash] = {"dates": [], "locations": [], "range": None, "score": 0} 173 | location = review["location"] 174 | for review2 in data: 175 | location2 = review2["location"] 176 | dis = distance.distance(location, location2).km 177 | 178 | if dis <= radius: 179 | locations[hash]["dates"].append(review2["date"]) 180 | locations[hash]["locations"].append(review2["location"]) 181 | 182 | maxdate = max(locations[hash]["dates"]) 183 | mindate = min(locations[hash]["dates"]) 184 | locations[hash]["range"] = maxdate - mindate 185 | tmprinter.out(f"Calculation of the distance of each review ({nb}/{len(data)})...") 186 | 187 | tmprinter.out("") 188 | 189 | locations = {k: v for k, v in 190 | sorted(locations.items(), key=lambda k: len(k[1]["locations"]), reverse=True)} # We sort it 191 | 192 | tmprinter.out("Identification of redundant areas...") 193 | to_del = [] 194 | for hash in locations: 195 | if hash in to_del: 196 | continue 197 | for hash2 in locations: 198 | if hash2 in to_del or hash == hash2: 199 | continue 200 | if all([loc in locations[hash]["locations"] for loc in locations[hash2]["locations"]]): 201 | to_del.append(hash2) 202 | for hash in to_del: 203 | del locations[hash] 204 | 205 | tmprinter.out("Calculating confidence...") 206 | maxrange = max([locations[hash]["range"] for hash in locations]) 207 | maxlen = max([len(locations[hash]["locations"]) for hash in locations]) 208 | minreq = 3 209 | mingroups = 3 210 | 211 | score_steps = 4 212 | for hash, loc in locations.items(): 213 | if len(loc["locations"]) == maxlen: 214 | locations[hash]["score"] += score_steps * 4 215 | if loc["range"] == maxrange: 216 | locations[hash]["score"] += score_steps * 3 217 | if len(locations) >= mingroups: 218 | others = sum([len(locations[h]["locations"]) for h in locations if h != hash]) 219 | if len(loc["locations"]) > others: 220 | locations[hash]["score"] += score_steps * 2 221 | if len(loc["locations"]) >= minreq: 222 | locations[hash]["score"] += score_steps 223 | 224 | # for hash,loc in locations.items(): 225 | # print(f"{hash} => {len(loc['locations'])} ({int(loc['score'])/40*100})") 226 | 227 | panels = sorted(set([loc["score"] for loc in locations.values()]), reverse=True) 228 | 229 | maxscore = sum([p * score_steps for p in range(1, score_steps + 1)]) 230 | for panel in panels: 231 | locs = [loc for loc in locations.values() if loc["score"] == panel] 232 | if len(locs[0]["locations"]) == 1: 233 | panel /= 2 234 | if len(data) < 4: 235 | panel /= 2 236 | confidence = translate_confidence(panel / maxscore * 100) 237 | for nb, loc in enumerate(locs): 238 | avg = avg_location(loc["locations"]) 239 | #import pdb; pdb.set_trace() 240 | while True: 241 | try: 242 | location = geolocator.reverse(f"{avg[0]}, {avg[1]}", timeout=10).raw["address"] 243 | break 244 | except: 245 | pass 246 | location = sanitize_location(location) 247 | locs[nb]["avg"] = location 248 | del locs[nb]["locations"] 249 | del locs[nb]["score"] 250 | del locs[nb]["range"] 251 | del locs[nb]["dates"] 252 | tmprinter.out("") 253 | return confidence, locs 254 | -------------------------------------------------------------------------------- /lib/listener.py: -------------------------------------------------------------------------------- 1 | from http.server import BaseHTTPRequestHandler, HTTPServer 2 | import threading 3 | 4 | from time import sleep 5 | 6 | class DataBridge(): 7 | def __init__(self): 8 | self.data = None 9 | 10 | class Server(BaseHTTPRequestHandler): 11 | def _set_response(self): 12 | self.send_response(200) 13 | self.send_header('Content-type', 'text/html') 14 | self.send_header('Access-Control-Allow-Origin','*') 15 | self.end_headers() 16 | 17 | def do_GET(self): 18 | if self.path == "/ghunt_ping": 19 | self._set_response() 20 | self.wfile.write(b"ghunt_pong") 21 | 22 | def do_POST(self): 23 | if self.path == "/ghunt_feed": 24 | content_length = int(self.headers['Content-Length']) # <--- Gets the size of data 25 | post_data = self.rfile.read(content_length) # <--- Gets the data itself 26 | self.data_bridge.data = post_data.decode('utf-8') 27 | 28 | self._set_response() 29 | self.wfile.write(b"ghunt_received_ok") 30 | 31 | def log_message(self, format, *args): 32 | return 33 | 34 | def run(server_class=HTTPServer, handler_class=Server, port=60067): 35 | server_address = ('127.0.0.1', port) 36 | handler_class.data_bridge = DataBridge() 37 | server = server_class(server_address, handler_class) 38 | try: 39 | print(f"GHunt is listening on port {port}...") 40 | 41 | while True: 42 | server.handle_request() 43 | if handler_class.data_bridge.data: 44 | break 45 | 46 | except KeyboardInterrupt: 47 | exit("[-] Exiting...") 48 | else: 49 | if handler_class.data_bridge.data: 50 | print("[+] Received cookies !") 51 | return handler_class.data_bridge.data -------------------------------------------------------------------------------- /lib/metadata.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from PIL import ExifTags 4 | from PIL.ExifTags import TAGS, GPSTAGS 5 | from geopy.geocoders import Nominatim 6 | 7 | from lib.utils import * 8 | 9 | 10 | class ExifEater(): 11 | 12 | def __init__(self): 13 | self.devices = {} 14 | self.softwares = {} 15 | self.locations = {} 16 | self.geolocator = Nominatim(user_agent="nominatim") 17 | 18 | def get_GPS(self, img): 19 | location = "" 20 | geoaxis = {} 21 | geotags = {} 22 | try: 23 | exif = img._getexif() 24 | 25 | for (idx, tag) in TAGS.items(): 26 | if tag == 'GPSInfo': 27 | if idx in exif: 28 | for (key, val) in GPSTAGS.items(): 29 | if key in exif[idx]: 30 | geotags[val] = exif[idx][key] 31 | 32 | for axis in ["Latitude", "Longitude"]: 33 | dms = geotags[f'GPS{axis}'] 34 | ref = geotags[f'GPS{axis}Ref'] 35 | 36 | degrees = dms[0][0] / dms[0][1] 37 | minutes = dms[1][0] / dms[1][1] / 60.0 38 | seconds = dms[2][0] / dms[2][1] / 3600.0 39 | 40 | if ref in ['S', 'W']: 41 | degrees = -degrees 42 | minutes = -minutes 43 | seconds = -seconds 44 | 45 | geoaxis[axis] = round(degrees + minutes + seconds, 5) 46 | location = \ 47 | self.geolocator.reverse("{}, {}".format(geoaxis["Latitude"], geoaxis["Longitude"])).raw[ 48 | "address"] 49 | except Exception: 50 | return "" 51 | else: 52 | if location: 53 | location = sanitize_location(location) 54 | if not location: 55 | return "" 56 | return f'{location["town"]}, {location["country"]}' 57 | else: 58 | return "" 59 | 60 | def feed(self, img): 61 | try: 62 | img._getexif() 63 | except: 64 | try: 65 | img._getexif = img.getexif 66 | except: 67 | img._getexif = lambda d={}:d 68 | if img._getexif(): 69 | location = self.get_GPS(img) 70 | exif = {ExifTags.TAGS[k]: v for k, v in img._getexif().items() if k in ExifTags.TAGS} 71 | interesting_fields = ["Make", "Model", "DateTime", "Software"] 72 | metadata = {k: v for k, v in exif.items() if k in interesting_fields} 73 | try: 74 | date = datetime.strptime(metadata["DateTime"], '%Y:%m:%d %H:%M:%S') 75 | is_date_valid = "Valid" 76 | except Exception: 77 | date = None 78 | is_date_valid = "Invalid" 79 | 80 | if location: 81 | if location not in self.locations: 82 | self.locations[location] = {"Valid": [], "Invalid": []} 83 | self.locations[location][is_date_valid].append(date) 84 | if "Make" in metadata and "Model" in metadata: 85 | if metadata["Model"] not in self.devices: 86 | self.devices[metadata["Model"]] = {"Make": metadata["Make"], 87 | "History": {"Valid": [], "Invalid": []}, "Firmwares": {}} 88 | self.devices[metadata["Model"]]["History"][is_date_valid].append(date) 89 | if "Software" in metadata: 90 | if metadata["Software"] not in self.devices[metadata["Model"]]["Firmwares"]: 91 | self.devices[metadata["Model"]]["Firmwares"][metadata["Software"]] = {"Valid": [], 92 | "Invalid": []} 93 | self.devices[metadata["Model"]]["Firmwares"][metadata["Software"]][is_date_valid].append(date) 94 | elif "Software" in metadata: 95 | if metadata["Software"] not in self.softwares: 96 | self.softwares[metadata["Software"]] = {"Valid": [], "Invalid": []} 97 | self.softwares[metadata["Software"]][is_date_valid].append(date) 98 | 99 | def give_back(self): 100 | return self.locations, self.devices 101 | 102 | def output(self): 103 | bkn = '\n' # to use in f-strings 104 | 105 | def picx(n): 106 | return "s" if n > 1 else "" 107 | 108 | def print_dates(dates_list): 109 | dates = {} 110 | dates["max"] = max(dates_list).strftime("%Y/%m/%d") 111 | dates["min"] = min(dates_list).strftime("%Y/%m/%d") 112 | if dates["max"] == dates["min"]: 113 | return dates["max"] 114 | else: 115 | return f'{dates["min"]} -> {dates["max"]}' 116 | 117 | # pprint((self.devices, self.softwares, self.locations)) 118 | 119 | devices = self.devices 120 | if devices: 121 | print(f"[+] {len(devices)} device{picx(len(devices))} found !") 122 | for model, data in devices.items(): 123 | make = data["Make"] 124 | if model.lower().startswith(make.lower()): 125 | model = model[len(make):].strip() 126 | n = len(data["History"]["Valid"] + data["History"]["Invalid"]) 127 | for validity, dateslist in data["History"].items(): 128 | if dateslist and ( 129 | (validity == "Valid") or (validity == "Invalid" and not data["History"]["Valid"])): 130 | if validity == "Valid": 131 | dates = print_dates(data["History"]["Valid"]) 132 | elif validity == "Valid" and data["History"]["Invalid"]: 133 | dates = print_dates(data["History"]["Valid"]) 134 | dates += " (+ ?)" 135 | elif validity == "Invalid" and not data["History"]["Valid"]: 136 | dates = "?" 137 | print( 138 | f"{bkn if data['Firmwares'] else ''}- {make.capitalize()} {model} ({n} pic{picx(n)}) [{dates}]") 139 | if data["Firmwares"]: 140 | n = len(data['Firmwares']) 141 | print(f"-> {n} Firmware{picx(n)} found !") 142 | for firmware, firmdata in data["Firmwares"].items(): 143 | for validity2, dateslist2 in firmdata.items(): 144 | if dateslist2 and ((validity2 == "Valid") or ( 145 | validity2 == "Invalid" and not firmdata["Valid"])): 146 | if validity2 == "Valid": 147 | dates2 = print_dates(firmdata["Valid"]) 148 | elif validity2 == "Valid" and firmdata["Invalid"]: 149 | dates2 = print_dates(firmdata["Valid"]) 150 | dates2 += " (+ ?)" 151 | elif validity2 == "Invalid" and not firmdata["Valid"]: 152 | dates2 = "?" 153 | print(f"--> {firmware} [{dates2}]") 154 | 155 | locations = self.locations 156 | if locations: 157 | print(f"\n[+] {len(locations)} location{picx(len(locations))} found !") 158 | for location, data in locations.items(): 159 | n = len(data["Valid"] + data["Invalid"]) 160 | for validity, dateslist in data.items(): 161 | if dateslist and ((validity == "Valid") or (validity == "Invalid" and not data["Valid"])): 162 | if validity == "Valid": 163 | dates = print_dates(data["Valid"]) 164 | elif validity == "Valid" and data["Invalid"]: 165 | dates = print_dates(data["Valid"]) 166 | dates += " (+ ?)" 167 | elif validity == "Invalid" and not data["Valid"]: 168 | dates = "?" 169 | print(f"- {location} ({n} pic{picx(n)}) [{dates}]") 170 | 171 | softwares = self.softwares 172 | if softwares: 173 | print(f"\n[+] {len(softwares)} software{picx(len(softwares))} found !") 174 | for software, data in softwares.items(): 175 | n = len(data["Valid"] + data["Invalid"]) 176 | for validity, dateslist in data.items(): 177 | if dateslist and ((validity == "Valid") or (validity == "Invalid" and not data["Valid"])): 178 | if validity == "Valid": 179 | dates = print_dates(data["Valid"]) 180 | elif validity == "Valid" and data["Invalid"]: 181 | dates = print_dates(data["Valid"]) 182 | dates += " (+ ?)" 183 | elif validity == "Invalid" and not data["Valid"]: 184 | dates = "?" 185 | print(f"- {software} ({n} pic{picx(n)}) [{dates}]") 186 | 187 | if not devices and not locations and not softwares: 188 | print("=> Nothing found") 189 | -------------------------------------------------------------------------------- /lib/modwall.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import parse_requirements, parse_version, working_set 2 | 3 | 4 | def print_help_and_exit(): 5 | print("- Windows : py -m pip install --upgrade -r requirements.txt") 6 | print("- Unix : python3 -m pip install --upgrade -r requirements.txt") 7 | exit() 8 | 9 | def check_versions(installed_version, op, version): 10 | if (op == ">" and parse_version(installed_version) > parse_version(version)) \ 11 | or (op == "<" and parse_version(installed_version) < parse_version(version)) \ 12 | or (op == "==" and parse_version(installed_version) == parse_version(version)) \ 13 | or (op == ">=" and parse_version(installed_version) >= parse_version(version)) \ 14 | or (op == "<=" and parse_version(installed_version) <= parse_version(version)) : 15 | return True 16 | return False 17 | 18 | def check(): 19 | with open('requirements.txt', "r") as requirements_raw: 20 | requirements = [{"specs": x.specs, "key": x.key} for x in parse_requirements(requirements_raw)] 21 | 22 | installed_mods = {mod.key:mod.version for mod in working_set} 23 | 24 | for req in requirements: 25 | if req["key"] not in installed_mods: 26 | print(f"[-] [modwall] I can't find the library {req['key']}, did you correctly installed the libraries specified in requirements.txt ? 😤\n") 27 | print_help_and_exit() 28 | else: 29 | if req["specs"] and (specs := req["specs"][0]): 30 | op, version = specs 31 | if not check_versions(installed_mods[req["key"]], op, version): 32 | print(f"[-] [modwall] The library {req['key']} version is {installed_mods[req['key']]} but it requires {op} {version}\n") 33 | print("Please upgrade your libraries specified in the requirements.txt file. 😇") 34 | print_help_and_exit() -------------------------------------------------------------------------------- /lib/os_detect.py: -------------------------------------------------------------------------------- 1 | from platform import system, uname 2 | 3 | 4 | class Os: 5 | """ 6 | returns class with properties: 7 | .cygwin Cygwin detected 8 | .wsl Windows Subsystem for Linux (WSL) detected 9 | .mac Mac OS detected 10 | .linux Linux detected 11 | .bsd BSD detected 12 | """ 13 | 14 | def __init__(self): 15 | syst = system().lower() 16 | 17 | # initialize 18 | self.cygwin = False 19 | self.wsl = False 20 | self.mac = False 21 | self.linux = False 22 | self.windows = False 23 | self.bsd = False 24 | 25 | if 'cygwin' in syst: 26 | self.cygwin = True 27 | self.os = 'cygwin' 28 | elif 'darwin' in syst: 29 | self.mac = True 30 | self.os = 'mac' 31 | elif 'linux' in syst: 32 | self.linux = True 33 | self.os = 'linux' 34 | if 'Microsoft' in uname().release: 35 | self.wsl = True 36 | self.linux = False 37 | self.os = 'wsl' 38 | elif 'windows' in syst: 39 | self.windows = True 40 | self.os = 'windows' 41 | elif 'bsd' in syst: 42 | self.bsd = True 43 | self.os = 'bsd' 44 | 45 | def __str__(self): 46 | return self.os 47 | -------------------------------------------------------------------------------- /lib/photos.py: -------------------------------------------------------------------------------- 1 | import re 2 | from io import BytesIO 3 | import pdb 4 | 5 | from PIL import Image 6 | from selenium.webdriver.common.by import By 7 | from selenium.webdriver.support import expected_conditions as EC 8 | from selenium.webdriver.support.ui import WebDriverWait 9 | from seleniumwire import webdriver 10 | from webdriver_manager.chrome import ChromeDriverManager 11 | 12 | from lib.metadata import ExifEater 13 | from lib.utils import * 14 | 15 | 16 | class element_has_substring_or_substring(object): 17 | def __init__(self, locator, substring1, substring2): 18 | self.locator = locator 19 | self.substring1 = substring1 20 | self.substring2 = substring2 21 | 22 | def __call__(self, driver): 23 | element = driver.find_element(*self.locator) # Finding the referenced element 24 | if self.substring1 in element.text: 25 | return self.substring1 26 | elif self.substring2 in element.text: 27 | return self.substring2 28 | else: 29 | return False 30 | 31 | 32 | def get_source(gaiaID, client, cookies, headers, is_headless): 33 | baseurl = f"https://get.google.com/albumarchive/{gaiaID}/albums/profile-photos?hl=en" 34 | req = client.get(baseurl) 35 | if req.status_code != 200: 36 | return False 37 | 38 | tmprinter = TMPrinter() 39 | chrome_options = get_chrome_options_args(is_headless) 40 | options = { 41 | 'connection_timeout': None # Never timeout, otherwise it floods errors 42 | } 43 | 44 | tmprinter.out("Starting browser...") 45 | 46 | driverpath = get_driverpath() 47 | driver = webdriver.Chrome(executable_path=driverpath, seleniumwire_options=options, options=chrome_options) 48 | driver.header_overrides = headers 49 | wait = WebDriverWait(driver, 30) 50 | 51 | tmprinter.out("Setting cookies...") 52 | driver.get("https://get.google.com/robots.txt") 53 | for k, v in cookies.items(): 54 | driver.add_cookie({'name': k, 'value': v}) 55 | 56 | tmprinter.out('Fetching Google Photos "Profile photos" album...') 57 | driver.get(baseurl) 58 | 59 | tmprinter.out('Fetching the Google Photos albums overview...') 60 | buttons = driver.find_elements(By.XPATH, "//button") 61 | for button in buttons: 62 | text = button.get_attribute('jsaction') 63 | if text and 'touchcancel' in text: 64 | button.click() 65 | break 66 | else: 67 | tmprinter.out("") 68 | print("Can't get the back button..") 69 | driver.close() 70 | return False 71 | 72 | wait.until(EC.text_to_be_present_in_element((By.XPATH, "//body"), "Album Archive")) 73 | tmprinter.out("Got the albums overview !") 74 | no_photos_trigger = "reached the end" 75 | photos_trigger = " item" 76 | body = driver.find_element(By.XPATH, "//body").text 77 | if no_photos_trigger in body: 78 | stats = "notfound" 79 | elif photos_trigger in body: 80 | stats = "found" 81 | else: 82 | try: 83 | result = wait.until(element_has_substring_or_substring((By.XPATH, "//body"), no_photos_trigger, photos_trigger)) 84 | except Exception: 85 | tmprinter.out("[-] Timeout while fetching photos.") 86 | return False 87 | else: 88 | if result == no_photos_trigger: 89 | stats = "notfound" 90 | elif result == photos_trigger: 91 | stats = "found" 92 | else: 93 | return False 94 | tmprinter.out("") 95 | source = driver.page_source 96 | driver.close() 97 | 98 | return {"stats": stats, "source": source} 99 | 100 | 101 | def gpics(gaiaID, client, cookies, headers, regex_albums, regex_photos, headless=True): 102 | baseurl = "https://get.google.com/albumarchive/" 103 | 104 | print(f"\nGoogle Photos : {baseurl + gaiaID + '/albums/profile-photos'}") 105 | out = get_source(gaiaID, client, cookies, headers, headless) 106 | 107 | if not out: 108 | print("=> Couldn't fetch the public photos.") 109 | return False 110 | if out["stats"] == "notfound": 111 | print("=> No album") 112 | return False 113 | 114 | # open('debug.html', 'w').write(repr(out["source"])) 115 | results = re.compile(regex_albums).findall(out["source"]) 116 | 117 | list_albums_length = len(results) 118 | 119 | if results: 120 | exifeater = ExifEater() 121 | pics = [] 122 | for album in results: 123 | album_name = album[1] 124 | album_link = baseurl + gaiaID + "/album/" + album[0] 125 | album_length = int(album[2]) 126 | 127 | if album_length >= 1: 128 | try: 129 | req = client.get(album_link) 130 | source = req.text.replace('\n', '') 131 | results_pics = re.compile(regex_photos).findall(source) 132 | for pic in results_pics: 133 | pic_name = pic[1] 134 | pic_link = pic[0] 135 | pics.append(pic_link) 136 | except: 137 | pass 138 | 139 | print(f"=> {list_albums_length} albums{', ' + str(len(pics)) + ' photos' if list_albums_length else ''}") 140 | for pic in pics: 141 | try: 142 | req = client.get(pic) 143 | img = Image.open(BytesIO(req.content)) 144 | exifeater.feed(img) 145 | except: 146 | pass 147 | 148 | print("\nSearching metadata...") 149 | exifeater.output() 150 | else: 151 | print("=> No album") 152 | -------------------------------------------------------------------------------- /lib/search.py: -------------------------------------------------------------------------------- 1 | import json 2 | import httpx 3 | 4 | from pprint import pprint 5 | from time import sleep 6 | 7 | 8 | def search(query, data_path, gdocs_public_doc, size=1000): 9 | cookies = "" 10 | token = "" 11 | 12 | with open(data_path, 'r') as f: 13 | out = json.loads(f.read()) 14 | token = out["keys"]["gdoc"] 15 | cookies = out["cookies"] 16 | data = {"request": '["documentsuggest.search.search_request","{}",[{}],null,1]'.format(query, size)} 17 | 18 | retries = 10 19 | time_to_wait = 5 20 | for retry in list(range(retries))[::-1]: 21 | req = httpx.post('https://docs.google.com/document/d/{}/explore/search?token={}'.format(gdocs_public_doc, token), 22 | cookies=cookies, data=data) 23 | #print(req.text) 24 | if req.status_code == 200: 25 | break 26 | if req.status_code == 500: 27 | if retry == 0: 28 | exit(f"[-] Error (GDocs): request gives {req.status_code}, wait a minute and retry !") 29 | print(f"[-] GDocs request gives a 500 status code, retrying in 5 seconds...") 30 | continue 31 | 32 | output = json.loads(req.text.replace(")]}'", "")) 33 | if isinstance(output[0][1], str) and output[0][1].lower() == "xsrf": 34 | exit(f"\n[-] Error : XSRF detected.\nIt means your cookies have expired, please generate new ones.") 35 | 36 | results = [] 37 | for result in output[0][1]: 38 | link = result[0][0] 39 | title = result[0][1] 40 | desc = result[0][2] 41 | results.append({"title": title, "desc": desc, "link": link}) 42 | 43 | return results 44 | -------------------------------------------------------------------------------- /lib/utils.py: -------------------------------------------------------------------------------- 1 | import imagehash 2 | from webdriver_manager.chrome import ChromeDriverManager 3 | from selenium.webdriver.chrome.options import Options 4 | from seleniumwire.webdriver import Chrome 5 | 6 | from lib.os_detect import Os 7 | 8 | from pathlib import Path 9 | import shutil 10 | import subprocess, os 11 | from os.path import isfile 12 | import json 13 | import re 14 | from pprint import pprint 15 | 16 | 17 | class TMPrinter(): 18 | def __init__(self): 19 | self.max_len = 0 20 | 21 | def out(self, text): 22 | if len(text) > self.max_len: 23 | self.max_len = len(text) 24 | else: 25 | text += (" " * (self.max_len - len(text))) 26 | print(text, end='\r') 27 | def clear(self): 28 | print(" " * self.max_len, end="\r") 29 | 30 | def within_docker(): 31 | return Path('/.dockerenv').is_file() 32 | 33 | class Picture: 34 | def __init__(self, url, is_default=False): 35 | self.url = url 36 | self.is_default = is_default 37 | 38 | class Contact: 39 | def __init__(self, val, is_primary=True): 40 | self.value = val 41 | self.is_secondary = not is_primary 42 | 43 | def is_normalized(self, val): 44 | return val.replace('.', '').lower() == self.value.replace('.', '').lower() 45 | 46 | def __str__(self): 47 | printable_value = self.value 48 | if self.is_secondary: 49 | printable_value += ' (secondary)' 50 | return printable_value 51 | 52 | def update_emails(emails, data): 53 | """ 54 | Typically canonical user email 55 | May not be present in the list method response 56 | """ 57 | if not "email" in data: 58 | return emails 59 | 60 | for e in data["email"]: 61 | is_primary = e.get("signupEmailMetadata", {}).get("primary") 62 | email = Contact(e["value"], is_primary) 63 | 64 | if email.value in emails: 65 | if is_primary: 66 | emails[email.value].is_secondary = False 67 | else: 68 | emails[email.value] = email 69 | 70 | return emails 71 | 72 | def is_email_google_account(httpx_client, auth, cookies, email, hangouts_token): 73 | host = "https://people-pa.clients6.google.com" 74 | url = "/v2/people/lookup?key={}".format(hangouts_token) 75 | body = """id={}&type=EMAIL&matchType=EXACT&extensionSet.extensionNames=HANGOUTS_ADDITIONAL_DATA&extensionSet.extensionNames=HANGOUTS_OFF_NETWORK_GAIA_LOOKUP&extensionSet.extensionNames=HANGOUTS_PHONE_DATA&coreIdParams.useRealtimeNotificationExpandedAcls=true&requestMask.includeField.paths=person.email&requestMask.includeField.paths=person.gender&requestMask.includeField.paths=person.in_app_reachability&requestMask.includeField.paths=person.metadata&requestMask.includeField.paths=person.name&requestMask.includeField.paths=person.phone&requestMask.includeField.paths=person.photo&requestMask.includeField.paths=person.read_only_profile_info&requestMask.includeContainer=AFFINITY&requestMask.includeContainer=PROFILE&requestMask.includeContainer=DOMAIN_PROFILE&requestMask.includeContainer=ACCOUNT&requestMask.includeContainer=EXTERNAL_ACCOUNT&requestMask.includeContainer=CIRCLE&requestMask.includeContainer=DOMAIN_CONTACT&requestMask.includeContainer=DEVICE_CONTACT&requestMask.includeContainer=GOOGLE_GROUP&requestMask.includeContainer=CONTACT""" 76 | 77 | headers = { 78 | "X-HTTP-Method-Override": "GET", 79 | "Authorization": auth, 80 | "Content-Type": "application/x-www-form-urlencoded", 81 | "Origin": "https://hangouts.google.com" 82 | } 83 | 84 | req = httpx_client.post(host + url, data=body.format(email), headers=headers, cookies=cookies) 85 | data = json.loads(req.text) 86 | #pprint(data) 87 | if "error" in data and "Request had invalid authentication credentials" in data["error"]["message"]: 88 | exit("[-] Cookies/Tokens seems expired, please verify them.") 89 | elif "error" in data: 90 | print("[-] Error :") 91 | pprint(data) 92 | exit() 93 | elif not "matches" in data: 94 | exit("[-] This email address does not belong to a Google Account.") 95 | 96 | return data 97 | 98 | def get_account_data(httpx_client, gaiaID, internal_auth, internal_token, config): 99 | # Bypass method 100 | req_headers = { 101 | "Origin": "https://drive.google.com", 102 | "authorization": internal_auth, 103 | "Host": "people-pa.clients6.google.com" 104 | } 105 | headers = {**config.headers, **req_headers} 106 | 107 | url = f"https://people-pa.clients6.google.com/v2/people?person_id={gaiaID}&request_mask.include_container=PROFILE&request_mask.include_container=DOMAIN_PROFILE&request_mask.include_field.paths=person.metadata.best_display_name&request_mask.include_field.paths=person.photo&request_mask.include_field.paths=person.cover_photo&request_mask.include_field.paths=person.email&request_mask.include_field.paths=person.organization&request_mask.include_field.paths=person.location&request_mask.include_field.paths=person.email&requestMask.includeField.paths=person.phone&core_id_params.enable_private_names=true&requestMask.includeField.paths=person.read_only_profile_info&key={internal_token}" 108 | req = httpx_client.get(url, headers=headers) 109 | data = json.loads(req.text) 110 | # pprint(data) 111 | if "error" in data and "Request had invalid authentication credentials" in data["error"]["message"]: 112 | exit("[-] Cookies/Tokens seems expired, please verify them.") 113 | elif "error" in data: 114 | print("[-] Error :") 115 | pprint(data) 116 | exit() 117 | if data["personResponse"][0]["status"].lower() == "not_found": 118 | return False 119 | 120 | name = get_account_name(httpx_client, gaiaID, data, internal_auth, internal_token, config) 121 | 122 | profile_data = data["personResponse"][0]["person"] 123 | 124 | profile_pics = [] 125 | for p in profile_data["photo"]: 126 | profile_pics.append(Picture(p["url"], p.get("isDefault", False))) 127 | 128 | # mostly is default 129 | cover_pics = [] 130 | for p in profile_data["coverPhoto"]: 131 | cover_pics.append(Picture(p["imageUrl"], p["isDefault"])) 132 | 133 | emails = update_emails({}, profile_data) 134 | 135 | # absent if user didn't enter or hide them 136 | phones = [] 137 | if "phone" in profile_data: 138 | for p in profile_data["phone"]: 139 | phones.append(f'{p["value"]} ({p["type"]})') 140 | 141 | # absent if user didn't enter or hide them 142 | locations = [] 143 | if "location" in profile_data: 144 | for l in profile_data["location"]: 145 | locations.append(l["value"] if not l.get("current") else f'{l["value"]} (current)') 146 | 147 | # absent if user didn't enter or hide them 148 | organizations = [] 149 | if "organization" in profile_data: 150 | organizations = (f'{o["name"]} ({o["type"]})' for o in profile_data["organization"]) 151 | 152 | return {"name": name, "profile_pics": profile_pics, "cover_pics": cover_pics, 153 | "organizations": ', '.join(organizations), "locations": ', '.join(locations), 154 | "emails_set": emails, "phones": ', '.join(phones)} 155 | 156 | def get_account_name(httpx_client, gaiaID, data, internal_auth, internal_token, config): 157 | try: 158 | name = data["personResponse"][0]["person"]["metadata"]["bestDisplayName"]["displayName"] 159 | except KeyError: 160 | pass # We fallback on the classic method 161 | else: 162 | return name 163 | 164 | # Classic method, but requires the target to have at least 1 GMaps contribution 165 | req = httpx_client.get(f"https://www.google.com/maps/contrib/{gaiaID}") 166 | gmaps_source = req.text 167 | match = re.search(r'', gmaps_source) 168 | if not match: 169 | return None 170 | return match[1] 171 | 172 | def image_hash(img): 173 | flathash = imagehash.average_hash(img) 174 | return flathash 175 | 176 | def detect_default_profile_pic(flathash): 177 | if flathash - imagehash.hex_to_flathash("000018183c3c0000", 8) < 10 : 178 | return True 179 | return False 180 | 181 | def sanitize_location(location): 182 | not_country = False 183 | not_town = False 184 | town = "?" 185 | country = "?" 186 | if "city" in location: 187 | town = location["city"] 188 | elif "village" in location: 189 | town = location["village"] 190 | elif "town" in location: 191 | town = location["town"] 192 | elif "municipality" in location: 193 | town = location["municipality"] 194 | else: 195 | not_town = True 196 | if not "country" in location: 197 | not_country = True 198 | location["country"] = country 199 | if not_country and not_town: 200 | return False 201 | location["town"] = town 202 | return location 203 | 204 | 205 | def get_driverpath(): 206 | driver_path = shutil.which("chromedriver") 207 | if driver_path: 208 | return driver_path 209 | if within_docker(): 210 | chromedrivermanager_silent = ChromeDriverManager(print_first_line=False, log_level=0, path="/usr/src/app") 211 | else: 212 | chromedrivermanager_silent = ChromeDriverManager(print_first_line=False, log_level=0) 213 | driver = chromedrivermanager_silent.driver 214 | driverpath_with_version = chromedrivermanager_silent.driver_cache.find_driver(driver.browser_version, driver.get_name(), driver.get_os_type(), driver.get_version()) 215 | driverpath_without_version = chromedrivermanager_silent.driver_cache.find_driver("", driver.get_name(), driver.get_os_type(), "") 216 | if driverpath_with_version: 217 | return driverpath_with_version 218 | elif not driverpath_with_version and driverpath_without_version: 219 | print("[Webdrivers Manager] I'm updating the chromedriver...") 220 | if within_docker(): 221 | driver_path = ChromeDriverManager(path="/usr/src/app").install() 222 | else: 223 | driver_path = ChromeDriverManager().install() 224 | print("[Webdrivers Manager] The chromedriver has been updated !\n") 225 | else: 226 | print("[Webdrivers Manager] I can't find the chromedriver, so I'm downloading and installing it for you...") 227 | if within_docker(): 228 | driver_path = ChromeDriverManager(path="/usr/src/app").install() 229 | else: 230 | driver_path = ChromeDriverManager().install() 231 | print("[Webdrivers Manager] The chromedriver has been installed !\n") 232 | return driver_path 233 | 234 | 235 | def get_chrome_options_args(is_headless): 236 | chrome_options = Options() 237 | chrome_options.add_argument('--log-level=3') 238 | chrome_options.add_experimental_option('excludeSwitches', ['enable-logging']) 239 | chrome_options.add_argument("--no-sandbox") 240 | if is_headless: 241 | chrome_options.add_argument("--headless") 242 | if (Os().wsl or Os().windows) and is_headless: 243 | chrome_options.add_argument("--disable-gpu") 244 | chrome_options.add_argument("--disable-dev-shm-usage") 245 | chrome_options.add_argument("--disable-setuid-sandbox") 246 | chrome_options.add_argument("--no-first-run") 247 | chrome_options.add_argument("--no-zygote") 248 | chrome_options.add_argument("--single-process") 249 | chrome_options.add_argument("--disable-features=VizDisplayCompositor") 250 | return chrome_options 251 | 252 | def inject_osid(cookies, service, config): 253 | with open(config.data_path, 'r') as f: 254 | out = json.loads(f.read()) 255 | 256 | cookies["OSID"] = out["osids"][service] 257 | return cookies -------------------------------------------------------------------------------- /lib/youtube.py: -------------------------------------------------------------------------------- 1 | import json 2 | import urllib.parse 3 | from io import BytesIO 4 | from urllib.parse import unquote as parse_url 5 | 6 | from PIL import Image 7 | 8 | from lib.search import search as gdoc_search 9 | from lib.utils import * 10 | 11 | 12 | def get_channel_data(client, channel_url): 13 | data = None 14 | 15 | retries = 2 16 | for retry in list(range(retries))[::-1]: 17 | req = client.get(f"{channel_url}/about") 18 | source = req.text 19 | try: 20 | data = json.loads(source.split('var ytInitialData = ')[1].split(';')[0]) 21 | except (KeyError, IndexError): 22 | if retry == 0: 23 | return False 24 | continue 25 | else: 26 | break 27 | 28 | handle = data["metadata"]["channelMetadataRenderer"]["vanityChannelUrl"].split("/")[-1] 29 | tabs = [x[list(x.keys())[0]] for x in data["contents"]["twoColumnBrowseResultsRenderer"]["tabs"]] 30 | about_tab = [x for x in tabs if x["title"].lower() == "about"][0] 31 | channel_details = about_tab["content"]["sectionListRenderer"]["contents"][0]["itemSectionRenderer"]["contents"][0]["channelAboutFullMetadataRenderer"] 32 | 33 | out = { 34 | "name": None, 35 | "description": None, 36 | "channel_urls": [], 37 | "email_contact": False, 38 | "views": None, 39 | "joined_date": None, 40 | "primary_links": [], 41 | "country": None 42 | } 43 | 44 | out["name"] = data["metadata"]["channelMetadataRenderer"]["title"] 45 | 46 | out["channel_urls"].append(data["metadata"]["channelMetadataRenderer"]["channelUrl"]) 47 | out["channel_urls"].append(f"https://www.youtube.com/c/{handle}") 48 | out["channel_urls"].append(f"https://www.youtube.com/user/{handle}") 49 | 50 | out["email_contact"] = "businessEmailLabel" in channel_details 51 | 52 | out["description"] = channel_details["description"]["simpleText"] if "description" in channel_details else None 53 | out["views"] = channel_details["viewCountText"]["simpleText"].split(" ")[0] if "viewCountText" in channel_details else None 54 | out["joined_date"] = channel_details["joinedDateText"]["runs"][1]["text"] if "joinedDateText" in channel_details else None 55 | out["country"] = channel_details["country"]["simpleText"] if "country" in channel_details else None 56 | 57 | if "primaryLinks" in channel_details: 58 | for primary_link in channel_details["primaryLinks"]: 59 | title = primary_link["title"]["simpleText"] 60 | url = parse_url(primary_link["navigationEndpoint"]["urlEndpoint"]["url"].split("&q=")[-1]) 61 | out["primary_links"].append({"title": title, "url": url}) 62 | 63 | return out 64 | 65 | def youtube_channel_search(client, query): 66 | try: 67 | link = "https://www.youtube.com/results?search_query={}&sp=EgIQAg%253D%253D" 68 | req = client.get(link.format(urllib.parse.quote(query))) 69 | source = req.text 70 | data = json.loads( 71 | source.split('window["ytInitialData"] = ')[1].split('window["ytInitialPlayerResponse"]')[0].split(';\n')[0]) 72 | channels = \ 73 | data["contents"]["twoColumnSearchResultsRenderer"]["primaryContents"]["sectionListRenderer"]["contents"][0][ 74 | "itemSectionRenderer"]["contents"] 75 | results = {"channels": [], "length": len(channels)} 76 | for channel in channels: 77 | if len(results["channels"]) >= 10: 78 | break 79 | title = channel["channelRenderer"]["title"]["simpleText"] 80 | if not query.lower() in title.lower(): 81 | continue 82 | avatar_link = channel["channelRenderer"]["thumbnail"]["thumbnails"][0]["url"].split('=')[0] 83 | if avatar_link[:2] == "//": 84 | avatar_link = "https:" + avatar_link 85 | profile_url = "https://youtube.com" + channel["channelRenderer"]["navigationEndpoint"]["browseEndpoint"][ 86 | "canonicalBaseUrl"] 87 | req = client.get(avatar_link) 88 | img = Image.open(BytesIO(req.content)) 89 | hash = str(image_hash(img)) 90 | results["channels"].append({"profile_url": profile_url, "name": title, "hash": hash}) 91 | return results 92 | except (KeyError, IndexError): 93 | return False 94 | 95 | 96 | def youtube_channel_search_gdocs(client, query, data_path, gdocs_public_doc): 97 | search_query = f"site:youtube.com/channel \\\"{query}\\\"" 98 | search_results = gdoc_search(search_query, data_path, gdocs_public_doc) 99 | channels = [] 100 | 101 | for result in search_results: 102 | sanitized = "https://youtube.com/" + ('/'.join(result["link"].split('/')[3:5]).split("?")[0]) 103 | if sanitized not in channels: 104 | channels.append(sanitized) 105 | 106 | if not channels: 107 | return False 108 | 109 | results = {"channels": [], "length": len(channels)} 110 | channels = channels[:5] 111 | 112 | for profile_url in channels: 113 | data = None 114 | avatar_link = None 115 | 116 | retries = 2 117 | for retry in list(range(retries))[::-1]: 118 | req = client.get(profile_url, follow_redirects=True) 119 | source = req.text 120 | try: 121 | data = json.loads(source.split('var ytInitialData = ')[1].split(';')[0]) 122 | avatar_link = data["metadata"]["channelMetadataRenderer"]["avatar"]["thumbnails"][0]["url"].split('=')[0] 123 | except (KeyError, IndexError) as e: 124 | #import pdb; pdb.set_trace() 125 | if retry == 0: 126 | return False 127 | continue 128 | else: 129 | break 130 | req = client.get(avatar_link) 131 | img = Image.open(BytesIO(req.content)) 132 | hash = str(image_hash(img)) 133 | title = data["metadata"]["channelMetadataRenderer"]["title"] 134 | results["channels"].append({"profile_url": profile_url, "name": title, "hash": hash}) 135 | return results 136 | 137 | 138 | def get_channels(client, query, data_path, gdocs_public_doc): 139 | from_youtube = youtube_channel_search(client, query) 140 | from_gdocs = youtube_channel_search_gdocs(client, query, data_path, gdocs_public_doc) 141 | to_process = [] 142 | if from_youtube: 143 | from_youtube["origin"] = "youtube" 144 | to_process.append(from_youtube) 145 | if from_gdocs: 146 | from_gdocs["origin"] = "gdocs" 147 | to_process.append(from_gdocs) 148 | if not to_process: 149 | return False 150 | return to_process 151 | 152 | 153 | def get_confidence(data, query, hash): 154 | score_steps = 4 155 | 156 | for source_nb, source in enumerate(data): 157 | for channel_nb, channel in enumerate(source["channels"]): 158 | score = 0 159 | 160 | if hash == imagehash.hex_to_flathash(channel["hash"], 8): 161 | score += score_steps * 4 162 | if query == channel["name"]: 163 | score += score_steps * 3 164 | if query in channel["name"]: 165 | score += score_steps * 2 166 | if ((source["origin"] == "youtube" and source["length"] <= 5) or 167 | (source["origin"] == "google" and source["length"] <= 4)): 168 | score += score_steps 169 | data[source_nb]["channels"][channel_nb]["score"] = score 170 | 171 | channels = [] 172 | for source in data: 173 | for channel in source["channels"]: 174 | found_better = False 175 | for source2 in data: 176 | for channel2 in source["channels"]: 177 | if channel["profile_url"] == channel2["profile_url"]: 178 | if channel2["score"] > channel["score"]: 179 | found_better = True 180 | break 181 | if found_better: 182 | break 183 | if found_better: 184 | continue 185 | else: 186 | channels.append(channel) 187 | channels = sorted([json.loads(chan) for chan in set([json.dumps(channel) for channel in channels])], 188 | key=lambda k: k['score'], reverse=True) 189 | panels = sorted(set([c["score"] for c in channels]), reverse=True) 190 | if not channels or (panels and panels[0] <= 0): 191 | return 0, [] 192 | 193 | maxscore = sum([p * score_steps for p in range(1, score_steps + 1)]) 194 | for panel in panels: 195 | chans = [c for c in channels if c["score"] == panel] 196 | if len(chans) > 1: 197 | panel -= 5 198 | return (panel / maxscore * 100), chans 199 | 200 | 201 | def extract_usernames(channels): 202 | return [chan['profile_url'].split("/user/")[1] for chan in channels if "/user/" in chan['profile_url']] 203 | -------------------------------------------------------------------------------- /modules/doc.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import sys 5 | import os 6 | from datetime import datetime 7 | from io import BytesIO 8 | from os.path import isfile 9 | from pathlib import Path 10 | from pprint import pprint 11 | 12 | import httpx 13 | from PIL import Image 14 | 15 | import config 16 | from lib.utils import * 17 | from lib.banner import banner 18 | 19 | 20 | def doc_hunt(doc_link): 21 | banner() 22 | 23 | tmprinter = TMPrinter() 24 | 25 | if not doc_link: 26 | exit("Please give the link to a Google resource.\nExample : https://docs.google.com/spreadsheets/d/1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms") 27 | 28 | is_within_docker = within_docker() 29 | if is_within_docker: 30 | print("[+] Docker detected, profile pictures will not be saved.") 31 | 32 | doc_id = ''.join([x for x in doc_link.split("?")[0].split("/") if len(x) in (33, 44)]) 33 | if doc_id: 34 | print(f"\nDocument ID : {doc_id}\n") 35 | else: 36 | exit("\nDocument ID not found.\nPlease make sure you have something that looks like this in your link :\1BxiMVs0XRA5nFMdKvBdBZjgmUUqptlbs74OgvE2upms") 37 | 38 | if not isfile(config.data_path): 39 | exit("Please generate cookies and tokens first, with the check_and_gen.py script.") 40 | 41 | internal_token = "" 42 | cookies = {} 43 | 44 | with open(config.data_path, 'r') as f: 45 | out = json.loads(f.read()) 46 | internal_token = out["keys"]["internal"] 47 | cookies = out["cookies"] 48 | 49 | headers = {**config.headers, **{"X-Origin": "https://drive.google.com"}} 50 | client = httpx.Client(cookies=cookies, headers=headers) 51 | 52 | url = f"https://clients6.google.com/drive/v2beta/files/{doc_id}?fields=alternateLink%2CcopyRequiresWriterPermission%2CcreatedDate%2Cdescription%2CdriveId%2CfileSize%2CiconLink%2Cid%2Clabels(starred%2C%20trashed)%2ClastViewedByMeDate%2CmodifiedDate%2Cshared%2CteamDriveId%2CuserPermission(id%2Cname%2CemailAddress%2Cdomain%2Crole%2CadditionalRoles%2CphotoLink%2Ctype%2CwithLink)%2Cpermissions(id%2Cname%2CemailAddress%2Cdomain%2Crole%2CadditionalRoles%2CphotoLink%2Ctype%2CwithLink)%2Cparents(id)%2Ccapabilities(canMoveItemWithinDrive%2CcanMoveItemOutOfDrive%2CcanMoveItemOutOfTeamDrive%2CcanAddChildren%2CcanEdit%2CcanDownload%2CcanComment%2CcanMoveChildrenWithinDrive%2CcanRename%2CcanRemoveChildren%2CcanMoveItemIntoTeamDrive)%2Ckind&supportsTeamDrives=true&enforceSingleParent=true&key={internal_token}" 53 | 54 | retries = 100 55 | for retry in range(retries): 56 | req = client.get(url) 57 | if "File not found" in req.text: 58 | exit("[-] This file does not exist or is not public") 59 | elif "rateLimitExceeded" in req.text: 60 | tmprinter.out(f"[-] Rate-limit detected, retrying... {retry+1}/{retries}") 61 | continue 62 | else: 63 | break 64 | else: 65 | tmprinter.clear() 66 | exit("[-] Rate-limit exceeded. Try again later.") 67 | 68 | if '"reason": "keyInvalid"' in req.text: 69 | exit("[-] Your key is invalid, try regenerating your cookies & keys.") 70 | 71 | tmprinter.clear() 72 | data = json.loads(req.text) 73 | 74 | # Extracting informations 75 | 76 | # Dates 77 | 78 | created_date = datetime.strptime(data["createdDate"], '%Y-%m-%dT%H:%M:%S.%fz') 79 | modified_date = datetime.strptime(data["modifiedDate"], '%Y-%m-%dT%H:%M:%S.%fz') 80 | 81 | print(f"[+] Creation date : {created_date.strftime('%Y/%m/%d %H:%M:%S')} (UTC)") 82 | print(f"[+] Last edit date : {modified_date.strftime('%Y/%m/%d %H:%M:%S')} (UTC)") 83 | 84 | # Permissions 85 | 86 | user_permissions = [] 87 | if data["userPermission"]: 88 | if data["userPermission"]["id"] == "me": 89 | user_permissions.append(data["userPermission"]["role"]) 90 | if "additionalRoles" in data["userPermission"]: 91 | user_permissions += data["userPermission"]["additionalRoles"] 92 | 93 | public_permissions = [] 94 | owner = None 95 | for permission in data["permissions"]: 96 | if permission["id"] in ["anyoneWithLink", "anyone"]: 97 | public_permissions.append(permission["role"]) 98 | if "additionalRoles" in data["permissions"]: 99 | public_permissions += permission["additionalRoles"] 100 | elif permission["role"] == "owner": 101 | owner = permission 102 | 103 | print("\nPublic permissions :") 104 | for permission in public_permissions: 105 | print(f"- {permission}") 106 | 107 | if public_permissions != user_permissions: 108 | print("[+] You have special permissions :") 109 | for permission in user_permissions: 110 | print(f"- {permission}") 111 | 112 | if owner: 113 | print("\n[+] Owner found !\n") 114 | print(f"Name : {owner['name']}") 115 | print(f"Email : {owner['emailAddress']}") 116 | print(f"Google ID : {owner['id']}") 117 | 118 | # profile picture 119 | profile_pic_link = owner['photoLink'] 120 | req = client.get(profile_pic_link) 121 | 122 | profile_pic_img = Image.open(BytesIO(req.content)) 123 | profile_pic_flathash = image_hash(profile_pic_img) 124 | is_default_profile_pic = detect_default_profile_pic(profile_pic_flathash) 125 | 126 | if not is_default_profile_pic and not is_within_docker: 127 | print("\n[+] Custom profile picture !") 128 | print(f"=> {profile_pic_link}") 129 | if config.write_profile_pic and not is_within_docker: 130 | open(Path(config.profile_pics_dir) / f'{owner["emailAddress"]}.jpg', 'wb').write(req.content) 131 | print("Profile picture saved !\n") 132 | else: 133 | print("\n[-] Default profile picture\n") -------------------------------------------------------------------------------- /modules/email.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import sys 5 | import os 6 | from datetime import datetime 7 | from io import BytesIO 8 | from os.path import isfile 9 | from pathlib import Path 10 | from pprint import pprint 11 | 12 | import httpx 13 | from PIL import Image 14 | from geopy.geocoders import Nominatim 15 | 16 | import config 17 | from lib.banner import banner 18 | import lib.gmaps as gmaps 19 | import lib.youtube as ytb 20 | from lib.photos import gpics 21 | from lib.utils import * 22 | import lib.calendar as gcalendar 23 | 24 | 25 | def email_hunt(email): 26 | banner() 27 | 28 | if not email: 29 | exit("Please give a valid email.\nExample : larry@google.com") 30 | 31 | if not isfile(config.data_path): 32 | exit("Please generate cookies and tokens first, with the check_and_gen.py script.") 33 | 34 | hangouts_auth = "" 35 | hangouts_token = "" 36 | internal_auth = "" 37 | internal_token = "" 38 | 39 | cookies = {} 40 | 41 | with open(config.data_path, 'r') as f: 42 | out = json.loads(f.read()) 43 | hangouts_auth = out["hangouts_auth"] 44 | hangouts_token = out["keys"]["hangouts"] 45 | internal_auth = out["internal_auth"] 46 | internal_token = out["keys"]["internal"] 47 | cookies = out["cookies"] 48 | 49 | client = httpx.Client(cookies=cookies, headers=config.headers) 50 | 51 | data = is_email_google_account(client, hangouts_auth, cookies, email, 52 | hangouts_token) 53 | 54 | is_within_docker = within_docker() 55 | if is_within_docker: 56 | print("[+] Docker detected, profile pictures will not be saved.") 57 | 58 | geolocator = Nominatim(user_agent="nominatim") 59 | print(f"[+] {len(data['matches'])} account found !") 60 | 61 | for user in data["matches"]: 62 | print("\n------------------------------\n") 63 | 64 | gaiaID = user["personId"][0] 65 | email = user["lookupId"] 66 | infos = data["people"][gaiaID] 67 | 68 | # get name & profile picture 69 | account = get_account_data(client, gaiaID, internal_auth, internal_token, config) 70 | name = account["name"] 71 | 72 | if name: 73 | print(f"Name : {name}") 74 | else: 75 | if "name" not in infos: 76 | print("[-] Couldn't find name") 77 | else: 78 | for i in range(len(infos["name"])): 79 | if 'displayName' in infos['name'][i].keys(): 80 | name = infos["name"][i]["displayName"] 81 | print(f"Name : {name}") 82 | 83 | organizations = account["organizations"] 84 | if organizations: 85 | print(f"Organizations : {organizations}") 86 | 87 | locations = account["locations"] 88 | if locations: 89 | print(f"Locations : {locations}") 90 | 91 | # profile picture 92 | profile_pic_url = account.get("profile_pics") and account["profile_pics"][0].url 93 | if profile_pic_url: 94 | req = client.get(profile_pic_url) 95 | 96 | # TODO: make sure it's necessary now 97 | profile_pic_img = Image.open(BytesIO(req.content)) 98 | profile_pic_flathash = image_hash(profile_pic_img) 99 | is_default_profile_pic = detect_default_profile_pic(profile_pic_flathash) 100 | 101 | if not is_default_profile_pic: 102 | print("\n[+] Custom profile picture !") 103 | print(f"=> {profile_pic_url}") 104 | if config.write_profile_pic and not is_within_docker: 105 | open(Path(config.profile_pics_dir) / f'{email}.jpg', 'wb').write(req.content) 106 | print("Profile picture saved !") 107 | else: 108 | print("\n[-] Default profile picture") 109 | 110 | # cover profile picture 111 | cover_pic = account.get("cover_pics") and account["cover_pics"][0] 112 | if cover_pic and not cover_pic.is_default: 113 | cover_pic_url = cover_pic.url 114 | req = client.get(cover_pic_url) 115 | 116 | print("\n[+] Custom profile cover picture !") 117 | print(f"=> {cover_pic_url}") 118 | if config.write_profile_pic and not is_within_docker: 119 | open(Path(config.profile_pics_dir) / f'cover_{email}.jpg', 'wb').write(req.content) 120 | print("Cover profile picture saved !") 121 | 122 | # last edit 123 | try: 124 | timestamp = int(infos["metadata"]["lastUpdateTimeMicros"][:-3]) 125 | last_edit = datetime.utcfromtimestamp(timestamp).strftime("%Y/%m/%d %H:%M:%S (UTC)") 126 | print(f"\nLast profile edit : {last_edit}") 127 | except KeyError: 128 | last_edit = None 129 | print(f"\nLast profile edit : Not found") 130 | 131 | canonical_email = "" 132 | emails = update_emails(account["emails_set"], infos) 133 | if emails and len(list(emails)) == 1: 134 | if list(emails.values())[0].is_normalized(email): 135 | new_email = list(emails.keys())[0] 136 | if email != new_email: 137 | canonical_email = f' (canonical email is {new_email})' 138 | emails = [] 139 | 140 | print(f"\nEmail : {email}{canonical_email}\nGaia ID : {gaiaID}\n") 141 | 142 | if emails: 143 | print(f"Contact emails : {', '.join(map(str, emails.values()))}") 144 | 145 | phones = account["phones"] 146 | if phones: 147 | print(f"Contact phones : {phones}") 148 | 149 | # is bot? 150 | if "extendedData" in infos: 151 | isBot = infos["extendedData"]["hangoutsExtendedData"]["isBot"] 152 | if isBot: 153 | print("Hangouts Bot : Yes !") 154 | else: 155 | print("Hangouts Bot : No") 156 | else: 157 | print("Hangouts Bot : Unknown") 158 | 159 | # decide to check YouTube 160 | ytb_hunt = False 161 | try: 162 | services = [x["appType"].lower() if x["appType"].lower() != "babel" else "hangouts" for x in 163 | infos["inAppReachability"]] 164 | if name and (config.ytb_hunt_always or "youtube" in services): 165 | ytb_hunt = True 166 | print("\n[+] Activated Google services :") 167 | print('\n'.join(["- " + x.capitalize() for x in services])) 168 | 169 | except KeyError: 170 | ytb_hunt = True 171 | print("\n[-] Unable to fetch connected Google services.") 172 | 173 | # check YouTube 174 | if name and ytb_hunt: 175 | confidence = None 176 | data = ytb.get_channels(client, name, config.data_path, 177 | config.gdocs_public_doc) 178 | if not data: 179 | print("\n[-] YouTube channel not found.") 180 | else: 181 | confidence, channels = ytb.get_confidence(data, name, profile_pic_flathash) 182 | 183 | if confidence: 184 | print(f"\n[+] YouTube channel (confidence => {confidence}%) :") 185 | for channel in channels: 186 | print(f"- [{channel['name']}] {channel['profile_url']}") 187 | possible_usernames = ytb.extract_usernames(channels) 188 | if possible_usernames: 189 | print("\n[+] Possible usernames found :") 190 | for username in possible_usernames: 191 | print(f"- {username}") 192 | else: 193 | print("\n[-] YouTube channel not found.") 194 | 195 | # TODO: return gpics function output here 196 | #gpics(gaiaID, client, cookies, config.headers, config.regexs["albums"], config.regexs["photos"], 197 | # config.headless) 198 | 199 | # reviews 200 | reviews = gmaps.scrape(gaiaID, client, cookies, config, config.headers, config.regexs["review_loc_by_id"], config.headless) 201 | 202 | if reviews: 203 | confidence, locations = gmaps.get_confidence(geolocator, reviews, config.gmaps_radius) 204 | print(f"\n[+] Probable location (confidence => {confidence}) :") 205 | 206 | loc_names = [] 207 | for loc in locations: 208 | loc_names.append( 209 | f"- {loc['avg']['town']}, {loc['avg']['country']}" 210 | ) 211 | 212 | loc_names = set(loc_names) # delete duplicates 213 | for loc in loc_names: 214 | print(loc) 215 | 216 | 217 | # Google Calendar 218 | calendar_response = gcalendar.fetch(email, client, config) 219 | if calendar_response: 220 | print("[+] Public Google Calendar found !") 221 | events = calendar_response["events"] 222 | if events: 223 | gcalendar.out(events) 224 | else: 225 | print("=> No recent events found.") 226 | else: 227 | print("[-] No public Google Calendar.") 228 | -------------------------------------------------------------------------------- /modules/gaia.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import sys 5 | import os 6 | from datetime import datetime 7 | from io import BytesIO 8 | from os.path import isfile 9 | from pathlib import Path 10 | from pprint import pprint 11 | 12 | import httpx 13 | from PIL import Image 14 | from geopy.geocoders import Nominatim 15 | 16 | import config 17 | from lib.banner import banner 18 | import lib.gmaps as gmaps 19 | import lib.youtube as ytb 20 | from lib.utils import * 21 | 22 | 23 | def gaia_hunt(gaiaID): 24 | banner() 25 | 26 | if not gaiaID: 27 | exit("Please give a valid GaiaID.\nExample : 113127526941309521065") 28 | 29 | if not isfile(config.data_path): 30 | exit("Please generate cookies and tokens first, with the check_and_gen.py script.") 31 | 32 | internal_auth = "" 33 | internal_token = "" 34 | 35 | cookies = {} 36 | 37 | with open(config.data_path, 'r') as f: 38 | out = json.loads(f.read()) 39 | internal_auth = out["internal_auth"] 40 | internal_token = out["keys"]["internal"] 41 | cookies = out["cookies"] 42 | 43 | client = httpx.Client(cookies=cookies, headers=config.headers) 44 | 45 | account = get_account_data(client, gaiaID, internal_auth, internal_token, config) 46 | if not account: 47 | exit("[-] No account linked to this Gaia ID.") 48 | 49 | is_within_docker = within_docker() 50 | if is_within_docker: 51 | print("[+] Docker detected, profile pictures will not be saved.") 52 | 53 | geolocator = Nominatim(user_agent="nominatim") 54 | 55 | # get name & other info 56 | name = account["name"] 57 | if name: 58 | print(f"Name : {name}") 59 | 60 | organizations = account["organizations"] 61 | if organizations: 62 | print(f"Organizations : {organizations}") 63 | 64 | locations = account["locations"] 65 | if locations: 66 | print(f"Locations : {locations}") 67 | 68 | # get profile picture 69 | profile_pic_url = account.get("profile_pics") and account["profile_pics"][0].url 70 | if profile_pic_url: 71 | req = client.get(profile_pic_url) 72 | 73 | # TODO: make sure it's necessary now 74 | profile_pic_img = Image.open(BytesIO(req.content)) 75 | profile_pic_flathash = image_hash(profile_pic_img) 76 | is_default_profile_pic = detect_default_profile_pic(profile_pic_flathash) 77 | 78 | if not is_default_profile_pic: 79 | print("\n[+] Custom profile picture !") 80 | print(f"=> {profile_pic_url}") 81 | if config.write_profile_pic and not is_within_docker: 82 | open(Path(config.profile_pics_dir) / f'{gaiaID}.jpg', 'wb').write(req.content) 83 | print("Profile picture saved !") 84 | else: 85 | print("\n[-] Default profile picture") 86 | 87 | # cover profile picture 88 | cover_pic = account.get("cover_pics") and account["cover_pics"][0] 89 | if cover_pic and not cover_pic.is_default: 90 | req = client.get(cover_pic_url) 91 | 92 | print("\n[+] Custom profile cover picture !") 93 | print(f"=> {cover_pic_url}") 94 | if config.write_profile_pic and not is_within_docker: 95 | open(Path(config.profile_pics_dir) / f'cover_{email}.jpg', 'wb').write(req.content) 96 | print("Cover profile picture saved !") 97 | 98 | 99 | print(f"\nGaia ID : {gaiaID}") 100 | 101 | emails = account["emails_set"] 102 | if emails: 103 | print(f"Contact emails : {', '.join(map(str, emails.values()))}") 104 | 105 | phones = account["phones"] 106 | if phones: 107 | print(f"Contact phones : {phones}") 108 | 109 | # check YouTube 110 | if name: 111 | confidence = None 112 | data = ytb.get_channels(client, name, config.data_path, 113 | config.gdocs_public_doc) 114 | if not data: 115 | print("\n[-] YouTube channel not found.") 116 | else: 117 | confidence, channels = ytb.get_confidence(data, name, profile_pic_flathash) 118 | 119 | if confidence: 120 | print(f"\n[+] YouTube channel (confidence => {confidence}%) :") 121 | for channel in channels: 122 | print(f"- [{channel['name']}] {channel['profile_url']}") 123 | possible_usernames = ytb.extract_usernames(channels) 124 | if possible_usernames: 125 | print("\n[+] Possible usernames found :") 126 | for username in possible_usernames: 127 | print(f"- {username}") 128 | else: 129 | print("\n[-] YouTube channel not found.") 130 | 131 | # reviews 132 | reviews = gmaps.scrape(gaiaID, client, cookies, config, config.headers, config.regexs["review_loc_by_id"], config.headless) 133 | 134 | if reviews: 135 | confidence, locations = gmaps.get_confidence(geolocator, reviews, config.gmaps_radius) 136 | print(f"\n[+] Probable location (confidence => {confidence}) :") 137 | 138 | loc_names = [] 139 | for loc in locations: 140 | loc_names.append( 141 | f"- {loc['avg']['town']}, {loc['avg']['country']}" 142 | ) 143 | 144 | loc_names = set(loc_names) # delete duplicates 145 | for loc in loc_names: 146 | print(loc) 147 | -------------------------------------------------------------------------------- /modules/youtube.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import sys 5 | from datetime import datetime 6 | from datetime import date 7 | from io import BytesIO 8 | from os.path import isfile 9 | from pathlib import Path 10 | from pprint import pprint 11 | 12 | import httpx 13 | import wayback 14 | from PIL import Image 15 | from bs4 import BeautifulSoup as bs 16 | from geopy.geocoders import Nominatim 17 | 18 | import config 19 | from lib.banner import banner 20 | import lib.gmaps as gmaps 21 | import lib.youtube as ytb 22 | from lib.utils import * 23 | 24 | 25 | def find_gaiaID(body): 26 | """ 27 | We don't use a regex to avoid extracting an other gaiaID 28 | for example if the target had put a secondary Google Plus blog in his channel social links. 29 | """ 30 | 31 | # 1st method ~ 2014 32 | try: 33 | publisher = body.find("link", {"rel": "publisher"}) 34 | gaiaID = publisher.attrs["href"].split("/")[-1] 35 | except: 36 | pass 37 | else: 38 | if gaiaID: 39 | return gaiaID 40 | 41 | # 2nd method ~ 2015 42 | try: 43 | author_links = [x.find_next("link") for x in body.find_all("span", {"itemprop": "author"})] 44 | valid_author_link = [x for x in author_links if "plus.google.com/" in x.attrs["href"]][0] 45 | gaiaID = valid_author_link.attrs["href"].split("/")[-1] 46 | except: 47 | pass 48 | else: 49 | if gaiaID: 50 | return gaiaID 51 | 52 | # 3rd method ~ 2019 53 | try: 54 | data = json.loads(str(body).split('window["ytInitialData"] = ')[1].split('window["ytInitialPlayerResponse"]')[0].strip().strip(";")) 55 | gaiaID = data["metadata"]["channelMetadataRenderer"]["plusPageLink"].split("/")[-1] 56 | except: 57 | pass 58 | else: 59 | if gaiaID: 60 | return gaiaID 61 | 62 | def analyze_snapshots(client, wb_client, channel_url, dates): 63 | body = None 64 | record = None 65 | for record in wb_client.search(channel_url, to_date=dates["to"], from_date=dates["from"]): 66 | try: 67 | req = client.get(record.raw_url) 68 | if req.status_code == 429: 69 | continue # Rate-limit is fucked up and is snapshot-based, we can just take the next snapshot 70 | except Exception as err: 71 | pass 72 | else: 73 | if re.compile(config.regexs["gplus"]).findall(req.text): 74 | body = bs(req.text, 'html.parser') 75 | #print(record) 76 | print(f'[+] Snapshot : {record.timestamp.strftime("%d/%m/%Y")}') 77 | break 78 | else: 79 | return None 80 | 81 | gaiaID = find_gaiaID(body) 82 | return gaiaID 83 | 84 | def check_channel(client, wb_client, channel_url): 85 | # Fast check (no doubt that GaiaID is present in this period) 86 | 87 | dates = {"to": date(2019, 12, 31), "from": date(2014, 1, 1)} 88 | gaiaID = analyze_snapshots(client, wb_client, channel_url, dates) 89 | 90 | # Complete check 91 | 92 | if not gaiaID: 93 | dates = {"to": date(2020, 7, 31), "from": date(2013, 6, 3)} 94 | gaiaID = analyze_snapshots(client, wb_client, channel_url, dates) 95 | 96 | return gaiaID 97 | 98 | def launch_checks(client, wb_client, channel_data): 99 | for channel_url in channel_data["channel_urls"]: 100 | gaiaID = check_channel(client, wb_client, channel_url) 101 | if gaiaID: 102 | return gaiaID 103 | 104 | return False 105 | 106 | def youtube_hunt(channel_url): 107 | banner() 108 | 109 | if not channel_url: 110 | exit("Please give a valid channel URL.\nExample : https://www.youtube.com/user/PewDiePie") 111 | 112 | if not isfile(config.data_path): 113 | exit("Please generate cookies and tokens first, with the check_and_gen.py script.") 114 | 115 | internal_auth = "" 116 | internal_token = "" 117 | 118 | cookies = {} 119 | 120 | with open(config.data_path, 'r') as f: 121 | out = json.loads(f.read()) 122 | internal_auth = out["internal_auth"] 123 | internal_token = out["keys"]["internal"] 124 | cookies = out["cookies"] 125 | 126 | if not "PREF" in cookies: 127 | pref_cookies = {"PREF": "tz=Europe.Paris&f6=40000000&hl=en"} # To set the lang in english 128 | cookies = {**cookies, **pref_cookies} 129 | 130 | client = httpx.Client(cookies=cookies, headers=config.headers) 131 | 132 | is_within_docker = within_docker() 133 | if is_within_docker: 134 | print("[+] Docker detected, profile pictures will not be saved.") 135 | 136 | geolocator = Nominatim(user_agent="nominatim") 137 | 138 | print("\n📌 [Youtube channel]") 139 | 140 | channel_data = ytb.get_channel_data(client, channel_url) 141 | if channel_data: 142 | is_channel_existing = True 143 | print(f'[+] Channel name : {channel_data["name"]}\n') 144 | else: 145 | is_channel_existing = False 146 | print("[-] Channel not found.\nSearching for a trace in the archives...\n") 147 | 148 | channel_data = { 149 | "name": None, 150 | "description": None, 151 | "channel_urls": [channel_url], 152 | "email_contact": False, 153 | "views": None, 154 | "joined_date": None, 155 | "primary_links": [], 156 | "country": None 157 | } 158 | 159 | wb_client = wayback.WaybackClient() 160 | gaiaID = launch_checks(client, wb_client, channel_data) 161 | if gaiaID: 162 | print(f"[+] GaiaID => {gaiaID}\n") 163 | else: 164 | print("[-] No interesting snapshot found.\n") 165 | 166 | if is_channel_existing: 167 | if channel_data["email_contact"]: 168 | print(f'[+] Email on profile : available !') 169 | else: 170 | print(f'[-] Email on profile : not available.') 171 | if channel_data["country"]: 172 | print(f'[+] Country : {channel_data["country"]}') 173 | print() 174 | if channel_data["description"]: 175 | print(f'🧬 Description : {channel_data["description"]}') 176 | if channel_data["views"]: 177 | print(f'🧬 Total views : {channel_data["views"]}') 178 | if channel_data["joined_date"]: 179 | print(f'🧬 Joined date : {channel_data["joined_date"]}') 180 | 181 | if channel_data["primary_links"]: 182 | print(f'\n[+] Primary links ({len(channel_data["primary_links"])} found)') 183 | for primary_link in channel_data["primary_links"]: 184 | print(f'- {primary_link["title"]} => {primary_link["url"]}') 185 | 186 | 187 | if not gaiaID: 188 | exit() 189 | 190 | print("\n📌 [Google account]") 191 | # get name & profile picture 192 | account = get_account_data(client, gaiaID, internal_auth, internal_token, config) 193 | name = account["name"] 194 | 195 | if name: 196 | print(f"Name : {name}") 197 | 198 | # profile picture 199 | profile_pic_url = account.get("profile_pics") and account["profile_pics"][0].url 200 | req = client.get(profile_pic_url) 201 | 202 | profile_pic_img = Image.open(BytesIO(req.content)) 203 | profile_pic_hash = image_hash(profile_pic_img) 204 | is_default_profile_pic = detect_default_profile_pic(profile_pic_hash) 205 | 206 | if profile_pic_url: 207 | req = client.get(profile_pic_url) 208 | 209 | # TODO: make sure it's necessary now 210 | profile_pic_img = Image.open(BytesIO(req.content)) 211 | profile_pic_flathash = image_hash(profile_pic_img) 212 | is_default_profile_pic = detect_default_profile_pic(profile_pic_flathash) 213 | 214 | if not is_default_profile_pic: 215 | print("\n[+] Custom profile picture !") 216 | print(f"=> {profile_pic_url}") 217 | if config.write_profile_pic and not is_within_docker: 218 | open(Path(config.profile_pics_dir) / f'{gaiaID}.jpg', 'wb').write(req.content) 219 | print("Profile picture saved !") 220 | else: 221 | print("\n[-] Default profile picture") 222 | 223 | # cover profile picture 224 | cover_pic = account.get("cover_pics") and account["cover_pics"][0] 225 | if cover_pic and not cover_pic.is_default: 226 | cover_pic_url = cover_pic.url 227 | req = client.get(cover_pic_url) 228 | 229 | print("\n[+] Custom profile cover picture !") 230 | print(f"=> {cover_pic_url}") 231 | if config.write_profile_pic and not is_within_docker: 232 | open(Path(config.profile_pics_dir) / f'cover_{gaiaID}.jpg', 'wb').write(req.content) 233 | print("Cover profile picture saved !") 234 | 235 | # reviews 236 | reviews = gmaps.scrape(gaiaID, client, cookies, config, config.headers, config.regexs["review_loc_by_id"], config.headless) 237 | 238 | if reviews: 239 | confidence, locations = gmaps.get_confidence(geolocator, reviews, config.gmaps_radius) 240 | print(f"\n[+] Probable location (confidence => {confidence}) :") 241 | 242 | loc_names = [] 243 | for loc in locations: 244 | loc_names.append( 245 | f"- {loc['avg']['town']}, {loc['avg']['country']}" 246 | ) 247 | 248 | loc_names = set(loc_names) # delete duplicates 249 | for loc in loc_names: 250 | print(loc) 251 | -------------------------------------------------------------------------------- /profile_pics/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxoj/GHunt/b41322364294850678f4b80f6a5cf0ee36a5dc54/profile_pics/.keep -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | geopy 2 | httpx>=0.20.0 3 | selenium-wire>=4.5.5 4 | selenium>=4.0.0 5 | imagehash 6 | pillow 7 | python-dateutil 8 | colorama 9 | beautifultable 10 | termcolor 11 | webdriver-manager 12 | wayback 13 | bs4 14 | packaging 15 | -------------------------------------------------------------------------------- /resources/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/soxoj/GHunt/b41322364294850678f4b80f6a5cf0ee36a5dc54/resources/.gitkeep --------------------------------------------------------------------------------