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

View File

@@ -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/
``` ```

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. 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/
``` ```

View File

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

View File

@@ -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:

View File

@@ -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:

View File

@@ -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]

View File

@@ -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:

View File

@@ -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)

View File

@@ -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,

View File

@@ -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())

View File

@@ -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:

View File

@@ -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
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( 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()