├── .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 |
9 |
--------------------------------------------------------------------------------
/assets/icon_black.svg:
--------------------------------------------------------------------------------
1 |
2 |
70 |
--------------------------------------------------------------------------------
/assets/icon_white.svg:
--------------------------------------------------------------------------------
1 |
2 |
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 |
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 | 
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 | }
--------------------------------------------------------------------------------