Compare commits

22 Commits
v1.1.0 ... main

Author SHA1 Message Date
rarebuffalo
ce898b094d Fix PDF layout rendering by resetting multi_cell cursor to LMARGIN 2026-06-15 03:19:22 +05:30
rarebuffalo
766fe22e1d Fix PDF export by sanitizing unicode characters before writing to FPDF 2026-06-15 03:08:08 +05:30
rarebuffalo
852c2f9776 use codex_cli_rs headers to bypass agentrouter client verification 2026-06-15 02:58:09 +05:30
rarebuffalo
d132536284 spoof User-Agent for Agent Router calls to bypass unauthorized client detection 2026-06-15 02:54:16 +05:30
rarebuffalo
b1fa5890e8 make securelens show usage when run with no subcommand and pass api_base in scan triage and analysis 2026-06-15 02:50:07 +05:30
rarebuffalo
6f83412d6f add support for custom AI api base url and Agent Router integrations 2026-06-15 01:30:24 +05:30
rarebuffalo
eb657ac30a add fpdf2 package dependency to cli package config 2026-06-15 01:18:03 +05:30
rarebuffalo
6dc816beba allow interactive repl launch in offline no-ai mode 2026-06-15 01:05:21 +05:30
rarebuffalo
aeef04ee00 add a 1000 candidate file capping safeguard to discovery walk 2026-06-15 01:02:00 +05:30
rarebuffalo
584ba8b149 add home and root directory safety check to prevent accidental scans 2026-06-15 01:02:00 +05:30
rarebuffalo
caba447de3 optimize discover_files walking using os.walk directory pruning 2026-06-15 00:58:40 +05:30
rarebuffalo
6c1caa2f25 default securelens command to scan the current codebase if run without subcommands 2026-06-15 00:53:16 +05:30
rarebuffalo
20bd779417 update setup onboarding with cross-platform git install commands 2026-06-15 00:52:32 +05:30
rarebuffalo
2d074d1d37 update readme cli install section with cross-platform git commands 2026-06-15 00:52:32 +05:30
rarebuffalo
1b976fa8fd allow local ollama models without keys in interactive chat repl 2026-06-15 00:42:06 +05:30
rarebuffalo
f9e1a15268 allow local model support and fall back to offline mode when api key is missing 2026-06-15 00:42:06 +05:30
rarebuffalo
fb74c00686 fix backend router to use effective_ai_key instead of ai_api_key 2026-06-15 00:42:06 +05:30
rarebuffalo
235126d9ab add github issue template for bug reports 2026-06-12 19:37:00 +05:30
rarebuffalo
5bba7b4042 add github issue template for automated code patching 2026-06-12 19:37:00 +05:30
rarebuffalo
67004b6584 add github issue template for dependency lockfile auditor 2026-06-12 19:37:00 +05:30
rarebuffalo
cf5c7d9b17 add github issue template for ci/cd integrations 2026-06-12 19:37:00 +05:30
rarebuffalo
e8c30b04cb update contributing guidelines with local setup and automation shortcuts 2026-06-12 19:35:33 +05:30
19 changed files with 464 additions and 82 deletions

View 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
View 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.

View 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.

View 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.

View File

@@ -6,7 +6,15 @@ Welcome to SecureLens! We appreciate your interest in contributing to the projec
## 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
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.
* 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
# Format the code base
black app/ cli/ tests/
# Install the hooks locally
pre-commit install
# Run static analysis
ruff check app/ cli/ tests/
# Manually run checks against all files
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
@@ -63,6 +80,10 @@ We use `pytest` for unit and integration testing. All tests are located under th
Run the entire test suite locally:
```bash
# Run via Makefile automation
make test
# Or run manually
pytest tests/ -v
```
@@ -73,6 +94,8 @@ pytest tests/test_cli_sync.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.
---
@@ -80,7 +103,8 @@ Ensure all tests pass and write new tests covering any logic changes or new feat
## Pull Request Guidelines
To help maintainers review your PR efficiently:
* 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.
* Reference any related issues (e.g., `Closes #123`).
* Verify that your branch is rebased on top of the latest `main` branch.
* **Keep PRs focused.** Do not combine multiple unrelated changes into one pull request.
* **Provide context.** Write a clear title and description explaining what was changed and why.
* **Link your issues.** Reference any related issues (e.g., `Closes #123`) so they close automatically upon merge.
* **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.

View File

@@ -130,18 +130,24 @@ uvicorn app.main:app --reload
### 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
# 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
./cli/install.sh
# Activate environment to use the tool
source venv/bin/activate
```
Alternatively, install in editable mode:
```bash
# On Windows or manual setup
pip install -e cli/
```

View File

@@ -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.
### 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
chmod +x cli/install.sh
./cli/install.sh
```
Ensure you have activated your virtual environment before running the tool:
```bash
source venv/bin/activate
```
### 2. Manual Installation
Alternatively, you can install the CLI in editable mode manually:
#### 2. Manual Installation (Windows / Cross-Platform)
```bash
pip install -e cli/
```

View File

@@ -55,6 +55,9 @@ class Settings(BaseSettings):
# Leave blank for Ollama (local, no key needed).
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.
# If AI_API_KEY is not set but GEMINI_API_KEY is, we use that automatically.

View File

@@ -138,7 +138,7 @@ async def chat_with_scan(
We load the scan from PostgreSQL using the scan_id, so this works
correctly across server restarts and multiple workers.
"""
if not settings.ai_api_key:
if not settings.effective_ai_key:
raise HTTPException(
status_code=400,
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.
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.")
try:
from google import genai
client = genai.Client(api_key=settings.ai_api_key)
client = genai.Client(api_key=settings.effective_ai_key)
models = []
for model in client.models.list():
if "generateContent" in model.supported_actions:

View File

@@ -74,6 +74,15 @@ async def call_ai(
"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.
# For providers that don't support it, LiteLLM silently ignores the flag.
if json_mode:

View File

@@ -18,6 +18,7 @@ dependencies = [
"pyyaml>=6.0",
"questionary>=2.0",
"pathspec>=0.12",
"fpdf2>=2.7",
]
[project.scripts]

View File

@@ -21,6 +21,7 @@ async def call_ai(
temperature: float = 0.3,
json_mode: bool = False,
conversation_history: Optional[list] = None,
api_base: Optional[str] = None,
) -> str:
"""
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
conversation_history : Optional list of {"role": ..., "content": ...} dicts
for multi-turn chat sessions
api_base : Optional custom API base URL (e.g. for Agent Router)
"""
import litellm
@@ -49,6 +51,15 @@ async def call_ai(
"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:
kwargs["response_format"] = {"type": "json_object"}
@@ -65,9 +76,10 @@ async def call_ai_json(
api_key: str,
model: str,
temperature: float = 0.2,
api_base: Optional[str] = None,
) -> Optional[dict]:
"""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:
return None
try:

View File

@@ -30,8 +30,8 @@ def _run(coro):
def _require_config(cfg):
"""Exit early with a friendly message if no API key is set."""
if not cfg.api_key:
"""Exit early with a friendly message if no API key is set and not using a local model."""
if not cfg.api_key and not cfg.default_model.startswith("ollama/"):
console.print(
"\n[bold yellow]⚠ No API key configured.[/bold yellow]\n"
" Run [bold cyan]securelens configure[/bold cyan] to set one up.\n"
@@ -42,15 +42,17 @@ def _require_config(cfg):
# ── Main group ─────────────────────────────────────────────────────────────────
@click.group()
@click.group(invoke_without_command=True)
@click.version_option("2.0.0", prog_name="SecureLens AI")
def main():
@click.pass_context
def main(ctx):
"""
\b
SecureLens AI — AI-powered security scanner
Scan codebases, URLs and get instant security reports.
"""
pass
if ctx.invoked_subcommand is None:
click.echo(ctx.get_help())
# ── configure ─────────────────────────────────────────────────────────────────
@@ -74,7 +76,8 @@ def configure():
"4": ("gpt-4o", "OpenAI GPT-4o"),
"5": ("claude-3-5-haiku-20241022","Anthropic Claude 3.5 Haiku"),
"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]")
for k, (_, desc) in providers.items():
@@ -83,11 +86,21 @@ def configure():
choice = Prompt.ask("Select", choices=list(providers.keys()), default="1")
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)")
cfg.default_model = model_str
cfg.api_base = api_base
# API key (skip for Ollama)
if not model_str.startswith("ollama/"):
@@ -106,6 +119,8 @@ def configure():
save_config(cfg)
console.print(f"\n[bold green]✓ Config saved to {CONFIG_FILE}[/bold green]")
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")
@@ -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
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()
# 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:
print_banner()
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
]
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")
# ── Build result ─────────────────────────────────────────────────────────
@@ -275,13 +312,14 @@ async def _scan_async(path, model, output, max_files, ci, fail_on, no_ai, sync):
return
# ── Interactive REPL ─────────────────────────────────────────────────────
if fmt in ("terminal", "all", "markdown") and not no_ai:
if fmt in ("terminal", "all", "markdown"):
ctx = ReplContext(
target=str(root),
scan_result=result,
target_type="code",
api_key=cfg.api_key,
api_key=cfg.api_key if not no_ai else None,
model=cfg.default_model,
api_base=cfg.api_base if not no_ai else None,
)
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),
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")
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")
return
if fmt in ("terminal", "all", "markdown") and not no_ai:
if fmt in ("terminal", "all", "markdown"):
ctx = ReplContext(
target=url,
scan_result=result,
target_type="web",
api_key=cfg.api_key,
api_key=cfg.api_key if not no_ai else None,
model=cfg.default_model,
api_base=cfg.api_base if not no_ai else None,
)
await run_repl(ctx)

View File

@@ -20,6 +20,7 @@ class CLIConfig:
# AI backend
default_model: str = "gemini/gemini-2.0-flash"
api_key: str = ""
api_base: str = ""
# Backend Integration (for sync / auth)
backend_url: str = "http://localhost:8000"
@@ -61,6 +62,7 @@ def load_config() -> CLIConfig:
data = yaml.safe_load(f) or {}
cfg.default_model = data.get("default_model", cfg.default_model)
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.token = data.get("token", cfg.token)
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 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
@@ -92,6 +99,7 @@ def save_config(cfg: CLIConfig) -> None:
data = {
"default_model": cfg.default_model,
"api_key": cfg.api_key,
"api_base": cfg.api_base,
"backend_url": cfg.backend_url,
"token": cfg.token,
"output_format": cfg.output_format,

View File

@@ -2,6 +2,39 @@ from fpdf import FPDF
import datetime
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):
def footer(self):
self.set_y(-15)
@@ -26,9 +59,9 @@ def export_code_pdf(result, output_path: str) -> str:
pdf.set_font("helvetica", "", 10)
pdf.set_text_color(100, 100, 100)
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"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)
# Executive Summary Section
@@ -40,7 +73,7 @@ def export_code_pdf(result, output_path: str) -> str:
pdf.set_text_color(0, 0, 0)
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."
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)
# Files Scanned Section
@@ -53,7 +86,7 @@ def export_code_pdf(result, output_path: str) -> str:
files_list = ", ".join(result.files_triaged[:15])
if len(result.files_triaged) > 15:
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)
# Issues Findings Section
@@ -78,26 +111,26 @@ def export_code_pdf(result, output_path: str) -> str:
else: pdf.set_text_color(0, 100, 0)
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
pdf.set_text_color(0, 0, 0)
pdf.set_font("helvetica", "B", 9)
pdf.cell(20, 6, "File:", border=0)
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.cell(0, 6, "Explanation:", new_x="LMARGIN", new_y="NEXT")
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:
pdf.set_font("helvetica", "B", 9)
pdf.cell(0, 6, "Suggested Fix:", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("courier", "", 8.5)
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.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_text_color(100, 100, 100)
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"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:
pdf.cell(0, 8, f"SSL Expiry: {result.ssl_expiry_days} days left", new_x="LMARGIN", new_y="NEXT")
pdf.ln(5)
@@ -139,7 +172,7 @@ def export_web_pdf(result, output_path: str) -> str:
pdf.set_text_color(0, 0, 0)
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."
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)
# 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)
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
pdf.set_text_color(0, 0, 0)
pdf.set_font("helvetica", "B", 9)
pdf.cell(20, 6, "Layer:", border=0)
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.cell(0, 6, "Remediation / Fix:", new_x="LMARGIN", new_y="NEXT")
pdf.set_font("courier", "", 8.5)
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.line(pdf.get_x(), pdf.get_y(), 200, pdf.get_y())

View File

@@ -57,6 +57,7 @@ class ReplContext:
target_type: str # "code" | "web" | "github"
api_key: str
model: str
api_base: Optional[str] = None
conversation_history: list = field(default_factory=list)
@@ -93,7 +94,7 @@ async def run_repl(ctx: ReplContext) -> None:
continue
# ── AI response ─────────────────────────────────────────────────────
if not ctx.api_key:
if not ctx.api_key and not ctx.model.startswith("ollama/"):
console.print(
"\n [bold red]✗ No API key configured.[/bold red] "
"Run [cyan]securelens configure[/cyan] to set one.\n"
@@ -109,6 +110,7 @@ async def run_repl(ctx: ReplContext) -> None:
model=ctx.model,
temperature=0.5,
conversation_history=ctx.conversation_history,
api_base=ctx.api_base,
)
if response:

View File

@@ -99,6 +99,7 @@ def discover_files(root: Path, cfg: CLIConfig) -> list[Path]:
Respects .gitignore in the root and cfg.ignore_patterns.
Skips binaries and files larger than cfg.max_file_size_kb.
"""
import os
# Build a combined spec from config ignore_patterns + .gitignore
ignore_patterns = list(cfg.ignore_patterns)
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)
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] = []
for p in root.rglob("*"):
if not p.is_file():
continue
rel = p.relative_to(root).as_posix()
if spec.match_file(rel):
continue
if p.suffix.lower() in BINARY_EXTENSIONS:
continue
if p.stat().st_size > max_bytes:
continue
candidates.append(p)
for dirpath, dirnames, filenames in os.walk(root):
# 1. Prune standard blacklisted folders in-place
dirnames[:] = [d for d in dirnames if d not in prune_dirs]
# 2. Prune directories matching the ignore spec
active_dirs = []
for d in dirnames:
rel_path = os.path.relpath(os.path.join(dirpath, d), root)
if not spec.match_file(rel_path + "/"):
active_dirs.append(d)
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)
@@ -165,7 +195,7 @@ async def triage_files(
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
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:
for rel in result["critical_files"]:
abs_path = root / rel
@@ -203,7 +233,7 @@ async def analyze_file(
content = content[:30_000] + "\n... (truncated)"
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:
return []

118
tests/test_cli_api_base.py Normal file
View 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",
}

View File

@@ -14,15 +14,15 @@ def test_export_code_pdf_compiles(tmp_path):
VulnerabilityFinding(
file_path="app.py",
severity="Critical",
issue="Hardcoded Secret Key",
explanation="Exposing secret key inside app.py.",
suggested_fix="Load key from environment",
issue="Hardcoded Secret Key with unicode smart quotes",
explanation="Exposing secret key inside app.py • vulnerable to attacks.",
suggested_fix="Load key from environment: jwt_secret = Field(default=\"\") \u25b6 check it.",
line_number=5
),
VulnerabilityFinding(
file_path="db.py",
severity="High",
issue="Raw SQL Statement",
issue="Raw SQL Statement \u2717 check fail",
explanation="SQL injection inside db.py.",
suggested_fix="Use parameterized queries",
line_number=20
@@ -34,7 +34,7 @@ def test_export_code_pdf_compiles(tmp_path):
total_files_found=10,
files_triaged=["app.py", "db.py"],
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()