mirror of
https://github.com/Rarebuffalo/securelens-backend.git
synced 2026-06-19 07:00:30 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce898b094d | ||
|
|
766fe22e1d | ||
|
|
852c2f9776 | ||
|
|
d132536284 | ||
|
|
b1fa5890e8 | ||
|
|
6f83412d6f | ||
|
|
eb657ac30a | ||
|
|
6dc816beba | ||
|
|
aeef04ee00 | ||
|
|
584ba8b149 | ||
|
|
caba447de3 | ||
|
|
6c1caa2f25 | ||
|
|
20bd779417 | ||
|
|
2d074d1d37 | ||
|
|
1b976fa8fd | ||
|
|
f9e1a15268 | ||
|
|
fb74c00686 | ||
|
|
235126d9ab | ||
|
|
5bba7b4042 | ||
|
|
67004b6584 | ||
|
|
cf5c7d9b17 | ||
|
|
e8c30b04cb |
22
.github/ISSUE_TEMPLATE/automated_patches.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/automated_patches.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: 'Feature: Automated Patches / Merge Requests'
|
||||||
|
about: Template for applying code patches and committing fixes.
|
||||||
|
title: 'Feature: Automated Patches / Merge Requests'
|
||||||
|
labels: ['help wanted', 'enhancement']
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
We want to close the loop on codebase remediation by letting the CLI suggest and commit code fixes automatically.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Implement a command (or interactive REPL slash command) that takes an AI-generated code patch, applies it to the local codebase, and creates a git branch or commit.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. Retrieve the code remediation suggested by the LLM.
|
||||||
|
2. Write the patch safely to the target file.
|
||||||
|
3. Optionally run a validation check (e.g. `pytest` or compiler checks) to verify the patch doesn't break tests.
|
||||||
|
4. Automatically create a local feature branch and git commit with the fix.
|
||||||
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
32
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Create a report to help us improve.
|
||||||
|
title: 'bug: '
|
||||||
|
labels: ['bug', 'triage']
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
A clear and concise description of what the bug is.
|
||||||
|
|
||||||
|
## Steps to Reproduce
|
||||||
|
|
||||||
|
Steps to reproduce the behavior:
|
||||||
|
1. Run CLI command '...'
|
||||||
|
2. See error '...'
|
||||||
|
|
||||||
|
## Expected Behavior
|
||||||
|
|
||||||
|
A clear and concise description of what you expected to happen.
|
||||||
|
|
||||||
|
## Environment Details
|
||||||
|
|
||||||
|
* OS: [e.g. Ubuntu 22.04, macOS Sonoma]
|
||||||
|
* Python Version: [e.g. 3.12.2]
|
||||||
|
* Installation Method: [e.g. cli/install.sh, pip install -e cli/]
|
||||||
|
* Active LLM Provider: [e.g. Gemini, OpenAI, Claude, Ollama]
|
||||||
|
|
||||||
|
## Additional Context
|
||||||
|
|
||||||
|
Add any other context or terminal log snippets about the problem here.
|
||||||
21
.github/ISSUE_TEMPLATE/ci_cd_integration.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/ci_cd_integration.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: 'Feature: CI/CD Integration Packages'
|
||||||
|
about: Template for building GitHub Actions and GitLab Runner wrappers.
|
||||||
|
title: 'Feature: CI/CD Integration Packages (GitHub Actions & GitLab Runner)'
|
||||||
|
labels: ['help wanted', 'enhancement']
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
We want to make it easy for developers to integrate SecureLens into their CI/CD pipelines.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Create a reusable GitHub Action and GitLab Runner template that runs the `securelens` CLI inside workflow pipelines.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. Wrap the CLI command `securelens scan` so it can run inside a container in GitHub Actions.
|
||||||
|
2. Implement configuration options to pass the `--ci` and `--fail-on` flags.
|
||||||
|
3. Ensure the action outputs logs cleanly and fails the build step with a non-zero exit code if critical or high vulnerabilities are detected.
|
||||||
21
.github/ISSUE_TEMPLATE/dependency_auditor.md
vendored
Normal file
21
.github/ISSUE_TEMPLATE/dependency_auditor.md
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: 'Feature: Dependency Lockfile Auditor'
|
||||||
|
about: Template for scanning package files against the OSV database.
|
||||||
|
title: 'Feature: Dependency Lockfile Auditor (securelens audit)'
|
||||||
|
labels: ['help wanted', 'enhancement', 'good first issue']
|
||||||
|
assignees: ''
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
We want to expand the CLI tool's capabilities to scan project dependencies for known vulnerabilities.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a new CLI command `securelens audit <path>` that scans package descriptors (such as `requirements.txt` or `package.json`) and runs checks against the Open Source Vulnerability (OSV.dev) database API.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
1. Parse common package files to extract dependency names and versions.
|
||||||
|
2. Submit query requests to the OSV API (`https://api.osv.dev/v1/query`).
|
||||||
|
3. Format the results in a clear CLI table using `rich` showing packages, affected versions, and severity.
|
||||||
@@ -6,7 +6,15 @@ Welcome to SecureLens! We appreciate your interest in contributing to the projec
|
|||||||
|
|
||||||
## Code of Conduct
|
## Code of Conduct
|
||||||
|
|
||||||
By participating in this project, you agree to abide by our Code of Conduct. Please read `CODE_OF_CONDUCT.md` to understand the expectations we have for community members.
|
By participating in this project, you agree to abide by our Code of Conduct. Please read [CODE_OF_CONDUCT.md](file:///home/Krishna-Singh/securelens-backend/CODE_OF_CONDUCT.md) to understand the expectations we have for community members.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where to Start?
|
||||||
|
|
||||||
|
If you are a first-time contributor or looking for a quick way to get involved, head over to our GitHub Issues tab. We actively maintain labels like:
|
||||||
|
* **`good first issue`** - Small, isolated tasks perfect for getting familiar with the repository layout.
|
||||||
|
* **`help wanted`** - Features, bugs, or refactoring tasks that are high priority for our current roadmap.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -40,22 +48,31 @@ To submit code changes:
|
|||||||
### 1. Code Style
|
### 1. Code Style
|
||||||
|
|
||||||
We enforce standard Python formatting to keep the codebase maintainable and readable:
|
We enforce standard Python formatting to keep the codebase maintainable and readable:
|
||||||
* Use standard line length limits (79 or 88 characters).
|
* Use standard line length limits (88 characters).
|
||||||
* Use explicit type hinting for function arguments and return values where possible.
|
* Use explicit type hinting for function arguments and return values where possible.
|
||||||
* Document modules, classes, and public functions with clear docstrings.
|
* Document modules, classes, and public functions with clear docstrings.
|
||||||
|
|
||||||
### 2. Linting and Formatting
|
### 2. Linting, Formatting & Pre-Commit
|
||||||
|
|
||||||
|
Run linting checks before committing. We recommend installing our pre-commit hook framework to automate these checks natively from your git environment:
|
||||||
|
|
||||||
Run linting checks before committing. If you use formatters like `black` or `ruff`:
|
|
||||||
```bash
|
```bash
|
||||||
# Format the code base
|
# Install the hooks locally
|
||||||
black app/ cli/ tests/
|
pre-commit install
|
||||||
|
|
||||||
# Run static analysis
|
# Manually run checks against all files
|
||||||
ruff check app/ cli/ tests/
|
pre-commit run --all-files
|
||||||
```
|
```
|
||||||
|
|
||||||
We recommend installing the pre-commit hook framework to automate checks (see `.pre-commit-config.yaml`).
|
If you prefer to run individual formatters and linters manually (or via our project shortcuts):
|
||||||
|
```bash
|
||||||
|
# Run via Makefile automation
|
||||||
|
make lint
|
||||||
|
|
||||||
|
# Or run manually
|
||||||
|
black app/ cli/ tests/
|
||||||
|
ruff check app/ cli/ tests/
|
||||||
|
```
|
||||||
|
|
||||||
### 3. Running the Test Suite
|
### 3. Running the Test Suite
|
||||||
|
|
||||||
@@ -63,6 +80,10 @@ We use `pytest` for unit and integration testing. All tests are located under th
|
|||||||
|
|
||||||
Run the entire test suite locally:
|
Run the entire test suite locally:
|
||||||
```bash
|
```bash
|
||||||
|
# Run via Makefile automation
|
||||||
|
make test
|
||||||
|
|
||||||
|
# Or run manually
|
||||||
pytest tests/ -v
|
pytest tests/ -v
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -73,6 +94,8 @@ pytest tests/test_cli_sync.py -v
|
|||||||
pytest tests/test_cli_pdf.py -v
|
pytest tests/test_cli_pdf.py -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Refer to [test_cli_patterns.py](file:///home/Krishna-Singh/securelens-backend/tests/test_cli_patterns.py), [test_cli_sync.py](file:///home/Krishna-Singh/securelens-backend/tests/test_cli_sync.py), and [test_cli_pdf.py](file:///home/Krishna-Singh/securelens-backend/tests/test_cli_pdf.py) for examples of testing our offline scanner, sync clients, and PDF exporters.
|
||||||
|
|
||||||
Ensure all tests pass and write new tests covering any logic changes or new features you introduce.
|
Ensure all tests pass and write new tests covering any logic changes or new features you introduce.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -80,7 +103,8 @@ Ensure all tests pass and write new tests covering any logic changes or new feat
|
|||||||
## Pull Request Guidelines
|
## Pull Request Guidelines
|
||||||
|
|
||||||
To help maintainers review your PR efficiently:
|
To help maintainers review your PR efficiently:
|
||||||
* Keep PRs focused. Do not combine multiple unrelated changes into one pull request.
|
* **Keep PRs focused.** Do not combine multiple unrelated changes into one pull request.
|
||||||
* Write a clear title and description explaining what was changed and why.
|
* **Provide context.** Write a clear title and description explaining what was changed and why.
|
||||||
* Reference any related issues (e.g., `Closes #123`).
|
* **Link your issues.** Reference any related issues (e.g., `Closes #123`) so they close automatically upon merge.
|
||||||
* Verify that your branch is rebased on top of the latest `main` branch.
|
* **Stay updated.** Verify that your branch is rebased on top of the latest upstream `main` branch.
|
||||||
|
* **CI Validation:** Every PR triggers an automated workflow running our formatting and test suites. PRs cannot be merged until these checks pass.
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -130,18 +130,24 @@ uvicorn app.main:app --reload
|
|||||||
|
|
||||||
### 2. Install Local CLI
|
### 2. Install Local CLI
|
||||||
|
|
||||||
Install the `securelens` CLI globally or inside your workspace virtual environment:
|
Choose one of the following installation methods based on your platform and preferences:
|
||||||
|
|
||||||
|
#### Method A: One-Line Global Installation (Cross-Platform)
|
||||||
|
Recommended for general use. Installs the CLI globally on Windows, macOS, or Linux directly from the repository without needing to clone it manually:
|
||||||
```bash
|
```bash
|
||||||
# Execute the automated installer script
|
pip install git+https://github.com/Rarebuffalo/securelens-backend.git#subdirectory=cli
|
||||||
|
```
|
||||||
|
*(Or use `pipx install git+...` to automatically isolate the tool in its own environment).*
|
||||||
|
|
||||||
|
#### Method B: Local Source Installation (Development)
|
||||||
|
If you have cloned this repository locally:
|
||||||
|
```bash
|
||||||
|
# On Linux/macOS
|
||||||
chmod +x cli/install.sh
|
chmod +x cli/install.sh
|
||||||
./cli/install.sh
|
./cli/install.sh
|
||||||
|
|
||||||
# Activate environment to use the tool
|
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
```
|
|
||||||
|
|
||||||
Alternatively, install in editable mode:
|
# On Windows or manual setup
|
||||||
```bash
|
|
||||||
pip install -e cli/
|
pip install -e cli/
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
23
SETUP.md
23
SETUP.md
@@ -88,25 +88,26 @@ This starts the containers in the background, maps port 8000 to the host, and pe
|
|||||||
|
|
||||||
The `securelens` CLI allows you to execute scans directly from your local terminal and synchronize reports back to your backend account.
|
The `securelens` CLI allows you to execute scans directly from your local terminal and synchronize reports back to your backend account.
|
||||||
|
|
||||||
### 1. Automated Script Installation
|
Choose one of the following installation methods based on your platform and preferences:
|
||||||
|
|
||||||
You can install the CLI automatically using the provided installer:
|
### Method A: One-Line Global Installation (Cross-Platform)
|
||||||
|
Recommended for general use. Installs the CLI globally on Windows, macOS, or Linux directly from the remote repository without needing to clone it:
|
||||||
|
```bash
|
||||||
|
pip install git+https://github.com/Rarebuffalo/securelens-backend.git#subdirectory=cli
|
||||||
|
```
|
||||||
|
*(Or use `pipx install git+...` to automatically isolate the tool in its own environment).*
|
||||||
|
|
||||||
|
### Method B: Local Source Installation (Development)
|
||||||
|
If you are contributing to this codebase or want to install it from the cloned repository folder:
|
||||||
|
|
||||||
|
#### 1. Automated Script Installation (Linux/macOS)
|
||||||
```bash
|
```bash
|
||||||
chmod +x cli/install.sh
|
chmod +x cli/install.sh
|
||||||
./cli/install.sh
|
./cli/install.sh
|
||||||
```
|
|
||||||
|
|
||||||
Ensure you have activated your virtual environment before running the tool:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Manual Installation
|
#### 2. Manual Installation (Windows / Cross-Platform)
|
||||||
|
|
||||||
Alternatively, you can install the CLI in editable mode manually:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install -e cli/
|
pip install -e cli/
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -55,6 +55,9 @@ class Settings(BaseSettings):
|
|||||||
# Leave blank for Ollama (local, no key needed).
|
# Leave blank for Ollama (local, no key needed).
|
||||||
ai_api_key: str | None = None
|
ai_api_key: str | None = None
|
||||||
|
|
||||||
|
# AI_API_BASE: Custom API base URL (e.g. for Agent Router or custom OpenAI-compatible proxies)
|
||||||
|
ai_api_base: str | None = None
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Legacy Gemini key — kept for backward compatibility.
|
# Legacy Gemini key — kept for backward compatibility.
|
||||||
# If AI_API_KEY is not set but GEMINI_API_KEY is, we use that automatically.
|
# If AI_API_KEY is not set but GEMINI_API_KEY is, we use that automatically.
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ async def chat_with_scan(
|
|||||||
We load the scan from PostgreSQL using the scan_id, so this works
|
We load the scan from PostgreSQL using the scan_id, so this works
|
||||||
correctly across server restarts and multiple workers.
|
correctly across server restarts and multiple workers.
|
||||||
"""
|
"""
|
||||||
if not settings.ai_api_key:
|
if not settings.effective_ai_key:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="AI Chat is disabled because no AI API key is configured.",
|
detail="AI Chat is disabled because no AI API key is configured.",
|
||||||
@@ -285,12 +285,12 @@ async def list_available_models():
|
|||||||
Lists AI models available to the configured provider.
|
Lists AI models available to the configured provider.
|
||||||
Only meaningful when using the Gemini provider.
|
Only meaningful when using the Gemini provider.
|
||||||
"""
|
"""
|
||||||
if not settings.ai_api_key:
|
if not settings.effective_ai_key:
|
||||||
raise HTTPException(status_code=500, detail="No AI API key is set.")
|
raise HTTPException(status_code=500, detail="No AI API key is set.")
|
||||||
try:
|
try:
|
||||||
from google import genai
|
from google import genai
|
||||||
|
|
||||||
client = genai.Client(api_key=settings.ai_api_key)
|
client = genai.Client(api_key=settings.effective_ai_key)
|
||||||
models = []
|
models = []
|
||||||
for model in client.models.list():
|
for model in client.models.list():
|
||||||
if "generateContent" in model.supported_actions:
|
if "generateContent" in model.supported_actions:
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ async def call_ai(
|
|||||||
"api_key": api_key,
|
"api_key": api_key,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if settings.ai_api_base:
|
||||||
|
kwargs["api_base"] = settings.ai_api_base
|
||||||
|
if "agentrouter.org" in settings.ai_api_base.lower():
|
||||||
|
kwargs["extra_headers"] = {
|
||||||
|
"Originator": "codex_cli_rs",
|
||||||
|
"User-Agent": "codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464",
|
||||||
|
"Version": "0.101.0",
|
||||||
|
}
|
||||||
|
|
||||||
# JSON mode: supported natively by OpenAI and LiteLLM proxied Gemini.
|
# JSON mode: supported natively by OpenAI and LiteLLM proxied Gemini.
|
||||||
# For providers that don't support it, LiteLLM silently ignores the flag.
|
# For providers that don't support it, LiteLLM silently ignores the flag.
|
||||||
if json_mode:
|
if json_mode:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ dependencies = [
|
|||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"questionary>=2.0",
|
"questionary>=2.0",
|
||||||
"pathspec>=0.12",
|
"pathspec>=0.12",
|
||||||
|
"fpdf2>=2.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ async def call_ai(
|
|||||||
temperature: float = 0.3,
|
temperature: float = 0.3,
|
||||||
json_mode: bool = False,
|
json_mode: bool = False,
|
||||||
conversation_history: Optional[list] = None,
|
conversation_history: Optional[list] = None,
|
||||||
|
api_base: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Single entry-point for all AI calls in the CLI.
|
Single entry-point for all AI calls in the CLI.
|
||||||
@@ -34,6 +35,7 @@ async def call_ai(
|
|||||||
json_mode : Ask the model to respond with valid JSON only
|
json_mode : Ask the model to respond with valid JSON only
|
||||||
conversation_history : Optional list of {"role": ..., "content": ...} dicts
|
conversation_history : Optional list of {"role": ..., "content": ...} dicts
|
||||||
for multi-turn chat sessions
|
for multi-turn chat sessions
|
||||||
|
api_base : Optional custom API base URL (e.g. for Agent Router)
|
||||||
"""
|
"""
|
||||||
import litellm
|
import litellm
|
||||||
|
|
||||||
@@ -49,6 +51,15 @@ async def call_ai(
|
|||||||
"api_key": api_key if api_key else None,
|
"api_key": api_key if api_key else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if api_base:
|
||||||
|
kwargs["api_base"] = api_base
|
||||||
|
if "agentrouter.org" in api_base.lower():
|
||||||
|
kwargs["extra_headers"] = {
|
||||||
|
"Originator": "codex_cli_rs",
|
||||||
|
"User-Agent": "codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464",
|
||||||
|
"Version": "0.101.0",
|
||||||
|
}
|
||||||
|
|
||||||
if json_mode:
|
if json_mode:
|
||||||
kwargs["response_format"] = {"type": "json_object"}
|
kwargs["response_format"] = {"type": "json_object"}
|
||||||
|
|
||||||
@@ -65,9 +76,10 @@ async def call_ai_json(
|
|||||||
api_key: str,
|
api_key: str,
|
||||||
model: str,
|
model: str,
|
||||||
temperature: float = 0.2,
|
temperature: float = 0.2,
|
||||||
|
api_base: Optional[str] = None,
|
||||||
) -> Optional[dict]:
|
) -> Optional[dict]:
|
||||||
"""Convenience wrapper — calls AI in JSON mode and parses the result."""
|
"""Convenience wrapper — calls AI in JSON mode and parses the result."""
|
||||||
raw = await call_ai(prompt, api_key, model, temperature=temperature, json_mode=True)
|
raw = await call_ai(prompt, api_key, model, temperature=temperature, json_mode=True, api_base=api_base)
|
||||||
if not raw:
|
if not raw:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ def _run(coro):
|
|||||||
|
|
||||||
|
|
||||||
def _require_config(cfg):
|
def _require_config(cfg):
|
||||||
"""Exit early with a friendly message if no API key is set."""
|
"""Exit early with a friendly message if no API key is set and not using a local model."""
|
||||||
if not cfg.api_key:
|
if not cfg.api_key and not cfg.default_model.startswith("ollama/"):
|
||||||
console.print(
|
console.print(
|
||||||
"\n[bold yellow]⚠ No API key configured.[/bold yellow]\n"
|
"\n[bold yellow]⚠ No API key configured.[/bold yellow]\n"
|
||||||
" Run [bold cyan]securelens configure[/bold cyan] to set one up.\n"
|
" Run [bold cyan]securelens configure[/bold cyan] to set one up.\n"
|
||||||
@@ -42,15 +42,17 @@ def _require_config(cfg):
|
|||||||
|
|
||||||
# ── Main group ─────────────────────────────────────────────────────────────────
|
# ── Main group ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@click.group()
|
@click.group(invoke_without_command=True)
|
||||||
@click.version_option("2.0.0", prog_name="SecureLens AI")
|
@click.version_option("2.0.0", prog_name="SecureLens AI")
|
||||||
def main():
|
@click.pass_context
|
||||||
|
def main(ctx):
|
||||||
"""
|
"""
|
||||||
\b
|
\b
|
||||||
SecureLens AI — AI-powered security scanner
|
SecureLens AI — AI-powered security scanner
|
||||||
Scan codebases, URLs and get instant security reports.
|
Scan codebases, URLs and get instant security reports.
|
||||||
"""
|
"""
|
||||||
pass
|
if ctx.invoked_subcommand is None:
|
||||||
|
click.echo(ctx.get_help())
|
||||||
|
|
||||||
|
|
||||||
# ── configure ─────────────────────────────────────────────────────────────────
|
# ── configure ─────────────────────────────────────────────────────────────────
|
||||||
@@ -74,7 +76,8 @@ def configure():
|
|||||||
"4": ("gpt-4o", "OpenAI GPT-4o"),
|
"4": ("gpt-4o", "OpenAI GPT-4o"),
|
||||||
"5": ("claude-3-5-haiku-20241022","Anthropic Claude 3.5 Haiku"),
|
"5": ("claude-3-5-haiku-20241022","Anthropic Claude 3.5 Haiku"),
|
||||||
"6": ("ollama/llama3.1", "Ollama (local, no key needed)"),
|
"6": ("ollama/llama3.1", "Ollama (local, no key needed)"),
|
||||||
"7": ("custom", "Custom model string"),
|
"7": ("agentrouter", "Agent Router (OpenAI-compatible gateway)"),
|
||||||
|
"8": ("custom", "Custom LiteLLM model / Endpoint"),
|
||||||
}
|
}
|
||||||
console.print("[bold]Choose AI Provider:[/bold]")
|
console.print("[bold]Choose AI Provider:[/bold]")
|
||||||
for k, (_, desc) in providers.items():
|
for k, (_, desc) in providers.items():
|
||||||
@@ -83,11 +86,21 @@ def configure():
|
|||||||
|
|
||||||
choice = Prompt.ask("Select", choices=list(providers.keys()), default="1")
|
choice = Prompt.ask("Select", choices=list(providers.keys()), default="1")
|
||||||
model_str, _ = providers[choice]
|
model_str, _ = providers[choice]
|
||||||
|
api_base = ""
|
||||||
|
|
||||||
if model_str == "custom":
|
if choice == "7":
|
||||||
|
model_str = Prompt.ask("Enter model name (must start with 'openai/')", default="openai/deepseek-chat")
|
||||||
|
if not model_str.startswith("openai/"):
|
||||||
|
model_str = "openai/" + model_str
|
||||||
|
api_base = Prompt.ask("Enter API base URL", default="https://agentrouter.org/v1").strip()
|
||||||
|
elif choice == "8":
|
||||||
|
model_str = Prompt.ask("Enter LiteLLM model string (e.g. openai/my-model-name)")
|
||||||
|
api_base = Prompt.ask("Enter custom API base URL (optional, e.g. https://my-endpoint/v1)", default="").strip()
|
||||||
|
elif model_str == "custom":
|
||||||
model_str = Prompt.ask("Enter LiteLLM model string (e.g. openrouter/google/gemini-flash)")
|
model_str = Prompt.ask("Enter LiteLLM model string (e.g. openrouter/google/gemini-flash)")
|
||||||
|
|
||||||
cfg.default_model = model_str
|
cfg.default_model = model_str
|
||||||
|
cfg.api_base = api_base
|
||||||
|
|
||||||
# API key (skip for Ollama)
|
# API key (skip for Ollama)
|
||||||
if not model_str.startswith("ollama/"):
|
if not model_str.startswith("ollama/"):
|
||||||
@@ -106,6 +119,8 @@ def configure():
|
|||||||
save_config(cfg)
|
save_config(cfg)
|
||||||
console.print(f"\n[bold green]✓ Config saved to {CONFIG_FILE}[/bold green]")
|
console.print(f"\n[bold green]✓ Config saved to {CONFIG_FILE}[/bold green]")
|
||||||
console.print(f" Model: [cyan]{cfg.default_model}[/cyan]")
|
console.print(f" Model: [cyan]{cfg.default_model}[/cyan]")
|
||||||
|
if cfg.api_base:
|
||||||
|
console.print(f" Base URL: [cyan]{cfg.api_base}[/cyan]")
|
||||||
console.print(f" Output: [cyan]{cfg.output_format}[/cyan]\n")
|
console.print(f" Output: [cyan]{cfg.output_format}[/cyan]\n")
|
||||||
|
|
||||||
|
|
||||||
@@ -158,10 +173,32 @@ async def _scan_async(path, model, output, max_files, ci, fail_on, no_ai, sync):
|
|||||||
cfg.max_files_to_scan = max_files
|
cfg.max_files_to_scan = max_files
|
||||||
|
|
||||||
if not no_ai:
|
if not no_ai:
|
||||||
_require_config(cfg)
|
if not cfg.api_key and not cfg.default_model.startswith("ollama/"):
|
||||||
|
console.print(
|
||||||
|
"\n[bold yellow]⚠ No API key configured.[/bold yellow] Automatically falling back to [bold cyan]offline pattern-based mode[/bold cyan].\n"
|
||||||
|
" To use AI capabilities, run [bold cyan]securelens configure[/bold cyan] to set an API key,\n"
|
||||||
|
" or set the [dim]SECURELENS_API_KEY[/dim] environment variable.\n"
|
||||||
|
)
|
||||||
|
no_ai = True
|
||||||
|
else:
|
||||||
|
_require_config(cfg)
|
||||||
|
|
||||||
root = Path(path).resolve()
|
root = Path(path).resolve()
|
||||||
|
|
||||||
|
# Safety check: prevent scanning home/root directories by mistake
|
||||||
|
if root == Path.home() or root == Path("/"):
|
||||||
|
if not ci:
|
||||||
|
if not Confirm.ask(
|
||||||
|
f"\n[bold yellow]⚠ Warning: You are attempting to scan your home or root directory ({root}).[/bold yellow]\n"
|
||||||
|
" This may contain system caches, virtual environments, and massive system files.\n"
|
||||||
|
" Do you want to continue?"
|
||||||
|
):
|
||||||
|
console.print("[dim]Scan cancelled.[/dim]\n")
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
console.print(f"[bold red]✗ Error: Cannot scan home/root directory ({root}) in CI mode.[/bold red]\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if not ci:
|
if not ci:
|
||||||
print_banner()
|
print_banner()
|
||||||
print_scan_header(str(root), cfg.default_model)
|
print_scan_header(str(root), cfg.default_model)
|
||||||
@@ -232,7 +269,7 @@ async def _scan_async(path, model, output, max_files, ci, fail_on, no_ai, sync):
|
|||||||
for v in vulnerabilities
|
for v in vulnerabilities
|
||||||
]
|
]
|
||||||
prompt = summary_prompt(str(root), _json.dumps(issues_data, indent=2))
|
prompt = summary_prompt(str(root), _json.dumps(issues_data, indent=2))
|
||||||
ai_summary = await call_ai(prompt, cfg.api_key, cfg.default_model, temperature=0.4)
|
ai_summary = await call_ai(prompt, cfg.api_key, cfg.default_model, temperature=0.4, api_base=cfg.api_base)
|
||||||
progress.update(task_summary, completed=100, total=100, detail="Done")
|
progress.update(task_summary, completed=100, total=100, detail="Done")
|
||||||
|
|
||||||
# ── Build result ─────────────────────────────────────────────────────────
|
# ── Build result ─────────────────────────────────────────────────────────
|
||||||
@@ -275,13 +312,14 @@ async def _scan_async(path, model, output, max_files, ci, fail_on, no_ai, sync):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# ── Interactive REPL ─────────────────────────────────────────────────────
|
# ── Interactive REPL ─────────────────────────────────────────────────────
|
||||||
if fmt in ("terminal", "all", "markdown") and not no_ai:
|
if fmt in ("terminal", "all", "markdown"):
|
||||||
ctx = ReplContext(
|
ctx = ReplContext(
|
||||||
target=str(root),
|
target=str(root),
|
||||||
scan_result=result,
|
scan_result=result,
|
||||||
target_type="code",
|
target_type="code",
|
||||||
api_key=cfg.api_key,
|
api_key=cfg.api_key if not no_ai else None,
|
||||||
model=cfg.default_model,
|
model=cfg.default_model,
|
||||||
|
api_base=cfg.api_base if not no_ai else None,
|
||||||
)
|
)
|
||||||
await run_repl(ctx)
|
await run_repl(ctx)
|
||||||
|
|
||||||
@@ -351,7 +389,7 @@ async def _web_async(url, model, output, ci, fail_on, no_ai):
|
|||||||
]
|
]
|
||||||
prompt = web_summary_prompt(url, _json.dumps(issues_data, indent=2),
|
prompt = web_summary_prompt(url, _json.dumps(issues_data, indent=2),
|
||||||
result.score, result.grade)
|
result.score, result.grade)
|
||||||
result.ai_summary = await call_ai(prompt, cfg.api_key, cfg.default_model, temperature=0.4)
|
result.ai_summary = await call_ai(prompt, cfg.api_key, cfg.default_model, temperature=0.4, api_base=cfg.api_base)
|
||||||
progress.update(task2, completed=100, total=100, detail="Done")
|
progress.update(task2, completed=100, total=100, detail="Done")
|
||||||
|
|
||||||
fmt = cfg.output_format
|
fmt = cfg.output_format
|
||||||
@@ -370,13 +408,14 @@ async def _web_async(url, model, output, ci, fail_on, no_ai):
|
|||||||
_ci_exit(result.issues, fail_on, "web")
|
_ci_exit(result.issues, fail_on, "web")
|
||||||
return
|
return
|
||||||
|
|
||||||
if fmt in ("terminal", "all", "markdown") and not no_ai:
|
if fmt in ("terminal", "all", "markdown"):
|
||||||
ctx = ReplContext(
|
ctx = ReplContext(
|
||||||
target=url,
|
target=url,
|
||||||
scan_result=result,
|
scan_result=result,
|
||||||
target_type="web",
|
target_type="web",
|
||||||
api_key=cfg.api_key,
|
api_key=cfg.api_key if not no_ai else None,
|
||||||
model=cfg.default_model,
|
model=cfg.default_model,
|
||||||
|
api_base=cfg.api_base if not no_ai else None,
|
||||||
)
|
)
|
||||||
await run_repl(ctx)
|
await run_repl(ctx)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CLIConfig:
|
|||||||
# AI backend
|
# AI backend
|
||||||
default_model: str = "gemini/gemini-2.0-flash"
|
default_model: str = "gemini/gemini-2.0-flash"
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
|
api_base: str = ""
|
||||||
|
|
||||||
# Backend Integration (for sync / auth)
|
# Backend Integration (for sync / auth)
|
||||||
backend_url: str = "http://localhost:8000"
|
backend_url: str = "http://localhost:8000"
|
||||||
@@ -61,6 +62,7 @@ def load_config() -> CLIConfig:
|
|||||||
data = yaml.safe_load(f) or {}
|
data = yaml.safe_load(f) or {}
|
||||||
cfg.default_model = data.get("default_model", cfg.default_model)
|
cfg.default_model = data.get("default_model", cfg.default_model)
|
||||||
cfg.api_key = data.get("api_key", cfg.api_key)
|
cfg.api_key = data.get("api_key", cfg.api_key)
|
||||||
|
cfg.api_base = data.get("api_base", cfg.api_base)
|
||||||
cfg.backend_url = data.get("backend_url", cfg.backend_url)
|
cfg.backend_url = data.get("backend_url", cfg.backend_url)
|
||||||
cfg.token = data.get("token", cfg.token)
|
cfg.token = data.get("token", cfg.token)
|
||||||
cfg.output_format = data.get("output_format", cfg.output_format)
|
cfg.output_format = data.get("output_format", cfg.output_format)
|
||||||
@@ -82,6 +84,11 @@ def load_config() -> CLIConfig:
|
|||||||
or os.environ.get("AI_MODEL")
|
or os.environ.get("AI_MODEL")
|
||||||
or cfg.default_model
|
or cfg.default_model
|
||||||
)
|
)
|
||||||
|
cfg.api_base = (
|
||||||
|
os.environ.get("SECURELENS_API_BASE")
|
||||||
|
or os.environ.get("AI_API_BASE")
|
||||||
|
or cfg.api_base
|
||||||
|
)
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
@@ -92,6 +99,7 @@ def save_config(cfg: CLIConfig) -> None:
|
|||||||
data = {
|
data = {
|
||||||
"default_model": cfg.default_model,
|
"default_model": cfg.default_model,
|
||||||
"api_key": cfg.api_key,
|
"api_key": cfg.api_key,
|
||||||
|
"api_base": cfg.api_base,
|
||||||
"backend_url": cfg.backend_url,
|
"backend_url": cfg.backend_url,
|
||||||
"token": cfg.token,
|
"token": cfg.token,
|
||||||
"output_format": cfg.output_format,
|
"output_format": cfg.output_format,
|
||||||
|
|||||||
@@ -2,6 +2,39 @@ from fpdf import FPDF
|
|||||||
import datetime
|
import datetime
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
def sanitize_text(text: Optional[str]) -> str:
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
replacements = {
|
||||||
|
"\u2018": "'",
|
||||||
|
"\u2019": "'",
|
||||||
|
"\u201c": '"',
|
||||||
|
"\u201d": '"',
|
||||||
|
"\u2013": "-",
|
||||||
|
"\u2014": "-",
|
||||||
|
"\u2022": "*",
|
||||||
|
"\u2026": "...",
|
||||||
|
"\u2713": "OK",
|
||||||
|
"\u2714": "OK",
|
||||||
|
"\u2715": "X",
|
||||||
|
"\u2717": "X",
|
||||||
|
"\u2718": "X",
|
||||||
|
"\u26a0": "!",
|
||||||
|
"\u25b6": ">",
|
||||||
|
"\u25c0": "<",
|
||||||
|
"\u25b2": "^",
|
||||||
|
"\u25bc": "v",
|
||||||
|
"\u25ae": "|",
|
||||||
|
"\u2588": "#",
|
||||||
|
"\u2591": ".",
|
||||||
|
"\u2592": ":",
|
||||||
|
"\u2593": "#",
|
||||||
|
"`": "'",
|
||||||
|
}
|
||||||
|
for orig, rep in replacements.items():
|
||||||
|
text = text.replace(orig, rep)
|
||||||
|
return text.encode("latin-1", errors="replace").decode("latin-1")
|
||||||
|
|
||||||
class SecureLensPDF(FPDF):
|
class SecureLensPDF(FPDF):
|
||||||
def footer(self):
|
def footer(self):
|
||||||
self.set_y(-15)
|
self.set_y(-15)
|
||||||
@@ -26,9 +59,9 @@ def export_code_pdf(result, output_path: str) -> str:
|
|||||||
pdf.set_font("helvetica", "", 10)
|
pdf.set_font("helvetica", "", 10)
|
||||||
pdf.set_text_color(100, 100, 100)
|
pdf.set_text_color(100, 100, 100)
|
||||||
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
pdf.cell(0, 8, f"Target Path: {result.target}", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 8, sanitize_text(f"Target Path: {result.target}"), new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.cell(0, 8, f"Scan Time: {now_str}", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 8, f"Scan Time: {now_str}", new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.cell(0, 8, f"Security Score: {result.score}/100 (Grade: {result.grade})", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 8, sanitize_text(f"Security Score: {result.score}/100 (Grade: {result.grade})"), new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.ln(5)
|
pdf.ln(5)
|
||||||
|
|
||||||
# Executive Summary Section
|
# Executive Summary Section
|
||||||
@@ -40,7 +73,7 @@ def export_code_pdf(result, output_path: str) -> str:
|
|||||||
pdf.set_text_color(0, 0, 0)
|
pdf.set_text_color(0, 0, 0)
|
||||||
pdf.ln(2)
|
pdf.ln(2)
|
||||||
summary_text = result.ai_summary or f"A static patterns analysis was performed on the codebase. Out of the files discovered, {len(result.vulnerabilities)} potential security vulnerabilities were reported."
|
summary_text = result.ai_summary or f"A static patterns analysis was performed on the codebase. Out of the files discovered, {len(result.vulnerabilities)} potential security vulnerabilities were reported."
|
||||||
pdf.multi_cell(0, 5, summary_text)
|
pdf.multi_cell(0, 5, sanitize_text(summary_text), new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.ln(8)
|
pdf.ln(8)
|
||||||
|
|
||||||
# Files Scanned Section
|
# Files Scanned Section
|
||||||
@@ -53,7 +86,7 @@ def export_code_pdf(result, output_path: str) -> str:
|
|||||||
files_list = ", ".join(result.files_triaged[:15])
|
files_list = ", ".join(result.files_triaged[:15])
|
||||||
if len(result.files_triaged) > 15:
|
if len(result.files_triaged) > 15:
|
||||||
files_list += f", and {len(result.files_triaged) - 15} more"
|
files_list += f", and {len(result.files_triaged) - 15} more"
|
||||||
pdf.multi_cell(0, 5, files_list or "No files selected.")
|
pdf.multi_cell(0, 5, sanitize_text(files_list or "No files selected."), new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.ln(8)
|
pdf.ln(8)
|
||||||
|
|
||||||
# Issues Findings Section
|
# Issues Findings Section
|
||||||
@@ -78,26 +111,26 @@ def export_code_pdf(result, output_path: str) -> str:
|
|||||||
else: pdf.set_text_color(0, 100, 0)
|
else: pdf.set_text_color(0, 100, 0)
|
||||||
|
|
||||||
line_str = f" [Line {v.line_number}]" if v.line_number else ""
|
line_str = f" [Line {v.line_number}]" if v.line_number else ""
|
||||||
pdf.cell(0, 8, f"{idx}. {severity}: {v.issue}{line_str}", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 8, sanitize_text(f"{idx}. {severity}: {v.issue}{line_str}"), new_x="LMARGIN", new_y="NEXT")
|
||||||
|
|
||||||
# Details
|
# Details
|
||||||
pdf.set_text_color(0, 0, 0)
|
pdf.set_text_color(0, 0, 0)
|
||||||
pdf.set_font("helvetica", "B", 9)
|
pdf.set_font("helvetica", "B", 9)
|
||||||
pdf.cell(20, 6, "File:", border=0)
|
pdf.cell(20, 6, "File:", border=0)
|
||||||
pdf.set_font("helvetica", "", 9)
|
pdf.set_font("helvetica", "", 9)
|
||||||
pdf.cell(0, 6, v.file_path, new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 6, sanitize_text(v.file_path), new_x="LMARGIN", new_y="NEXT")
|
||||||
|
|
||||||
pdf.set_font("helvetica", "B", 9)
|
pdf.set_font("helvetica", "B", 9)
|
||||||
pdf.cell(0, 6, "Explanation:", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 6, "Explanation:", new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.set_font("helvetica", "", 9)
|
pdf.set_font("helvetica", "", 9)
|
||||||
pdf.multi_cell(0, 4.5, v.explanation)
|
pdf.multi_cell(0, 4.5, sanitize_text(v.explanation), new_x="LMARGIN", new_y="NEXT")
|
||||||
|
|
||||||
if v.suggested_fix:
|
if v.suggested_fix:
|
||||||
pdf.set_font("helvetica", "B", 9)
|
pdf.set_font("helvetica", "B", 9)
|
||||||
pdf.cell(0, 6, "Suggested Fix:", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 6, "Suggested Fix:", new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.set_font("courier", "", 8.5)
|
pdf.set_font("courier", "", 8.5)
|
||||||
pdf.set_fill_color(245, 245, 245)
|
pdf.set_fill_color(245, 245, 245)
|
||||||
pdf.multi_cell(0, 4.5, v.suggested_fix, fill=True)
|
pdf.multi_cell(0, 4.5, sanitize_text(v.suggested_fix), fill=True, new_x="LMARGIN", new_y="NEXT")
|
||||||
|
|
||||||
pdf.ln(4)
|
pdf.ln(4)
|
||||||
pdf.line(pdf.get_x(), pdf.get_y(), 200, pdf.get_y())
|
pdf.line(pdf.get_x(), pdf.get_y(), 200, pdf.get_y())
|
||||||
@@ -123,9 +156,9 @@ def export_web_pdf(result, output_path: str) -> str:
|
|||||||
pdf.set_font("helvetica", "", 10)
|
pdf.set_font("helvetica", "", 10)
|
||||||
pdf.set_text_color(100, 100, 100)
|
pdf.set_text_color(100, 100, 100)
|
||||||
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
pdf.cell(0, 8, f"Target URL: {result.url}", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 8, sanitize_text(f"Target URL: {result.url}"), new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.cell(0, 8, f"Scan Time: {now_str}", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 8, f"Scan Time: {now_str}", new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.cell(0, 8, f"Security Score: {result.score}/100 (Grade: {result.grade})", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 8, sanitize_text(f"Security Score: {result.score}/100 (Grade: {result.grade})"), new_x="LMARGIN", new_y="NEXT")
|
||||||
if result.ssl_expiry_days is not None:
|
if result.ssl_expiry_days is not None:
|
||||||
pdf.cell(0, 8, f"SSL Expiry: {result.ssl_expiry_days} days left", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 8, f"SSL Expiry: {result.ssl_expiry_days} days left", new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.ln(5)
|
pdf.ln(5)
|
||||||
@@ -139,7 +172,7 @@ def export_web_pdf(result, output_path: str) -> str:
|
|||||||
pdf.set_text_color(0, 0, 0)
|
pdf.set_text_color(0, 0, 0)
|
||||||
pdf.ln(2)
|
pdf.ln(2)
|
||||||
summary_text = result.ai_summary or f"An automated live security audit was performed on {result.url}. Out of the layers checked, {len(result.issues)} potential issues were flagged."
|
summary_text = result.ai_summary or f"An automated live security audit was performed on {result.url}. Out of the layers checked, {len(result.issues)} potential issues were flagged."
|
||||||
pdf.multi_cell(0, 5, summary_text)
|
pdf.multi_cell(0, 5, sanitize_text(summary_text), new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.ln(8)
|
pdf.ln(8)
|
||||||
|
|
||||||
# Issues Section
|
# Issues Section
|
||||||
@@ -162,20 +195,20 @@ def export_web_pdf(result, output_path: str) -> str:
|
|||||||
elif severity == "Warning": pdf.set_text_color(218, 165, 32)
|
elif severity == "Warning": pdf.set_text_color(218, 165, 32)
|
||||||
else: pdf.set_text_color(0, 100, 0)
|
else: pdf.set_text_color(0, 100, 0)
|
||||||
|
|
||||||
pdf.cell(0, 8, f"{idx}. {severity}: {i.issue}", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 8, sanitize_text(f"{idx}. {severity}: {i.issue}"), new_x="LMARGIN", new_y="NEXT")
|
||||||
|
|
||||||
# Details
|
# Details
|
||||||
pdf.set_text_color(0, 0, 0)
|
pdf.set_text_color(0, 0, 0)
|
||||||
pdf.set_font("helvetica", "B", 9)
|
pdf.set_font("helvetica", "B", 9)
|
||||||
pdf.cell(20, 6, "Layer:", border=0)
|
pdf.cell(20, 6, "Layer:", border=0)
|
||||||
pdf.set_font("helvetica", "", 9)
|
pdf.set_font("helvetica", "", 9)
|
||||||
pdf.cell(0, 6, i.layer, new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 6, sanitize_text(i.layer), new_x="LMARGIN", new_y="NEXT")
|
||||||
|
|
||||||
pdf.set_font("helvetica", "B", 9)
|
pdf.set_font("helvetica", "B", 9)
|
||||||
pdf.cell(0, 6, "Remediation / Fix:", new_x="LMARGIN", new_y="NEXT")
|
pdf.cell(0, 6, "Remediation / Fix:", new_x="LMARGIN", new_y="NEXT")
|
||||||
pdf.set_font("courier", "", 8.5)
|
pdf.set_font("courier", "", 8.5)
|
||||||
pdf.set_fill_color(245, 245, 245)
|
pdf.set_fill_color(245, 245, 245)
|
||||||
pdf.multi_cell(0, 4.5, i.fix, fill=True)
|
pdf.multi_cell(0, 4.5, sanitize_text(i.fix), fill=True, new_x="LMARGIN", new_y="NEXT")
|
||||||
|
|
||||||
pdf.ln(4)
|
pdf.ln(4)
|
||||||
pdf.line(pdf.get_x(), pdf.get_y(), 200, pdf.get_y())
|
pdf.line(pdf.get_x(), pdf.get_y(), 200, pdf.get_y())
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ class ReplContext:
|
|||||||
target_type: str # "code" | "web" | "github"
|
target_type: str # "code" | "web" | "github"
|
||||||
api_key: str
|
api_key: str
|
||||||
model: str
|
model: str
|
||||||
|
api_base: Optional[str] = None
|
||||||
conversation_history: list = field(default_factory=list)
|
conversation_history: list = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ async def run_repl(ctx: ReplContext) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# ── AI response ─────────────────────────────────────────────────────
|
# ── AI response ─────────────────────────────────────────────────────
|
||||||
if not ctx.api_key:
|
if not ctx.api_key and not ctx.model.startswith("ollama/"):
|
||||||
console.print(
|
console.print(
|
||||||
"\n [bold red]✗ No API key configured.[/bold red] "
|
"\n [bold red]✗ No API key configured.[/bold red] "
|
||||||
"Run [cyan]securelens configure[/cyan] to set one.\n"
|
"Run [cyan]securelens configure[/cyan] to set one.\n"
|
||||||
@@ -109,6 +110,7 @@ async def run_repl(ctx: ReplContext) -> None:
|
|||||||
model=ctx.model,
|
model=ctx.model,
|
||||||
temperature=0.5,
|
temperature=0.5,
|
||||||
conversation_history=ctx.conversation_history,
|
conversation_history=ctx.conversation_history,
|
||||||
|
api_base=ctx.api_base,
|
||||||
)
|
)
|
||||||
|
|
||||||
if response:
|
if response:
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ def discover_files(root: Path, cfg: CLIConfig) -> list[Path]:
|
|||||||
Respects .gitignore in the root and cfg.ignore_patterns.
|
Respects .gitignore in the root and cfg.ignore_patterns.
|
||||||
Skips binaries and files larger than cfg.max_file_size_kb.
|
Skips binaries and files larger than cfg.max_file_size_kb.
|
||||||
"""
|
"""
|
||||||
|
import os
|
||||||
# Build a combined spec from config ignore_patterns + .gitignore
|
# Build a combined spec from config ignore_patterns + .gitignore
|
||||||
ignore_patterns = list(cfg.ignore_patterns)
|
ignore_patterns = list(cfg.ignore_patterns)
|
||||||
gitignore_path = root / ".gitignore"
|
gitignore_path = root / ".gitignore"
|
||||||
@@ -113,18 +114,47 @@ def discover_files(root: Path, cfg: CLIConfig) -> list[Path]:
|
|||||||
spec = pathspec.PathSpec.from_lines("gitwildmatch", ignore_patterns)
|
spec = pathspec.PathSpec.from_lines("gitwildmatch", ignore_patterns)
|
||||||
max_bytes = cfg.max_file_size_kb * 1024
|
max_bytes = cfg.max_file_size_kb * 1024
|
||||||
|
|
||||||
|
# Hardcoded directory blacklist to prune execution paths immediately
|
||||||
|
prune_dirs = {
|
||||||
|
".git", "node_modules", "venv", ".venv", "__pycache__",
|
||||||
|
"dist", "build", ".next", ".cache", ".npm", ".cargo",
|
||||||
|
".rustup", ".local", ".ssh", ".gnupg", ".docker", ".vscode",
|
||||||
|
".idea", "Library", "Pictures", "Music", "Videos", "Documents"
|
||||||
|
}
|
||||||
|
|
||||||
candidates: list[Path] = []
|
candidates: list[Path] = []
|
||||||
for p in root.rglob("*"):
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
if not p.is_file():
|
# 1. Prune standard blacklisted folders in-place
|
||||||
continue
|
dirnames[:] = [d for d in dirnames if d not in prune_dirs]
|
||||||
rel = p.relative_to(root).as_posix()
|
|
||||||
if spec.match_file(rel):
|
# 2. Prune directories matching the ignore spec
|
||||||
continue
|
active_dirs = []
|
||||||
if p.suffix.lower() in BINARY_EXTENSIONS:
|
for d in dirnames:
|
||||||
continue
|
rel_path = os.path.relpath(os.path.join(dirpath, d), root)
|
||||||
if p.stat().st_size > max_bytes:
|
if not spec.match_file(rel_path + "/"):
|
||||||
continue
|
active_dirs.append(d)
|
||||||
candidates.append(p)
|
dirnames[:] = active_dirs
|
||||||
|
|
||||||
|
# 3. Process files in the active directory
|
||||||
|
for f in filenames:
|
||||||
|
p = Path(dirpath) / f
|
||||||
|
rel = p.relative_to(root).as_posix()
|
||||||
|
if spec.match_file(rel):
|
||||||
|
continue
|
||||||
|
if p.suffix.lower() in BINARY_EXTENSIONS:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
if p.stat().st_size > max_bytes:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
candidates.append(p)
|
||||||
|
|
||||||
|
# Capping safeguard: limit to 1000 candidate files
|
||||||
|
if len(candidates) >= 1000:
|
||||||
|
break
|
||||||
|
if len(candidates) >= 1000:
|
||||||
|
break
|
||||||
|
|
||||||
return sorted(candidates)
|
return sorted(candidates)
|
||||||
|
|
||||||
@@ -165,7 +195,7 @@ async def triage_files(
|
|||||||
if rel_paths and remaining_budget > 0 and cfg.api_key:
|
if rel_paths and remaining_budget > 0 and cfg.api_key:
|
||||||
file_list_str = "\n".join(rel_paths[:300]) # cap to ~300 paths for token budget
|
file_list_str = "\n".join(rel_paths[:300]) # cap to ~300 paths for token budget
|
||||||
prompt = triage_prompt(file_list_str, remaining_budget)
|
prompt = triage_prompt(file_list_str, remaining_budget)
|
||||||
result = await call_ai_json(prompt, cfg.api_key, cfg.default_model, temperature=0.1)
|
result = await call_ai_json(prompt, cfg.api_key, cfg.default_model, temperature=0.1, api_base=cfg.api_base)
|
||||||
if result and "critical_files" in result:
|
if result and "critical_files" in result:
|
||||||
for rel in result["critical_files"]:
|
for rel in result["critical_files"]:
|
||||||
abs_path = root / rel
|
abs_path = root / rel
|
||||||
@@ -203,7 +233,7 @@ async def analyze_file(
|
|||||||
content = content[:30_000] + "\n... (truncated)"
|
content = content[:30_000] + "\n... (truncated)"
|
||||||
|
|
||||||
prompt = analysis_prompt(rel, content)
|
prompt = analysis_prompt(rel, content)
|
||||||
result = await call_ai_json(prompt, cfg.api_key, cfg.default_model, temperature=0.2)
|
result = await call_ai_json(prompt, cfg.api_key, cfg.default_model, temperature=0.2, api_base=cfg.api_base)
|
||||||
if not result:
|
if not result:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|||||||
118
tests/test_cli_api_base.py
Normal file
118
tests/test_cli_api_base.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
import pytest
|
||||||
|
from securelens.ai import call_ai
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def setup_db():
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_ai_passes_api_base():
|
||||||
|
with patch("litellm.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||||
|
mock_acompletion.return_value.choices = [
|
||||||
|
AsyncMock(message=AsyncMock(content="Mock response"))
|
||||||
|
]
|
||||||
|
|
||||||
|
await call_ai(
|
||||||
|
prompt="Hello",
|
||||||
|
api_key="mock_key",
|
||||||
|
model="openai/deepseek-chat",
|
||||||
|
api_base="https://agentrouter.org/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_acompletion.assert_called_once()
|
||||||
|
called_kwargs = mock_acompletion.call_args[1]
|
||||||
|
assert called_kwargs["api_base"] == "https://agentrouter.org/v1"
|
||||||
|
assert called_kwargs["model"] == "openai/deepseek-chat"
|
||||||
|
assert called_kwargs["api_key"] == "mock_key"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_backend_call_ai_passes_api_base():
|
||||||
|
from app.services.ai import call_ai as backend_call_ai
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
with patch("litellm.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||||
|
mock_acompletion.return_value.choices = [
|
||||||
|
AsyncMock(message=AsyncMock(content="Mock response"))
|
||||||
|
]
|
||||||
|
|
||||||
|
original_key = settings.ai_api_key
|
||||||
|
original_base = settings.ai_api_base
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings.ai_api_key = "mock_key"
|
||||||
|
settings.ai_api_base = "https://agentrouter.org/v1"
|
||||||
|
|
||||||
|
await backend_call_ai(prompt="Hello")
|
||||||
|
|
||||||
|
mock_acompletion.assert_called_once()
|
||||||
|
called_kwargs = mock_acompletion.call_args[1]
|
||||||
|
assert called_kwargs["api_base"] == "https://agentrouter.org/v1"
|
||||||
|
assert called_kwargs["api_key"] == "mock_key"
|
||||||
|
finally:
|
||||||
|
settings.ai_api_key = original_key
|
||||||
|
settings.ai_api_base = original_base
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_triage_files_passes_api_base():
|
||||||
|
from securelens.scanners import triage_files
|
||||||
|
from securelens.config import CLIConfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
cfg = CLIConfig()
|
||||||
|
cfg.api_key = "mock_key"
|
||||||
|
cfg.api_base = "https://agentrouter.org/v1"
|
||||||
|
cfg.default_model = "openai/deepseek-chat"
|
||||||
|
|
||||||
|
with patch("securelens.scanners.call_ai_json", new_callable=AsyncMock) as mock_call_ai_json:
|
||||||
|
mock_call_ai_json.return_value = {"critical_files": []}
|
||||||
|
|
||||||
|
await triage_files([Path("test.py")], Path("."), cfg)
|
||||||
|
|
||||||
|
mock_call_ai_json.assert_called_once()
|
||||||
|
called_kwargs = mock_call_ai_json.call_args[1]
|
||||||
|
assert called_kwargs["api_base"] == "https://agentrouter.org/v1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_analyze_file_passes_api_base():
|
||||||
|
from securelens.scanners import analyze_file
|
||||||
|
from securelens.config import CLIConfig
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
cfg = CLIConfig()
|
||||||
|
cfg.api_key = "mock_key"
|
||||||
|
cfg.api_base = "https://agentrouter.org/v1"
|
||||||
|
cfg.default_model = "openai/deepseek-chat"
|
||||||
|
|
||||||
|
mock_file = Path("test.py")
|
||||||
|
with patch("pathlib.Path.read_text", return_value="print('hello')"):
|
||||||
|
with patch("securelens.scanners.call_ai_json", new_callable=AsyncMock) as mock_call_ai_json:
|
||||||
|
mock_call_ai_json.return_value = {"vulnerabilities": []}
|
||||||
|
|
||||||
|
await analyze_file(mock_file, Path("."), cfg)
|
||||||
|
|
||||||
|
mock_call_ai_json.assert_called_once()
|
||||||
|
called_kwargs = mock_call_ai_json.call_args[1]
|
||||||
|
assert called_kwargs["api_base"] == "https://agentrouter.org/v1"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_call_ai_injects_agentrouter_headers():
|
||||||
|
with patch("litellm.acompletion", new_callable=AsyncMock) as mock_acompletion:
|
||||||
|
mock_acompletion.return_value.choices = [
|
||||||
|
AsyncMock(message=AsyncMock(content="Mock response"))
|
||||||
|
]
|
||||||
|
|
||||||
|
await call_ai(
|
||||||
|
prompt="Hello",
|
||||||
|
api_key="mock_key",
|
||||||
|
model="openai/deepseek-chat",
|
||||||
|
api_base="https://agentrouter.org/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_acompletion.assert_called_once()
|
||||||
|
called_kwargs = mock_acompletion.call_args[1]
|
||||||
|
assert called_kwargs["extra_headers"] == {
|
||||||
|
"Originator": "codex_cli_rs",
|
||||||
|
"User-Agent": "codex_cli_rs/0.101.0 (Mac OS 26.0.1; arm64) Apple_Terminal/464",
|
||||||
|
"Version": "0.101.0",
|
||||||
|
}
|
||||||
@@ -14,15 +14,15 @@ def test_export_code_pdf_compiles(tmp_path):
|
|||||||
VulnerabilityFinding(
|
VulnerabilityFinding(
|
||||||
file_path="app.py",
|
file_path="app.py",
|
||||||
severity="Critical",
|
severity="Critical",
|
||||||
issue="Hardcoded Secret Key",
|
issue="Hardcoded Secret Key with unicode ’smart’ quotes",
|
||||||
explanation="Exposing secret key inside app.py.",
|
explanation="Exposing secret key inside app.py • vulnerable to attacks.",
|
||||||
suggested_fix="Load key from environment",
|
suggested_fix="Load key from environment: jwt_secret = Field(default=\"\") \u25b6 check it.",
|
||||||
line_number=5
|
line_number=5
|
||||||
),
|
),
|
||||||
VulnerabilityFinding(
|
VulnerabilityFinding(
|
||||||
file_path="db.py",
|
file_path="db.py",
|
||||||
severity="High",
|
severity="High",
|
||||||
issue="Raw SQL Statement",
|
issue="Raw SQL Statement \u2717 check fail",
|
||||||
explanation="SQL injection inside db.py.",
|
explanation="SQL injection inside db.py.",
|
||||||
suggested_fix="Use parameterized queries",
|
suggested_fix="Use parameterized queries",
|
||||||
line_number=20
|
line_number=20
|
||||||
@@ -34,7 +34,7 @@ def test_export_code_pdf_compiles(tmp_path):
|
|||||||
total_files_found=10,
|
total_files_found=10,
|
||||||
files_triaged=["app.py", "db.py"],
|
files_triaged=["app.py", "db.py"],
|
||||||
vulnerabilities=findings,
|
vulnerabilities=findings,
|
||||||
ai_summary="This is a dummy AI report summary describing security posture."
|
ai_summary="This is a dummy AI report summary describing security posture with check \u2713 and block \u2588."
|
||||||
)
|
)
|
||||||
result.compute_score()
|
result.compute_score()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user