diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..118203e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,79 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ['3.10', '3.11', '3.12'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-cov + + - name: Run tests + run: | + python -m pytest test_perplexity.py -v --cov=perplexity --cov-report=term-missing + + - name: Syntax check + run: | + python -m py_compile perplexity.py + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install linting tools + run: | + python -m pip install --upgrade pip + pip install flake8 + + - name: Run flake8 + run: | + flake8 perplexity.py --max-line-length=120 --ignore=E501,W503 + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install security tools + run: | + python -m pip install --upgrade pip + pip install bandit safety + + - name: Run Bandit security scanner + run: | + bandit -r perplexity.py -ll + + - name: Check dependencies for vulnerabilities + run: | + pip install -r requirements.txt + safety check --full-report || true diff --git a/README.md b/README.md index e4a3ccb..717f688 100644 --- a/README.md +++ b/README.md @@ -15,14 +15,45 @@ Perplexity CLI is a simple and convenient command-line client for the Perplexity - `requests` library ## Installation -No installation is required. You can run the script directly from the GitHub repository or install it locally.Example of local installation: +No installation is required. You can run the script directly from the GitHub repository or install it locally. +### Quick Installation (curl) ```bash curl -s https://raw.githubusercontent.com/dawid-szewc/perplexity-cli/main/perplexity.py > ~/.local/bin/perplexity \ chmod +x ~/.local/bin/perplexity \ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc \ echo 'export PERPLEXITY_API_KEY="your-api-key"' >> ~/.bashrc ``` + +### Alternative Installation (Clone Method) +This method allows you to easily update the tool and keep track of changes. Similar to Oh My Zsh installation: + +1. Clone the repository: +```bash +git clone https://github.com/dawid-szewc/perplexity-cli.git ~/.perplexity-cli +``` + +2. Add the following lines to your `~/.bashrc` or `~/.zshrc`: +```bash +# Perplexity CLI +export PERPLEXITY_API_KEY="your-api-key" +alias perplexity="python3 $HOME/.perplexity-cli/perplexity.py" +``` + +3. Apply the changes: +```bash +# For Bash +source ~/.bashrc + +# For Zsh +source ~/.zshrc +``` + +4. To update Perplexity CLI in the future, simply run: +```bash +cd ~/.perplexity-cli && git pull +``` + ## Usage ```bash perplexity "What is the meaning of life?" diff --git a/perplexity.py b/perplexity.py index 7fd661e..0b1f194 100644 --- a/perplexity.py +++ b/perplexity.py @@ -96,7 +96,7 @@ def __init__(self, args) -> None: logger.debug("Api key not found on system!") raise ApiKeyNotFoundException else: - logger.debug(f"Api key found on system: {api_key}") + logger.debug("Api key found on system") self.setup.api_key = api_key else: self.setup.api_key = args.api_key @@ -107,7 +107,7 @@ def get_response(self, message) -> None: "content-type": "application/json", "Authorization": f"Bearer {self.setup.api_key}", } - logger.debug(f"Headers: {headers}") + logger.debug("Request headers prepared (Authorization header redacted for security)") query_data = { "model": self.setup.model, "messages": [ @@ -115,10 +115,10 @@ def get_response(self, message) -> None: {"role": "user", "content": message}, ], } - logger.debug(f"Query data: {query_data}") + logger.debug(f"Query data prepared for model: {self.setup.model}") response = requests.post( - self.setup.api_url, headers=headers, data=json.dumps(query_data) + self.setup.api_url, headers=headers, data=json.dumps(query_data), timeout=30 ) if response.status_code == 200: @@ -191,7 +191,8 @@ def main() -> None: format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) - logger.debug(f"args: {args}") + # Avoid logging sensitive args like api_key and potentially sensitive query content + logger.debug(f"args: model={args.model}, verbose={args.verbose}, usage={args.usage}, citations={args.citations}, glow={args.glow}") try: perplexity = Perplexity(args) perplexity.get_response(args.query) diff --git a/requirements.txt b/requirements.txt index f229360..0eb8cae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -requests +requests>=2.31.0 diff --git a/test_perplexity.py b/test_perplexity.py new file mode 100644 index 0000000..e400d5d --- /dev/null +++ b/test_perplexity.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Tests for perplexity.py""" + +import unittest +from unittest.mock import patch, MagicMock +import argparse +import os +import io +import sys + +from perplexity import ( + ModelValidator, + ApiKeyValidator, + Perplexity, + ApiKeyNotFoundException, + InvalidSelectedModelException, + display, + AVAILABLE_MODELS, +) + + +class TestModelValidator(unittest.TestCase): + """Tests for ModelValidator class""" + + def test_validate_valid_models(self): + """Test that all available models are valid""" + for model in AVAILABLE_MODELS: + self.assertTrue(ModelValidator.validate(model)) + + def test_validate_invalid_model(self): + """Test that invalid models are rejected""" + self.assertFalse(ModelValidator.validate("invalid-model")) + self.assertFalse(ModelValidator.validate("")) + self.assertFalse(ModelValidator.validate("gpt-4")) + + def test_get_available_models(self): + """Test that get_AVAILABLE_MODELS returns the correct list""" + models = ModelValidator.get_AVAILABLE_MODELS() + self.assertEqual(models, AVAILABLE_MODELS) + self.assertIn("sonar-pro", models) + + +class TestApiKeyValidator(unittest.TestCase): + """Tests for ApiKeyValidator class""" + + @patch.dict(os.environ, {"PERPLEXITY_API_KEY": "test-api-key"}) + def test_get_api_key_from_system_exists(self): + """Test getting API key when it exists""" + api_key = ApiKeyValidator.get_api_key_from_system() + self.assertEqual(api_key, "test-api-key") + + @patch.dict(os.environ, {}, clear=True) + def test_get_api_key_from_system_not_exists(self): + """Test getting API key when it doesn't exist""" + # Remove the key if it exists + os.environ.pop("PERPLEXITY_API_KEY", None) + api_key = ApiKeyValidator.get_api_key_from_system() + self.assertIsNone(api_key) + + +class TestPerplexityInit(unittest.TestCase): + """Tests for Perplexity class initialization""" + + def create_args(self, model="sonar-pro", api_key=None, usage=False, citations=False, glow=False): + """Helper to create args namespace""" + return argparse.Namespace( + model=model, + api_key=api_key, + usage=usage, + citations=citations, + glow=glow + ) + + def test_invalid_model_raises_exception(self): + """Test that invalid model raises InvalidSelectedModelException""" + args = self.create_args(model="invalid-model", api_key="test-key") + with self.assertRaises(InvalidSelectedModelException): + Perplexity(args) + + @patch.dict(os.environ, {}, clear=True) + def test_missing_api_key_raises_exception(self): + """Test that missing API key raises ApiKeyNotFoundException""" + os.environ.pop("PERPLEXITY_API_KEY", None) + args = self.create_args(api_key=None) + with self.assertRaises(ApiKeyNotFoundException): + Perplexity(args) + + @patch.dict(os.environ, {"PERPLEXITY_API_KEY": "env-api-key"}) + def test_api_key_from_env(self): + """Test that API key is read from environment""" + args = self.create_args(api_key=None) + perplexity = Perplexity(args) + self.assertEqual(perplexity.setup.api_key, "env-api-key") + + def test_api_key_from_args(self): + """Test that API key from args takes precedence""" + args = self.create_args(api_key="arg-api-key") + perplexity = Perplexity(args) + self.assertEqual(perplexity.setup.api_key, "arg-api-key") + + +class TestPerplexityGetResponse(unittest.TestCase): + """Tests for Perplexity.get_response method""" + + def create_perplexity_instance(self): + """Helper to create a Perplexity instance""" + args = argparse.Namespace( + model="sonar-pro", + api_key="test-api-key", + usage=False, + citations=False, + glow=False + ) + return Perplexity(args) + + @patch('perplexity.requests.post') + def test_request_has_timeout(self, mock_post): + """Test that requests are made with a timeout (security fix)""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "choices": [{"message": {"content": "Test response"}}], + "citations": [], + "usage": {} + } + mock_post.return_value = mock_response + + perplexity = self.create_perplexity_instance() + perplexity.get_response("test query") + + # Verify timeout was passed to requests.post + mock_post.assert_called_once() + call_kwargs = mock_post.call_args[1] + self.assertIn('timeout', call_kwargs) + self.assertEqual(call_kwargs['timeout'], 30) + + @patch('perplexity.requests.post') + def test_authorization_header_format(self, mock_post): + """Test that Authorization header uses Bearer token""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "choices": [{"message": {"content": "Test response"}}], + "citations": [], + "usage": {} + } + mock_post.return_value = mock_response + + perplexity = self.create_perplexity_instance() + perplexity.get_response("test query") + + call_kwargs = mock_post.call_args[1] + headers = call_kwargs['headers'] + self.assertIn('Authorization', headers) + self.assertTrue(headers['Authorization'].startswith('Bearer ')) + + +class TestDisplay(unittest.TestCase): + """Tests for display function""" + + def test_display_output(self): + """Test that display produces output""" + captured_output = io.StringIO() + sys.stdout = captured_output + display("Test message", "white", False, "black") + sys.stdout = sys.__stdout__ + output = captured_output.getvalue() + self.assertIn("Test message", output) + + +class TestSecurityNoSensitiveDataInLogs(unittest.TestCase): + """Security tests to ensure sensitive data is not logged""" + + @patch('perplexity.logger') + @patch.dict(os.environ, {"PERPLEXITY_API_KEY": "secret-api-key-12345"}) + def test_api_key_not_logged_on_init(self, mock_logger): + """Test that API key value is not logged during initialization""" + args = argparse.Namespace( + model="sonar-pro", + api_key=None, + usage=False, + citations=False, + glow=False + ) + Perplexity(args) + + # Check all debug calls don't contain the actual API key + for call in mock_logger.debug.call_args_list: + call_str = str(call) + self.assertNotIn("secret-api-key-12345", call_str) + + +if __name__ == "__main__": + unittest.main()