├── .envrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── actions │ └── devenv │ │ └── action.yml ├── copilot-instructions.md ├── dependabot.yml └── workflows │ ├── build.yml │ ├── dependency-review.yml │ ├── distro.yml │ ├── gosec.yml │ ├── nix-check-test.yml │ ├── release.yml │ ├── scorecard.yml │ └── unit.yml ├── .gitignore ├── .goreleaser.yaml ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── apt ├── ParetoSecurity.desktop ├── ParetoSecurityLink.desktop ├── conf │ ├── distributions │ └── options ├── index.html ├── install.sh ├── paretosecurity-trayicon.service ├── paretosecurity-user.service ├── paretosecurity-user.timer ├── paretosecurity.asc ├── paretosecurity.gpg ├── paretosecurity.service ├── paretosecurity.socket ├── postinstall.sh └── rpm │ └── paretosecurity.repo ├── assets ├── icon.png ├── icon.svg ├── icon_black.svg ├── icon_white.svg └── logo.svg ├── check ├── check.go └── check_test.go ├── checks ├── check_test.go ├── darwin │ ├── password_manager.go │ └── password_manager_test.go ├── linux │ ├── application_updates.go │ ├── application_updates_test.go │ ├── autologin.go │ ├── autologin_test.go │ ├── docker.go │ ├── docker_test.go │ ├── firewall.go │ ├── firewall_test.go │ ├── luks.go │ ├── luks_test.go │ ├── mock.go │ ├── password_manager.go │ ├── password_manager_test.go │ ├── password_unlock.go │ ├── password_unlock_test.go │ ├── printer.go │ ├── printer_test.go │ ├── secure_boot.go │ ├── secure_boot_test.go │ ├── sharing.go │ └── sharing_test.go ├── shared │ ├── mock.go │ ├── pareto_updated.go │ ├── pareto_updated_test.go │ ├── port.go │ ├── remote_login.go │ ├── remote_login_test.go │ ├── ssh_keys.go │ ├── ssh_keys_algo.go │ ├── ssh_keys_algo_test.go │ └── ssh_keys_test.go └── windows │ ├── automatic_updates.go │ ├── automatic_updates_test.go │ ├── defender.go │ ├── defender_test.go │ ├── firewall.go │ ├── firewall_test.go │ ├── mock.go │ ├── password_manager.go │ └── password_manager_test.go ├── claims ├── checks_darwin.go ├── checks_linux.go ├── checks_windows.go └── claim.go ├── cmd ├── check.go ├── check_test.go ├── config.go ├── helper.go ├── info.go ├── link.go ├── link_test.go ├── paretosecurity-installer │ ├── installer_unix.go │ ├── installer_windows.go │ ├── main.go │ └── ui │ │ ├── .gitignore │ │ ├── dist │ │ ├── assets │ │ │ ├── main-DO0Ogokg.js │ │ │ └── main-DUiShk8X.css │ │ └── index.html │ │ ├── elm.json │ │ ├── index.html │ │ ├── package.json │ │ ├── src │ │ ├── Welcome.css │ │ ├── Welcome.elm │ │ ├── assets │ │ │ ├── icon.png │ │ │ ├── icon_black.svg │ │ │ └── icon_white.svg │ │ ├── index.js │ │ └── windowservice.js │ │ ├── vite.config.ts │ │ └── yarn.lock ├── paretosecurity-tray │ └── main.go ├── paretosecurity │ └── main.go ├── root.go ├── schema.go ├── status.go ├── toast.go ├── trayicon.go ├── unlink.go ├── unlink_test.go └── update.go ├── devenv.lock ├── devenv.nix ├── devenv.yaml ├── flake.lock ├── flake.nix ├── go.mod ├── go.sum ├── icon.ico ├── icons.icns ├── notify ├── notify_darwin.go ├── notify_linux.go └── notify_windows.go ├── package.nix ├── renovate.json ├── runner ├── checks_runner.go ├── checks_runner_test.go ├── root_runner.go ├── root_runner_test.go ├── socket.go └── socket_test.go ├── shared ├── broadcaster.go ├── broadcaster_test.go ├── cache.go ├── cache_test.go ├── command_unix.go ├── command_windows.go ├── config.go ├── config_test.go ├── device_all.go ├── device_all_test.go ├── device_darwin.go ├── device_linux.go ├── device_windows.go ├── http.go ├── icon.go ├── icon_black.png ├── icon_white.png ├── install.ps1 ├── last_state.go ├── last_state_test.go ├── nixos.go ├── os.go ├── os_test.go ├── status.go ├── status_test.go ├── string.go ├── string_test.go ├── system.go ├── uninstall.ps1 ├── updater.go └── version.go ├── systemd ├── shared.go ├── timer.go ├── timer_test.go ├── tray.go └── tray_test.go ├── team ├── report.go └── report_test.go ├── test └── integration │ ├── README.md │ ├── cli.nix │ ├── common.nix │ ├── desktop │ └── xfce.nix │ ├── firewall.nix │ ├── help.nix │ ├── luks.nix │ ├── pwd-manager.nix │ ├── screenlock.nix │ ├── secureboot.nix │ └── vms.png ├── trayapp ├── format.go ├── format_test.go ├── icon.go ├── theme_darwin.go ├── theme_linux.go ├── theme_windows.go ├── tray.go └── watcher.go └── winres ├── icon.png └── winres.json /.envrc: -------------------------------------------------------------------------------- 1 | export DIRENV_WARN_TIMEOUT=20s 2 | 3 | eval "$(devenv direnvrc)" 4 | 5 | use devenv 6 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug, triage 6 | assignees: dz0ny 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 | **Version** 24 | Run `paretosecurity -v` and copy report here. 25 | 26 | 27 | **Additional context** 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/actions/devenv/action.yml: -------------------------------------------------------------------------------- 1 | name: "Devenv setup" 2 | description: "Setup devenv for running tests and linters" 3 | inputs: 4 | authToken: 5 | description: "Cachix auth token" 6 | required: true 7 | devenv: 8 | description: "Devenv version to install" 9 | required: false 10 | default: "github:cachix/devenv/v1.6.1" 11 | runs: 12 | using: "composite" 13 | steps: 14 | - name: namespacelabs/nscloud-cache-action cannot mkdir /nix so we do it manually 15 | shell: bash 16 | run: | 17 | sudo mkdir /nix 18 | sudo chown $USER /nix 19 | 20 | - uses: namespacelabs/nscloud-cache-action@v1 21 | with: 22 | path: | 23 | /home/runner/go/pkg/mod 24 | /nix 25 | ~/.cache 26 | .devenv 27 | 28 | - uses: cachix/install-nix-action@v31 29 | with: 30 | extra_nix_config: | 31 | system-features = kvm nixos-test 32 | - uses: cachix/cachix-action@v16 33 | with: 34 | name: niteo 35 | authToken: ${{ inputs.authToken }} 36 | 37 | - name: Install devenv.sh 38 | shell: bash 39 | run: | 40 | if ! command -v devenv &> /dev/null; then 41 | nix profile install --accept-flake-config ${{ inputs.devenv }} 42 | fi 43 | -------------------------------------------------------------------------------- /.github/copilot-instructions.md: -------------------------------------------------------------------------------- 1 | - Be casual unless otherwise specified 2 | - Be terse 3 | - Suggest solutions that I didn't think about-anticipate my needs 4 | - Treat me as an expert 5 | - Be accurate and thorough 6 | - Give the answer immediately. Provide detailed explanations and restate my query in your own words if necessary after giving the answer 7 | - Value good arguments over authorities, the source is irrelevant 8 | - Consider new technologies and contrarian ideas, not just the conventional wisdom 9 | - You may use high levels of speculation or prediction, just flag it for me 10 | - No moral lectures 11 | - Discuss safety only when it's crucial and non-obvious 12 | - If your content policy is an issue, provide the closest acceptable response and explain the content policy issue afterward 13 | - Cite sources whenever possible at the end, not inline 14 | - No need to mention your knowledge cutoff 15 | - No need to disclose you're an AI 16 | - Please respect my formatting preferences when you provide code. 17 | - Please respect all code comments, they're usually there for a reason. Remove them ONLY if they're completely irrelevant after a code change. if unsure, do not remove the comment. 18 | - Split into multiple responses if one response isn't enough to answer the question. 19 | If I ask for adjustments to code I have provided you, do not repeat all of my code unnecessarily. Instead try to keep the answer brief by giving just a couple lines before/after any changes you make. Multiple code blocks are ok. 20 | - When writing test that include http use gock for mocking 21 | - WhenMocking shared.RunCommand use follwoing mock : 22 | ``` 23 | shared.RunCommandMocks = type RunCommandMock struct { 24 | Command string 25 | Args []string 26 | Out string 27 | Err error 28 | } 29 | ``` -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | 13 | - package-ecosystem: github-actions 14 | directory: / 15 | schedule: 16 | interval: daily 17 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml : -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, 6 | # PRs introducing known-vulnerable packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | name: "Dependency Review" 10 | on: [pull_request] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | dependency-review: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Harden Runner 20 | uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 21 | with: 22 | egress-policy: audit 23 | 24 | - name: "Checkout Repository" 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | - name: "Dependency Review" 27 | uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4.5.0 28 | -------------------------------------------------------------------------------- /.github/workflows/gosec.yml: -------------------------------------------------------------------------------- 1 | name: Run Gosec 2 | on: 3 | push: 4 | branches: 5 | - main 6 | pull_request: 7 | branches: 8 | - main 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | env: 13 | GO111MODULE: on 14 | steps: 15 | - name: Checkout Source 16 | uses: actions/checkout@v4 17 | - name: Run Gosec Security Scanner 18 | uses: securego/gosec@master 19 | with: 20 | args: ./... 21 | -------------------------------------------------------------------------------- /.github/workflows/nix-check-test.yml: -------------------------------------------------------------------------------- 1 | name: Integration Tests 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - main 8 | schedule: 9 | - cron: '0 0 * * 0' # Runs every Sunday at midnight UTC 10 | 11 | jobs: 12 | check-on-nix: 13 | name: "Check: ${{ matrix.check }}" 14 | runs-on: namespace-profile-pareto-linux 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | check: [ 19 | pwd-manager, 20 | firewall, 21 | screenlock, 22 | secureboot, 23 | luks, 24 | help, 25 | xfce 26 | ] 27 | steps: 28 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 29 | - uses: ./.github/actions/devenv 30 | with: 31 | authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} 32 | - run: chmod o+rx /home/runner 33 | name: Update /home/runner permissions so that Nix is happy 34 | 35 | - run: nix build .#checks.x86_64-linux.${{ matrix.check }} --print-build-logs 36 | env: 37 | NIX_PAGER: cat 38 | -------------------------------------------------------------------------------- /.github/workflows/scorecard.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '45 0 * * 5' 14 | push: 15 | branches: [ "main" ] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-latest 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | # Uncomment the permissions below if installing in a private repository. 30 | # contents: read 31 | # actions: read 32 | 33 | steps: 34 | - name: "Checkout code" 35 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 36 | with: 37 | persist-credentials: false 38 | 39 | - name: "Run analysis" 40 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 41 | with: 42 | results_file: results.sarif 43 | results_format: sarif 44 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 45 | # - you want to enable the Branch-Protection check on a *public* repository, or 46 | # - you are installing Scorecard on a *private* repository 47 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action?tab=readme-ov-file#authentication-with-fine-grained-pat-optional. 48 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 49 | 50 | # Public repositories: 51 | # - Publish results to OpenSSF REST API for easy access by consumers 52 | # - Allows the repository to include the Scorecard badge. 53 | # - See https://github.com/ossf/scorecard-action#publishing-results. 54 | # For private repositories: 55 | # - `publish_results` will always be set to `false`, regardless 56 | # of the value entered here. 57 | publish_results: true 58 | 59 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 60 | # format to the repository Actions tab. 61 | - name: "Upload artifact" 62 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.pre.node20 63 | with: 64 | name: SARIF file 65 | path: results.sarif 66 | retention-days: 5 67 | 68 | # Upload the results to GitHub's code scanning dashboard (optional). 69 | # Commenting out will disable upload of results to your repo's Code Scanning dashboard 70 | - name: "Upload to code-scanning" 71 | uses: github/codeql-action/upload-sarif@ce28f5bb42b7a9f2c824e633a3f6ee835bab6858 # v3.29.0 72 | with: 73 | sarif_file: results.sarif 74 | -------------------------------------------------------------------------------- /.github/workflows/unit.yml: -------------------------------------------------------------------------------- 1 | # Run all tests, linters, code analysis and other QA tasks on 2 | # every push to master and PRs. 3 | # 4 | # To SSH into the runner to debug a failure, add the following step before 5 | # the failing step 6 | # - uses: mxschmitt/action-tmate@v3 7 | # with: 8 | # install-dependencies: false 9 | 10 | name: Unit Tests 11 | 12 | on: 13 | workflow_dispatch: 14 | pull_request: 15 | push: 16 | branches: 17 | - main 18 | 19 | # Prevent multiple jobs running after fast subsequent pushes 20 | concurrency: 21 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 22 | cancel-in-progress: true 23 | 24 | jobs: 25 | dependabot: 26 | needs: [ test ] 27 | runs-on: ubuntu-latest 28 | permissions: 29 | pull-requests: write 30 | contents: write 31 | if: ${{ github.actor == 'dependabot[bot]' && github.event_name == 'pull_request'}} 32 | steps: 33 | - id: metadata 34 | uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b # v2.4.0 35 | with: 36 | github-token: "${{ secrets.GITHUB_TOKEN }}" 37 | - run: | 38 | gh pr review --approve "$PR_URL" 39 | gh pr merge --squash --auto "$PR_URL" 40 | env: 41 | PR_URL: ${{github.event.pull_request.html_url}} 42 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 43 | test: 44 | name: Tests 45 | runs-on: namespace-profile-pareto-linux 46 | steps: 47 | - uses: namespacelabs/nscloud-checkout-action@953fed31a6113cc2347ca69c9d823743c65bc84b # v6 48 | - uses: ./.github/actions/devenv 49 | with: 50 | authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} 51 | - run: devenv test -d 52 | - name: Archive code coverage results 53 | uses: actions/upload-artifact@v4 54 | with: 55 | name: code-coverage 56 | path: coverage.txt # Make sure to use the same file name you chose for the "-coverprofile" in the "Test" step 57 | - uses: actions/setup-go@v5 58 | with: 59 | go-version: 1.24 60 | if: github.ref == 'refs/heads/main' 61 | - uses: ncruces/go-coverage-report@main 62 | if: github.ref == 'refs/heads/main' 63 | with: 64 | report: true 65 | chart: true 66 | amend: true 67 | coverage-file: coverage.txt 68 | continue-on-error: true 69 | code_coverage: 70 | name: "Code coverage report" 71 | if: github.event_name == 'pull_request' 72 | runs-on: ubuntu-latest 73 | needs: test 74 | permissions: 75 | contents: read 76 | actions: read 77 | pull-requests: write 78 | steps: 79 | - uses: teamniteo/go-coverage-report@v3 80 | with: 81 | coverage-artifact-name: "code-coverage" 82 | coverage-file-name: "coverage.txt" 83 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Devenv 2 | .devenv* 3 | devenv.local.nix 4 | 5 | # direnv 6 | .direnv 7 | 8 | # pre-commit 9 | .pre-commit-config.yaml 10 | 11 | # Added by cargo 12 | /target 13 | .DS_Store 14 | coverage.txt 15 | /dist 16 | 17 | # nix stuff 18 | result 19 | .nixos-test-history 20 | 21 | # Devenv 22 | .devenv* 23 | devenv.local.nix 24 | 25 | # direnv 26 | .direnv 27 | 28 | # pre-commit 29 | .pre-commit-config.yaml 30 | *.syso 31 | **/.claude/settings.local.json 32 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # Pareto Security Agent Development Guide 2 | 3 | ## Build & Test Commands 4 | - Build: `go build ./cmd/paretosecurity` 5 | - Test all: `go test ./...` 6 | - Test specific package: `go test github.com/ParetoSecurity/agent/checks/linux` 7 | - Test single test: `go test -run TestApplicationUpdates_Run ./checks/linux` 8 | - Coverage: `go test -coverprofile=coverage.txt ./...` 9 | - Lint: Uses pre-commit hooks with `alejandra` and `gofmt` 10 | 11 | ## Code Style Guidelines 12 | - Imports: Standard library first, third-party packages second, project-specific last 13 | - Formatting: Use `gofmt` standard formatting; tabs for indentation 14 | - Naming: CamelCase for exported identifiers, camelCase for unexported 15 | - Interfaces: Prefer small, focused interfaces with clear purpose 16 | - Tests: Table-driven tests with descriptive names, using assert package 17 | - Error handling: Return errors up the stack, use early returns with `if err != nil` 18 | - Logging: Use `log.WithField/WithError` for structured logging 19 | - Mocks: Use dependency injection patterns with interface mocks for testing 20 | - Documentation: Add descriptive comments for all exported functions/methods 21 | - File layout: Related types and their implementations together -------------------------------------------------------------------------------- /apt/ParetoSecurity.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Type=Application 3 | Exec=/usr/bin/paretosecurity trayicon 4 | Icon=ParetoSecurity 5 | Hidden=false 6 | NoDisplay=false 7 | Terminal=false 8 | Name=Pareto Security 9 | Comment=Launch Pareto Security 10 | Categories=Utility; 11 | -------------------------------------------------------------------------------- /apt/ParetoSecurityLink.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=ParetoSecurity 3 | Exec=/usr/bin/paretosecurity link %u 4 | Type=Application 5 | NoDisplay=true 6 | MimeType=x-scheme-handler/paretosecurity; -------------------------------------------------------------------------------- /apt/conf/distributions: -------------------------------------------------------------------------------- 1 | Origin: pkg.paretosecurity.com 2 | Codename: stable 3 | Architectures: amd64 arm64 4 | Components: main 5 | SignWith: 196FECB38F8C215A 6 | Tracking: minimal 7 | -------------------------------------------------------------------------------- /apt/conf/options: -------------------------------------------------------------------------------- 1 | # output dir 2 | outdir +b/../apt/debian 3 | -------------------------------------------------------------------------------- /apt/paretosecurity-trayicon.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ParetoSecurity TrayIcon for desktop manager 3 | 4 | [Service] 5 | ExecStart=/usr/bin/paretosecurity trayicon 6 | StandardOutput=journal 7 | StandardError=journal 8 | Type=simple 9 | 10 | [Install] 11 | WantedBy=graphical-session.target -------------------------------------------------------------------------------- /apt/paretosecurity-user.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ParetoSecurity hourly runner 3 | 4 | [Service] 5 | ExecStart=/usr/bin/paretosecurity check 6 | StandardOutput=journal 7 | StandardError=journal 8 | 9 | [Install] 10 | WantedBy=default.target -------------------------------------------------------------------------------- /apt/paretosecurity-user.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Run ParetoSecurity Check every hour 3 | 4 | [Timer] 5 | OnCalendar=hourly 6 | Persistent=true 7 | 8 | [Install] 9 | WantedBy=timers.target 10 | -------------------------------------------------------------------------------- /apt/paretosecurity.asc: -------------------------------------------------------------------------------- 1 | -----BEGIN PGP PUBLIC KEY BLOCK----- 2 | 3 | mDMEZ0R8nRYJKwYBBAHaRw8BAQdAKA/u1xSwPFMoSb1GjWfTwiYQiwW9Awb7M+W5 4 | LG0Tvsi0FU5pdGVvIDxpbmZvQG5pdGVvLmNvPoiZBBMWCgBBFiEEpNSkU/i82ARB 5 | iLANGW/ss4+MIVoFAmdEfJ0CGwMFCQWjmoAFCwkIBwICIgIGFQoJCAsCBBYCAwEC 6 | HgcCF4AACgkQGW/ss4+MIVqt0wEA0GdhtYoxh+rZOL4tKUOMP24NdTCWuM49bd2U 7 | TesnvxIA/jY082pmuuKBzjMsRbSqSOSdSEgeKPydezgafvjfelcJuDgEZ0R8nRIK 8 | KwYBBAGXVQEFAQEHQItTOsbMfEbRltg60yMYhgWMPicCyHlqdme9g90sv/c2AwEI 9 | B4h+BBgWCgAmFiEEpNSkU/i82ARBiLANGW/ss4+MIVoFAmdEfJ0CGwwFCQWjmoAA 10 | CgkQGW/ss4+MIVrmnQEA0BsolWj/6kLwI/lCLxfIpJEtWxrTR64iGAWbUyfom+MB 11 | AN8vvd2ALuHIYTLw8chyZs8EknRYqpTjGqUsYlAN2BAD 12 | =nJ9L 13 | -----END PGP PUBLIC KEY BLOCK----- 14 | -------------------------------------------------------------------------------- /apt/paretosecurity.gpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParetoSecurity/agent/52f3b7fb02d2731ff8bbd824e5665a8fbd363a93/apt/paretosecurity.gpg -------------------------------------------------------------------------------- /apt/paretosecurity.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=ParetoSecurity root helper 3 | 4 | [Service] 5 | ExecStart=/usr/bin/paretosecurity helper 6 | User=root 7 | Group=root 8 | StandardInput=socket 9 | Type=oneshot 10 | RemainAfterExit=no 11 | StartLimitBurst=100 12 | ProtectSystem=full 13 | ProtectHome=yes 14 | StandardOutput=journal 15 | StandardError=journal 16 | 17 | [Install] 18 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /apt/paretosecurity.socket: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Socket for ParetoSecurity agent <-> root helper communication 3 | 4 | [Socket] 5 | ListenStream=/run/paretosecurity.sock 6 | SocketMode=0666 7 | 8 | [Install] 9 | WantedBy=sockets.target -------------------------------------------------------------------------------- /apt/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Check for systemd 5 | if command -v systemctl >/dev/null 2>&1; then 6 | # Reload systemd and enable socket 7 | systemctl daemon-reload 8 | systemctl enable paretosecurity.service 9 | systemctl enable --now paretosecurity.socket 10 | fi 11 | -------------------------------------------------------------------------------- /apt/rpm/paretosecurity.repo: -------------------------------------------------------------------------------- 1 | [paretosecurity-stable] 2 | name=paretosecurity-stable 3 | baseurl=https://pkg.paretosecurity.com/rpm 4 | enabled=1 5 | type=rpm 6 | gpgcheck=0 7 | gpgkey=https://pkg.paretosecurity.com/paretosecurity.gpg -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParetoSecurity/agent/52f3b7fb02d2731ff8bbd824e5665a8fbd363a93/assets/icon.png -------------------------------------------------------------------------------- /assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /assets/icon_black.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 32 | 34 | 36 | 37 | 41 | 44 | 46 | 50 | 54 | 58 | 62 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /assets/icon_white.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 33 | 35 | 37 | 38 | 44 | 47 | 49 | 53 | 57 | 61 | 65 | 69 | 70 | 71 | 72 | 73 | -------------------------------------------------------------------------------- /check/check.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | type CheckState string 4 | 5 | const ( 6 | CheckStatePassed CheckState = "pass" 7 | CheckStateFailed CheckState = "fail" 8 | CheckStateDisabled CheckState = "off" 9 | CheckStateError CheckState = "error" 10 | ) 11 | 12 | type Check interface { 13 | Name() string 14 | PassedMessage() string 15 | FailedMessage() string 16 | Run() error 17 | Passed() bool 18 | IsRunnable() bool 19 | UUID() string 20 | Status() string 21 | RequiresRoot() bool 22 | } 23 | -------------------------------------------------------------------------------- /check/check_test.go: -------------------------------------------------------------------------------- 1 | package check 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | type MockCheck struct { 8 | uuid string 9 | passed bool 10 | isRunnable bool 11 | } 12 | 13 | func (m *MockCheck) Name() string { return "MockCheck" } 14 | func (m *MockCheck) PassedMessage() string { return "Passed" } 15 | func (m *MockCheck) FailedMessage() string { return "Failed" } 16 | func (m *MockCheck) Run() error { return nil } 17 | func (m *MockCheck) Passed() bool { return m.passed } 18 | func (m *MockCheck) IsRunnable() bool { return m.isRunnable } 19 | func (m *MockCheck) UUID() string { return m.uuid } 20 | func (m *MockCheck) Status() string { return "Status" } 21 | func (m *MockCheck) RequiresRoot() bool { return false } 22 | 23 | func TestMockCheck_Name(t *testing.T) { 24 | mockCheck := &MockCheck{} 25 | expectedName := "MockCheck" 26 | if mockCheck.Name() != expectedName { 27 | t.Errorf("Expected Name %s, got %s", expectedName, mockCheck.Name()) 28 | } 29 | } 30 | 31 | func TestMockCheck_Status(t *testing.T) { 32 | mockCheck := &MockCheck{} 33 | expectedStatus := "Status" 34 | if mockCheck.Status() != expectedStatus { 35 | t.Errorf("Expected Status %s, got %s", expectedStatus, mockCheck.Status()) 36 | } 37 | } 38 | 39 | func TestMockCheck_UUID(t *testing.T) { 40 | mockCheck := &MockCheck{uuid: "1234"} 41 | expectedUUID := "1234" 42 | if mockCheck.UUID() != expectedUUID { 43 | t.Errorf("Expected UUID %s, got %s", expectedUUID, mockCheck.UUID()) 44 | } 45 | } 46 | 47 | func TestMockCheck_Passed(t *testing.T) { 48 | mockCheck := &MockCheck{passed: true} 49 | expectedPassed := true 50 | if mockCheck.Passed() != expectedPassed { 51 | t.Errorf("Expected Passed %v, got %v", expectedPassed, mockCheck.Passed()) 52 | } 53 | } 54 | 55 | func TestMockCheck_FailedMessage(t *testing.T) { 56 | mockCheck := &MockCheck{} 57 | expectedFailedMessage := "Failed" 58 | if mockCheck.FailedMessage() != expectedFailedMessage { 59 | t.Errorf("Expected FailedMessage %s, got %s", expectedFailedMessage, mockCheck.FailedMessage()) 60 | } 61 | } 62 | 63 | func TestMockCheck_PassedMessage(t *testing.T) { 64 | mockCheck := &MockCheck{} 65 | expectedPassedMessage := "Passed" 66 | if mockCheck.PassedMessage() != expectedPassedMessage { 67 | t.Errorf("Expected PassedMessage %s, got %s", expectedPassedMessage, mockCheck.PassedMessage()) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /checks/check_test.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ParetoSecurity/agent/claims" 7 | "github.com/samber/lo" 8 | ) 9 | 10 | func TestClaims(t *testing.T) { 11 | uuids := []string{} 12 | 13 | for _, claim := range claims.All { 14 | for _, check := range claim.Checks { 15 | if lo.Contains(uuids, check.UUID()) { 16 | t.Errorf("Duplicate check UUID %s", check.UUID()) 17 | } 18 | uuids = append(uuids, check.UUID()) 19 | 20 | if check == nil { 21 | t.Errorf("Claim %s has a nil check", claim.Title) 22 | } 23 | check.RequiresRoot() 24 | check.Status() 25 | 26 | check.Passed() 27 | check.Name() 28 | check.PassedMessage() 29 | check.FailedMessage() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /checks/darwin/password_manager.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/samber/lo" 9 | ) 10 | 11 | type PasswordManagerCheck struct { 12 | passed bool 13 | } 14 | 15 | func (pmc *PasswordManagerCheck) Name() string { 16 | return "Password Manager Presence" 17 | } 18 | 19 | func (pmc *PasswordManagerCheck) Run() error { 20 | appNames := []string{ 21 | "1Password.app", 22 | "1Password 8.app", 23 | "1Password 7.app", 24 | "Bitwarden.app", 25 | "Dashlane.app", 26 | "KeePassXC.app", 27 | "KeePassX.app", 28 | } 29 | 30 | if checkInstalledApplications(appNames) || checkForBrowserExtensions() { 31 | pmc.passed = true 32 | } else { 33 | pmc.passed = false 34 | } 35 | return nil 36 | } 37 | 38 | func checkInstalledApplications(appNames []string) bool { 39 | searchPaths := []string{ 40 | "/Applications", 41 | "/System/Applications", 42 | filepath.Join(os.Getenv("HOME"), "Applications"), 43 | } 44 | 45 | for _, path := range searchPaths { 46 | if contents, err := os.ReadDir(path); err == nil { 47 | for _, entry := range contents { 48 | if entry.IsDir() && lo.Contains(appNames, entry.Name()) { 49 | return true 50 | } 51 | } 52 | } 53 | } 54 | return false 55 | } 56 | 57 | func checkForBrowserExtensions() bool { 58 | home := os.Getenv("HOME") 59 | extensionPaths := map[string]string{ 60 | "Google Chrome": filepath.Join(home, "Library", "Application Support", "Google", "Chrome", "Default", "Extensions"), 61 | "Firefox": filepath.Join(home, "Library", "Application Support", "Firefox", "Profiles"), 62 | "Microsoft Edge": filepath.Join(home, "Library", "Application Support", "Microsoft Edge", "Default", "Extensions"), 63 | "Brave Browser": filepath.Join(home, "Library", "Application Support", "BraveSoftware", "Brave-Browser", "Default", "Extensions"), 64 | } 65 | 66 | browserExtensions := []string{ 67 | "LastPass", 68 | "ProtonPass", 69 | "NordPass", 70 | "Bitwarden", 71 | "1Password", 72 | "KeePass", 73 | "Dashlane", 74 | } 75 | 76 | for _, extPath := range extensionPaths { 77 | if _, err := os.Stat(extPath); err == nil { 78 | entries, err := os.ReadDir(extPath) 79 | if err == nil { 80 | for _, entry := range entries { 81 | name := strings.ToLower(entry.Name()) 82 | for _, ext := range browserExtensions { 83 | if strings.Contains(name, strings.ToLower(ext)) { 84 | return true 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | return false 92 | } 93 | 94 | func (pmc *PasswordManagerCheck) Passed() bool { 95 | return pmc.passed 96 | } 97 | 98 | func (pmc *PasswordManagerCheck) IsRunnable() bool { 99 | return true 100 | } 101 | 102 | func (pmc *PasswordManagerCheck) UUID() string { 103 | return "f962c423-fdf5-428a-a57a-827abc9b253e" 104 | } 105 | 106 | func (pmc *PasswordManagerCheck) PassedMessage() string { 107 | return "Password manager is present" 108 | } 109 | 110 | func (pmc *PasswordManagerCheck) FailedMessage() string { 111 | return "No password manager found" 112 | } 113 | 114 | func (pmc *PasswordManagerCheck) RequiresRoot() bool { 115 | return false 116 | } 117 | 118 | func (pmc *PasswordManagerCheck) Status() string { 119 | if pmc.Passed() { 120 | return pmc.PassedMessage() 121 | } 122 | return pmc.FailedMessage() 123 | } 124 | -------------------------------------------------------------------------------- /checks/linux/autologin.go: -------------------------------------------------------------------------------- 1 | // Package linux provides checks for Linux systems. 2 | package checks 3 | 4 | import ( 5 | "strings" 6 | 7 | "github.com/ParetoSecurity/agent/shared" 8 | ) 9 | 10 | // Autologin checks for autologin misconfiguration. 11 | type Autologin struct { 12 | passed bool 13 | status string 14 | } 15 | 16 | // Name returns the name of the check 17 | func (f *Autologin) Name() string { 18 | return "Automatic login is disabled" 19 | } 20 | 21 | // Run executes the check 22 | func (f *Autologin) Run() error { 23 | f.passed = true 24 | 25 | // Check KDE (SDDM) autologin 26 | sddmFiles, _ := filepathGlob("/etc/sddm.conf.d/*.conf") 27 | for _, file := range sddmFiles { 28 | content, err := shared.ReadFile(file) 29 | if err == nil { 30 | if strings.Contains(string(content), "Autologin=true") { 31 | f.passed = false 32 | f.status = "Autologin=true in SDDM is enabled" 33 | return nil 34 | } 35 | } 36 | } 37 | 38 | // Check main SDDM config 39 | if content, err := shared.ReadFile("/etc/sddm.conf"); err == nil { 40 | if strings.Contains(string(content), "Autologin=true") { 41 | f.passed = false 42 | f.status = "Autologin=true in SDDM is enabled" 43 | return nil 44 | } 45 | } 46 | 47 | // Check GNOME (GDM) autologin 48 | gdmPaths := []string{"/etc/gdm3/custom.conf", "/etc/gdm/custom.conf"} 49 | for _, path := range gdmPaths { 50 | if content, err := shared.ReadFile(path); err == nil { 51 | if strings.Contains(string(content), "AutomaticLoginEnable=true") { 52 | f.passed = false 53 | f.status = "AutomaticLoginEnable=true in GDM is enabled" 54 | return nil 55 | } 56 | } 57 | } 58 | 59 | // Check GNOME (GDM) autologin using dconf 60 | output, err := shared.RunCommand("dconf", "read", "/org/gnome/login-screen/enable-automatic-login") 61 | if err == nil && strings.TrimSpace(string(output)) == "true" { 62 | f.passed = false 63 | f.status = "Automatic login is enabled in GNOME" 64 | return nil 65 | } 66 | 67 | return nil 68 | } 69 | 70 | // Passed returns the status of the check 71 | func (f *Autologin) Passed() bool { 72 | return f.passed 73 | } 74 | 75 | // IsRunnable returns whether Autologin is runnable. 76 | func (f *Autologin) IsRunnable() bool { 77 | return true 78 | } 79 | 80 | // UUID returns the UUID of the check 81 | func (f *Autologin) UUID() string { 82 | return "f962c423-fdf5-428a-a57a-816abc9b253e" 83 | } 84 | 85 | // PassedMessage returns the message to return if the check passed 86 | func (f *Autologin) PassedMessage() string { 87 | return "Automatic login is off" 88 | } 89 | 90 | // FailedMessage returns the message to return if the check failed 91 | func (f *Autologin) FailedMessage() string { 92 | return "Automatic login is on" 93 | } 94 | 95 | // RequiresRoot returns whether the check requires root access 96 | func (f *Autologin) RequiresRoot() bool { 97 | return false 98 | } 99 | 100 | // Status returns the status of the check 101 | func (f *Autologin) Status() string { 102 | if !f.Passed() { 103 | return f.status 104 | } 105 | return f.PassedMessage() 106 | } 107 | -------------------------------------------------------------------------------- /checks/linux/docker.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/ParetoSecurity/agent/shared" 7 | "github.com/samber/lo" 8 | ) 9 | 10 | type DockerAccess struct { 11 | passed bool 12 | status string 13 | } 14 | 15 | // Name returns the name of the check 16 | func (f *DockerAccess) Name() string { 17 | return "Access to Docker is restricted" 18 | } 19 | 20 | // Run executes the check 21 | func (f *DockerAccess) Run() error { 22 | // Check if we deprecate packages installed via apt 23 | // https://docs.docker.com/engine/install/ubuntu/#uninstall-old-versions 24 | if _, err := shared.RunCommand("which", "dpkg-query"); err == nil { 25 | out, err := shared.RunCommand("dpkg-query", "-W", "-f='${Package}'", "docker.io") 26 | if err == nil && strings.Contains(out, "docker") { 27 | f.passed = false 28 | f.status = "Deprecated docker.io package installed via apt" 29 | return nil 30 | } 31 | } 32 | 33 | output, err := shared.RunCommand("docker", "info", "--format", "{{.SecurityOptions}}") 34 | if err != nil || lo.IsEmpty(output) { 35 | f.passed = false 36 | f.status = "Failed to get Docker info" 37 | return err 38 | } 39 | 40 | if !strings.Contains(output, "rootless") { 41 | f.passed = false 42 | f.status = f.FailedMessage() 43 | return nil 44 | } 45 | 46 | f.passed = true 47 | 48 | return nil 49 | } 50 | 51 | // Passed returns the status of the check 52 | func (f *DockerAccess) Passed() bool { 53 | return f.passed 54 | } 55 | 56 | // CanRun returns whether the check can run 57 | func (f *DockerAccess) IsRunnable() bool { 58 | 59 | // Check if Docker is installed 60 | out, _ := shared.RunCommand("docker", "version") 61 | if !strings.Contains(out, "Version") { 62 | f.status = "Docker is not installed" 63 | return false 64 | } 65 | 66 | // Check if the user has access to the Docker daemon 67 | // This is a workaround for the issue where the Docker daemon is running as manager only (via systemd) 68 | // and the user does not access to the Docker daemon 69 | if strings.Contains(out, "Cannot connect to the Docker daemon") { 70 | f.status = "No access to Docker daemon, with the current user" 71 | return false 72 | } 73 | 74 | return true 75 | } 76 | 77 | // UUID returns the UUID of the check 78 | func (f *DockerAccess) UUID() string { 79 | return "25443ceb-c1ec-408c-b4f3-2328ea0c84e1" 80 | } 81 | 82 | // PassedMessage returns the message to return if the check passed 83 | func (f *DockerAccess) PassedMessage() string { 84 | return "Docker is running in rootless mode" 85 | } 86 | 87 | // FailedMessage returns the message to return if the check failed 88 | func (f *DockerAccess) FailedMessage() string { 89 | return "Docker is not running in rootless mode" 90 | } 91 | 92 | // RequiresRoot returns whether the check requires root access 93 | func (f *DockerAccess) RequiresRoot() bool { 94 | return false 95 | } 96 | 97 | // Status returns the status of the check 98 | func (f *DockerAccess) Status() string { 99 | if !f.Passed() { 100 | return f.status 101 | } 102 | return f.PassedMessage() 103 | } 104 | -------------------------------------------------------------------------------- /checks/linux/luks.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "bufio" 5 | "strings" 6 | 7 | "github.com/ParetoSecurity/agent/shared" 8 | "github.com/caarlos0/log" 9 | ) 10 | 11 | type EncryptingFS struct { 12 | passed bool 13 | } 14 | 15 | // Name returns the name of the check 16 | func (f *EncryptingFS) Name() string { 17 | return "Filesystem encryption is enabled" 18 | } 19 | 20 | // Passed returns the status of the check 21 | func (f *EncryptingFS) Passed() bool { 22 | return f.passed 23 | } 24 | 25 | // CanRun returns whether the check can run 26 | func (f *EncryptingFS) IsRunnable() bool { 27 | return true 28 | } 29 | 30 | // UUID returns the UUID of the check 31 | func (f *EncryptingFS) UUID() string { 32 | return "21830a4e-84f1-48fe-9c5b-beab436b2cdb" 33 | } 34 | 35 | // PassedMessage returns the message to return if the check passed 36 | func (f *EncryptingFS) PassedMessage() string { 37 | return "Block device encryption is enabled" 38 | } 39 | 40 | // FailedMessage returns the message to return if the check failed 41 | func (f *EncryptingFS) FailedMessage() string { 42 | return "Block device encryption is disabled" 43 | } 44 | 45 | // RequiresRoot returns whether the check requires root access 46 | func (f *EncryptingFS) RequiresRoot() bool { 47 | return true 48 | } 49 | 50 | // Run executes the check 51 | func (f *EncryptingFS) Run() error { 52 | f.passed = false 53 | 54 | // Check if the system is using LUKS 55 | if maybeCryptoViaLuks() { 56 | f.passed = true 57 | return nil 58 | } 59 | // Check if the system is using kernel parameters for encryption 60 | if maybeCryptoViaKernel() { 61 | f.passed = true 62 | return nil 63 | } 64 | 65 | return nil 66 | } 67 | 68 | // Status returns the status of the check 69 | func (f *EncryptingFS) Status() string { 70 | if f.Passed() { 71 | return f.PassedMessage() 72 | } 73 | return f.FailedMessage() 74 | } 75 | 76 | func maybeCryptoViaLuks() bool { 77 | // Check if the system is using LUKS 78 | lsblk, err := shared.RunCommand("lsblk", "-o", "TYPE,MOUNTPOINT") 79 | if err != nil { 80 | log.WithError(err).Warn("Failed to run lsblk command") 81 | return false 82 | } 83 | 84 | scanner := bufio.NewScanner(strings.NewReader(lsblk)) 85 | for scanner.Scan() { 86 | line := scanner.Text() 87 | if strings.Contains(line, "crypt") { 88 | log.WithField("line", line).Debug("LUKS encryption detected") 89 | return true 90 | } 91 | } 92 | log.WithField("output", lsblk).Warn("Failed to scan lsblk output") 93 | return false 94 | } 95 | 96 | func maybeCryptoViaKernel() bool { 97 | // Read kernel parameters to check if root is booted via crypt 98 | cmdline, err := shared.ReadFile("/proc/cmdline") 99 | if err != nil { 100 | log.WithError(err).Warn("Failed to read /proc/cmdline") 101 | } 102 | 103 | params := strings.Fields(string(cmdline)) 104 | for _, param := range params { 105 | if strings.HasPrefix(param, "cryptdevice=") { 106 | parts := strings.Split(param, ":") 107 | if len(parts) == 3 && parts[2] == "root" { 108 | log.WithField("param", param).Debug("Kernel crypto parameters detected") 109 | return true 110 | } 111 | } 112 | } 113 | return false 114 | } 115 | -------------------------------------------------------------------------------- /checks/linux/password_unlock.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/ParetoSecurity/agent/shared" 8 | "github.com/caarlos0/log" 9 | ) 10 | 11 | // PasswordToUnlock represents a check to ensure that a password is required to unlock the screen. 12 | type PasswordToUnlock struct { 13 | passed bool 14 | } 15 | 16 | // Name returns the name of the check 17 | func (f *PasswordToUnlock) Name() string { 18 | return "Password is required to unlock the screen" 19 | } 20 | 21 | func (f *PasswordToUnlock) checkGnome() bool { 22 | out, err := shared.RunCommand("gsettings", "get", "org.gnome.desktop.screensaver", "lock-enabled") 23 | if err != nil { 24 | log.WithError(err).Debug("Failed to check GNOME screensaver settings") 25 | return false 26 | } 27 | result := strings.TrimSpace(string(out)) == "true" 28 | log.WithField("setting", out).WithField("passed", result).Debug("GNOME screensaver lock check") 29 | return result 30 | } 31 | 32 | func (f *PasswordToUnlock) checkKDE5() bool { 33 | // First try reading config file directly 34 | if homeDir, err := shared.UserHomeDir(); err == nil { 35 | configPath := filepath.Join(homeDir, ".config", "kscreenlockerrc") 36 | if content, err := shared.ReadFile(configPath); err == nil { 37 | configStr := string(content) 38 | // Check if LockOnResume=false is present 39 | if strings.Contains(configStr, "LockOnResume=false") { 40 | log.WithField("config_file", configPath).Debug("Found LockOnResume=false in KDE config") 41 | return false 42 | } 43 | // If LockOnResume=true is explicitly set or not present (defaults to true) 44 | log.WithField("config_file", configPath).Debug("KDE config allows screen locking") 45 | return true 46 | } 47 | return true // Default to true if config file not found or read error, we trust that runtime settings are correct 48 | } 49 | return true // Default to true if config file not found or read error 50 | } 51 | 52 | // Run executes the check 53 | func (f *PasswordToUnlock) Run() error { 54 | 55 | // Check if running GNOME 56 | if _, err := lookPath("gsettings"); err == nil { 57 | f.passed = f.checkGnome() 58 | } else { 59 | log.Info("GNOME environment not detected for screensaver lock check") 60 | } 61 | 62 | // Check if running KDE 63 | if _, err := lookPath("kreadconfig5"); err == nil { 64 | f.passed = f.checkKDE5() 65 | } else { 66 | log.Debug("KDE environment(5) not detected for screensaver lock check") 67 | } 68 | 69 | return nil 70 | } 71 | 72 | // Passed returns the status of the check 73 | func (f *PasswordToUnlock) Passed() bool { 74 | return f.passed 75 | } 76 | 77 | // IsRunnable returns whether the check can run 78 | func (f *PasswordToUnlock) IsRunnable() bool { 79 | return true 80 | } 81 | 82 | // UUID returns the UUID of the check 83 | func (f *PasswordToUnlock) UUID() string { 84 | return "37dee029-605b-4aab-96b9-5438e5aa44d8" 85 | } 86 | 87 | // PassedMessage returns the message to return if the check passed 88 | func (f *PasswordToUnlock) PassedMessage() string { 89 | return "Password after sleep or screensaver is on" 90 | } 91 | 92 | // FailedMessage returns the message to return if the check failed 93 | func (f *PasswordToUnlock) FailedMessage() string { 94 | return "Password after sleep or screensaver is off" 95 | } 96 | 97 | // RequiresRoot returns whether the check requires root access 98 | func (f *PasswordToUnlock) RequiresRoot() bool { 99 | return false 100 | } 101 | 102 | // Status returns the status of the check 103 | func (f *PasswordToUnlock) Status() string { 104 | if f.Passed() { 105 | return f.PassedMessage() 106 | } 107 | return f.FailedMessage() 108 | } 109 | -------------------------------------------------------------------------------- /checks/linux/printer.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "fmt" 5 | 6 | sharedchecks "github.com/ParetoSecurity/agent/checks/shared" 7 | "github.com/caarlos0/log" 8 | ) 9 | 10 | type Printer struct { 11 | passed bool 12 | ports map[int]string 13 | } 14 | 15 | // Name returns the name of the check 16 | func (f *Printer) Name() string { 17 | return "Sharing printers is off" 18 | } 19 | 20 | // Run executes the check 21 | func (f *Printer) Run() error { 22 | f.passed = true 23 | f.ports = make(map[int]string) 24 | 25 | // Samba, NFS and CUPS ports to check 26 | printService := map[int]string{ 27 | 631: "CUPS", 28 | } 29 | 30 | for port, service := range printService { 31 | if sharedchecks.CheckPort(port, "tcp") { 32 | log.WithField("check", f.Name()).WithField("port", port).WithField("service", service).Debug("Port open") 33 | f.passed = false 34 | f.ports[port] = service 35 | } 36 | } 37 | 38 | return nil 39 | } 40 | 41 | // Passed returns the status of the check 42 | func (f *Printer) Passed() bool { 43 | return f.passed 44 | } 45 | 46 | // CanRun returns whether the check can run 47 | func (f *Printer) IsRunnable() bool { 48 | return true 49 | } 50 | 51 | // UUID returns the UUID of the check 52 | func (f *Printer) UUID() string { 53 | return "b96524e0-150b-4bb8-abc7-517051b6c14e" 54 | } 55 | 56 | // PassedMessage returns the message to return if the check passed 57 | func (f *Printer) PassedMessage() string { 58 | return "Sharing printers is off" 59 | } 60 | 61 | // FailedMessage returns the message to return if the check failed 62 | func (f *Printer) FailedMessage() string { 63 | return "Sharing printers is on" 64 | } 65 | 66 | // RequiresRoot returns whether the check requires root access 67 | func (f *Printer) RequiresRoot() bool { 68 | return false 69 | } 70 | 71 | // Status returns the status of the check 72 | func (f *Printer) Status() string { 73 | if !f.Passed() { 74 | msg := "Printer sharing services found running on ports:" 75 | for port, service := range f.ports { 76 | msg += fmt.Sprintf(" %s(%d)", service, port) 77 | } 78 | return msg 79 | } 80 | return f.PassedMessage() 81 | } 82 | -------------------------------------------------------------------------------- /checks/linux/printer_test.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "testing" 5 | 6 | sharedchecks "github.com/ParetoSecurity/agent/checks/shared" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestPrinterRun(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | mockCheckPort func(_ int, _ string) bool 14 | expectedPassed bool 15 | expectedPorts map[int]string 16 | }{ 17 | { 18 | name: "No ports open", 19 | mockCheckPort: func(_ int, _ string) bool { 20 | return false 21 | }, 22 | expectedPassed: true, 23 | expectedPorts: map[int]string{}, 24 | }, 25 | { 26 | name: "CUPS port open", 27 | mockCheckPort: func(port int, proto string) bool { 28 | return port == 631 29 | }, 30 | expectedPassed: false, 31 | expectedPorts: map[int]string{ 32 | 631: "CUPS", 33 | }, 34 | }, 35 | { 36 | name: "Multiple ports open", 37 | mockCheckPort: func(port int, proto string) bool { 38 | return port == 631 || port == 515 39 | }, 40 | expectedPassed: false, 41 | expectedPorts: map[int]string{ 42 | 631: "CUPS", 43 | }, 44 | }, 45 | } 46 | 47 | for _, tt := range tests { 48 | t.Run(tt.name, func(t *testing.T) { 49 | sharedchecks.CheckPortMock = tt.mockCheckPort 50 | printer := &Printer{} 51 | 52 | err := printer.Run() 53 | assert.NoError(t, err) 54 | assert.Equal(t, tt.expectedPassed, printer.Passed()) 55 | assert.Equal(t, tt.expectedPorts, printer.ports) 56 | assert.NotEmpty(t, printer.UUID()) 57 | assert.False(t, printer.RequiresRoot()) 58 | }) 59 | } 60 | } 61 | 62 | func TestPrinter_Name(t *testing.T) { 63 | printer := &Printer{} 64 | expectedName := "Sharing printers is off" 65 | if printer.Name() != expectedName { 66 | t.Errorf("Expected Name %s, got %s", expectedName, printer.Name()) 67 | } 68 | } 69 | 70 | func TestPrinter_Status(t *testing.T) { 71 | printer := &Printer{} 72 | expectedStatus := "Printer sharing services found running on ports:" 73 | if printer.Status() != expectedStatus { 74 | t.Errorf("Expected Status %s, got %s", expectedStatus, printer.Status()) 75 | } 76 | } 77 | 78 | func TestPrinter_UUID(t *testing.T) { 79 | printer := &Printer{} 80 | expectedUUID := "b96524e0-150b-4bb8-abc7-517051b6c14e" 81 | if printer.UUID() != expectedUUID { 82 | t.Errorf("Expected UUID %s, got %s", expectedUUID, printer.UUID()) 83 | } 84 | } 85 | 86 | func TestPrinter_Passed(t *testing.T) { 87 | printer := &Printer{passed: true} 88 | expectedPassed := true 89 | if printer.Passed() != expectedPassed { 90 | t.Errorf("Expected Passed %v, got %v", expectedPassed, printer.Passed()) 91 | } 92 | } 93 | 94 | func TestPrinter_FailedMessage(t *testing.T) { 95 | printer := &Printer{} 96 | expectedFailedMessage := "Sharing printers is on" 97 | if printer.FailedMessage() != expectedFailedMessage { 98 | t.Errorf("Expected FailedMessage %s, got %s", expectedFailedMessage, printer.FailedMessage()) 99 | } 100 | } 101 | 102 | func TestPrinter_PassedMessage(t *testing.T) { 103 | printer := &Printer{} 104 | expectedPassedMessage := "Sharing printers is off" 105 | if printer.PassedMessage() != expectedPassedMessage { 106 | t.Errorf("Expected PassedMessage %s, got %s", expectedPassedMessage, printer.PassedMessage()) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /checks/linux/secure_boot.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import "os" 4 | 5 | // SecureBoot checks secure boot configuration. 6 | type SecureBoot struct { 7 | passed bool 8 | status string 9 | } 10 | 11 | // Name returns the name of the check 12 | func (f *SecureBoot) Name() string { 13 | return "SecureBoot is enabled" 14 | } 15 | 16 | // Run executes the check 17 | func (f *SecureBoot) Run() error { 18 | 19 | if _, err := osStat("/sys/firmware/efi/efivars/"); err != nil && os.IsNotExist(err) { 20 | f.passed = false 21 | f.status = "System is not running in UEFI mode" 22 | return nil 23 | } 24 | 25 | // Find and read the SecureBoot EFI variable 26 | pattern := "/sys/firmware/efi/efivars/SecureBoot-*" 27 | matches, err := filepathGlob(pattern) 28 | if err != nil || len(matches) == 0 { 29 | f.passed = false 30 | f.status = "Could not find SecureBoot EFI variable" 31 | return nil 32 | } 33 | 34 | data, err := osReadFile(matches[0]) 35 | if err != nil { 36 | f.passed = false 37 | f.status = "Could not read SecureBoot status" 38 | return nil 39 | } 40 | 41 | // The SecureBoot variable has a 5-byte structure 42 | // First 4 bytes are the attribute flags, last byte is the value 43 | // Value of 1 means enabled, 0 means disabled 44 | if len(data) >= 5 && data[4] == 1 { 45 | f.passed = true 46 | return nil 47 | } 48 | f.passed = false 49 | 50 | return nil 51 | } 52 | 53 | // Passed returns the status of the check 54 | func (f *SecureBoot) Passed() bool { 55 | return f.passed 56 | } 57 | 58 | // IsRunnable returns whether SecureBoot is runnable. 59 | func (f *SecureBoot) IsRunnable() bool { 60 | return true 61 | } 62 | 63 | // UUID returns the UUID of the check 64 | func (f *SecureBoot) UUID() string { 65 | return "c96524f2-850b-4bb9-abc7-517051b6c14e" 66 | } 67 | 68 | // PassedMessage returns the message to return if the check passed 69 | func (f *SecureBoot) PassedMessage() string { 70 | return "SecureBoot is enabled" 71 | } 72 | 73 | // FailedMessage returns the message to return if the check failed 74 | func (f *SecureBoot) FailedMessage() string { 75 | return "SecureBoot is disabled" 76 | } 77 | 78 | // RequiresRoot returns whether the check requires root access 79 | func (f *SecureBoot) RequiresRoot() bool { 80 | return false 81 | } 82 | 83 | // Status returns the status of the check 84 | func (f *SecureBoot) Status() string { 85 | if f.Passed() { 86 | return f.PassedMessage() 87 | } 88 | if f.status != "" { 89 | return f.status 90 | } 91 | return f.FailedMessage() 92 | } 93 | -------------------------------------------------------------------------------- /checks/linux/sharing.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "fmt" 5 | 6 | sharedchecks "github.com/ParetoSecurity/agent/checks/shared" 7 | "github.com/caarlos0/log" 8 | ) 9 | 10 | type Sharing struct { 11 | passed bool 12 | ports map[int]string 13 | } 14 | 15 | // Name returns the name of the check 16 | func (f *Sharing) Name() string { 17 | return "File Sharing is disabled" 18 | } 19 | 20 | // Run executes the check 21 | func (f *Sharing) Run() error { 22 | f.passed = true 23 | f.ports = make(map[int]string) 24 | 25 | // Samba and NFS ports to check 26 | shareServices := map[int]string{ 27 | 139: "NetBIOS", 28 | 445: "SMB", 29 | 2049: "NFS", 30 | 111: "RPC", 31 | 8200: "DLNA", 32 | 1900: "Ubuntu Media Sharing", 33 | } 34 | 35 | for port, service := range shareServices { 36 | if sharedchecks.CheckPort(port, "tcp") { 37 | f.passed = false 38 | log.WithField("check", f.Name()).WithField("port:tcp", port).WithField("service", service).Debug("Port open") 39 | f.ports[port] = service 40 | } 41 | } 42 | 43 | return nil 44 | } 45 | 46 | // Passed returns the status of the check 47 | func (f *Sharing) Passed() bool { 48 | return f.passed 49 | } 50 | 51 | // CanRun returns whether the check can run 52 | func (f *Sharing) IsRunnable() bool { 53 | return true 54 | } 55 | 56 | // UUID returns the UUID of the check 57 | func (f *Sharing) UUID() string { 58 | return "b96524e0-850b-4bb8-abc7-517051b6c14e" 59 | } 60 | 61 | // PassedMessage returns the message to return if the check passed 62 | func (f *Sharing) PassedMessage() string { 63 | return "No file sharing services found running" 64 | } 65 | 66 | // FailedMessage returns the message to return if the check failed 67 | func (f *Sharing) FailedMessage() string { 68 | return "Sharing services found running " 69 | } 70 | 71 | // RequiresRoot returns whether the check requires root access 72 | func (f *Sharing) RequiresRoot() bool { 73 | return false 74 | } 75 | 76 | // Status returns the status of the check 77 | func (f *Sharing) Status() string { 78 | if !f.Passed() { 79 | msg := "Sharing services found running on ports:" 80 | for port, service := range f.ports { 81 | msg += fmt.Sprintf(" %s(%d)", service, port) 82 | } 83 | return msg 84 | } 85 | return f.PassedMessage() 86 | } 87 | -------------------------------------------------------------------------------- /checks/linux/sharing_test.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "testing" 5 | 6 | sharedchecks "github.com/ParetoSecurity/agent/checks/shared" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestSharing_Run(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | mockFunc func(port int, proto string) bool 14 | expected bool 15 | }{ 16 | { 17 | name: "No ports open", 18 | mockFunc: func(port int, proto string) bool { 19 | return false 20 | }, 21 | expected: true, 22 | }, 23 | { 24 | name: "Some ports open", 25 | mockFunc: func(port int, proto string) bool { 26 | if port == 445 || port == 2049 { 27 | return true 28 | } 29 | return false 30 | }, 31 | expected: false, 32 | }, 33 | { 34 | name: "All ports open", 35 | mockFunc: func(port int, proto string) bool { 36 | return true 37 | }, 38 | expected: false, 39 | }, 40 | { 41 | name: "Only one port open", 42 | mockFunc: func(port int, proto string) bool { 43 | return port == 445 44 | }, 45 | expected: false, 46 | }, 47 | } 48 | 49 | for _, tt := range tests { 50 | t.Run(tt.name, func(t *testing.T) { 51 | // Mock the checkPort function 52 | sharedchecks.CheckPortMock = tt.mockFunc 53 | 54 | sharing := &Sharing{} 55 | err := sharing.Run() 56 | assert.NoError(t, err) 57 | assert.Equal(t, tt.expected, sharing.Passed()) 58 | assert.NotEmpty(t, sharing.UUID()) 59 | assert.False(t, sharing.RequiresRoot()) 60 | }) 61 | } 62 | } 63 | 64 | func TestSharing_Name(t *testing.T) { 65 | sharing := &Sharing{} 66 | expectedName := "File Sharing is disabled" 67 | if sharing.Name() != expectedName { 68 | t.Errorf("Expected Name %s, got %s", expectedName, sharing.Name()) 69 | } 70 | } 71 | 72 | func TestSharing_Status(t *testing.T) { 73 | sharing := &Sharing{} 74 | expectedStatus := "Sharing services found running on ports:" 75 | if sharing.Status() != expectedStatus { 76 | t.Errorf("Expected Status %s, got %s", expectedStatus, sharing.Status()) 77 | } 78 | } 79 | 80 | func TestSharing_UUID(t *testing.T) { 81 | sharing := &Sharing{} 82 | expectedUUID := "b96524e0-850b-4bb8-abc7-517051b6c14e" 83 | if sharing.UUID() != expectedUUID { 84 | t.Errorf("Expected UUID %s, got %s", expectedUUID, sharing.UUID()) 85 | } 86 | } 87 | 88 | func TestSharing_Passed(t *testing.T) { 89 | sharing := &Sharing{passed: true} 90 | expectedPassed := true 91 | if sharing.Passed() != expectedPassed { 92 | t.Errorf("Expected Passed %v, got %v", expectedPassed, sharing.Passed()) 93 | } 94 | } 95 | 96 | func TestSharing_FailedMessage(t *testing.T) { 97 | sharing := &Sharing{} 98 | expectedFailedMessage := "Sharing services found running " 99 | if sharing.FailedMessage() != expectedFailedMessage { 100 | t.Errorf("Expected FailedMessage %s, got %s", expectedFailedMessage, sharing.FailedMessage()) 101 | } 102 | } 103 | 104 | func TestSharing_PassedMessage(t *testing.T) { 105 | sharing := &Sharing{} 106 | expectedPassedMessage := "No file sharing services found running" 107 | if sharing.PassedMessage() != expectedPassedMessage { 108 | t.Errorf("Expected PassedMessage %s, got %s", expectedPassedMessage, sharing.PassedMessage()) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /checks/shared/mock.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | // checkPortMock is a mock function used for testing purposes. It simulates 9 | // checking the availability of a port for a given protocol. The function 10 | // takes an integer port number and a string representing the protocol 11 | // (e.g., "tcp", "udp") as arguments, and returns a boolean indicating 12 | // whether the port is available (true) or not (false). 13 | var CheckPortMock func(port int, proto string) bool 14 | 15 | var osReadFileMock func(file string) ([]byte, error) 16 | 17 | // osReadFile reads the contents of the specified file. 18 | // 19 | // If the testing mode is enabled, it delegates the file reading to a mock function. 20 | // Otherwise, it reads the file from disk using the standard os.ReadFile function. 21 | func osReadFile(file string) ([]byte, error) { 22 | if testing.Testing() { 23 | return osReadFileMock(file) 24 | } 25 | return os.ReadFile(file) 26 | } 27 | -------------------------------------------------------------------------------- /checks/shared/port.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/caarlos0/log" 11 | ) 12 | 13 | // checkPort tests if a port is open 14 | func CheckPort(port int, proto string) bool { 15 | 16 | if testing.Testing() { 17 | return CheckPortMock(port, proto) 18 | } 19 | 20 | addrs, err := net.InterfaceAddrs() 21 | if err != nil { 22 | return false 23 | } 24 | 25 | var wg sync.WaitGroup 26 | resultCh := make(chan bool, len(addrs)) 27 | 28 | for _, addr := range addrs { 29 | ip, _, err := net.ParseCIDR(addr.String()) 30 | if err != nil { 31 | continue 32 | } 33 | 34 | // Filter out 127.0.0.1 35 | if ip.IsLoopback() { 36 | continue 37 | } 38 | 39 | wg.Add(1) 40 | go func(ipAddr net.IP) { 41 | defer wg.Done() 42 | address := net.JoinHostPort(ipAddr.String(), fmt.Sprintf("%d", port)) 43 | conn, err := net.DialTimeout(proto, address, 1*time.Second) 44 | if err == nil { 45 | defer conn.Close() 46 | log.WithField("address", address).WithField("state", true).Debug("Checking port") 47 | resultCh <- true 48 | } 49 | }(ip) 50 | } 51 | 52 | // Wait in a separate goroutine 53 | go func() { 54 | wg.Wait() 55 | close(resultCh) 56 | }() 57 | 58 | // Check if any connection succeeded 59 | for result := range resultCh { 60 | if result { 61 | return true 62 | } 63 | } 64 | 65 | return false 66 | } 67 | -------------------------------------------------------------------------------- /checks/shared/remote_login.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/caarlos0/log" 7 | ) 8 | 9 | type RemoteLogin struct { 10 | passed bool 11 | ports map[int]string 12 | } 13 | 14 | // Name returns the name of the check 15 | func (f *RemoteLogin) Name() string { 16 | return "Remote login is disabled" 17 | } 18 | 19 | // Run executes the check 20 | func (f *RemoteLogin) Run() error { 21 | f.passed = true 22 | f.ports = make(map[int]string) 23 | 24 | // Check common remote access ports 25 | portsToCheck := map[int]string{ 26 | 22: "SSH", 27 | 3389: "RDP", 28 | 3390: "RDP", 29 | 5900: "VNC", 30 | } 31 | 32 | for port, service := range portsToCheck { 33 | if CheckPort(port, "tcp") { 34 | log.WithField("check", f.Name()).WithField("port", port).WithField("service", service).Debug("Remote access service found") 35 | f.passed = false 36 | f.ports[port] = service 37 | } 38 | } 39 | 40 | return nil 41 | } 42 | 43 | // Passed returns the status of the check 44 | func (f *RemoteLogin) Passed() bool { 45 | return f.passed 46 | } 47 | 48 | // CanRun returns whether the check can run 49 | func (f *RemoteLogin) IsRunnable() bool { 50 | return true 51 | } 52 | 53 | // UUID returns the UUID of the check 54 | func (f *RemoteLogin) UUID() string { 55 | return "4ced961d-7cfc-4e7b-8f80-195f6379446e" 56 | } 57 | 58 | // PassedMessage returns the message to return if the check passed 59 | func (f *RemoteLogin) PassedMessage() string { 60 | return "No remote access services found running" 61 | } 62 | 63 | // FailedMessage returns the message to return if the check failed 64 | func (f *RemoteLogin) FailedMessage() string { 65 | return "Remote access services found running" 66 | } 67 | 68 | // RequiresRoot returns whether the check requires root access 69 | func (f *RemoteLogin) RequiresRoot() bool { 70 | return false 71 | } 72 | 73 | // Status returns the status of the check 74 | func (f *RemoteLogin) Status() string { 75 | if !f.Passed() { 76 | msg := "Remote access services found running on ports:" 77 | for port, service := range f.ports { 78 | msg += fmt.Sprintf(" %s(%d)", service, port) 79 | } 80 | return msg 81 | } 82 | return f.PassedMessage() 83 | } 84 | -------------------------------------------------------------------------------- /checks/shared/remote_login_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestRemoteLogin_Run_NoOpenPorts(t *testing.T) { 10 | remoteLogin := &RemoteLogin{} 11 | 12 | // Mock checkPort to always return false 13 | CheckPortMock = func(port int, proto string) bool { 14 | return false 15 | } 16 | 17 | err := remoteLogin.Run() 18 | assert.NoError(t, err) 19 | assert.True(t, remoteLogin.Passed()) 20 | assert.Empty(t, remoteLogin.ports) 21 | } 22 | 23 | func TestRemoteLogin_Run_OpenPorts(t *testing.T) { 24 | remoteLogin := &RemoteLogin{} 25 | 26 | // Mock checkPort to return true for specific ports 27 | CheckPortMock = func(port int, _ string) bool { 28 | return port == 22 || port == 3389 29 | } 30 | 31 | err := remoteLogin.Run() 32 | assert.NoError(t, err) 33 | assert.False(t, remoteLogin.Passed()) 34 | assert.NotEmpty(t, remoteLogin.ports) 35 | assert.Contains(t, remoteLogin.ports, 22) 36 | assert.Contains(t, remoteLogin.ports, 3389) 37 | assert.NotContains(t, remoteLogin.ports, 3390) 38 | assert.NotContains(t, remoteLogin.ports, 5900) 39 | assert.NotEmpty(t, remoteLogin.UUID()) 40 | assert.False(t, remoteLogin.RequiresRoot()) 41 | } 42 | 43 | func TestRemoteLogin_Name(t *testing.T) { 44 | remoteLogin := &RemoteLogin{} 45 | expectedName := "Remote login is disabled" 46 | if remoteLogin.Name() != expectedName { 47 | t.Errorf("Expected Name %s, got %s", expectedName, remoteLogin.Name()) 48 | } 49 | } 50 | 51 | func TestRemoteLogin_Status(t *testing.T) { 52 | remoteLogin := &RemoteLogin{} 53 | expectedStatus := "Remote access services found running on ports:" 54 | if remoteLogin.Status() != expectedStatus { 55 | t.Errorf("Expected Status %s, got %s", expectedStatus, remoteLogin.Status()) 56 | } 57 | } 58 | 59 | func TestRemoteLogin_UUID(t *testing.T) { 60 | remoteLogin := &RemoteLogin{} 61 | expectedUUID := "4ced961d-7cfc-4e7b-8f80-195f6379446e" 62 | if remoteLogin.UUID() != expectedUUID { 63 | t.Errorf("Expected UUID %s, got %s", expectedUUID, remoteLogin.UUID()) 64 | } 65 | } 66 | 67 | func TestRemoteLogin_Passed(t *testing.T) { 68 | remoteLogin := &RemoteLogin{passed: true} 69 | expectedPassed := true 70 | if remoteLogin.Passed() != expectedPassed { 71 | t.Errorf("Expected Passed %v, got %v", expectedPassed, remoteLogin.Passed()) 72 | } 73 | } 74 | 75 | func TestRemoteLogin_FailedMessage(t *testing.T) { 76 | remoteLogin := &RemoteLogin{} 77 | expectedFailedMessage := "Remote access services found running" 78 | if remoteLogin.FailedMessage() != expectedFailedMessage { 79 | t.Errorf("Expected FailedMessage %s, got %s", expectedFailedMessage, remoteLogin.FailedMessage()) 80 | } 81 | } 82 | 83 | func TestRemoteLogin_PassedMessage(t *testing.T) { 84 | remoteLogin := &RemoteLogin{} 85 | expectedPassedMessage := "No remote access services found running" 86 | if remoteLogin.PassedMessage() != expectedPassedMessage { 87 | t.Errorf("Expected PassedMessage %s, got %s", expectedPassedMessage, remoteLogin.PassedMessage()) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /checks/shared/ssh_keys.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | 8 | sharedG "github.com/ParetoSecurity/agent/shared" 9 | "github.com/caarlos0/log" 10 | "golang.org/x/crypto/ssh" 11 | ) 12 | 13 | type SSHKeys struct { 14 | passed bool 15 | failedKeys []string 16 | details string 17 | } 18 | 19 | // Name returns the name of the check 20 | func (f *SSHKeys) Name() string { 21 | return "SSH keys have password protection" 22 | } 23 | 24 | // checks if private key has password protection 25 | func (f *SSHKeys) hasPassword(privateKeyPath string) bool { 26 | keyBytes, err := sharedG.ReadFile(privateKeyPath) 27 | if err != nil { 28 | return true // assume secure if can't read 29 | } 30 | 31 | _, err = ssh.ParsePrivateKey(keyBytes) 32 | return err != nil // if error occurs, key likely has password or it's FIDO2 managed key 33 | } 34 | 35 | // Run executes the check 36 | func (f *SSHKeys) Run() error { 37 | home, err := os.UserHomeDir() 38 | if err != nil { 39 | return err 40 | } 41 | sshDir := filepath.Join(home, ".ssh") 42 | 43 | files, err := os.ReadDir(sshDir) 44 | if err != nil { 45 | f.passed = true 46 | return nil 47 | } 48 | 49 | f.passed = true 50 | for _, file := range files { 51 | if strings.HasSuffix(file.Name(), ".pub") { 52 | privateKeyPath := filepath.Join(sshDir, strings.TrimSuffix(file.Name(), ".pub")) 53 | if _, err := os.Stat(privateKeyPath); err == nil { 54 | if !f.hasPassword(privateKeyPath) { 55 | f.passed = false 56 | f.failedKeys = append(f.failedKeys, file.Name()) 57 | } 58 | } 59 | } 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // Passed returns the status of the check 66 | func (f *SSHKeys) Passed() bool { 67 | return f.passed 68 | } 69 | 70 | // CanRun returns whether the check can run 71 | func (f *SSHKeys) IsRunnable() bool { 72 | f.details = "No private keys found in .ssh directory" 73 | home, err := os.UserHomeDir() 74 | if err != nil { 75 | return false 76 | } 77 | 78 | sshPath := filepath.Join(home, ".ssh") 79 | if _, err := os.Stat(sshPath); os.IsNotExist(err) { 80 | return false 81 | } 82 | 83 | //check if there are any private keys in the .ssh directory 84 | files, err := os.ReadDir(sshPath) 85 | if err != nil { 86 | return false 87 | } 88 | 89 | for _, file := range files { 90 | if strings.HasSuffix(file.Name(), ".pub") { 91 | privateKeyPath := filepath.Join(sshPath, strings.TrimSuffix(file.Name(), ".pub")) 92 | if _, err := os.Stat(privateKeyPath); err == nil { 93 | f.details = "Found private key: " + file.Name() 94 | log.WithField("file", file.Name()).Debug("Found private key") 95 | return true 96 | } 97 | } 98 | } 99 | 100 | return false 101 | 102 | } 103 | 104 | // UUID returns the UUID of the check 105 | func (f *SSHKeys) UUID() string { 106 | return "b6aaec0f-d76c-429e-aecf-edab7f1ac400" 107 | } 108 | 109 | // PassedMessage returns the message to return if the check passed 110 | func (f *SSHKeys) PassedMessage() string { 111 | return "SSH keys are password protected" 112 | } 113 | 114 | // FailedMessage returns the message to return if the check failed 115 | func (f *SSHKeys) FailedMessage() string { 116 | return "SSH keys are not using password" 117 | } 118 | 119 | // RequiresRoot returns whether the check requires root access 120 | func (f *SSHKeys) RequiresRoot() bool { 121 | return false 122 | } 123 | 124 | // Status returns the status of the check 125 | func (f *SSHKeys) Status() string { 126 | if f.Passed() { 127 | return f.PassedMessage() 128 | } 129 | if f.details != "" { 130 | return f.details 131 | } 132 | return "Found unprotected SSH key(s): " + strings.Join(f.failedKeys, ", ") 133 | } 134 | -------------------------------------------------------------------------------- /checks/windows/automatic_updates.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/ParetoSecurity/agent/shared" 9 | ) 10 | 11 | type AutomaticUpdatesCheck struct { 12 | passed bool 13 | status string 14 | } 15 | 16 | type autoUpdateSettings struct { 17 | NotificationLevel int `json:"NotificationLevel"` 18 | } 19 | 20 | func (a *AutomaticUpdatesCheck) Name() string { 21 | return "Automatic Updates are enabled" 22 | } 23 | 24 | func (a *AutomaticUpdatesCheck) Run() error { 25 | out, err := shared.RunCommand("powershell", "-Command", "(New-Object -ComObject Microsoft.Update.AutoUpdate).Settings | ConvertTo-Json") 26 | 27 | if err != nil { 28 | a.passed = false 29 | a.status = "Failed to query update settings" 30 | return nil 31 | } 32 | var settings autoUpdateSettings 33 | if err := json.Unmarshal([]byte(out), &settings); err != nil { 34 | a.passed = false 35 | a.status = "Failed to parse update settings" 36 | return nil 37 | } 38 | // NotificationLevel 1 = Never check for updates, 2 = Notify before download, 3 = Notify before install, 4 = Scheduled install 39 | if settings.NotificationLevel == 1 { 40 | a.status = "Automatic Updates are disabled" 41 | a.passed = false 42 | return nil 43 | } 44 | 45 | // Check if updates are paused 46 | psCmd := `try { Get-ItemPropertyValue -Path "HKLM:\SOFTWARE\Microsoft\WindowsUpdate\UX\Settings" -Name "PauseUpdatesExpiryTime" } catch { 0 }` 47 | pauseOut, pauseErr := shared.RunCommand("powershell", "-Command", psCmd) 48 | if pauseErr == nil && len(pauseOut) > 0 { 49 | // Parse output as int64 (epoch seconds) 50 | var expiry int64 51 | _, scanErr := fmt.Sscanf(pauseOut, "%d", &expiry) 52 | if scanErr == nil && expiry > 0 { 53 | now := time.Now().Unix() 54 | if expiry > now { 55 | a.passed = false 56 | a.status = "Updates are paused" 57 | return nil 58 | } 59 | } 60 | } 61 | 62 | a.passed = true 63 | return nil 64 | } 65 | 66 | func (a *AutomaticUpdatesCheck) Passed() bool { 67 | return a.passed 68 | } 69 | func (a *AutomaticUpdatesCheck) IsRunnable() bool { 70 | return true 71 | } 72 | func (a *AutomaticUpdatesCheck) UUID() string { 73 | return "28d98536-a93a-4092-845a-92ec081cc82a" 74 | } 75 | func (a *AutomaticUpdatesCheck) PassedMessage() string { 76 | return "Automatic Updates are on" 77 | } 78 | func (a *AutomaticUpdatesCheck) FailedMessage() string { 79 | return "Automatic Updates are off/paused" 80 | } 81 | func (a *AutomaticUpdatesCheck) RequiresRoot() bool { 82 | return false 83 | } 84 | func (a *AutomaticUpdatesCheck) Status() string { 85 | if a.Passed() { 86 | return a.PassedMessage() 87 | } 88 | if a.status != "" { 89 | return a.status 90 | } 91 | return a.FailedMessage() 92 | } 93 | -------------------------------------------------------------------------------- /checks/windows/defender.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | "github.com/ParetoSecurity/agent/shared" 8 | ) 9 | 10 | type WindowsDefender struct { 11 | passed bool 12 | status string 13 | } 14 | 15 | type mpStatus struct { 16 | RealTimeProtectionEnabled bool 17 | IoavProtectionEnabled bool 18 | AntispywareEnabled bool 19 | } 20 | 21 | func (d *WindowsDefender) Name() string { 22 | return "Windows Defender is enabled" 23 | } 24 | 25 | func (d *WindowsDefender) Run() error { 26 | out, err := shared.RunCommand("powershell", "-Command", "Get-MpComputerStatus | Select-Object RealTimeProtectionEnabled, IoavProtectionEnabled, AntispywareEnabled | ConvertTo-Json") 27 | if err != nil { 28 | d.passed = false 29 | d.status = "Failed to query Defender status" 30 | return nil 31 | } 32 | // Remove BOM if present 33 | outStr := strings.TrimPrefix(string(out), "\xef\xbb\xbf") 34 | var status mpStatus 35 | if err := json.Unmarshal([]byte(outStr), &status); err != nil { 36 | d.passed = false 37 | d.status = "Failed to parse Defender status" 38 | return nil 39 | } 40 | if status.RealTimeProtectionEnabled && status.IoavProtectionEnabled && status.AntispywareEnabled { 41 | d.passed = true 42 | d.status = "" 43 | } else { 44 | d.passed = false 45 | // Compose a status message with details 46 | if !status.RealTimeProtectionEnabled { 47 | d.status = "Defender has disabled real-time protection" 48 | return nil 49 | } 50 | if !status.IoavProtectionEnabled { 51 | d.status = "Defender has disabled tamper protection" 52 | return nil 53 | } 54 | if !status.AntispywareEnabled { 55 | d.status = "Defender is disabled" 56 | return nil 57 | } 58 | } 59 | return nil 60 | } 61 | 62 | func (d *WindowsDefender) Passed() bool { 63 | return d.passed 64 | } 65 | func (d *WindowsDefender) IsRunnable() bool { 66 | return true 67 | } 68 | func (d *WindowsDefender) UUID() string { 69 | return "2be03cd7-5cb5-4778-a01a-7ba2fb22750a" 70 | } 71 | func (d *WindowsDefender) PassedMessage() string { 72 | return "Microsoft Defender is on" 73 | } 74 | func (d *WindowsDefender) FailedMessage() string { 75 | return "Microsoft Defender is off" 76 | } 77 | func (d *WindowsDefender) RequiresRoot() bool { 78 | return false 79 | } 80 | func (d *WindowsDefender) Status() string { 81 | if d.Passed() { 82 | return d.PassedMessage() 83 | } 84 | if d.status != "" { 85 | return d.status 86 | } 87 | return d.FailedMessage() 88 | } 89 | -------------------------------------------------------------------------------- /checks/windows/defender_test.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | 7 | "github.com/ParetoSecurity/agent/shared" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestWindowsDefender_Run(t *testing.T) { 12 | tests := []struct { 13 | name string 14 | mockCommand shared.RunCommandMock 15 | expectedPassed bool 16 | expectedStatus string 17 | }{ 18 | { 19 | name: "All protections enabled", 20 | mockCommand: shared.RunCommandMock{ 21 | Command: "powershell", 22 | Args: []string{"-Command", "Get-MpComputerStatus | Select-Object RealTimeProtectionEnabled, IoavProtectionEnabled, AntispywareEnabled | ConvertTo-Json"}, 23 | Out: `{"RealTimeProtectionEnabled":true,"IoavProtectionEnabled":true,"AntispywareEnabled":true}`, 24 | Err: nil, 25 | }, 26 | expectedPassed: true, 27 | expectedStatus: "Microsoft Defender is on", 28 | }, 29 | { 30 | name: "Real-time protection disabled", 31 | mockCommand: shared.RunCommandMock{ 32 | Command: "powershell", 33 | Args: []string{"-Command", "Get-MpComputerStatus | Select-Object RealTimeProtectionEnabled, IoavProtectionEnabled, AntispywareEnabled | ConvertTo-Json"}, 34 | Out: `{"RealTimeProtectionEnabled":false,"IoavProtectionEnabled":true,"AntispywareEnabled":true}`, 35 | Err: nil, 36 | }, 37 | expectedPassed: false, 38 | expectedStatus: "Defender has disabled real-time protection", 39 | }, 40 | { 41 | name: "Tamper protection disabled", 42 | mockCommand: shared.RunCommandMock{ 43 | Command: "powershell", 44 | Args: []string{"-Command", "Get-MpComputerStatus | Select-Object RealTimeProtectionEnabled, IoavProtectionEnabled, AntispywareEnabled | ConvertTo-Json"}, 45 | Out: `{"RealTimeProtectionEnabled":true,"IoavProtectionEnabled":false,"AntispywareEnabled":true}`, 46 | Err: nil, 47 | }, 48 | expectedPassed: false, 49 | expectedStatus: "Defender has disabled tamper protection", 50 | }, 51 | { 52 | name: "Antispyware disabled", 53 | mockCommand: shared.RunCommandMock{ 54 | Command: "powershell", 55 | Args: []string{"-Command", "Get-MpComputerStatus | Select-Object RealTimeProtectionEnabled, IoavProtectionEnabled, AntispywareEnabled | ConvertTo-Json"}, 56 | Out: `{"RealTimeProtectionEnabled":true,"IoavProtectionEnabled":true,"AntispywareEnabled":false}`, 57 | Err: nil, 58 | }, 59 | expectedPassed: false, 60 | expectedStatus: "Defender is disabled", 61 | }, 62 | { 63 | name: "Command execution error", 64 | mockCommand: shared.RunCommandMock{ 65 | Command: "powershell", 66 | Args: []string{"-Command", "Get-MpComputerStatus | Select-Object RealTimeProtectionEnabled, IoavProtectionEnabled, AntispywareEnabled | ConvertTo-Json"}, 67 | Out: "", 68 | Err: errors.New("command failed"), 69 | }, 70 | expectedPassed: false, 71 | expectedStatus: "Failed to query Defender status", 72 | }, 73 | { 74 | name: "Invalid JSON output", 75 | mockCommand: shared.RunCommandMock{ 76 | Command: "powershell", 77 | Args: []string{"-Command", "Get-MpComputerStatus | Select-Object RealTimeProtectionEnabled, IoavProtectionEnabled, AntispywareEnabled | ConvertTo-Json"}, 78 | Out: `invalid-json`, 79 | Err: nil, 80 | }, 81 | expectedPassed: false, 82 | expectedStatus: "Failed to parse Defender status", 83 | }, 84 | } 85 | 86 | for _, tt := range tests { 87 | t.Run(tt.name, func(t *testing.T) { 88 | shared.RunCommandMocks = []shared.RunCommandMock{tt.mockCommand} 89 | 90 | check := &WindowsDefender{} 91 | err := check.Run() 92 | assert.NoError(t, err) 93 | assert.Equal(t, tt.expectedPassed, check.Passed()) 94 | assert.Equal(t, tt.expectedStatus, check.Status()) 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /checks/windows/firewall.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/ParetoSecurity/agent/shared" 7 | ) 8 | 9 | type WindowsFirewall struct { 10 | passed bool 11 | status string 12 | } 13 | 14 | func (f *WindowsFirewall) checkFirewallProfile(profile string) bool { 15 | out, err := shared.RunCommand("powershell", "-Command", "Get-NetFirewallProfile -Name '"+profile+"' | Select-Object -ExpandProperty Enabled") 16 | 17 | if err != nil { 18 | f.status = "Failed to query Windows Firewall for " + profile + " profile" 19 | return false 20 | } 21 | enabled := strings.TrimSpace(string(out)) 22 | if enabled == "True" { 23 | return true 24 | } 25 | f.status = "Windows Firewall is not enabled for " + profile + " profile" 26 | return false 27 | } 28 | 29 | func (f *WindowsFirewall) Name() string { 30 | return "Windows Firewall is enabled" 31 | } 32 | 33 | func (f *WindowsFirewall) Run() error { 34 | f.passed = f.checkFirewallProfile("Public") && f.checkFirewallProfile("Private") 35 | return nil 36 | } 37 | 38 | func (f *WindowsFirewall) Passed() bool { 39 | return f.passed 40 | } 41 | func (f *WindowsFirewall) IsRunnable() bool { 42 | return true 43 | } 44 | func (f *WindowsFirewall) UUID() string { 45 | return "e632fdd2-b939-4aeb-9a3e-5df2d67d3110" 46 | } 47 | func (f *WindowsFirewall) PassedMessage() string { 48 | return "Windows Firewall is on" 49 | } 50 | func (f *WindowsFirewall) FailedMessage() string { 51 | return "Windows Firewall is off" 52 | } 53 | func (f *WindowsFirewall) RequiresRoot() bool { 54 | return false 55 | } 56 | func (f *WindowsFirewall) Status() string { 57 | if f.Passed() { 58 | return f.PassedMessage() 59 | } 60 | if f.status != "" { 61 | return f.status 62 | } 63 | return f.FailedMessage() 64 | } 65 | -------------------------------------------------------------------------------- /checks/windows/firewall_test.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ParetoSecurity/agent/shared" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestCheckFirewallProfile(t *testing.T) { 11 | 12 | tests := []struct { 13 | name string 14 | profile string 15 | mockOutput string 16 | mockError error 17 | expectedResult bool 18 | expectedStatus string 19 | }{ 20 | { 21 | name: "Firewall enabled for Public profile", 22 | profile: "Public", 23 | mockOutput: "True", 24 | mockError: nil, 25 | expectedResult: true, 26 | expectedStatus: "", 27 | }, 28 | { 29 | name: "Firewall disabled for Private profile", 30 | profile: "Private", 31 | mockOutput: "False", 32 | mockError: nil, 33 | expectedResult: false, 34 | expectedStatus: "Windows Firewall is not enabled for Private profile", 35 | }, 36 | { 37 | name: "Error querying firewall profile", 38 | profile: "Domain", 39 | mockOutput: "", 40 | mockError: assert.AnError, 41 | expectedResult: false, 42 | expectedStatus: "Failed to query Windows Firewall for Domain profile", 43 | }, 44 | } 45 | 46 | for _, tt := range tests { 47 | t.Run(tt.name, func(t *testing.T) { 48 | // Mock shared.RunCommand 49 | shared.RunCommandMocks = []shared.RunCommandMock{ 50 | { 51 | Command: "powershell", 52 | Args: []string{"-Command", "Get-NetFirewallProfile -Name '" + tt.profile + "' | Select-Object -ExpandProperty Enabled"}, 53 | Out: tt.mockOutput, 54 | Err: tt.mockError, 55 | }, 56 | } 57 | 58 | firewall := &WindowsFirewall{} 59 | result := firewall.checkFirewallProfile(tt.profile) 60 | 61 | assert.Equal(t, tt.expectedResult, result) 62 | assert.Equal(t, tt.expectedStatus, firewall.status) 63 | }) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /checks/windows/mock.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | var osStatMock map[string]bool 9 | 10 | // osStat checks if a file exists by attempting to get its file info. 11 | // During testing, it uses a mock implementation via osStatMock. 12 | // It returns the file path if the file exists, otherwise returns an empty string and error. 13 | func osStat(file string) (string, error) { 14 | if testing.Testing() { 15 | if found := osStatMock[file]; found { 16 | return file, nil 17 | } 18 | return "", os.ErrNotExist 19 | } 20 | _, err := os.Stat(file) 21 | if err != nil { 22 | return "", err 23 | } 24 | return file, nil 25 | } 26 | -------------------------------------------------------------------------------- /checks/windows/password_manager.go: -------------------------------------------------------------------------------- 1 | package checks 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | type PasswordManagerCheck struct { 10 | passed bool 11 | } 12 | 13 | func (pmc *PasswordManagerCheck) Name() string { 14 | return "Password Manager Presence" 15 | } 16 | 17 | func (pmc *PasswordManagerCheck) Run() error { 18 | // TODO; need real paths 19 | userProfile := os.Getenv("USERPROFILE") 20 | paths := []string{ 21 | filepath.Join(userProfile, "AppData", "Local", "1Password", "app", "8", "1Password.exe"), 22 | filepath.Join(userProfile, "AppData", "Local", "Programs", "Bitwarden", "Bitwarden.exe"), 23 | filepath.Join(os.Getenv("PROGRAMFILES"), "KeePass Password Safe 2", "KeePass.exe"), 24 | filepath.Join(os.Getenv("PROGRAMFILES(X86)"), "KeePass Password Safe 2", "KeePass.exe"), 25 | filepath.Join(os.Getenv("PROGRAMFILES"), "KeePassXC", "KeePassXC.exe"), 26 | filepath.Join(os.Getenv("PROGRAMFILES(X86)"), "KeePassXC", "KeePassXC.exe"), 27 | } 28 | 29 | for _, path := range paths { 30 | if _, err := osStat(path); err == nil { 31 | pmc.passed = true 32 | return nil 33 | } 34 | } 35 | 36 | pmc.passed = checkForBrowserExtensions() 37 | return nil 38 | } 39 | 40 | func checkForBrowserExtensions() bool { 41 | home := os.Getenv("USERPROFILE") 42 | extensionPaths := map[string]string{ 43 | "Google Chrome": filepath.Join(home, "AppData", "Local", "Google", "Chrome", "User Data", "Default", "Extensions"), 44 | "Firefox": filepath.Join(home, "AppData", "Roaming", "Mozilla", "Firefox", "Profiles"), 45 | "Microsoft Edge": filepath.Join(home, "AppData", "Local", "Microsoft", "Edge", "User Data", "Default", "Extensions"), 46 | "Brave Browser": filepath.Join(home, "AppData", "Local", "BraveSoftware", "Brave-Browser", "User Data", "Default", "Extensions"), 47 | } 48 | 49 | browserExtensions := []string{ 50 | "LastPass", 51 | "ProtonPass", 52 | "NordPass", 53 | "Bitwarden", 54 | "1Password", 55 | "KeePass", 56 | "Dashlane", 57 | } 58 | 59 | for _, extPath := range extensionPaths { 60 | if _, err := os.Stat(extPath); err == nil { 61 | entries, err := os.ReadDir(extPath) 62 | if err == nil { 63 | for _, entry := range entries { 64 | name := strings.ToLower(entry.Name()) 65 | for _, ext := range browserExtensions { 66 | if strings.Contains(name, strings.ToLower(ext)) { 67 | return true 68 | } 69 | } 70 | } 71 | } 72 | } 73 | } 74 | return false 75 | } 76 | 77 | func (pmc *PasswordManagerCheck) Passed() bool { 78 | return pmc.passed 79 | } 80 | 81 | func (pmc *PasswordManagerCheck) IsRunnable() bool { 82 | return true 83 | } 84 | 85 | func (pmc *PasswordManagerCheck) UUID() string { 86 | return "f962c423-fdf5-428a-a57a-827abc9b253e" 87 | } 88 | 89 | func (pmc *PasswordManagerCheck) PassedMessage() string { 90 | return "Password manager is present" 91 | } 92 | 93 | func (pmc *PasswordManagerCheck) FailedMessage() string { 94 | return "No password manager found" 95 | } 96 | 97 | func (pmc *PasswordManagerCheck) RequiresRoot() bool { 98 | return false 99 | } 100 | 101 | func (pmc *PasswordManagerCheck) Status() string { 102 | if pmc.Passed() { 103 | return pmc.PassedMessage() 104 | } 105 | return pmc.FailedMessage() 106 | } 107 | -------------------------------------------------------------------------------- /claims/checks_darwin.go: -------------------------------------------------------------------------------- 1 | package claims 2 | 3 | import ( 4 | "github.com/ParetoSecurity/agent/check" 5 | checks "github.com/ParetoSecurity/agent/checks/darwin" 6 | shared "github.com/ParetoSecurity/agent/checks/shared" 7 | ) 8 | 9 | var All = []Claim{ 10 | {"Access Security", []check.Check{ 11 | &shared.SSHKeys{}, 12 | &shared.SSHKeysAlgo{}, 13 | &checks.PasswordManagerCheck{}, 14 | }}, 15 | {"Application Updates", []check.Check{ 16 | &shared.ParetoUpdated{}, 17 | }}, 18 | {"Firewall & Sharing", []check.Check{ 19 | &shared.RemoteLogin{}, 20 | }}, 21 | {"System Integrity", []check.Check{}}, 22 | } 23 | -------------------------------------------------------------------------------- /claims/checks_linux.go: -------------------------------------------------------------------------------- 1 | package claims 2 | 3 | import ( 4 | "github.com/ParetoSecurity/agent/check" 5 | checks "github.com/ParetoSecurity/agent/checks/linux" 6 | shared "github.com/ParetoSecurity/agent/checks/shared" 7 | ) 8 | 9 | var All = []Claim{ 10 | {"Access Security", []check.Check{ 11 | &checks.Autologin{}, 12 | &checks.DockerAccess{}, 13 | &checks.PasswordToUnlock{}, 14 | &shared.SSHKeys{}, 15 | &shared.SSHKeysAlgo{}, 16 | &checks.PasswordManagerCheck{}, 17 | }}, 18 | {"Application Updates", []check.Check{ 19 | &checks.ApplicationUpdates{}, 20 | &shared.ParetoUpdated{}, 21 | }}, 22 | {"Firewall & Sharing", []check.Check{ 23 | &checks.Firewall{}, 24 | &checks.Printer{}, 25 | &shared.RemoteLogin{}, 26 | &checks.Sharing{}, 27 | }}, 28 | {"System Integrity", []check.Check{ 29 | &checks.SecureBoot{}, 30 | &checks.EncryptingFS{}, 31 | }}, 32 | } 33 | -------------------------------------------------------------------------------- /claims/checks_windows.go: -------------------------------------------------------------------------------- 1 | package claims 2 | 3 | import ( 4 | "github.com/ParetoSecurity/agent/check" 5 | shared "github.com/ParetoSecurity/agent/checks/shared" 6 | checks "github.com/ParetoSecurity/agent/checks/windows" 7 | ) 8 | 9 | var All = []Claim{ 10 | {"Access Security", []check.Check{ 11 | &checks.PasswordManagerCheck{}, 12 | }}, 13 | {"Application Updates", []check.Check{ 14 | &shared.ParetoUpdated{}, 15 | &checks.AutomaticUpdatesCheck{}, 16 | }}, 17 | {"Firewall & Sharing", []check.Check{ 18 | &shared.RemoteLogin{}, 19 | &checks.WindowsFirewall{}, 20 | }}, 21 | {"System Integrity", []check.Check{ 22 | &checks.WindowsDefender{}, 23 | }}, 24 | } 25 | -------------------------------------------------------------------------------- /claims/claim.go: -------------------------------------------------------------------------------- 1 | package claims 2 | 3 | import "github.com/ParetoSecurity/agent/check" 4 | 5 | type Claim struct { 6 | Title string 7 | Checks []check.Check 8 | } 9 | -------------------------------------------------------------------------------- /cmd/check.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/ParetoSecurity/agent/claims" 8 | "github.com/ParetoSecurity/agent/runner" 9 | shared "github.com/ParetoSecurity/agent/shared" 10 | team "github.com/ParetoSecurity/agent/team" 11 | "github.com/caarlos0/log" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | var checkCmd = &cobra.Command{ 16 | Use: "check [--skip ] [--only ]", 17 | Short: "Run checks on your system", 18 | Run: func(cc *cobra.Command, args []string) { 19 | skipUUIDs, _ := cc.Flags().GetStringArray("skip") 20 | onlyUUID, _ := cc.Flags().GetString("only") 21 | checkCommand(skipUUIDs, onlyUUID) 22 | }, 23 | } 24 | 25 | func init() { 26 | rootCmd.AddCommand(checkCmd) 27 | checkCmd.Flags().StringArray("skip", []string{}, "skip checks by UUID") 28 | checkCmd.Flags().String("only", "", "only run checks by UUID") 29 | } 30 | 31 | func checkCommand(skipUUIDs []string, onlyUUID string) { 32 | if shared.IsRoot() { 33 | log.Warn("Please run this command as a normal user, as it won't report all checks correctly.") 34 | } 35 | 36 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 37 | defer cancel() 38 | 39 | done := make(chan struct{}) 40 | go func() { 41 | runner.Check(ctx, claims.All, skipUUIDs, onlyUUID) 42 | close(done) 43 | }() 44 | 45 | select { 46 | case <-done: 47 | if shared.IsLinked() { 48 | err := team.ReportToTeam(false) 49 | if err != nil { 50 | log.WithError(err).Warn("failed to report to team") 51 | } 52 | } 53 | 54 | // if checks failed, exit with a non-zero status code 55 | if !shared.AllChecksPassed() { 56 | // Log the failed checks 57 | if failedChecks := shared.GetFailedChecks(); len(failedChecks) > 0 && verbose { 58 | for _, check := range failedChecks { 59 | log.Errorf("Failed check: %s (UUID: %s)", check.Name, check.UUID) 60 | } 61 | } 62 | log.Fatal("You can use `paretosecurity check --verbose` to get a detailed report.") 63 | } 64 | 65 | case <-ctx.Done(): 66 | log.Fatal("Check run timed out") 67 | 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /cmd/check_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func Test_CheckCMD(t *testing.T) { 11 | expected := "check Run checks" 12 | b := bytes.NewBufferString("") 13 | checkCmd.SetOut(b) 14 | checkCmd.Execute() 15 | out, err := io.ReadAll(b) 16 | if err != nil { 17 | t.Fatal(err) 18 | } 19 | if strings.Contains(string(out), expected) { 20 | t.Fatalf("expected \"%s\" got \"%s\"", expected, string(out)) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /cmd/config.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/ParetoSecurity/agent/shared" 5 | "github.com/caarlos0/log" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var configCmd = &cobra.Command{ 10 | Use: "config", 11 | Short: "Configure application settings", 12 | Long: "Configure application settings, such as enabling or disabling specific checks.", 13 | } 14 | 15 | var resetCmd = &cobra.Command{ 16 | Use: "reset", 17 | Short: "Reset configuration settings", 18 | Long: "Reset configuration settings to their default values.", 19 | Run: func(cmd *cobra.Command, args []string) { 20 | shared.ResetConfig() 21 | log.WithField("config", shared.ConfigPath).Info("Configuration reset to default values.") 22 | }, 23 | } 24 | 25 | var enableCmd = &cobra.Command{ 26 | Use: "enable [check UUID]", 27 | Short: "Enable a specific check", 28 | Long: "Enable a specific check by providing its id.", 29 | Args: cobra.ExactArgs(1), 30 | Run: func(cmd *cobra.Command, args []string) { 31 | check := args[0] 32 | err := shared.EnableCheck(check) 33 | if err != nil { 34 | log.WithError(err).Fatalf("Failed to enable check: %s", check) 35 | } else { 36 | log.WithField("check", check).Info("Check enabled successfully.") 37 | } 38 | }, 39 | } 40 | 41 | var disableCmd = &cobra.Command{ 42 | Use: "disable [check UUID]", 43 | Short: "Disable a specific check", 44 | Long: "Disable a specific check by providing its id.", 45 | Args: cobra.ExactArgs(1), 46 | Run: func(cmd *cobra.Command, args []string) { 47 | check := args[0] 48 | err := shared.DisableCheck(check) 49 | if err != nil { 50 | log.WithError(err).Fatalf("Failed to disable check: %s", check) 51 | } else { 52 | log.WithField("check", check).Info("Check disabled successfully.") 53 | } 54 | }, 55 | } 56 | 57 | func init() { 58 | rootCmd.AddCommand(configCmd) 59 | configCmd.AddCommand(resetCmd) 60 | configCmd.AddCommand(enableCmd) 61 | configCmd.AddCommand(disableCmd) 62 | } 63 | -------------------------------------------------------------------------------- /cmd/helper.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "net" 5 | "os" 6 | 7 | "github.com/ParetoSecurity/agent/runner" 8 | shared "github.com/ParetoSecurity/agent/shared" 9 | "github.com/caarlos0/log" 10 | "github.com/samber/lo" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // runHelperServer listens on a socket (passed via file descriptor 0) and handles incoming connections. 15 | // It's designed to be run in a systemd context where systemd provides the socket. 16 | // The server accepts a single connection, handles it using runner.HandleConnection, and then exits. 17 | // It logs the socket path and version information upon startup and logs any errors encountered during socket creation or connection acceptance. 18 | func runHelperServer() { 19 | // Get the socket from file descriptor 0 20 | file := os.NewFile(0, "socket") 21 | listener, err := net.FileListener(file) 22 | if err != nil { 23 | log.WithError(err).Fatal("Failed to create listener, not running in systemd context") 24 | } 25 | defer listener.Close() 26 | log.WithField("socket", runner.SocketPath).WithField("version", shared.Version).Info("Listening on socket") 27 | 28 | for { 29 | conn, err := listener.Accept() 30 | if err != nil { 31 | log.WithError(err).Warn("Failed to accept connection") 32 | continue 33 | } 34 | 35 | runner.HandleConnection(conn) 36 | break 37 | } 38 | } 39 | 40 | var helperCmd = &cobra.Command{ 41 | Use: "helper [--socket]", 42 | Short: "A root helper", 43 | Long: `A root helper that listens on a Unix domain socket and responds to authenticated requests.`, 44 | Run: func(cmd *cobra.Command, args []string) { 45 | 46 | socketFlag, _ := cmd.Flags().GetString("socket") 47 | if lo.IsNotEmpty(socketFlag) { 48 | runner.SocketPath = socketFlag 49 | } 50 | 51 | runHelperServer() 52 | }, 53 | } 54 | 55 | func init() { 56 | rootCmd.AddCommand(helperCmd) 57 | helperCmd.Flags().Bool("socket", false, "socket path") 58 | } 59 | -------------------------------------------------------------------------------- /cmd/info.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "encoding/json" 5 | "runtime" 6 | 7 | "github.com/ParetoSecurity/agent/shared" 8 | "github.com/caarlos0/log" 9 | "github.com/elastic/go-sysinfo" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var infoCmd = &cobra.Command{ 14 | Use: "info", 15 | Short: "Print the system information", 16 | Run: func(cmd *cobra.Command, args []string) { 17 | 18 | log.Infof("%s@%s %s", shared.Version, shared.Commit, shared.Date) 19 | log.Infof("Built with %s", runtime.Version()) 20 | log.Infof("Team: %s\n", shared.Config.TeamID) 21 | 22 | device := shared.CurrentReportingDevice() 23 | jsonOutput, err := json.MarshalIndent(device, "", " ") 24 | if err != nil { 25 | log.Warn("Failed to marshal host info") 26 | } 27 | log.Infof("Device Info: %s\n", string(jsonOutput)) 28 | 29 | hostInfo, err := sysinfo.Host() 30 | if err != nil { 31 | log.Warn("Failed to get process information") 32 | } 33 | envInfo := hostInfo.Info() 34 | envInfo.IPs = []string{} // Exclude IPs for privacy 35 | envInfo.MACs = []string{} // Exclude MACs for privacy 36 | jsonOutput, err = json.MarshalIndent(envInfo, "", " ") 37 | if err != nil { 38 | log.Warn("Failed to marshal host info") 39 | } 40 | log.Infof("Host Info: %s\n", string(jsonOutput)) 41 | }, 42 | } 43 | 44 | func init() { 45 | rootCmd.AddCommand(infoCmd) 46 | } 47 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/installer_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package main 5 | 6 | type WindowService struct { 7 | } 8 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/installer_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | 9 | _ "embed" 10 | 11 | "github.com/ParetoSecurity/agent/shared" 12 | ) 13 | 14 | type WindowService struct{} 15 | 16 | func (w *WindowService) QuitApp() error { 17 | os.Exit(0) 18 | return nil 19 | } 20 | 21 | func (w *WindowService) InstallApp(withStartup bool) error { 22 | return shared.InstallApp(withStartup) 23 | } 24 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/main.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package main 5 | 6 | import ( 7 | "embed" 8 | "log/slog" 9 | "os" 10 | "strings" 11 | 12 | "github.com/ParetoSecurity/agent/shared" 13 | "github.com/wailsapp/wails/v3/pkg/application" 14 | ) 15 | 16 | //go:embed ui/dist/* 17 | var welcomeAssets embed.FS 18 | 19 | func main() { 20 | 21 | for _, arg := range os.Args[1:] { 22 | arg = strings.ToLower(arg) 23 | if arg == "/qs" || arg == "/qsp" || arg == "/s" || arg == "/q" { 24 | // install the app 25 | err := shared.InstallApp(true) 26 | if err != nil { 27 | slog.Error(err.Error()) 28 | } 29 | os.Exit(0) 30 | return 31 | } 32 | } 33 | 34 | app := application.New(application.Options{ 35 | Name: "Pareto Security Installer", 36 | LogLevel: slog.LevelInfo, 37 | Description: "Installer for Pareto Security Agent", 38 | Services: []application.Service{ 39 | application.NewService(&WindowService{}), 40 | }, 41 | Assets: application.AssetOptions{ 42 | Handler: application.BundledAssetFileServer(welcomeAssets), 43 | }, 44 | Mac: application.MacOptions{ 45 | ApplicationShouldTerminateAfterLastWindowClosed: true, 46 | }, 47 | Windows: application.WindowsOptions{ 48 | WndProcInterceptor: nil, 49 | DisableQuitOnLastWindowClosed: false, 50 | WebviewUserDataPath: "", 51 | WebviewBrowserPath: "", 52 | }, 53 | }) 54 | 55 | app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ 56 | Title: "Welcome to Pareto Security", 57 | Width: 360, 58 | Height: 580, 59 | URL: "/", 60 | AlwaysOnTop: true, 61 | DisableResize: true, 62 | FullscreenButtonEnabled: false, 63 | DefaultContextMenuDisabled: true, 64 | MinimiseButtonState: application.ButtonHidden, 65 | MaximiseButtonState: application.ButtonHidden, 66 | Mac: application.MacWindow{ 67 | Backdrop: application.MacBackdropTranslucent, 68 | TitleBar: application.MacTitleBarHiddenInsetUnified, 69 | InvisibleTitleBarHeight: 50, 70 | WindowLevel: application.MacWindowLevelFloating, 71 | }, 72 | }) 73 | 74 | app.Run() 75 | } 76 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | elm-stuff -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/browser": "1.0.2", 10 | "elm/core": "1.0.5", 11 | "elm/html": "1.0.0", 12 | "hmsk/elm-vite-plugin-helper": "1.0.1" 13 | }, 14 | "indirect": { 15 | "elm/json": "1.1.3", 16 | "elm/time": "1.0.0", 17 | "elm/url": "1.0.0", 18 | "elm/virtual-dom": "1.0.3" 19 | } 20 | }, 21 | "test-dependencies": { 22 | "direct": {}, 23 | "indirect": {} 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "paretoui", 3 | "private": true, 4 | "version": "0.1.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "devDependencies": { 12 | "@tailwindcss/vite": "^4.1.3", 13 | "daisyui": "^5.0.18", 14 | "tailwindcss": "^4.1.3", 15 | "elm": "0.19.1-6", 16 | "elm-optimize-level-2": "0.3.5", 17 | "tempy": "3.1.0", 18 | "vite-plugin-elm": "v3.1.0-2", 19 | "vite": "^6.3.4" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/src/Welcome.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @plugin "daisyui"; 3 | 4 | body, * { 5 | -webkit-user-select: none; /* Safari */ 6 | -ms-user-select: none; /* IE 10 and IE 11 */ 7 | user-select: none; /* Standard syntax */ 8 | } 9 | 10 | html { 11 | height: 100%; 12 | overflow: hidden; 13 | } 14 | 15 | 16 | @plugin "daisyui/theme" { 17 | name: "light"; 18 | default: true; 19 | prefersdark: false; 20 | color-scheme: "light"; 21 | --color-base-100: oklch(100% 0 0); 22 | --color-base-200: oklch(98% 0 0); 23 | --color-base-300: oklch(95% 0 0); 24 | --color-base-content: oklch(21% 0.006 285.885); 25 | --color-primary: oklch(56.86% 0.255 257.57); 26 | --color-primary-content: oklch(93% 0.034 272.788); 27 | --color-secondary: oklch(65% 0.241 354.308); 28 | --color-secondary-content: oklch(94% 0.028 342.258); 29 | --color-accent: oklch(77% 0.152 181.912); 30 | --color-accent-content: oklch(38% 0.063 188.416); 31 | --color-neutral: oklch(14% 0.005 285.823); 32 | --color-neutral-content: oklch(92% 0.004 286.32); 33 | --color-info: oklch(74% 0.16 232.661); 34 | --color-info-content: oklch(29% 0.066 243.157); 35 | --color-success: oklch(76% 0.177 163.223); 36 | --color-success-content: oklch(37% 0.077 168.94); 37 | --color-warning: oklch(82% 0.189 84.429); 38 | --color-warning-content: oklch(41% 0.112 45.904); 39 | --color-error: oklch(71% 0.194 13.428); 40 | --color-error-content: oklch(27% 0.105 12.094); 41 | --radius-selector: 0.5rem; 42 | --radius-field: 0.25rem; 43 | --radius-box: 0.5rem; 44 | --size-selector: 0.25rem; 45 | --size-field: 0.25rem; 46 | --border: 1px; 47 | --depth: 1; 48 | --noise: 0; 49 | } 50 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/src/assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParetoSecurity/agent/52f3b7fb02d2731ff8bbd824e5665a8fbd363a93/cmd/paretosecurity-installer/ui/src/assets/icon.png -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/src/assets/icon_black.svg: -------------------------------------------------------------------------------- 1 | 2 | 14 | 32 | 34 | 36 | 37 | 41 | 44 | 46 | 50 | 54 | 58 | 62 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/src/index.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 3 | // This file is automatically generated. DO NOT EDIT 4 | 5 | import * as WindowService from "./windowservice.js"; 6 | export { 7 | WindowService 8 | }; 9 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/src/windowservice.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL 3 | // This file is automatically generated. DO NOT EDIT 4 | 5 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 6 | // @ts-ignore: Unused imports 7 | import { Call as $Call, CancellablePromise as $CancellablePromise, Create as $Create } from "/wails/runtime.js"; 8 | 9 | /** 10 | * @param {boolean} withStartup 11 | * @returns {$CancellablePromise} 12 | */ 13 | export function InstallApp(withStartup) { 14 | return $Call.ByID(2881211655, withStartup); 15 | } 16 | 17 | /** 18 | * @returns {$CancellablePromise} 19 | */ 20 | export function QuitApp() { 21 | return $Call.ByID(23891361); 22 | } 23 | -------------------------------------------------------------------------------- /cmd/paretosecurity-installer/ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import tailwindcss from '@tailwindcss/vite'; 3 | import elmPlugin from 'vite-plugin-elm' 4 | import { resolve } from 'node:path' 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig(async () => ({ 8 | plugins: [elmPlugin({ 9 | 10 | }), tailwindcss()], 11 | build: { 12 | rollupOptions: { 13 | external: ['@wailsio/runtime', '/wails/runtime.js'], 14 | input: { 15 | main: resolve(__dirname, 'index.html'), 16 | }, 17 | }, 18 | }, 19 | })); 20 | -------------------------------------------------------------------------------- /cmd/paretosecurity-tray/main.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package main 5 | 6 | import ( 7 | "math/rand" 8 | "os" 9 | "time" 10 | 11 | "fyne.io/systray" 12 | "github.com/ParetoSecurity/agent/shared" 13 | "github.com/ParetoSecurity/agent/trayapp" 14 | "github.com/caarlos0/log" 15 | ) 16 | 17 | func main() { 18 | if err := shared.LoadConfig(); err != nil { 19 | log.WithError(err).Warn("failed to load config") 20 | } 21 | 22 | // Scheduled update command 23 | go func() { 24 | log.Info("Starting update command scheduler...") 25 | for { 26 | // This is to avoid running the update command too frequently 27 | // and to ensure that the update command is run at least once an hour 28 | // also prevent update on first run 29 | if time.Since(shared.GetModifiedTime()) > time.Hour && !shared.GetModifiedTime().IsZero() { 30 | _, err := shared.RunCommand(shared.SelfExe(), "update") 31 | if err != nil { 32 | log.WithError(err).Error("Failed to run update command") 33 | } 34 | } 35 | time.Sleep(time.Duration(50+rand.Intn(15)) * time.Minute) 36 | } 37 | }() 38 | 39 | // Scheduled check command 40 | go func() { 41 | log.Info("Starting check command scheduler...") 42 | for { 43 | // This is to avoid running the check command too frequently 44 | time.Sleep(time.Duration(45+rand.Intn(15)) * time.Minute) 45 | _, err := shared.RunCommand(shared.SelfExe(), "check") 46 | if err != nil { 47 | log.WithError(err).Error("Failed to run check command") 48 | } 49 | } 50 | }() 51 | 52 | // Initialize the state file 53 | if shared.GetModifiedTime().IsZero() { 54 | log.Info("Initializing state file...") // by running the check command 55 | go func() { 56 | _, err := shared.RunCommand(shared.SelfExe(), "check") 57 | if err != nil { 58 | log.WithError(err).Error("Failed to run check command") 59 | } 60 | }() 61 | } 62 | 63 | onExit := func() { 64 | log.Info("Exiting...") 65 | os.Exit(0) 66 | } 67 | log.Info("Starting system tray application...") 68 | systray.Run(trayapp.OnReady, onExit) 69 | } 70 | -------------------------------------------------------------------------------- /cmd/paretosecurity/main.go: -------------------------------------------------------------------------------- 1 | // Package main provides the entry point for the application. 2 | package main 3 | 4 | import ( 5 | "github.com/ParetoSecurity/agent/cmd" 6 | shared "github.com/ParetoSecurity/agent/shared" 7 | "github.com/caarlos0/log" 8 | ) 9 | 10 | func main() { 11 | if err := shared.LoadConfig(); err != nil { 12 | if !shared.IsRoot() { 13 | log.WithError(err).Warn("failed to load config") 14 | } 15 | } 16 | cmd.Execute() 17 | } 18 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | shared "github.com/ParetoSecurity/agent/shared" 5 | "github.com/caarlos0/log" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var verbose bool 10 | 11 | var rootCmd = &cobra.Command{ 12 | Use: "paretosecurity --help --version [command]", 13 | Short: "Pareto Security CLI", 14 | Version: shared.Version, 15 | Long: `Pareto Security CLI is a tool for running and reporting audits to paretosecurity.com.`, 16 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 17 | if verbose { 18 | log.SetLevel(log.DebugLevel) 19 | } 20 | }, 21 | } 22 | 23 | func init() { 24 | rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "output verbose logs") 25 | } 26 | 27 | func Execute() { 28 | if rootCmd.Execute() != nil { 29 | log.Fatal("Failed to execute command") 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /cmd/schema.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/ParetoSecurity/agent/claims" 5 | "github.com/ParetoSecurity/agent/runner" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var schemaCmd = &cobra.Command{ 10 | Use: "schema", 11 | Short: "Output schema for all checks", 12 | Long: "Output schema for all checks in JSON format.", 13 | Run: func(cc *cobra.Command, args []string) { 14 | runner.PrintSchemaJSON(claims.All) 15 | }, 16 | } 17 | 18 | func init() { 19 | rootCmd.AddCommand(schemaCmd) 20 | } 21 | -------------------------------------------------------------------------------- /cmd/status.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/ParetoSecurity/agent/shared" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var statusCmd = &cobra.Command{ 9 | Use: "status", 10 | Short: "Print the status of the checks", 11 | Run: func(cmd *cobra.Command, args []string) { 12 | shared.PrintStates() 13 | }, 14 | } 15 | 16 | func init() { 17 | rootCmd.AddCommand(statusCmd) 18 | } 19 | -------------------------------------------------------------------------------- /cmd/toast.go: -------------------------------------------------------------------------------- 1 | //go:build toast 2 | // +build toast 3 | 4 | package cmd 5 | 6 | import ( 7 | "github.com/ParetoSecurity/agent/notify" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var toastCMD = &cobra.Command{ 12 | Use: "toast", 13 | Short: "Display random toast messages", 14 | Run: func(cc *cobra.Command, args []string) { 15 | notify.Toast("Welcome to Pareto Security Agent!") 16 | }, 17 | } 18 | 19 | func init() { 20 | 21 | rootCmd.AddCommand(toastCMD) 22 | } 23 | -------------------------------------------------------------------------------- /cmd/trayicon.go: -------------------------------------------------------------------------------- 1 | //go:build linux || darwin 2 | // +build linux darwin 3 | 4 | package cmd 5 | 6 | import ( 7 | "fyne.io/systray" 8 | "github.com/ParetoSecurity/agent/trayapp" 9 | "github.com/caarlos0/log" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var trayiconCmd = &cobra.Command{ 14 | Use: "trayicon", 15 | Short: "Display the status of the checks in the system tray", 16 | Run: func(cc *cobra.Command, args []string) { 17 | 18 | onExit := func() { 19 | log.Info("Exiting...") 20 | } 21 | 22 | systray.Run(trayapp.OnReady, onExit) 23 | }, 24 | } 25 | 26 | func init() { 27 | 28 | rootCmd.AddCommand(trayiconCmd) 29 | } 30 | -------------------------------------------------------------------------------- /cmd/unlink.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/ParetoSecurity/agent/shared" 5 | "github.com/caarlos0/log" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var unlinkCmd = &cobra.Command{ 10 | Use: "unlink", 11 | Short: "Unlink this device from the team", 12 | Run: func(cc *cobra.Command, args []string) { 13 | log.Info("Unlinking device ...") 14 | shared.Config.TeamID = "" 15 | shared.Config.AuthToken = "" 16 | if err := shared.SaveConfig(); err != nil { 17 | log.WithError(err).Fatal("failed to save config") 18 | } 19 | }, 20 | } 21 | 22 | func init() { 23 | rootCmd.AddCommand(unlinkCmd) 24 | } 25 | -------------------------------------------------------------------------------- /cmd/unlink_test.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func Test_Unlink(t *testing.T) { 11 | 12 | actual := new(bytes.Buffer) 13 | unlinkCmd.SetOut(actual) 14 | unlinkCmd.SetErr(actual) 15 | unlinkCmd.SetArgs([]string{"unlink"}) 16 | unlinkCmd.Execute() 17 | 18 | expected := "" 19 | 20 | assert.Equal(t, expected, actual.String()) 21 | } 22 | -------------------------------------------------------------------------------- /cmd/update.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package cmd 5 | 6 | import ( 7 | "github.com/ParetoSecurity/agent/shared" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var updateCmd = &cobra.Command{ 12 | Use: "update", 13 | Short: "Update the Pareto Security Agent", 14 | Long: `Update the Pareto Security Agent to the latest version.`, 15 | Run: func(cmd *cobra.Command, args []string) { 16 | shared.UpdateApp() 17 | }, 18 | } 19 | 20 | func init() { 21 | rootCmd.AddCommand(updateCmd) 22 | } 23 | -------------------------------------------------------------------------------- /devenv.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "devenv": { 4 | "locked": { 5 | "dir": "src/modules", 6 | "lastModified": 1746707904, 7 | "owner": "cachix", 8 | "repo": "devenv", 9 | "rev": "fada79d97f2066c444766d039b0a62affd3e3cab", 10 | "type": "github" 11 | }, 12 | "original": { 13 | "dir": "src/modules", 14 | "owner": "cachix", 15 | "repo": "devenv", 16 | "type": "github" 17 | } 18 | }, 19 | "flake-compat": { 20 | "flake": false, 21 | "locked": { 22 | "lastModified": 1733328505, 23 | "owner": "edolstra", 24 | "repo": "flake-compat", 25 | "rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec", 26 | "type": "github" 27 | }, 28 | "original": { 29 | "owner": "edolstra", 30 | "repo": "flake-compat", 31 | "type": "github" 32 | } 33 | }, 34 | "git-hooks": { 35 | "inputs": { 36 | "flake-compat": "flake-compat", 37 | "gitignore": "gitignore", 38 | "nixpkgs": [ 39 | "nixpkgs" 40 | ] 41 | }, 42 | "locked": { 43 | "lastModified": 1746537231, 44 | "owner": "cachix", 45 | "repo": "git-hooks.nix", 46 | "rev": "fa466640195d38ec97cf0493d6d6882bc4d14969", 47 | "type": "github" 48 | }, 49 | "original": { 50 | "owner": "cachix", 51 | "repo": "git-hooks.nix", 52 | "type": "github" 53 | } 54 | }, 55 | "gitignore": { 56 | "inputs": { 57 | "nixpkgs": [ 58 | "git-hooks", 59 | "nixpkgs" 60 | ] 61 | }, 62 | "locked": { 63 | "lastModified": 1709087332, 64 | "owner": "hercules-ci", 65 | "repo": "gitignore.nix", 66 | "rev": "637db329424fd7e46cf4185293b9cc8c88c95394", 67 | "type": "github" 68 | }, 69 | "original": { 70 | "owner": "hercules-ci", 71 | "repo": "gitignore.nix", 72 | "type": "github" 73 | } 74 | }, 75 | "nixpkgs": { 76 | "locked": { 77 | "lastModified": 1745934659, 78 | "owner": "cachix", 79 | "repo": "devenv-nixpkgs", 80 | "rev": "fbc071e5c11e23fba50037de37268e3d8a1858eb", 81 | "type": "github" 82 | }, 83 | "original": { 84 | "owner": "cachix", 85 | "ref": "rolling", 86 | "repo": "devenv-nixpkgs", 87 | "type": "github" 88 | } 89 | }, 90 | "root": { 91 | "inputs": { 92 | "devenv": "devenv", 93 | "git-hooks": "git-hooks", 94 | "nixpkgs": "nixpkgs", 95 | "pre-commit-hooks": [ 96 | "git-hooks" 97 | ], 98 | "upstream": "upstream" 99 | } 100 | }, 101 | "upstream": { 102 | "locked": { 103 | "lastModified": 1746576598, 104 | "owner": "nixos", 105 | "repo": "nixpkgs", 106 | "rev": "b3582c75c7f21ce0b429898980eddbbf05c68e55", 107 | "type": "github" 108 | }, 109 | "original": { 110 | "owner": "nixos", 111 | "ref": "nixpkgs-unstable", 112 | "repo": "nixpkgs", 113 | "type": "github" 114 | } 115 | } 116 | }, 117 | "root": "root", 118 | "version": 7 119 | } 120 | -------------------------------------------------------------------------------- /devenv.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs, 3 | lib, 4 | config, 5 | inputs, 6 | ... 7 | }: let 8 | flakePackage = import ./package.nix {inherit pkgs lib;}; 9 | upstream = import inputs.upstream {system = pkgs.stdenv.system;}; 10 | in { 11 | packages = [ 12 | upstream.alejandra 13 | upstream.goreleaser 14 | upstream.go_1_24 15 | ]; 16 | languages.nix.enable = true; 17 | 18 | env.GOROOT = upstream.go_1_24 + "/share/go/"; 19 | env.GOPATH = config.env.DEVENV_STATE + "/go"; 20 | env.GOTOOLCHAIN = "local"; 21 | 22 | # apple.sdk = null; # use installed apple sdk, fixes broken nix ui skd packages on darwin, but breaks debugger 23 | 24 | scripts.help-scripts.description = "List all available scripts"; 25 | scripts.help-scripts.exec = '' 26 | echo 27 | echo Helper scripts: 28 | echo 29 | ${upstream.gnused}/bin/sed -e 's| |••|g' -e 's|=| |' <&1) 50 | specified=$(echo "$output" | grep "specified:" | awk '{print $2}') 51 | got=$(echo "$output" | grep "got:" | awk '{print $2}') 52 | echo "Specified: $specified" 53 | echo "Got: $got" 54 | if [ "$specified" != "$got" ]; then 55 | echo "Mismatch detected, updating package.nix hash from $specified to $got" 56 | sed -i -e "s|$specified|$got|g" ./package.nix 57 | else 58 | echo "Hashes match; no update required." 59 | fi 60 | ''; 61 | 62 | enterShell = '' 63 | export PATH=$GOPATH/bin:$PATH 64 | help-scripts 65 | 66 | echo "Hint: Run 'devenv test -d' to run tests" 67 | ''; 68 | 69 | # https://devenv.sh/tests/ 70 | enterTest = '' 71 | go mod verify 72 | coverage 73 | ''; 74 | 75 | # https://devenv.sh/pre-commit-hooks/ 76 | pre-commit.hooks = { 77 | alejandra.enable = true; 78 | gofmt.enable = true; 79 | # golangci-lint.enable = true; 80 | # revive.enable = true; 81 | packaga-sha = { 82 | name = "Verify package.nix hash"; 83 | enable = true; 84 | pass_filenames = false; 85 | files = "go.(mod|sum)$"; 86 | entry = "verify-package"; 87 | }; 88 | }; 89 | 90 | # See full reference at https://devenv.sh/reference/options/ 91 | } 92 | -------------------------------------------------------------------------------- /devenv.yaml: -------------------------------------------------------------------------------- 1 | inputs: 2 | nixpkgs: 3 | url: github:cachix/devenv-nixpkgs/rolling 4 | upstream: 5 | url: github:nixos/nixpkgs/nixpkgs-unstable 6 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "flake-parts": { 4 | "inputs": { 5 | "nixpkgs-lib": "nixpkgs-lib" 6 | }, 7 | "locked": { 8 | "lastModified": 1743550720, 9 | "narHash": "sha256-hIshGgKZCgWh6AYJpJmRgFdR3WUbkY04o82X05xqQiY=", 10 | "owner": "hercules-ci", 11 | "repo": "flake-parts", 12 | "rev": "c621e8422220273271f52058f618c94e405bb0f5", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "id": "flake-parts", 17 | "type": "indirect" 18 | } 19 | }, 20 | "nix-vm-test": { 21 | "inputs": { 22 | "nixpkgs": "nixpkgs" 23 | }, 24 | "locked": { 25 | "lastModified": 1746458227, 26 | "narHash": "sha256-T8kT73joeMXKWcw0IQcNoTcr9IF63Mcm4Bi0TpfxdJA=", 27 | "owner": "numtide", 28 | "repo": "nix-vm-test", 29 | "rev": "a9aba69cd05d604c4ece66780c77219714ebd91a", 30 | "type": "github" 31 | }, 32 | "original": { 33 | "owner": "numtide", 34 | "repo": "nix-vm-test", 35 | "type": "github" 36 | } 37 | }, 38 | "nixpkgs": { 39 | "locked": { 40 | "lastModified": 1708475490, 41 | "narHash": "sha256-g1v0TsWBQPX97ziznfJdWhgMyMGtoBFs102xSYO4syU=", 42 | "owner": "nixos", 43 | "repo": "nixpkgs", 44 | "rev": "0e74ca98a74bc7270d28838369593635a5db3260", 45 | "type": "github" 46 | }, 47 | "original": { 48 | "owner": "nixos", 49 | "ref": "nixos-unstable", 50 | "repo": "nixpkgs", 51 | "type": "github" 52 | } 53 | }, 54 | "nixpkgs-lib": { 55 | "locked": { 56 | "lastModified": 1743296961, 57 | "narHash": "sha256-b1EdN3cULCqtorQ4QeWgLMrd5ZGOjLSLemfa00heasc=", 58 | "owner": "nix-community", 59 | "repo": "nixpkgs.lib", 60 | "rev": "e4822aea2a6d1cdd36653c134cacfd64c97ff4fa", 61 | "type": "github" 62 | }, 63 | "original": { 64 | "owner": "nix-community", 65 | "repo": "nixpkgs.lib", 66 | "type": "github" 67 | } 68 | }, 69 | "nixpkgs_2": { 70 | "locked": { 71 | "lastModified": 1746397377, 72 | "narHash": "sha256-5oLdRa3vWSRbuqPIFFmQBGGUqaYZBxX+GGtN9f/n4lU=", 73 | "owner": "NixOS", 74 | "repo": "nixpkgs", 75 | "rev": "ed30f8aba41605e3ab46421e3dcb4510ec560ff8", 76 | "type": "github" 77 | }, 78 | "original": { 79 | "owner": "NixOS", 80 | "ref": "nixpkgs-unstable", 81 | "repo": "nixpkgs", 82 | "type": "github" 83 | } 84 | }, 85 | "root": { 86 | "inputs": { 87 | "flake-parts": "flake-parts", 88 | "nix-vm-test": "nix-vm-test", 89 | "nixpkgs": "nixpkgs_2" 90 | } 91 | } 92 | }, 93 | "root": "root", 94 | "version": 7 95 | } 96 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 4 | nix-vm-test.url = "github:numtide/nix-vm-test"; 5 | }; 6 | 7 | outputs = inputs @ { 8 | flake-parts, 9 | nixpkgs, 10 | nix-vm-test, 11 | self, 12 | ... 13 | }: 14 | flake-parts.lib.mkFlake {inherit inputs;} { 15 | systems = nixpkgs.lib.systems.flakeExposed; 16 | 17 | perSystem = { 18 | config, 19 | pkgs, 20 | lib, 21 | self, 22 | system, 23 | ... 24 | }: let 25 | flakePackage = import ./package.nix {inherit pkgs lib;}; 26 | testPackage = { 27 | distro, 28 | version, 29 | script, 30 | }: 31 | (inputs.nix-vm-test.lib.x86_64-linux.${distro}.${version} { 32 | sharedDirs.packageDir = { 33 | source = "${toString ./.}/pkg"; 34 | target = "/mnt/package"; 35 | }; 36 | testScript = builtins.readFile "${toString ./.}/test/integration/${script}"; 37 | }) 38 | .driver; 39 | testRelease = { 40 | distro, 41 | version, 42 | script, 43 | }: 44 | (inputs.nix-vm-test.lib.x86_64-linux.${distro}.${version} { 45 | sharedDirs = {}; 46 | testScript = builtins.readFile "${toString ./.}/test/integration/${script}"; 47 | }) 48 | .driver; 49 | in { 50 | packages.default = flakePackage; 51 | 52 | checks = let 53 | # Create a custom version of pkgs with allowUnsupportedSystem = true, so 54 | # that we can run tests on Macs too: 55 | # $ nix build .#checks.aarch64-darwin.firewall 56 | pkgsAllowUnsupported = import nixpkgs { 57 | inherit system; 58 | config = {allowUnsupportedSystem = true;}; 59 | }; 60 | in { 61 | cli = pkgsAllowUnsupported.testers.runNixOSTest ./test/integration/cli.nix; 62 | firewall = pkgsAllowUnsupported.testers.runNixOSTest ./test/integration/firewall.nix; 63 | help = pkgsAllowUnsupported.testers.runNixOSTest ./test/integration/help.nix; 64 | luks = pkgsAllowUnsupported.testers.runNixOSTest ./test/integration/luks.nix; 65 | pwd-manager = pkgsAllowUnsupported.testers.runNixOSTest ./test/integration/pwd-manager.nix; 66 | screenlock = pkgsAllowUnsupported.testers.runNixOSTest ./test/integration/screenlock.nix; 67 | secureboot = pkgsAllowUnsupported.testers.runNixOSTest ./test/integration/secureboot.nix; 68 | xfce = pkgsAllowUnsupported.testers.runNixOSTest ./test/integration/desktop/xfce.nix; 69 | }; 70 | }; 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParetoSecurity/agent/52f3b7fb02d2731ff8bbd824e5665a8fbd363a93/icon.ico -------------------------------------------------------------------------------- /icons.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParetoSecurity/agent/52f3b7fb02d2731ff8bbd824e5665a8fbd363a93/icons.icns -------------------------------------------------------------------------------- /notify/notify_darwin.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | ) 7 | 8 | // Toast displays a system notification on macOS using AppleScript. 9 | func Toast(message string) { 10 | cmd := exec.Command("osascript", "-e", fmt.Sprintf(`display notification "%s" with title "Pareto Security"`, message)) 11 | cmd.Run() 12 | } 13 | -------------------------------------------------------------------------------- /notify/notify_linux.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "github.com/caarlos0/log" 5 | "github.com/godbus/dbus/v5" 6 | ) 7 | 8 | // Toast sends a persistent desktop notification using D-Bus on Linux systems. 9 | // It displays a notification with the title "Pareto Security" and the provided body text. 10 | // The notification is configured to be resident (persistent) and will expire after 10 seconds. 11 | func Toast(body string) { 12 | conn, err := dbus.SessionBus() 13 | if err != nil { 14 | log.WithError(err).Error("failed to connect to session bus") 15 | return 16 | } 17 | defer conn.Close() 18 | obj := conn.Object("org.freedesktop.Notifications", "/org/freedesktop/Notifications") 19 | 20 | call := obj.Call("org.freedesktop.Notifications.Notify", 0, 21 | "ParetoSecurity", // app_name 22 | uint32(0), // replaces_id 23 | "dialog-information", // app_icon 24 | "Pareto Security", // summary 25 | body, // body 26 | []string{}, // actions 27 | map[string]dbus.Variant{ 28 | "resident": dbus.MakeVariant(true), // keeps notification persistent 29 | }, 30 | int32(10000), // expire_timeout (0 = no expiration) 31 | ) 32 | 33 | if call.Err != nil { 34 | log.WithError(call.Err).Error("failed to send notification") 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /notify/notify_windows.go: -------------------------------------------------------------------------------- 1 | package notify 2 | 3 | import ( 4 | "github.com/caarlos0/log" 5 | "github.com/kolide/toast" 6 | ) 7 | 8 | // Toast displays a notification balloon on Windows using the Shell_NotifyIcon API. 9 | // If the notification fails to display, an error is logged. 10 | func Toast(message string) { 11 | notification := toast.Notification{ 12 | AppID: "Pareto Security", 13 | Title: "Notification", 14 | Message: message, 15 | } 16 | err := notification.Push() 17 | if err != nil { 18 | log.WithError(err).Error("failed to send notification") 19 | return 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /package.nix: -------------------------------------------------------------------------------- 1 | { 2 | pkgs, 3 | lib, 4 | }: let 5 | nixpkgsPareto = pkgs.callPackage (pkgs.path + "/pkgs/by-name/pa/paretosecurity/package.nix") {}; 6 | 7 | # Create a fake src with rev attribute 8 | srcWithRev = { 9 | outPath = ./.; 10 | rev = lib.substring 0 8 (builtins.hashFile "sha256" ./go.sum); 11 | }; 12 | in 13 | nixpkgsPareto.overrideAttrs (oldAttrs: { 14 | src = srcWithRev; 15 | version = "${builtins.hashFile "sha256" "${toString ./go.sum}"}"; 16 | 17 | # Updated with pre-commit, don't change manually 18 | vendorHash = "sha256-RAKYaNi+MXUfNnEJmZF5g9jFBDOPIVBOZWtqZp2FwWY="; 19 | 20 | # Uncomment this while developing to skip Go tests 21 | # doCheck = false; 22 | }) 23 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /runner/root_runner.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net" 7 | 8 | "github.com/ParetoSecurity/agent/claims" 9 | "github.com/caarlos0/log" 10 | ) 11 | 12 | type CheckStatus struct { 13 | UUID string `json:"uuid"` 14 | Passed bool `json:"passed"` 15 | Details string `json:"details"` 16 | } 17 | 18 | // handleConnection handles an incoming network connection. 19 | // It reads input from the connection, processes the input to run checks, 20 | // and sends back the status of the checks as a JSON response. 21 | // 22 | // The input is expected to be a JSON object containing a "uuid" key. 23 | // The function will look for checks that are runnable, require root, 24 | // and match the provided UUID. It will run those checks and collect their status. 25 | func HandleConnection(conn net.Conn) { 26 | defer conn.Close() 27 | log.Info("Connection received") 28 | 29 | // Read input from connection 30 | decoder := json.NewDecoder(conn) 31 | var input map[string]string 32 | if err := decoder.Decode(&input); err != nil { 33 | log.Debugf("Failed to decode input: %v\n", err) 34 | return 35 | } 36 | uuid, ok := input["uuid"] 37 | if !ok { 38 | log.Debugf("UUID not found in input") 39 | return 40 | } 41 | log.Debugf("Received UUID: %s", uuid) 42 | 43 | status := &CheckStatus{ 44 | UUID: uuid, 45 | Passed: false, 46 | Details: "Check not found", 47 | } 48 | for _, claim := range claims.All { 49 | for _, chk := range claim.Checks { 50 | if chk.RequiresRoot() && uuid == chk.UUID() { 51 | log.Infof("Running check %s\n", chk.UUID()) 52 | if chk.Run() != nil { 53 | log.Warnf("Failed to run check %s\n", chk.UUID()) 54 | continue 55 | } 56 | log.Infof("Check %s completed\n", chk.UUID()) 57 | status.Passed = chk.Passed() 58 | status.Details = chk.Status() 59 | log.Infof("Check %s status: %v\n", chk.UUID(), status.Passed) 60 | } 61 | } 62 | } 63 | 64 | // Handle the request 65 | response, err := json.Marshal(status) 66 | if err != nil { 67 | log.Debugf("Failed to marshal response: %v\n", err) 68 | return 69 | } 70 | if _, err = conn.Write(response); err != nil { 71 | log.Debugf("Failed to write to connection: %v\n", err) 72 | } 73 | } 74 | 75 | // RunCheckViaRoot connects to a Unix socket, sends a UUID, and receives a boolean status. 76 | // It is used to execute a check with root privileges via a helper process. 77 | // The function establishes a connection to the socket specified by SocketPath, 78 | // sends the UUID as a JSON-encoded string, and then decodes the JSON response 79 | // to determine the status of the check. It returns the boolean status associated 80 | // with the UUID and any error encountered during the process. 81 | func RunCheckViaRoot(uuid string) (*CheckStatus, error) { 82 | 83 | rateLimitCall.Take() 84 | log.WithField("uuid", uuid).Debug("Running check via root helper") 85 | 86 | conn, err := net.Dial("unix", SocketPath) 87 | if err != nil { 88 | log.WithError(err).Warn("Failed to connect to root helper") 89 | return &CheckStatus{}, errors.New("failed to connect to root helper") 90 | } 91 | defer conn.Close() 92 | 93 | // Send UUID 94 | input := map[string]string{"uuid": uuid} 95 | encoder := json.NewEncoder(conn) 96 | log.WithField("input", input).Debug("Sending input to helper") 97 | if err := encoder.Encode(input); err != nil { 98 | log.WithError(err).Warn("Failed to encode JSON") 99 | return &CheckStatus{}, errors.New("failed to encode JSON") 100 | } 101 | 102 | // Read response 103 | decoder := json.NewDecoder(conn) 104 | var status = &CheckStatus{} 105 | if err := decoder.Decode(status); err != nil { 106 | log.WithError(err).Warn("Failed to decode JSON") 107 | return &CheckStatus{}, errors.New("failed to decode JSON") 108 | } 109 | log.WithField("status", status).Debug("Received status from helper") 110 | return status, nil 111 | } 112 | -------------------------------------------------------------------------------- /runner/socket.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "github.com/ParetoSecurity/agent/shared" 5 | "go.uber.org/ratelimit" 6 | ) 7 | 8 | var SocketPath = "/run/paretosecurity.sock" 9 | var rateLimitCall = ratelimit.New(1) 10 | 11 | func IsSocketServicePresent() bool { 12 | _, err := shared.RunCommand("systemctl", "is-enabled", "--quiet", "paretosecurity.socket") 13 | return err == nil 14 | } 15 | -------------------------------------------------------------------------------- /runner/socket_test.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ParetoSecurity/agent/shared" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestIsSocketServicePresent(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | mockOutput string 14 | mockError bool 15 | expectedResult bool 16 | }{ 17 | 18 | { 19 | name: "service is not enabled", 20 | mockOutput: "", 21 | mockError: true, 22 | expectedResult: false, 23 | }, 24 | } 25 | 26 | for _, tt := range tests { 27 | t.Run(tt.name, func(t *testing.T) { 28 | // Setup mock 29 | shared.RunCommandMocks = []shared.RunCommandMock{ 30 | {Command: "systemctl", Args: []string{"is-enabled", "pareto-socket"}, Out: tt.mockOutput, Err: nil}, 31 | } 32 | 33 | // Run test 34 | result := IsSocketServicePresent() 35 | 36 | // Assert 37 | assert.Equal(t, tt.expectedResult, result) 38 | }) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /shared/broadcaster.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | // Broadcaster structure 8 | type Broadcaster struct { 9 | mu sync.RWMutex 10 | consumers map[chan string]struct{} 11 | input chan string 12 | } 13 | 14 | // NewBroadcaster creates a new Broadcaster 15 | func NewBroadcaster() *Broadcaster { 16 | b := &Broadcaster{ 17 | consumers: make(map[chan string]struct{}), 18 | input: make(chan string), 19 | } 20 | 21 | go b.startBroadcasting() 22 | return b 23 | } 24 | 25 | // Register adds a new consumer channel 26 | func (b *Broadcaster) Register() chan string { 27 | b.mu.Lock() 28 | defer b.mu.Unlock() 29 | ch := make(chan string) 30 | b.consumers[ch] = struct{}{} 31 | return ch 32 | } 33 | 34 | // Unregister removes a consumer channel 35 | func (b *Broadcaster) Unregister(ch chan string) { 36 | b.mu.Lock() 37 | defer b.mu.Unlock() 38 | delete(b.consumers, ch) 39 | close(ch) 40 | } 41 | 42 | // Send sends a message to the broadcaster's input channel 43 | func (b *Broadcaster) Send() { 44 | b.input <- "update" 45 | } 46 | 47 | // startBroadcasting listens to the input channel and broadcasts messages 48 | func (b *Broadcaster) startBroadcasting() { 49 | for msg := range b.input { 50 | b.mu.RLock() 51 | for ch := range b.consumers { 52 | select { 53 | case ch <- msg: 54 | default: 55 | // Skip slow consumers 56 | } 57 | } 58 | b.mu.RUnlock() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /shared/broadcaster_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func TestNewBroadcaster(t *testing.T) { 9 | b := NewBroadcaster() 10 | if b == nil { 11 | t.Fatal("NewBroadcaster returned nil") 12 | } 13 | // Check that the consumers map is initialized and empty. 14 | if b.consumers == nil { 15 | t.Fatal("Expected consumers map to be initialized") 16 | } 17 | if len(b.consumers) != 0 { 18 | t.Fatal("Expected consumers map to be empty") 19 | } 20 | // Check that the input channel is initialized. 21 | if b.input == nil { 22 | t.Fatal("Expected input channel to be initialized") 23 | } 24 | } 25 | 26 | func TestBroadcasting(t *testing.T) { 27 | b := NewBroadcaster() 28 | consumer := b.Register() 29 | defer b.Unregister(consumer) 30 | 31 | // Send a message using Send() which sends "update". 32 | go b.Send() 33 | 34 | select { 35 | case msg := <-consumer: 36 | if msg != "update" { 37 | t.Fatalf("Expected 'update', got %s", msg) 38 | } 39 | case <-time.After(5 * time.Second): 40 | t.Fatal("Did not receive broadcast message within timeout") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /shared/cache.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "sync" 5 | 6 | "time" 7 | ) 8 | 9 | type cacheItem struct { 10 | data string 11 | expires time.Time 12 | } 13 | 14 | var ( 15 | cache = make(map[string]cacheItem) 16 | cacheMutex sync.RWMutex 17 | ) 18 | 19 | func GetCache(key string) (string, bool) { 20 | 21 | cacheMutex.RLock() 22 | defer cacheMutex.RUnlock() 23 | 24 | if item, exists := cache[key]; exists { 25 | if time.Now().After(item.expires) { 26 | return "", false 27 | } 28 | return item.data, true 29 | } 30 | 31 | return "", false 32 | } 33 | 34 | func SetCache(key string, value string, ttlSeconds int) { 35 | 36 | cacheMutex.Lock() 37 | defer cacheMutex.Unlock() 38 | 39 | cache[key] = cacheItem{ 40 | data: value, 41 | expires: time.Now().Add(time.Duration(ttlSeconds) * time.Second), 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /shared/cache_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | // clearCache resets the cache. This helps ensure that tests are isolated. 9 | func clearCache() { 10 | cacheMutex.Lock() 11 | defer cacheMutex.Unlock() 12 | cache = make(map[string]cacheItem) 13 | } 14 | 15 | func TestGetCacheMiss(t *testing.T) { 16 | clearCache() 17 | // For a non-existent key, we expect no data and false. 18 | value, ok := GetCache("nonexistent") 19 | if ok || value != "" { 20 | t.Errorf("expected miss for key 'nonexistent', got value=%q, ok=%v", value, ok) 21 | } 22 | } 23 | 24 | func TestGetCacheHit(t *testing.T) { 25 | clearCache() 26 | // Set a value with a TTL of 5 seconds. 27 | SetCache("testKey", "testValue", 5) 28 | value, ok := GetCache("testKey") 29 | if !ok || value != "testValue" { 30 | t.Errorf("expected hit for key 'testKey' with value 'testValue', got value=%q, ok=%v", value, ok) 31 | } 32 | } 33 | 34 | func TestGetCacheExpired(t *testing.T) { 35 | clearCache() 36 | // Set a value with a TTL of 1 second. 37 | SetCache("expireKey", "expireValue", 1) 38 | // Wait for the key to expire. 39 | time.Sleep(2 * time.Second) 40 | value, ok := GetCache("expireKey") 41 | if ok || value != "" { 42 | t.Errorf("expected expired key 'expireKey' to return empty value and false, got value=%q, ok=%v", value, ok) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /shared/command_unix.go: -------------------------------------------------------------------------------- 1 | //go:build unix 2 | // +build unix 3 | 4 | package shared 5 | 6 | import ( 7 | "errors" 8 | 9 | "os/exec" 10 | "strings" 11 | "testing" 12 | 13 | "github.com/caarlos0/log" 14 | ) 15 | 16 | // RunCommandMock represents a mock command with its arguments, output, and error 17 | type RunCommandMock struct { 18 | Command string 19 | Args []string 20 | Out string 21 | Err error 22 | } 23 | 24 | // RunCommandMocks is a slice that stores mock command outputs. 25 | var RunCommandMocks []RunCommandMock 26 | 27 | // RunCommand executes a command with the given name and arguments, and returns 28 | // the combined standard output and standard error as a string. If testing is 29 | // enabled, it returns a predefined fixture instead of executing the command. 30 | func RunCommand(name string, arg ...string) (string, error) { 31 | 32 | // Check if testing is enabled and enable harnessing 33 | if testing.Testing() { 34 | for _, mock := range RunCommandMocks { 35 | isCmd := mock.Command == name 36 | isArg := strings.TrimSpace(strings.Join(mock.Args, " ")) == strings.TrimSpace(strings.Join(arg, " ")) 37 | if isCmd && isArg { 38 | return mock.Out, mock.Err 39 | } 40 | } 41 | return "", errors.New("RunCommand fixture not found: " + name + " " + strings.TrimSpace(strings.Join(arg, " "))) 42 | } 43 | 44 | cmd := exec.Command(name, arg...) 45 | 46 | output, err := cmd.CombinedOutput() 47 | log.WithField("cmd", string(name+" "+strings.TrimSpace(strings.Join(arg, " ")))).WithError(err).Debug(string(output)) 48 | return string(output), err 49 | } 50 | -------------------------------------------------------------------------------- /shared/command_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package shared 5 | 6 | import ( 7 | "errors" 8 | "syscall" 9 | 10 | "os/exec" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/caarlos0/log" 15 | ) 16 | 17 | // RunCommandMock represents a mock command with its arguments, output, and error 18 | type RunCommandMock struct { 19 | Command string 20 | Args []string 21 | Out string 22 | Err error 23 | } 24 | 25 | // RunCommandMocks is a slice that stores mock command outputs. 26 | var RunCommandMocks []RunCommandMock 27 | 28 | // RunCommand executes a command with the given name and arguments, and returns 29 | // the combined standard output and standard error as a string. If testing is 30 | // enabled, it returns a predefined fixture instead of executing the command. 31 | func RunCommand(name string, arg ...string) (string, error) { 32 | 33 | // Check if testing is enabled and enable harnessing 34 | if testing.Testing() { 35 | for _, mock := range RunCommandMocks { 36 | isCmd := mock.Command == name 37 | isArg := strings.TrimSpace(strings.Join(mock.Args, " ")) == strings.TrimSpace(strings.Join(arg, " ")) 38 | if isCmd && isArg { 39 | return mock.Out, mock.Err 40 | } 41 | } 42 | return "", errors.New("RunCommand fixture not found: " + name + " " + strings.TrimSpace(strings.Join(arg, " "))) 43 | } 44 | 45 | cmd := exec.Command(name, arg...) 46 | 47 | // Hide the window and set the creation flags to prevent the command from 48 | // creating a new console window 49 | // This is important for running commands in the background without 50 | // displaying a console window 51 | cmd.SysProcAttr = &syscall.SysProcAttr{ 52 | HideWindow: true, 53 | CreationFlags: 0x08000000, 54 | } 55 | 56 | output, err := cmd.CombinedOutput() 57 | log.WithField("cmd", string(name+" "+strings.TrimSpace(strings.Join(arg, " ")))).WithError(err).Debug(string(output)) 58 | return string(output), err 59 | } 60 | -------------------------------------------------------------------------------- /shared/device_all.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "runtime" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/caarlos0/log" 11 | "github.com/elastic/go-sysinfo" 12 | "github.com/google/uuid" 13 | "github.com/samber/lo" 14 | ) 15 | 16 | func CurrentReportingDevice() ReportingDevice { 17 | device, err := NewLinkingDevice() 18 | if err != nil { 19 | log.WithError(err).Fatal("Failed to get device information") 20 | } 21 | 22 | osVersion := device.OS 23 | osVersion = Sanitize(fmt.Sprintf("%s %s", osVersion, device.OSVersion)) 24 | 25 | if runtime.GOOS == "windows" { 26 | productName, err := RunCommand("powershell", "-Command", `(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion").ProductName`) 27 | if err != nil { 28 | log.WithError(err).Warn("Failed to get Windows product name") 29 | } 30 | displayVersion, err := RunCommand("powershell", "-Command", `(Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion").DisplayVersion`) 31 | if err != nil { 32 | log.WithError(err).Warn("Failed to get Windows version") 33 | } 34 | if lo.IsNotEmpty(productName) && lo.IsNotEmpty(displayVersion) { 35 | osVersion = Sanitize(strings.TrimSpace(productName + " " + displayVersion)) 36 | } 37 | } 38 | 39 | return ReportingDevice{ 40 | MachineUUID: device.UUID, 41 | MachineName: Sanitize(device.Hostname), 42 | Auth: Config.AuthToken, 43 | OSVersion: osVersion, 44 | ModelName: func() string { 45 | modelName, err := SystemDevice() 46 | if err != nil || modelName == "" { 47 | return "Unknown" 48 | } 49 | 50 | return Sanitize(modelName) 51 | }(), 52 | ModelSerial: func() string { 53 | serial, err := SystemSerial() 54 | if err != nil || serial == "" { 55 | return "Unknown" 56 | } 57 | 58 | return Sanitize(serial) 59 | }(), 60 | } 61 | } 62 | 63 | type LinkingDevice struct { 64 | Hostname string `json:"hostname"` 65 | OS string `json:"os"` 66 | OSVersion string `json:"osVersion"` 67 | Kernel string `json:"kernel"` 68 | UUID string `json:"uuid"` 69 | Ticket string `json:"ticket"` 70 | Version string `json:"version"` 71 | } 72 | 73 | // NewLinkingDevice creates a new instance of LinkingDevice with system information. 74 | // It retrieves the system UUID and device ticket, and populates the LinkingDevice struct 75 | // with the hostname, OS name, OS version, kernel version, UUID, and ticket. 76 | // Returns a pointer to the LinkingDevice and an error if any occurs during the process. 77 | func NewLinkingDevice() (*LinkingDevice, error) { 78 | 79 | if testing.Testing() { 80 | return &LinkingDevice{ 81 | Hostname: "test-hostname", 82 | OS: "test-os", 83 | OSVersion: "test-os-version", 84 | Kernel: "test-kernel", 85 | UUID: "test-uuid", 86 | Ticket: "test-ticket", 87 | }, nil 88 | } 89 | 90 | hostInfo, err := sysinfo.Host() 91 | if err != nil { 92 | log.Warn("Failed to get process information") 93 | return nil, err 94 | } 95 | envInfo := hostInfo.Info() 96 | 97 | ticket, err := uuid.NewRandom() 98 | if err != nil { 99 | log.Warn("Failed to generate ticket") 100 | return nil, err 101 | } 102 | hostname, err := os.Hostname() 103 | if err != nil { 104 | log.Warn("Failed to get hostname") 105 | return nil, err 106 | } 107 | 108 | return &LinkingDevice{ 109 | Hostname: hostname, 110 | OS: envInfo.OS.Name, 111 | OSVersion: envInfo.OS.Version, 112 | Kernel: envInfo.OS.Build, 113 | UUID: GetDeviceUUID(), 114 | Ticket: ticket.String(), 115 | }, nil 116 | } 117 | -------------------------------------------------------------------------------- /shared/device_all_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "runtime" 8 | "strings" 9 | "testing" 10 | ) 11 | 12 | func TestCurrentReportingDevice(t *testing.T) { 13 | // Ensure Config.AuthToken is cleared by default. 14 | Config.AuthToken = "" 15 | 16 | // determine expected OSVersion based on runtime 17 | expectedOSVersion := fmt.Sprintf("%s %s", "test-os", "test-os-version") 18 | if runtime.GOOS == "windows" { 19 | // additional formatting on windows 20 | expectedOSVersion = fmt.Sprintf("%s %s", expectedOSVersion, "test-os-version") 21 | } 22 | 23 | t.Run("successful device info with working SystemDevice and SystemSerial", func(t *testing.T) { 24 | 25 | rd := CurrentReportingDevice() 26 | 27 | if rd.MachineUUID != "test-uuid" { 28 | t.Errorf("Expected MachineUUID %q, got %q", "test-uuid", rd.MachineUUID) 29 | } 30 | if rd.MachineName != "test-hostname" { 31 | t.Errorf("Expected MachineName %q, got %q", "test-hostname", rd.MachineName) 32 | } 33 | if rd.Auth != "" { 34 | t.Errorf("Expected empty Auth, got %q", rd.Auth) 35 | } 36 | if rd.OSVersion != expectedOSVersion { 37 | t.Errorf("Expected OSVersion %q, got %q", expectedOSVersion, rd.OSVersion) 38 | } 39 | if rd.ModelName != "Unknown" { 40 | t.Errorf("Expected ModelName %q, got %q", "Unknown", rd.ModelName) 41 | } 42 | if rd.ModelSerial != "Unknown" { 43 | t.Errorf("Expected ModelSerial %q, got %q", "Unknown", rd.ModelSerial) 44 | } 45 | }) 46 | 47 | t.Run("SystemDevice error returns Unknown model name", func(t *testing.T) { 48 | 49 | rd := CurrentReportingDevice() 50 | 51 | if rd.ModelName != "Unknown" { 52 | t.Errorf("Expected ModelName to be \"Unknown\" on error, got %q", rd.ModelName) 53 | } 54 | if rd.ModelSerial != "Unknown" { 55 | t.Errorf("Expected ModelSerial %q, got %q", "Unknown", rd.ModelSerial) 56 | } 57 | }) 58 | 59 | t.Run("SystemSerial error returns Unknown serial", func(t *testing.T) { 60 | 61 | rd := CurrentReportingDevice() 62 | 63 | if rd.ModelSerial != "Unknown" { 64 | t.Errorf("Expected ModelSerial to be \"Unknown\" on error, got %q", rd.ModelSerial) 65 | } 66 | if rd.ModelName != "Unknown" { 67 | t.Errorf("Expected ModelName %q, got %q", "Unknown", rd.ModelName) 68 | } 69 | }) 70 | 71 | t.Run("with valid auth token", func(t *testing.T) { 72 | // Prepare a dummy JWT-like token. 73 | payload := map[string]interface{}{ 74 | "sub": "dummy", 75 | "teamID": "dummy", 76 | "role": "dummy", 77 | "iat": 1, 78 | "token": "authValue", 79 | } 80 | payloadJSON, err := json.Marshal(payload) 81 | if err != nil { 82 | t.Fatalf("failed to marshal payload: %v", err) 83 | } 84 | encodedPayload := base64.RawURLEncoding.EncodeToString(payloadJSON) 85 | // simple dummy header and signature parts. 86 | dummyToken := strings.Join([]string{"header", encodedPayload, "signature"}, ".") 87 | Config.AuthToken = dummyToken 88 | 89 | rd := CurrentReportingDevice() 90 | if rd.Auth != "header.eyJpYXQiOjEsInJvbGUiOiJkdW1teSIsInN1YiI6ImR1bW15IiwidGVhbUlEIjoiZHVtbXkiLCJ0b2tlbiI6ImF1dGhWYWx1ZSJ9.signature" { 91 | t.Errorf("Expected Auth %q, got %q", "authValue", rd.Auth) 92 | } 93 | }) 94 | } 95 | -------------------------------------------------------------------------------- /shared/device_darwin.go: -------------------------------------------------------------------------------- 1 | //go:build darwin 2 | // +build darwin 3 | 4 | package shared 5 | 6 | type ReportingDevice struct { 7 | MachineUUID string `json:"machineUUID"` // e.g. 123e4567-e89b-12d3-a456-426614174000 8 | MachineName string `json:"machineName"` // e.g. MacBook-Pro.local 9 | Auth string `json:"auth"` 10 | OSVersion string `json:"macOSVersion"` // e.g. Ubuntu 20.04 11 | ModelName string `json:"modelName"` // e.g. MacBook Pro 12 | ModelSerial string `json:"modelSerial"` // e.g. C02C1234 13 | } 14 | 15 | // SystemSerial retrieves the system's serial number by executing a shell command 16 | // that queries hardware information on Darwin (macOS) systems. It returns the 17 | // serial number as a string, or an error if the command fails. 18 | func SystemSerial() (string, error) { 19 | serial, err := RunCommand("system_profiler", "SPHardwareDataType", "|", "grep", "Serial", "|", "awk", "'{print $4}'") 20 | if err != nil { 21 | return "", err 22 | } 23 | return serial, nil 24 | } 25 | 26 | // SystemDevice retrieves the system's device model name by executing a shell command 27 | // that queries the hardware data using 'system_profiler' and processes the output. 28 | // It returns the device model name as a string, or an error if the command fails. 29 | func SystemDevice() (string, error) { 30 | device, err := RunCommand("system_profiler", "SPHardwareDataType", "|", "grep", "Model Name", "|", "awk", "'{print $3}'") 31 | if err != nil { 32 | return "", err 33 | } 34 | return device, nil 35 | } 36 | -------------------------------------------------------------------------------- /shared/device_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | // +build linux 3 | 4 | package shared 5 | 6 | type ReportingDevice struct { 7 | MachineUUID string `json:"machineUUID"` // e.g. 123e4567-e89b-12d3-a456-426614174000 8 | MachineName string `json:"machineName"` // e.g. MacBook-Pro.local 9 | Auth string `json:"auth"` 10 | OSVersion string `json:"linuxOSVersion"` // e.g. Ubuntu 20.04 11 | ModelName string `json:"modelName"` // e.g. MacBook Pro 12 | ModelSerial string `json:"modelSerial"` // e.g. C02C1234 13 | } 14 | 15 | // SystemSerial retrieves the system's serial number by reading the contents of 16 | // "/sys/class/dmi/id/product_serial" using the RunCommand helper function. 17 | // It returns the serial number as a string, or an error if the command fails. 18 | func SystemSerial() (string, error) { 19 | serial, err := RunCommand("cat", "/sys/class/dmi/id/product_serial") 20 | if err != nil { 21 | return "", err 22 | } 23 | return serial, nil 24 | } 25 | 26 | // SystemDevice retrieves the system's device name by reading the contents of 27 | // "/sys/class/dmi/id/product_name" using the RunCommand function. It returns 28 | // the device name as a string, or an error if the command fails. 29 | func SystemDevice() (string, error) { 30 | device, err := RunCommand("cat", "/sys/class/dmi/id/product_name") 31 | if err != nil { 32 | return "", err 33 | } 34 | return device, nil 35 | } 36 | -------------------------------------------------------------------------------- /shared/device_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | // +build windows 3 | 4 | package shared 5 | 6 | type ReportingDevice struct { 7 | MachineUUID string `json:"machineUUID"` // e.g. 123e4567-e89b-12d3-a456-426614174000 8 | MachineName string `json:"machineName"` // e.g. MacBook-Pro.local 9 | Auth string `json:"auth"` 10 | OSVersion string `json:"windowsOSVersion"` // e.g. Windows 10 Pro (24H2) 11 | ModelName string `json:"modelName"` // e.g. MacBook Pro 12 | ModelSerial string `json:"modelSerial"` // e.g. C02C1234 13 | } 14 | 15 | // SystemSerial retrieves the system's BIOS serial number by executing a PowerShell command. 16 | // It returns the serial number as a string, or an error if the command fails. 17 | func SystemSerial() (string, error) { 18 | serial, err := RunCommand("powershell", "-Command", `(Get-WmiObject -Class Win32_BIOS).SerialNumber`) 19 | if err != nil { 20 | return "", err 21 | } 22 | return serial, nil 23 | } 24 | 25 | // SystemDevice retrieves the model name of the current Windows computer system 26 | // by executing a PowerShell command that queries the Win32_ComputerSystem WMI class. 27 | // It returns the device model as a string, or an error if the command fails. 28 | func SystemDevice() (string, error) { 29 | device, err := RunCommand("powershell", "-Command", `(Get-WmiObject -Class Win32_ComputerSystem).Model`) 30 | if err != nil { 31 | return "", err 32 | } 33 | return device, nil 34 | } 35 | -------------------------------------------------------------------------------- /shared/http.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | ) 7 | 8 | func UserAgent() string { 9 | platform := runtime.GOOS 10 | if runtime.GOOS == "darwin" { 11 | platform = "macos" 12 | } 13 | return fmt.Sprintf("ParetoSecurity/agent %s/%s", platform, Version) 14 | } 15 | -------------------------------------------------------------------------------- /shared/icon.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | var ( 8 | //go:embed icon_white.png 9 | IconWhite []byte 10 | 11 | //go:embed icon_black.png 12 | IconBlack []byte 13 | ) 14 | -------------------------------------------------------------------------------- /shared/icon_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParetoSecurity/agent/52f3b7fb02d2731ff8bbd824e5665a8fbd363a93/shared/icon_black.png -------------------------------------------------------------------------------- /shared/icon_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParetoSecurity/agent/52f3b7fb02d2731ff8bbd824e5665a8fbd363a93/shared/icon_white.png -------------------------------------------------------------------------------- /shared/nixos.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "os" 5 | "sync" 6 | "testing" 7 | 8 | "github.com/caarlos0/log" 9 | ) 10 | 11 | var isNixOSOnce sync.Once 12 | var isNixOS bool 13 | 14 | // IsNixOS checks if the current system is NixOS by attempting to run the 15 | // `nixos-version` command. It returns true if the command executes without 16 | // error, indicating that NixOS is likely the operating system. 17 | func IsNixOS() bool { 18 | if testing.Testing() { 19 | return false 20 | } 21 | isNixOSOnce.Do(func() { 22 | _, err := os.Stat("/run/current-system/sw") 23 | isNixOS = err == nil 24 | log.WithField("isNixOS", isNixOS).Debug("Checking if system is NixOS") 25 | }) 26 | return isNixOS 27 | } 28 | -------------------------------------------------------------------------------- /shared/os.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | // ReadFileMocks is a map that simulates file reading operations by mapping 9 | // file paths (as keys) to their corresponding file contents (as values). 10 | // This can be used for testing purposes to mock the behavior of reading files 11 | // without actually accessing the file system. 12 | var ReadFileMock func(name string) ([]byte, error) 13 | 14 | // ReadFile reads the content of the file specified by the given name. 15 | // If the code is running in a testing environment, it will return the content 16 | // from the ReadFileMocks map instead of reading from the actual file system. 17 | // If the file name is not found in the ReadFileMocks map, it returns an error. 18 | // Otherwise, it reads the file content from the file system. 19 | func ReadFile(name string) ([]byte, error) { 20 | if testing.Testing() { 21 | return ReadFileMock(name) 22 | 23 | } 24 | return os.ReadFile(name) 25 | } 26 | 27 | var UserHomeDirMock func() (string, error) 28 | 29 | // UserHomeDir returns the current user's home directory. 30 | // 31 | // On Unix, including macOS, it returns the $HOME environment variable. 32 | // On Windows, it returns %USERPROFILE%. 33 | // On Plan 9, it returns the $home environment variable. 34 | // 35 | // If the expected variable is not set in the environment, UserHomeDir 36 | // returns either a platform-specific default value or a non-nil error. 37 | func UserHomeDir() (string, error) { 38 | if testing.Testing() { 39 | // In tests, return a mock home directory 40 | return UserHomeDirMock() 41 | } 42 | homeDir, err := os.UserHomeDir() 43 | if err != nil { 44 | return "", err 45 | } 46 | return homeDir, nil 47 | } 48 | -------------------------------------------------------------------------------- /shared/os_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | ) 7 | 8 | func TestReadFile(t *testing.T) { 9 | // Mock data for testing 10 | 11 | ReadFileMock = func(name string) ([]byte, error) { 12 | if name == "testfile1.txt" { 13 | return []byte("This is a test file content"), nil 14 | } 15 | return nil, fmt.Errorf("ReadFile fixture not found: %s", name) 16 | } 17 | 18 | t.Run("ReadFile from mock", func(t *testing.T) { 19 | content, err := ReadFile("testfile1.txt") 20 | if err != nil { 21 | t.Fatalf("expected no error, got %v", err) 22 | } 23 | expected := "This is a test file content" 24 | if string(content) != expected { 25 | t.Fatalf("expected %s, got %s", expected, string(content)) 26 | } 27 | }) 28 | 29 | t.Run("ReadFile mock not found", func(t *testing.T) { 30 | _, err := ReadFile("nonexistent.txt") 31 | if err == nil { 32 | t.Fatalf("expected error, got nil") 33 | } 34 | expectedErr := "ReadFile fixture not found: nonexistent.txt" 35 | if err.Error() != expectedErr { 36 | t.Fatalf("expected %s, got %s", expectedErr, err.Error()) 37 | } 38 | }) 39 | 40 | } 41 | func TestUserHomeDir(t *testing.T) { 42 | t.Run("UserHomeDir returns mock path in tests", func(t *testing.T) { 43 | 44 | UserHomeDirMock = func() (string, error) { 45 | return "/home/user", nil 46 | } 47 | 48 | homeDir, err := UserHomeDir() 49 | if err != nil { 50 | t.Fatalf("expected no error, got %v", err) 51 | } 52 | expected := "/home/user" 53 | if homeDir != expected { 54 | t.Fatalf("expected %s, got %s", expected, homeDir) 55 | } 56 | }) 57 | } 58 | -------------------------------------------------------------------------------- /shared/status.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | // IsLinked checks if the team is linked by verifying that both the TeamID and AuthToken 4 | // in the shared configuration are not empty strings. 5 | // It returns true if both values are present, indicating that the team is linked; 6 | // otherwise, it returns false. 7 | func IsLinked() bool { 8 | return Config.TeamID != "" && Config.AuthToken != "" 9 | } 10 | -------------------------------------------------------------------------------- /shared/status_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import "testing" 4 | 5 | func TestIsLinked(t *testing.T) { 6 | 7 | tests := []struct { 8 | name string 9 | teamID string 10 | authToken string 11 | expected bool 12 | }{ 13 | {"both empty", "", "", false}, 14 | {"only teamID set", "team123", "", false}, 15 | {"only auth token set", "", "token123", false}, 16 | {"both set", "team123", "token123", true}, 17 | } 18 | 19 | for _, tt := range tests { 20 | Config.TeamID = tt.teamID 21 | Config.AuthToken = tt.authToken 22 | if got := IsLinked(); got != tt.expected { 23 | t.Errorf("%s: expected %v, got %v", tt.name, tt.expected, got) 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /shared/string.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | // Sanitize takes a string and returns a sanitized version containing only ASCII characters. 4 | // It converts non-ASCII characters to underscores and keeps only alphanumeric characters 5 | // and select punctuation marks (., !, -, ', ", _, ,). 6 | func Sanitize(s string) string { 7 | // Convert to ASCII 8 | ascii := make([]byte, len(s)) 9 | for i, r := range s { 10 | if r < 128 { 11 | ascii[i] = byte(r) 12 | } else { 13 | ascii[i] = '_' 14 | } 15 | } 16 | 17 | // Filter allowed characters 18 | allowed := func(r byte) bool { 19 | return (r >= 'a' && r <= 'z') || 20 | (r >= 'A' && r <= 'Z') || 21 | (r >= '0' && r <= '9') || 22 | r == '.' || r == '!' || r == '-' || 23 | r == '\'' || r == '"' || r == '_' || 24 | r == ',' || r == ' ' 25 | } 26 | 27 | // Remove special characters like newline, carriage return but keep spaces 28 | result := make([]byte, 0, len(ascii)) 29 | for _, c := range ascii { 30 | if allowed(c) { 31 | result = append(result, c) 32 | } 33 | } 34 | 35 | return string(result) 36 | } 37 | -------------------------------------------------------------------------------- /shared/string_test.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestSanitize(t *testing.T) { 8 | tests := []struct { 9 | input string 10 | expected string 11 | }{ 12 | {"Hello, 世界!", "Hello, __!"}, 13 | {"123 ABC abc", "123 ABC abc"}, 14 | {"Special chars: @#$%^&*()", "Special chars "}, 15 | {"Mixed: 你好, 世界! 123", "Mixed __, __! 123"}, 16 | {"borxed\r\n", "borxed"}, 17 | } 18 | 19 | for _, test := range tests { 20 | t.Run(test.input, func(t *testing.T) { 21 | result := Sanitize(test.input) 22 | if result != test.expected { 23 | t.Errorf("Sanitize(%q) = %q; want %q", test.input, result, test.expected) 24 | } 25 | }) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /shared/system.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "os" 7 | "testing" 8 | 9 | "strings" 10 | 11 | "github.com/google/uuid" 12 | ) 13 | 14 | // systemUUID generates a unique system identifier based on the first available 15 | // network interface's hardware address (MAC address). It iterates through all 16 | // network interfaces, skips loopback interfaces, and uses the first interface 17 | // with a valid hardware address (at least 6 bytes) to generate a SHA1-based 18 | // UUID using the hardware address as input. Returns an error if no suitable 19 | // network interface is found. 20 | func systemUUID() (string, error) { 21 | interfaces, err := net.Interfaces() 22 | if err != nil { 23 | return "", err 24 | } 25 | 26 | for _, iface := range interfaces { 27 | 28 | // Skip loopback interfaces 29 | if iface.Flags&net.FlagLoopback != 0 { 30 | continue 31 | } 32 | 33 | if len(iface.HardwareAddr) >= 6 { 34 | hwAddr := iface.HardwareAddr 35 | // Create a namespace UUID from hardware address 36 | nsUUID := uuid.NewSHA1(uuid.NameSpaceOID, hwAddr) 37 | return nsUUID.String(), nil 38 | } 39 | } 40 | 41 | return "", fmt.Errorf("no network interface found") 42 | } 43 | 44 | // IsRoot returns true if the current process is running with root privileges. 45 | // When running tests, it always returns true to avoid permission-related test failures. 46 | // For normal execution, it checks if the effective user ID is 0 (root). 47 | func IsRoot() bool { 48 | if testing.Testing() { 49 | return true 50 | } 51 | return os.Geteuid() == 0 52 | } 53 | 54 | // SelfExe returns the path to the current executable. 55 | // If the executable path cannot be determined, it returns "paretosecurity" as a fallback. 56 | // The function also removes any "-tray" suffix from the executable path, which is used 57 | // for Windows standalone builds where the tray version has a different executable name. 58 | func SelfExe() string { 59 | exePath, err := os.Executable() 60 | if err != nil { 61 | return "paretosecurity" 62 | } 63 | // Remove the -tray suffix from the executable name (WIN, standalone) 64 | return strings.Replace(exePath, "-tray", "", -1) // Remove -tray from the path) 65 | } 66 | -------------------------------------------------------------------------------- /shared/uninstall.ps1: -------------------------------------------------------------------------------- 1 | $ErrorActionPreference = "Stop" 2 | 3 | # Close running instances of ParetoSecurity applications 4 | Write-Host "Closing running instances of ParetoSecurity..." 5 | $procNames = @("paretosecurity-tray.exe", "paretosecurity.exe", "paretosecurity-tray", "paretosecurity") 6 | foreach ($name in $procNames) { 7 | do { 8 | $procs = Get-Process -Name $name -ErrorAction SilentlyContinue 9 | if ($procs) { 10 | $procs | Stop-Process -Force -ErrorAction SilentlyContinue 11 | Start-Sleep -Milliseconds 300 12 | } 13 | } while ($procs) 14 | } 15 | 16 | # Remove installation directory 17 | $RoamingDir = [Environment]::GetFolderPath("ApplicationData") 18 | $InstallPath = Join-Path $RoamingDir "ParetoSecurity" 19 | Remove-Item -Recurse -Force -Path $InstallPath -ErrorAction SilentlyContinue 20 | 21 | # Remove uninstaller registry entry 22 | Remove-Item -Path "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\ParetoSecurity" -Force -ErrorAction SilentlyContinue 23 | 24 | # Remove URI handler 25 | $URLHandlerKey = "HKCU:\Software\Classes\paretosecurity" 26 | Remove-Item -Path $URLHandlerKey -Recurse -Force -ErrorAction SilentlyContinue 27 | 28 | # Remove desktop shortcut 29 | $DesktopDir = [Environment]::GetFolderPath("Desktop") 30 | $DesktopShortcut = Join-Path $DesktopDir "Pareto Security.lnk" 31 | Remove-Item -Path $DesktopShortcut -Force -ErrorAction SilentlyContinue 32 | 33 | # Remove startup shortcut 34 | $StartupDir = [Environment]::GetFolderPath("Startup") 35 | $StartupShortcut = Join-Path $StartupDir "Pareto Security.lnk" 36 | Remove-Item -Path $StartupShortcut -Force -ErrorAction SilentlyContinue 37 | -------------------------------------------------------------------------------- /shared/version.go: -------------------------------------------------------------------------------- 1 | package shared 2 | 3 | var ( 4 | Version = "dev" 5 | Commit = "none" 6 | Date = "unknown" 7 | ) 8 | -------------------------------------------------------------------------------- /systemd/shared.go: -------------------------------------------------------------------------------- 1 | package systemd 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/ParetoSecurity/agent/shared" 7 | ) 8 | 9 | func isEnabled(service string) bool { 10 | state, err := shared.RunCommand("systemctl", "--user", "is-enabled", service) 11 | if strings.TrimSpace(state) == "enabled" && err == nil { 12 | return true 13 | } 14 | return false 15 | } 16 | 17 | func enable(service string) error { 18 | _, err := shared.RunCommand("systemctl", "--user", "enable", service) 19 | return err 20 | } 21 | 22 | func disable(service string) error { 23 | _, err := shared.RunCommand("systemctl", "--user", "disable", service) 24 | return err 25 | } 26 | -------------------------------------------------------------------------------- /systemd/timer.go: -------------------------------------------------------------------------------- 1 | package systemd 2 | 3 | func IsTimerEnabled() bool { 4 | return isEnabled("paretosecurity-user.timer") && isEnabled("paretosecurity-user.service") 5 | } 6 | 7 | func EnableTimer() error { 8 | if err := enable("paretosecurity-user.timer"); err != nil { 9 | return err 10 | } 11 | return enable("paretosecurity-user.service") 12 | } 13 | 14 | func DisableTimer() error { 15 | if err := disable("paretosecurity-user.timer"); err != nil { 16 | return err 17 | } 18 | return disable("paretosecurity-user.service") 19 | } 20 | -------------------------------------------------------------------------------- /systemd/tray.go: -------------------------------------------------------------------------------- 1 | package systemd 2 | 3 | func IsTrayIconEnabled() bool { 4 | return isEnabled("paretosecurity-trayicon.service") 5 | } 6 | 7 | func EnableTrayIcon() error { 8 | return enable("paretosecurity-trayicon.service") 9 | } 10 | 11 | func DisableTrayIcon() error { 12 | return disable("paretosecurity-trayicon.service") 13 | } 14 | -------------------------------------------------------------------------------- /systemd/tray_test.go: -------------------------------------------------------------------------------- 1 | package systemd 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/ParetoSecurity/agent/shared" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestIsTrayIconEnabled(t *testing.T) { 11 | tests := []struct { 12 | name string 13 | mock shared.RunCommandMock 14 | expected bool 15 | }{ 16 | { 17 | name: "service is enabled", 18 | mock: shared.RunCommandMock{ 19 | Command: "systemctl", 20 | Args: []string{"--user", "is-enabled", "paretosecurity-trayicon.service"}, 21 | Out: "enabled\n", 22 | Err: nil, 23 | }, 24 | expected: true, 25 | }, 26 | { 27 | name: "service is disabled", 28 | mock: shared.RunCommandMock{ 29 | Command: "systemctl", 30 | Args: []string{"--user", "is-enabled", "paretosecurity-trayicon.service"}, 31 | Out: "disabled\n", 32 | Err: nil, 33 | }, 34 | expected: false, 35 | }, 36 | } 37 | 38 | for _, tc := range tests { 39 | t.Run(tc.name, func(t *testing.T) { 40 | shared.RunCommandMocks = []shared.RunCommandMock{tc.mock} 41 | defer func() { shared.RunCommandMocks = nil }() 42 | 43 | result := IsTrayIconEnabled() 44 | assert.Equal(t, tc.expected, result) 45 | }) 46 | } 47 | } 48 | func TestEnableTrayIcon(t *testing.T) { 49 | tests := []struct { 50 | name string 51 | mock shared.RunCommandMock 52 | wantErr bool 53 | }{ 54 | { 55 | name: "enable succeeds", 56 | mock: shared.RunCommandMock{ 57 | Command: "systemctl", 58 | Args: []string{"--user", "enable", "paretosecurity-trayicon.service"}, 59 | Out: "", 60 | Err: nil, 61 | }, 62 | wantErr: false, 63 | }, 64 | { 65 | name: "enable fails", 66 | mock: shared.RunCommandMock{ 67 | Command: "systemctl", 68 | Args: []string{"--user", "enable", "paretosecurity-trayicon.service"}, 69 | Out: "", 70 | Err: assert.AnError, 71 | }, 72 | wantErr: true, 73 | }, 74 | } 75 | 76 | for _, tc := range tests { 77 | t.Run(tc.name, func(t *testing.T) { 78 | shared.RunCommandMocks = []shared.RunCommandMock{tc.mock} 79 | defer func() { shared.RunCommandMocks = nil }() 80 | 81 | err := EnableTrayIcon() 82 | if tc.wantErr { 83 | assert.Error(t, err) 84 | } else { 85 | assert.NoError(t, err) 86 | } 87 | }) 88 | } 89 | } 90 | func TestDisableTrayIcon(t *testing.T) { 91 | tests := []struct { 92 | name string 93 | mock shared.RunCommandMock 94 | wantErr bool 95 | }{ 96 | { 97 | name: "disable succeeds", 98 | mock: shared.RunCommandMock{ 99 | Command: "systemctl", 100 | Args: []string{"--user", "disable", "paretosecurity-trayicon.service"}, 101 | Out: "", 102 | Err: nil, 103 | }, 104 | wantErr: false, 105 | }, 106 | { 107 | name: "disable fails", 108 | mock: shared.RunCommandMock{ 109 | Command: "systemctl", 110 | Args: []string{"--user", "disable", "paretosecurity-trayicon.service"}, 111 | Out: "", 112 | Err: assert.AnError, 113 | }, 114 | wantErr: true, 115 | }, 116 | } 117 | 118 | for _, tc := range tests { 119 | t.Run(tc.name, func(t *testing.T) { 120 | shared.RunCommandMocks = []shared.RunCommandMock{tc.mock} 121 | defer func() { shared.RunCommandMocks = nil }() 122 | 123 | err := DisableTrayIcon() 124 | if tc.wantErr { 125 | assert.Error(t, err) 126 | } else { 127 | assert.NoError(t, err) 128 | } 129 | }) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/integration/README.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | This directory contains integration tests for the Pareto Security Agent. These tests verify that the agent works correctly on various Linux distributions and system configurations. 4 | 5 | ## Running Tests 6 | 7 | On NixOS, you can run the tests with the following command: 8 | 9 | ```console 10 | $ nix build .#checks.x86_64-linux.firewall 11 | $ nix build .#checks.aarch64-linux.firewall 12 | ``` 13 | 14 | On macOS with nix-darwin and linux-builder enabled, you can run the tests with the following command: 15 | 16 | ```console 17 | $ nix build .#checks.aarch64-darwin.firewall 18 | ``` 19 | 20 | ## Debugging Tests 21 | 22 | Appending `.driverInteractive` to the test name will build the test runner with interactive mode enabled. This allows you to debug the test by SSHing into the test VM. 23 | 24 | ```console 25 | $ nix build .#checks.aarch64-darwin.firewall.driverInteractive 26 | ./result/bin/nixos-test-driver 27 | >>> start_all() 28 | >>> machine.shell_interact() 29 | ``` 30 | 31 | For a nicer shell, add the following to `firewall.nix`, rebuild the test and run 32 | `start_all()`. Now you can SSH into the test VM with `ssh root@localhost -p2222`. 33 | 34 | ```nix 35 | interactive.nodes.machine = { ... }: { 36 | services.openssh = { 37 | enable = true; 38 | settings = { 39 | PermitRootLogin = "yes"; 40 | PermitEmptyPasswords = "yes"; 41 | }; 42 | }; 43 | security.pam.services.sshd.allowNullPassword = true; 44 | virtualisation.forwardPorts = [ 45 | { from = "host"; host.port = 2222; guest.port = 22; } 46 | ]; 47 | }; 48 | ``` 49 | 50 | ## Seeing UI changes 51 | 52 | A quick way to see the changes you made to the UI is to build the test runner in 53 | `screenlock.nix` and run it on a NixOS (VM) machine, so that QEMU can display the UI. 54 | 55 | ```console 56 | $ nix build .#checks.x86_64-linux.screenlock.driverInteractive 57 | $ ./result/bin/nixos-test-driver 58 | >>> start_all() 59 | ``` 60 | 61 | A NixOS VM, managed by UTM on a Mac, running the `screenlock` test VMs: 62 | 63 | ![NixOS VM with screenlock test](vms.png) 64 | 65 | -------------------------------------------------------------------------------- /test/integration/cli.nix: -------------------------------------------------------------------------------- 1 | let 2 | common = import ./common.nix; 3 | inherit (common) users pareto ssh; 4 | in { 5 | name = "CLI"; 6 | 7 | nodes = { 8 | agent = { 9 | pkgs, 10 | lib, 11 | ... 12 | }: { 13 | imports = [ 14 | (users {}) 15 | (pareto {inherit pkgs lib;}) 16 | ]; 17 | }; 18 | }; 19 | 20 | interactive.nodes.vanilla = {...}: 21 | ssh {port = 2221;} {}; 22 | 23 | testScript = '' 24 | # Test setup 25 | agent.succeed("su - alice -c 'mkdir -p /home/alice/.config'") 26 | agent.systemctl("start network-online.target") 27 | agent.wait_for_unit("network-online.target") 28 | 29 | # Test 1: Test the systemd socket is installed & enabled 30 | agent.succeed('systemctl is-enabled paretosecurity.socket') 31 | 32 | # Test 2: run all checks, with failures 33 | out = agent.fail("su - alice -c 'paretosecurity check'") 34 | expected = sorted(( 35 | " • Starting checks...\n" 36 | " • Access Security: Automatic login is disabled > [OK] Automatic login is off\n" 37 | " • Access Security: Access to Docker is restricted > [DISABLED] Docker is not installed\n" 38 | " • Access Security: Password is required to unlock the screen > [FAIL] Password after sleep or screensaver is off\n" 39 | " • Access Security: SSH keys have password protection > [DISABLED] No private keys found in .ssh directory\n" 40 | " • Access Security: SSH keys have sufficient algorithm strength > [DISABLED] No private keys found in the .ssh directory\n" 41 | " • System Integrity: SecureBoot is enabled > [FAIL] System is not running in UEFI mode\n" 42 | " • Application Updates: Apps are up to date > [OK] All packages are up to date\n" 43 | " • Firewall & Sharing: Sharing printers is off > [OK] Sharing printers is off\n" 44 | " • [root] System Integrity: Filesystem encryption is enabled > [FAIL] Block device encryption is disabled\n" 45 | " • Firewall & Sharing: Remote login is disabled > [OK] No remote access services found running\n" 46 | " • Firewall & Sharing: File Sharing is disabled > [OK] No file sharing services found running\n" 47 | " • Application Updates: Pareto Security is up to date > [ERROR] ErrTransport: Get \"https://api.github.com/repos/ParetoSecurity/agent/releases\": dial tcp: lookup api.github.com: no such host\n" 48 | " • Access Security: Password Manager Presence > [FAIL] No password manager found\n" 49 | " • [root] Firewall & Sharing: Firewall is configured > [OK] Firewall is on\n" 50 | " • Checks completed.\n" 51 | ).split("\n")) 52 | 53 | assert sorted(out.split("\n")) == expected, f"{expected} did not match actual, got \n{out}" 54 | 55 | # Test 3: disable failing checks, run again 56 | agent.succeed("su - alice -c 'paretosecurity config disable 37dee029-605b-4aab-96b9-5438e5aa44d8'") # screenlock 57 | agent.succeed("su - alice -c 'paretosecurity config disable c96524f2-850b-4bb9-abc7-517051b6c14e'") # secureboot 58 | agent.succeed("su - alice -c 'paretosecurity config disable 21830a4e-84f1-48fe-9c5b-beab436b2cdb'") # luks 59 | agent.succeed("su - alice -c 'paretosecurity config disable f962c423-fdf5-428a-a57a-827abc9b253e'") # password manager 60 | out = agent.succeed("su - alice -c 'paretosecurity check'") 61 | ''; 62 | } 63 | -------------------------------------------------------------------------------- /test/integration/common.nix: -------------------------------------------------------------------------------- 1 | let 2 | # Use local Pareto codebase 3 | paretoLocalPkg = { 4 | pkgs, 5 | lib, 6 | }: 7 | pkgs.callPackage ../../package.nix {inherit lib;}; 8 | in { 9 | # Create dummy user account 10 | users = {}: { 11 | users.users.alice = { 12 | isNormalUser = true; 13 | description = "Alice Foobar"; 14 | password = "foobar"; 15 | uid = 1000; 16 | }; 17 | }; 18 | 19 | # Paretosecurity that uses local codebase 20 | pareto = { 21 | pkgs, 22 | lib, 23 | ... 24 | }: { 25 | services.paretosecurity = { 26 | enable = true; 27 | package = paretoLocalPkg {inherit pkgs lib;}; 28 | }; 29 | }; 30 | 31 | # Paretosecurity with local codebase and patched dashboard URL 32 | paretoPatchedDash = { 33 | pkgs, 34 | lib, 35 | ... 36 | }: let 37 | paretoPkg = paretoLocalPkg {inherit pkgs lib;}; 38 | in { 39 | services.paretosecurity = { 40 | enable = true; 41 | package = paretoPkg.overrideAttrs (oldAttrs: { 42 | postPatch = 43 | oldAttrs.postPatch or "" 44 | + '' 45 | substituteInPlace team/report.go \ 46 | --replace-warn 'const reportURL = "https://cloud.paretosecurity.com"' \ 47 | 'const reportURL = "http://dashboard"' 48 | ''; 49 | }); 50 | }; 51 | }; 52 | 53 | # Dashboard mockup server 54 | dashboard = {}: { 55 | networking.firewall.allowedTCPPorts = [80]; 56 | 57 | services.nginx = { 58 | enable = true; 59 | virtualHosts."dashboard" = { 60 | locations."/api/v1/team/".extraConfig = '' 61 | add_header Content-Type application/json; 62 | return 200 '{"message": "Linked device."}'; 63 | ''; 64 | }; 65 | }; 66 | }; 67 | 68 | # Common configuration for Display Manager 69 | displayManager = {pkgs}: { 70 | services.displayManager.autoLogin = { 71 | enable = true; 72 | user = "alice"; 73 | }; 74 | 75 | virtualisation.resolution = { 76 | x = 800; 77 | y = 600; 78 | }; 79 | 80 | environment.systemPackages = [pkgs.xdotool]; 81 | environment.variables.XAUTHORITY = "/home/alice/.Xauthority"; 82 | }; 83 | 84 | # Easier tests debugging by SSH-ing into nodes 85 | ssh = {port}: {...}: { 86 | services.openssh = { 87 | enable = true; 88 | settings = { 89 | PermitRootLogin = "yes"; 90 | PermitEmptyPasswords = "yes"; 91 | }; 92 | }; 93 | security.pam.services.sshd.allowNullPassword = true; 94 | virtualisation.forwardPorts = [ 95 | { 96 | from = "host"; 97 | host.port = port; 98 | guest.port = 22; 99 | } 100 | ]; 101 | }; 102 | } 103 | -------------------------------------------------------------------------------- /test/integration/desktop/xfce.nix: -------------------------------------------------------------------------------- 1 | let 2 | common = import ../common.nix; 3 | inherit (common) users paretoPatchedDash dashboard displayManager ssh; 4 | in { 5 | name = "XFCE"; 6 | 7 | nodes.dashboard = { 8 | imports = [ 9 | (dashboard {}) 10 | ]; 11 | }; 12 | 13 | nodes.xfce = { 14 | pkgs, 15 | lib, 16 | ... 17 | }: { 18 | imports = [ 19 | (users {}) 20 | (paretoPatchedDash {inherit pkgs lib;}) 21 | (displayManager {inherit pkgs;}) 22 | ]; 23 | 24 | services.xserver.enable = true; 25 | services.xserver.displayManager.lightdm.enable = true; 26 | services.xserver.desktopManager.xfce.enable = true; 27 | }; 28 | 29 | interactive.nodes.xfce = {...}: 30 | ssh {port = 2221;} {}; 31 | 32 | enableOCR = true; 33 | 34 | testScript = '' 35 | # Test setup 36 | for m in [xfce, dashboard]: 37 | m.systemctl("start network-online.target") 38 | m.wait_for_unit("network-online.target") 39 | 40 | # Test: Tray icon 41 | xfce.wait_for_x() 42 | for unit in [ 43 | 'paretosecurity-trayicon', 44 | 'paretosecurity-user', 45 | 'paretosecurity-user.timer' 46 | ]: 47 | status, out = xfce.systemctl("is-enabled " + unit, "alice") 48 | assert status == 0, f"Unit {unit} is not enabled (status: {status}): {out}" 49 | xfce.succeed("xdotool mousemove 630 10") 50 | xfce.wait_for_text("Pareto Security") 51 | xfce.succeed("xdotool click 1") 52 | xfce.wait_for_text("Run Checks") 53 | 54 | # Test: Desktop entry 55 | xfce.succeed("xdotool mousemove 10 10") 56 | xfce.succeed("xdotool click 1") # hide the tray icon window 57 | xfce.succeed("xdotool click 1") # show the Applications menu 58 | xfce.succeed("xdotool mousemove 10 200") 59 | xfce.succeed("xdotool click 1") 60 | xfce.wait_for_text("Pareto Security", timeout=20) 61 | 62 | # Test: paretosecurity:// URL handler is registered 63 | xfce.succeed("su - alice -c 'xdg-open" 64 | + " paretosecurity://enrollTeam/?token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." 65 | + "eyJ0b2tlbiI6ImR1bW15LXRva2VuIiwidGVhbUlEIjoiZHVtbXktdGVhbS1pZCIsImlhdCI6" 66 | + "MTcwMDAwMDAwMCwiZXhwIjoxOTAwMDAwMDAwfQ.WgnL6_S0EBJHwF1wEVUG8GtIcoVvK5IjWbZpUeZr4Qw'" 67 | + " >/dev/null &") 68 | 69 | xfce.wait_for_text("Device successfully linked", timeout=20) 70 | ''; 71 | } 72 | -------------------------------------------------------------------------------- /test/integration/firewall.nix: -------------------------------------------------------------------------------- 1 | let 2 | common = import ./common.nix; 3 | inherit (common) pareto ssh; 4 | 5 | # A simple web server for testing connectivity 6 | nginx = {pkgs, ...}: { 7 | services.nginx = { 8 | enable = true; 9 | virtualHosts."localhost" = { 10 | locations."/" = { 11 | root = pkgs.writeTextDir "index.html" "

Test Server

"; 12 | }; 13 | }; 14 | }; 15 | }; 16 | in { 17 | name = "Firewall"; 18 | 19 | nodes = { 20 | wideopen = { 21 | pkgs, 22 | lib, 23 | ... 24 | }: { 25 | imports = [ 26 | (pareto {inherit pkgs lib;}) 27 | (nginx {inherit pkgs;}) 28 | ]; 29 | networking.firewall.enable = false; 30 | }; 31 | 32 | iptables = { 33 | pkgs, 34 | lib, 35 | ... 36 | }: { 37 | imports = [ 38 | (pareto {inherit pkgs lib;}) 39 | (nginx {inherit pkgs;}) 40 | ]; 41 | networking.firewall.enable = true; 42 | }; 43 | 44 | nftables = { 45 | pkgs, 46 | lib, 47 | ... 48 | }: { 49 | imports = [ 50 | (pareto {inherit pkgs lib;}) 51 | (nginx {inherit pkgs;}) 52 | ]; 53 | networking.nftables.enable = true; 54 | }; 55 | }; 56 | 57 | interactive.nodes.wideopen = {...}: 58 | ssh {port = 2221;} {}; 59 | 60 | interactive.nodes.iptables = {...}: 61 | ssh {port = 2222;} {}; 62 | 63 | interactive.nodes.nftables = {...}: 64 | ssh {port = 2222;} {}; 65 | 66 | testScript = '' 67 | # Test Setup 68 | for m in [wideopen, iptables, nftables]: 69 | m.systemctl("start network-online.target") 70 | m.wait_for_unit("network-online.target") 71 | m.wait_for_unit("nginx") 72 | 73 | # Test 0: assert firewall is actually configured 74 | wideopen.fail("curl --fail --connect-timeout 2 http://iptables") 75 | wideopen.fail("curl --fail --connect-timeout 2 http://nftables") 76 | iptables.succeed("curl --fail --connect-timeout 2 http://wideopen") 77 | 78 | # Test 1: check fails with no firewall enabled 79 | out = wideopen.fail("paretosecurity check --only 2e46c89a-5461-4865-a92e-3b799c12034a") 80 | expected = ( 81 | " • Starting checks...\n" 82 | " • [root] Firewall & Sharing: Firewall is configured > [FAIL] Firewall is off\n" 83 | " • Checks completed.\n" 84 | ) 85 | assert out == expected, f"{expected} did not match actual, got \n{out}" 86 | 87 | # Test 2: check succeeds with iptables enabled 88 | out = iptables.succeed("paretosecurity check --only 2e46c89a-5461-4865-a92e-3b799c12034a") 89 | expected = ( 90 | " • Starting checks...\n" 91 | " • [root] Firewall & Sharing: Firewall is configured > [OK] Firewall is on\n" 92 | " • Checks completed.\n" 93 | ) 94 | assert out == expected, f"{expected} did not match actual, got \n{out}" 95 | 96 | # Test 3: check succeeds with nftables enabled 97 | out = nftables.succeed("paretosecurity check --only 2e46c89a-5461-4865-a92e-3b799c12034a") 98 | expected = ( 99 | " • Starting checks...\n" 100 | " • [root] Firewall & Sharing: Firewall is configured > [OK] Firewall is on\n" 101 | " • Checks completed.\n" 102 | ) 103 | assert out == expected, f"{expected} did not match actual, got \n{out}" 104 | ''; 105 | } 106 | -------------------------------------------------------------------------------- /test/integration/help.nix: -------------------------------------------------------------------------------- 1 | let 2 | common = import ./common.nix; 3 | inherit (common) pareto ssh; 4 | in { 5 | name = "Help"; 6 | 7 | nodes = { 8 | vanilla = { 9 | pkgs, 10 | lib, 11 | ... 12 | }: { 13 | imports = [(pareto {inherit pkgs lib;})]; 14 | }; 15 | }; 16 | 17 | interactive.nodes.vanilla = {...}: 18 | ssh {port = 2221;} {}; 19 | 20 | testScript = '' 21 | from textwrap import dedent 22 | expected = dedent("""\ 23 | Pareto Security CLI is a tool for running and reporting audits to paretosecurity.com. 24 | 25 | Usage: 26 | paretosecurity [command] 27 | 28 | Available Commands: 29 | check Run checks on your system 30 | completion Generate the autocompletion script for the specified shell 31 | config Configure application settings 32 | help Help about any command 33 | helper A root helper 34 | info Print the system information 35 | link Link this device to a team 36 | schema Output schema for all checks 37 | status Print the status of the checks 38 | trayicon Display the status of the checks in the system tray 39 | unlink Unlink this device from the team 40 | 41 | Flags: 42 | -h, --help help for paretosecurity 43 | --verbose output verbose logs 44 | -v, --version version for paretosecurity 45 | 46 | Use "paretosecurity [command] --help" for more information about a command. 47 | """) 48 | 49 | # Test 1: assert default output 50 | out = vanilla.succeed("paretosecurity") 51 | assert out == expected, f"Expected did not match actual, got \n{out}" 52 | 53 | # Test 2: assert `--help` output 54 | out = vanilla.succeed("paretosecurity --help") 55 | assert out == expected, f"Expected did not match actual, got \n{out}" 56 | 57 | # Test 3: assert `-h` output 58 | out = vanilla.succeed("paretosecurity -h") 59 | assert out == expected, f"Expected did not match actual, got \n{out}" 60 | 61 | # Test 4: assert `help` output 62 | out = vanilla.succeed("paretosecurity help") 63 | assert out == expected, f"Expected did not match actual, got \n{out}" 64 | ''; 65 | } 66 | -------------------------------------------------------------------------------- /test/integration/pwd-manager.nix: -------------------------------------------------------------------------------- 1 | let 2 | common = import ./common.nix; 3 | inherit (common) pareto ssh; 4 | in { 5 | name = "Password Manager"; 6 | 7 | nodes = { 8 | withPwdManager = { 9 | pkgs, 10 | lib, 11 | ... 12 | }: { 13 | imports = [ 14 | (pareto {inherit pkgs lib;}) 15 | ]; 16 | environment.systemPackages = with pkgs; [ 17 | bitwarden 18 | ]; 19 | }; 20 | 21 | noPwdManager = { 22 | pkgs, 23 | lib, 24 | ... 25 | }: { 26 | imports = [ 27 | (pareto {inherit pkgs lib;}) 28 | ]; 29 | # No password manager installed 30 | }; 31 | }; 32 | 33 | interactive.nodes.withPwdManager = {...}: 34 | ssh {port = 2221;} {}; 35 | 36 | interactive.nodes.noPwdManager = {...}: 37 | ssh {port = 2222;} {}; 38 | 39 | testScript = '' 40 | # Test 1: Check passes with password managers installed 41 | out = withPwdManager.succeed("paretosecurity check --only f962c423-fdf5-428a-a57a-827abc9b253e") 42 | expected = ( 43 | " • Starting checks...\n" 44 | " • Access Security: Password Manager Presence > [OK] Password manager is present\n" 45 | " • Checks completed.\n" 46 | ) 47 | assert out == expected, f"Expected did not match actual, got \n{out}" 48 | 49 | # Test 2: Check fails without password manager 50 | status, out = noPwdManager.execute("paretosecurity check --only f962c423-fdf5-428a-a57a-827abc9b253e") 51 | expected = ( 52 | " • Starting checks...\n" 53 | " • Access Security: Password Manager Presence > [FAIL] No password manager found\n" 54 | " • Checks completed.\n" 55 | ) 56 | assert out == expected, f"Expected did not match actual, got \n{out}" 57 | ''; 58 | } 59 | -------------------------------------------------------------------------------- /test/integration/screenlock.nix: -------------------------------------------------------------------------------- 1 | let 2 | common = import ./common.nix; 3 | inherit (common) pareto ssh; 4 | in { 5 | name = "Screen Lock"; 6 | 7 | nodes = { 8 | gnome = { 9 | pkgs, 10 | lib, 11 | ... 12 | }: { 13 | imports = [ 14 | (pareto {inherit pkgs lib;}) 15 | ]; 16 | # Install GNOME Desktop Environment 17 | services.xserver.desktopManager.gnome.enable = true; 18 | services.xserver.displayManager.gdm.enable = true; 19 | }; 20 | 21 | kde = { 22 | pkgs, 23 | lib, 24 | ... 25 | }: { 26 | imports = [ 27 | (pareto {inherit pkgs lib;}) 28 | ]; 29 | # Install KDE Plasma 5 Desktop Environment 30 | services.xserver.enable = true; 31 | services.xserver.desktopManager.plasma5.enable = true; 32 | services.xserver.displayManager.sddm.enable = true; 33 | services.colord.enable = false; 34 | }; 35 | }; 36 | 37 | interactive.nodes.gnome = {...}: 38 | ssh {port = 2221;} {}; 39 | 40 | interactive.nodes.kde = {...}: 41 | ssh {port = 2222;} {}; 42 | 43 | testScript = '' 44 | # Test GNOME 45 | # Test 1: Check passes by default 46 | out = gnome.succeed("paretosecurity check --only 37dee029-605b-4aab-96b9-5438e5aa44d8") 47 | expected = ( 48 | " • Starting checks...\n" 49 | " • Access Security: Password is required to unlock the screen > [OK] Password after sleep or screensaver is on\n" 50 | " • Checks completed.\n" 51 | ) 52 | assert out == expected, f"Expected did not match actual, got \n{out}" 53 | 54 | # Test 2: Check fails when lock is disabled 55 | gnome.succeed("dbus-run-session -- gsettings set org.gnome.desktop.screensaver lock-enabled false") 56 | status, out = gnome.execute("paretosecurity check --only 37dee029-605b-4aab-96b9-5438e5aa44d8") 57 | expected = ( 58 | " • Starting checks...\n" 59 | " • Access Security: Password is required to unlock the screen > [FAIL] Password after sleep or screensaver is off\n" 60 | " • Checks completed.\n" 61 | ) 62 | assert out == expected, f"Expected did not match actual, got \n{out}" 63 | 64 | # Test KDE 65 | # Test 1: Check passes with lock enabled 66 | kde.succeed("kwriteconfig5 --file kscreenlockerrc --group Daemon --key LockOnResume true") 67 | out = kde.succeed("paretosecurity check --only 37dee029-605b-4aab-96b9-5438e5aa44d8") 68 | expected = ( 69 | " • Starting checks...\n" 70 | " • Access Security: Password is required to unlock the screen > [OK] Password after sleep or screensaver is on\n" 71 | " • Checks completed.\n" 72 | ) 73 | assert out == expected, f"Expected did not match actual, got \n{out}" 74 | 75 | # Test 2: Check fails when lock is disabled 76 | kde.succeed("kwriteconfig5 --file kscreenlockerrc --group Daemon --key LockOnResume false") 77 | status, out = kde.execute("paretosecurity check --only 37dee029-605b-4aab-96b9-5438e5aa44d8") 78 | expected = ( 79 | " • Starting checks...\n" 80 | " • Access Security: Password is required to unlock the screen > [FAIL] Password after sleep or screensaver is off\n" 81 | " • Checks completed.\n" 82 | ) 83 | assert out == expected, f"Expected did not match actual, got \n{out}" 84 | ''; 85 | } 86 | -------------------------------------------------------------------------------- /test/integration/secureboot.nix: -------------------------------------------------------------------------------- 1 | let 2 | common = import ./common.nix; 3 | inherit (common) pareto ssh; 4 | in { 5 | name = "SecureBoot"; 6 | 7 | nodes = { 8 | regularboot = { 9 | pkgs, 10 | lib, 11 | ... 12 | }: { 13 | imports = [ 14 | (pareto {inherit pkgs lib;}) 15 | ]; 16 | }; 17 | 18 | secureboot = { 19 | pkgs, 20 | lib, 21 | ... 22 | }: { 23 | imports = [ 24 | (pareto {inherit pkgs lib;}) 25 | ]; 26 | # NixOS SecureBoot test VM configuration taken from 27 | # https://github.com/NixOS/nixpkgs/blob/master/nixos/tests/systemd-boot.nix 28 | 29 | virtualisation.useSecureBoot = true; 30 | virtualisation.useBootLoader = true; 31 | virtualisation.useEFIBoot = true; 32 | boot.loader.systemd-boot.enable = true; 33 | boot.loader.efi.canTouchEfiVariables = true; 34 | environment.systemPackages = [pkgs.efibootmgr pkgs.sbctl]; 35 | system.switch.enable = true; 36 | }; 37 | }; 38 | 39 | interactive.nodes.regularboot = {...}: 40 | ssh {port = 2221;} {}; 41 | 42 | interactive.nodes.secureboot = {...}: 43 | ssh {port = 2222;} {}; 44 | 45 | testScript = {nodes, ...}: '' 46 | # Test 1: check fails with SecureBoot disabled 47 | out = regularboot.fail("paretosecurity check --only c96524f2-850b-4bb9-abc7-517051b6c14e") 48 | expected = ( 49 | " • Starting checks...\n" 50 | " • System Integrity: SecureBoot is enabled > [FAIL] System is not running in UEFI mode\n" 51 | " • Checks completed.\n" 52 | ) 53 | assert out == expected, f"Expected did not match actual, got \n{out}" 54 | 55 | # Test 2: check succeeds with SecureBoot enabled 56 | secureboot.start(allow_reboot=True) 57 | secureboot.wait_for_unit("multi-user.target") 58 | 59 | secureboot.succeed("sbctl create-keys") 60 | secureboot.succeed("sbctl enroll-keys --yes-this-might-brick-my-machine") 61 | secureboot.succeed('sbctl sign /boot/EFI/systemd/systemd-boot*.efi') 62 | secureboot.succeed('sbctl sign /boot/EFI/BOOT/BOOT*.EFI') 63 | secureboot.succeed('sbctl sign /boot/EFI/nixos/*-linux-*Image.efi') 64 | 65 | secureboot.reboot() 66 | assert "Secure Boot: enabled (user)" in secureboot.succeed("bootctl status") 67 | 68 | out = secureboot.succeed("paretosecurity check --only c96524f2-850b-4bb9-abc7-517051b6c14e") 69 | expected = ( 70 | " • Starting checks...\n" 71 | " • System Integrity: SecureBoot is enabled > [OK] SecureBoot is enabled\n" 72 | " • Checks completed.\n" 73 | ) 74 | assert out == expected, f"Expected did not match actual, got \n{out}" 75 | ''; 76 | } 77 | -------------------------------------------------------------------------------- /test/integration/vms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParetoSecurity/agent/52f3b7fb02d2731ff8bbd824e5665a8fbd363a93/test/integration/vms.png -------------------------------------------------------------------------------- /trayapp/format.go: -------------------------------------------------------------------------------- 1 | package trayapp 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/ParetoSecurity/agent/shared" 8 | ) 9 | 10 | // lastUpdated calculates and returns a human-readable string representing the time elapsed since the last modification. 11 | func lastUpdated() string { 12 | if shared.GetModifiedTime().IsZero() { 13 | return "never" 14 | } 15 | 16 | t := time.Since(shared.GetModifiedTime()) 17 | 18 | switch { 19 | case t < time.Minute: 20 | return "just now" 21 | 22 | case t < time.Hour: 23 | // Less than an hour, show minutes 24 | minutes := int(t.Minutes()) 25 | if minutes == 1 { 26 | return "1m ago" 27 | } 28 | return fmt.Sprintf("%dm ago", minutes) 29 | 30 | case t < time.Hour*24: 31 | // Less than a day, show hours and minutes 32 | hours := int(t.Hours()) 33 | minutes := int(t.Minutes()) % 60 34 | if minutes == 0 { 35 | return fmt.Sprintf("%dh ago", hours) 36 | } 37 | return fmt.Sprintf("%dh %dm ago", hours, minutes) 38 | 39 | case t < time.Hour*24*7: 40 | // Less than a week, show days and hours 41 | days := int(t.Hours() / 24) 42 | hours := int(t.Hours()) % 24 43 | if hours == 0 { 44 | return fmt.Sprintf("%dd ago", days) 45 | } 46 | return fmt.Sprintf("%dd %dh ago", days, hours) 47 | 48 | default: 49 | // More than a week, show in weeks 50 | days := int(t.Hours() / 24) 51 | weeks := days / 7 52 | remainingDays := days % 7 53 | 54 | if remainingDays == 0 { 55 | return fmt.Sprintf("%dw ago", weeks) 56 | } 57 | return fmt.Sprintf("%dw %dd ago", weeks, remainingDays) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /trayapp/format_test.go: -------------------------------------------------------------------------------- 1 | package trayapp 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | "time" 7 | 8 | "github.com/ParetoSecurity/agent/shared" 9 | ) 10 | 11 | func TestLastUpdated(t *testing.T) { 12 | now := time.Now() 13 | shared.StatePath = t.TempDir() + "/test_state.json" 14 | defer func() { 15 | os.Remove(shared.StatePath) 16 | }() 17 | testCases := []struct { 18 | name string 19 | modifiedTime time.Time 20 | expected string 21 | timeTravelOffset time.Duration 22 | }{ 23 | { 24 | name: "never updated", 25 | modifiedTime: time.Time{}, 26 | expected: "never", 27 | }, 28 | { 29 | name: "just now", 30 | modifiedTime: now, 31 | timeTravelOffset: time.Duration(0), 32 | expected: "just now", 33 | }, 34 | { 35 | name: "1 minute ago", 36 | modifiedTime: now.Add(-time.Minute), 37 | timeTravelOffset: time.Duration(0), 38 | expected: "1m ago", 39 | }, 40 | { 41 | name: "less than an hour", 42 | modifiedTime: now.Add(-25 * time.Minute), 43 | timeTravelOffset: time.Duration(0), 44 | expected: "25m ago", 45 | }, 46 | { 47 | name: "an hour ago", 48 | modifiedTime: now.Add(-time.Hour), 49 | timeTravelOffset: time.Duration(0), 50 | expected: "1h ago", 51 | }, 52 | { 53 | name: "less than a day", 54 | modifiedTime: now.Add(-5 * time.Hour), 55 | timeTravelOffset: time.Duration(0), 56 | expected: "5h ago", 57 | }, 58 | { 59 | name: "less than a day with minutes", 60 | modifiedTime: now.Add(-5*time.Hour - 30*time.Minute), 61 | timeTravelOffset: time.Duration(0), 62 | expected: "5h 30m ago", 63 | }, 64 | { 65 | name: "a day ago", 66 | modifiedTime: now.Add(-24 * time.Hour), 67 | timeTravelOffset: time.Duration(0), 68 | expected: "1d ago", 69 | }, 70 | { 71 | name: "less than a week", 72 | modifiedTime: now.Add(-3 * 24 * time.Hour), 73 | timeTravelOffset: time.Duration(0), 74 | expected: "3d ago", 75 | }, 76 | { 77 | name: "less than a week with hours", 78 | modifiedTime: now.Add(-3*24*time.Hour - 12*time.Hour), 79 | timeTravelOffset: time.Duration(0), 80 | expected: "3d 12h ago", 81 | }, 82 | { 83 | name: "a week ago", 84 | modifiedTime: now.Add(-7 * 24 * time.Hour), 85 | timeTravelOffset: time.Duration(0), 86 | expected: "1w ago", 87 | }, 88 | { 89 | name: "more than a week", 90 | modifiedTime: now.Add(-9 * 24 * time.Hour), 91 | timeTravelOffset: time.Duration(0), 92 | expected: "1w 2d ago", 93 | }, 94 | { 95 | name: "many weeks", 96 | modifiedTime: now.Add(-30 * 24 * time.Hour), 97 | timeTravelOffset: time.Duration(0), 98 | expected: "4w 2d ago", 99 | }, 100 | { 101 | name: "many weeks, no extra days", 102 | modifiedTime: now.Add(-28 * 24 * time.Hour), 103 | timeTravelOffset: time.Duration(0), 104 | expected: "4w ago", 105 | }, 106 | } 107 | 108 | for _, tc := range testCases { 109 | t.Run(tc.name, func(t *testing.T) { 110 | shared.SetModifiedTime(tc.modifiedTime) 111 | actual := lastUpdated() 112 | if actual != tc.expected { 113 | t.Fatalf("expected %q, got %q", tc.expected, actual) 114 | } 115 | }) 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /trayapp/theme_darwin.go: -------------------------------------------------------------------------------- 1 | package trayapp 2 | 3 | func SubscribeToThemeChanges(themeChangeChan chan<- bool) { 4 | // This function is a placeholder for Linux theme change subscription 5 | // Linux does not have a standard way to detect theme changes across all distributions 6 | } 7 | 8 | func IsDarkTheme() bool { 9 | // Check if the system is using a dark theme 10 | // This is a placeholder implementation and should be replaced with actual logic 11 | return false 12 | } 13 | -------------------------------------------------------------------------------- /trayapp/theme_linux.go: -------------------------------------------------------------------------------- 1 | package trayapp 2 | 3 | func SubscribeToThemeChanges(themeChangeChan chan<- bool) { 4 | // This function is a placeholder for Linux theme change subscription 5 | // Linux does not have a standard way to detect theme changes across all distributions 6 | } 7 | 8 | func IsDarkTheme() bool { 9 | // Check if the system is using a dark theme 10 | // This is a placeholder implementation and should be replaced with actual logic 11 | return false 12 | } 13 | -------------------------------------------------------------------------------- /trayapp/theme_windows.go: -------------------------------------------------------------------------------- 1 | package trayapp 2 | 3 | import ( 4 | "log" 5 | "sync" 6 | "time" 7 | 8 | "golang.org/x/sys/windows" 9 | "golang.org/x/sys/windows/registry" 10 | ) 11 | 12 | var ( 13 | themeChangeOnce sync.Once 14 | ) 15 | 16 | // SubscribeToThemeChanges starts monitoring theme changes and sends updates to the provided channel. 17 | func SubscribeToThemeChanges(themeChangeChan chan<- bool) { 18 | themeChangeOnce.Do(func() { 19 | go func() { 20 | for { 21 | // Open the registry key for monitoring 22 | key, err := registry.OpenKey(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize`, registry.NOTIFY) 23 | if err != nil { 24 | log.Printf("Failed to open registry key: %v", err) 25 | time.Sleep(10 * time.Second) // Retry after delay 26 | continue 27 | } 28 | 29 | // Create an event for change notifications 30 | event, err := windows.CreateEvent(nil, 0, 0, nil) 31 | if err != nil { 32 | log.Printf("Failed to create event: %v", err) 33 | key.Close() 34 | time.Sleep(10 * time.Second) 35 | continue 36 | } 37 | err = windows.RegNotifyChangeKeyValue(windows.Handle(key), true, windows.REG_NOTIFY_CHANGE_LAST_SET, event, false) 38 | key.Close() // Close the key after setting up notification 39 | 40 | // Wait for the event to be triggered 41 | windows.WaitForSingleObject(event, windows.INFINITE) 42 | windows.CloseHandle(event) 43 | 44 | // Notify the channel about the theme change 45 | select { 46 | case themeChangeChan <- IsDarkTheme(): 47 | default: 48 | log.Println("Theme change notification dropped due to no listener") 49 | } 50 | 51 | time.Sleep(10 * time.Second) // Retry after delay 52 | } 53 | }() 54 | }) 55 | } 56 | 57 | func IsDarkTheme() bool { 58 | // Equivalent to: (Get-ItemProperty -Path "HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize").AppsUseLightTheme -eq 0 59 | key, err := registry.OpenKey(registry.CURRENT_USER, `SOFTWARE\Microsoft\Windows\CurrentVersion\Themes\Personalize`, registry.QUERY_VALUE) 60 | if err != nil { 61 | return false 62 | } 63 | defer key.Close() 64 | 65 | val, _, err := key.GetIntegerValue("AppsUseLightTheme") 66 | if err != nil { 67 | return false 68 | } 69 | return val == 0 70 | } 71 | -------------------------------------------------------------------------------- /trayapp/watcher.go: -------------------------------------------------------------------------------- 1 | package trayapp 2 | 3 | import ( 4 | "github.com/ParetoSecurity/agent/shared" 5 | "github.com/caarlos0/log" 6 | "github.com/fsnotify/fsnotify" 7 | ) 8 | 9 | func watch(broadcaster *shared.Broadcaster) { 10 | go func() { 11 | watcher, err := fsnotify.NewWatcher() 12 | if err != nil { 13 | log.WithError(err).Error("Failed to create file watcher") 14 | return 15 | } 16 | defer watcher.Close() 17 | 18 | err = watcher.Add(shared.StatePath) 19 | if err != nil { 20 | log.WithError(err).WithField("path", shared.StatePath).Error("Failed to add state file to watcher") 21 | return 22 | } 23 | 24 | for { 25 | select { 26 | case event, ok := <-watcher.Events: 27 | if !ok { 28 | return 29 | } 30 | if event.Op&fsnotify.Write == fsnotify.Write { 31 | log.Info("State file modified, updating...") 32 | broadcaster.Send() 33 | } 34 | case err, ok := <-watcher.Errors: 35 | if !ok { 36 | return 37 | } 38 | log.WithError(err).Error("File watcher error") 39 | } 40 | } 41 | }() 42 | } 43 | -------------------------------------------------------------------------------- /winres/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ParetoSecurity/agent/52f3b7fb02d2731ff8bbd824e5665a8fbd363a93/winres/icon.png -------------------------------------------------------------------------------- /winres/winres.json: -------------------------------------------------------------------------------- 1 | { 2 | "RT_GROUP_ICON": { 3 | "APP": { 4 | "0000": [ 5 | "icon.png" 6 | ] 7 | } 8 | }, 9 | "RT_MANIFEST": { 10 | "#1": { 11 | "0409": { 12 | "identity": { 13 | "name": "ParetoSecurity", 14 | "version": "" 15 | }, 16 | "description": "Automatically audit your device for basic security hygiene.", 17 | "minimum-os": "win10", 18 | "execution-level": "as invoker", 19 | "ui-access": false, 20 | "auto-elevate": false, 21 | "dpi-awareness": "system", 22 | "disable-theming": false, 23 | "disable-window-filtering": false, 24 | "high-resolution-scrolling-aware": false, 25 | "ultra-high-resolution-scrolling-aware": false, 26 | "long-path-aware": false, 27 | "printer-driver-isolation": false, 28 | "gdi-scaling": false, 29 | "segment-heap": false, 30 | "use-common-controls-v6": false 31 | } 32 | } 33 | }, 34 | "RT_VERSION": { 35 | "#1": { 36 | "0000": { 37 | "fixed": { 38 | "file_version": "0.0.0.0", 39 | "product_version": "0.0.0.0" 40 | }, 41 | "info": { 42 | "0409": { 43 | "Comments": "", 44 | "CompanyName": "Niteo GmbH", 45 | "FileDescription": "", 46 | "FileVersion": "", 47 | "InternalName": "", 48 | "LegalCopyright": "", 49 | "LegalTrademarks": "", 50 | "OriginalFilename": "", 51 | "PrivateBuild": "", 52 | "ProductName": "ParetoSecurity", 53 | "ProductVersion": "", 54 | "SpecialBuild": "" 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } --------------------------------------------------------------------------------