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
|
||||
|
||||
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.
|
||||
|
||||
20
README.md
20
README.md
@@ -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/
|
||||
```
|
||||
|
||||
|
||||
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.
|
||||
|
||||
### 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/
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -18,6 +18,7 @@ dependencies = [
|
||||
"pyyaml>=6.0",
|
||||
"questionary>=2.0",
|
||||
"pathspec>=0.12",
|
||||
"fpdf2>=2.7",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
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(
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user