From 6897cb9bad3decc53e21632ef16a7a2421c571df Mon Sep 17 00:00:00 2001 From: QA Team Date: Fri, 8 May 2026 20:48:12 +0000 Subject: [PATCH 01/15] extra tools --- .github/workflows/tests.yml | 30 ++ README.md | 64 +++- pytest.ini | 3 + requirements-dev.txt | 2 + saist/llm/adapters/faike.py | 18 +- saist/main.py | 63 +++- saist/scm/__init__.py | 31 +- saist/scm/adapters/__init__.py | 77 ++++- saist/scm/adapters/filesystem.py | 42 ++- saist/scm/adapters/git.py | 43 ++- saist/shell/__init__.py | 12 +- saist/util/argparsing.py | 100 +++++-- saist/util/skills.py | 489 +++++++++++++++++++++++++++++++ tests/test_argparsing.py | 305 +++++++++++++++++++ tests/test_caching_output.py | 71 +++++ tests/test_filesystem_adapter.py | 93 ++++++ tests/test_filtering.py | 46 +++ tests/test_git.py | 40 +++ tests/test_git_adapter.py | 68 +++++ tests/test_main_analysis.py | 190 ++++++++++++ tests/test_scm_tools.py | 56 ++++ tests/test_skills.py | 143 +++++++++ 22 files changed, 1917 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 saist/util/skills.py create mode 100644 tests/test_argparsing.py create mode 100644 tests/test_caching_output.py create mode 100644 tests/test_filesystem_adapter.py create mode 100644 tests/test_filtering.py create mode 100644 tests/test_git.py create mode 100644 tests/test_git_adapter.py create mode 100644 tests/test_main_analysis.py create mode 100644 tests/test_scm_tools.py create mode 100644 tests/test_skills.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1b05748 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,30 @@ +name: Tests + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + pytest: + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: | + requirements.txt + requirements-dev.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run pytest + run: pytest -q diff --git a/README.md b/README.md index 201db28..4239659 100644 --- a/README.md +++ b/README.md @@ -29,10 +29,12 @@ We support OLLAMA for local / offline code scanning. - **Diff scanning**: Git commits, branches, or PRs - **Multi-LLM support**: `OpenAI`, `Anthropic`, `Bedrock`, `DeepSeek`, `Gemini`, `Ollama` - **Filesystem, Git, GitHub PR scanning modes** -- **Pattern-based file inclusion/exclusion** using `.saist.include` and `.saist.ignore` -- **Interactive chat** with your findings -- **Web server** UI to view results -- **CSV export** of findings +- **Pattern-based file inclusion/exclusion** using `.saist.include` and `.saist.ignore` +- **Project-specific analysis skills** loaded from Markdown files to teach SAIST app routing, authorization, framework conventions, and other local security context +- **LLM-generated analysis skills** for bootstrapping those files in a separate run +- **Interactive chat** with your findings +- **Web server** UI to view results +- **CSV export** of findings - **PDF report**: Generate PDF reports of SAIST findings - **CI/CD pipeline friendly** (exit 1 on findings) @@ -81,8 +83,9 @@ export SAIST_LLM_API_KEY=your-api-key | Scan a GitHub PR (and update the PR) | `saist/main.py --llm anthropic github yourorg/yourrepo 1234 --github-token your-token` | | Launch web server to view findings | `saist/main.py --llm deepseek --web filesystem /path/to/code` | | Interactive shell after scanning | `saist/main.py --llm ollama --interactive filesystem /path/to/code` | -| Export findings as CSV | `saist/main.py --llm openai --csv filesystem /path/to/code` | -| Scan with docker and export findings as PDF report | `docker run -v :/vulnerableapp -v $PWD/reporting:/app/reporting punksecurity/saist --llm openai --pdf filesystem /vulnerableapp` | +| Export findings as CSV | `saist/main.py --llm openai --csv filesystem /path/to/code` | +| Generate analysis skills | `saist/main.py --llm openai --generate-skills filesystem /path/to/code` | +| Scan with docker and export findings as PDF report | `docker run -v :/vulnerableapp -v $PWD/reporting:/app/reporting punksecurity/saist --llm openai --pdf filesystem /vulnerableapp` | | Scan with docker and export findings as PDF report with a project title | `docker run -v :/vulnerableapp -v $PWD/reporting:/app/reporting punksecurity/saist --llm openai --pdf --project-name "Project Name" filesystem /vulnerableapp` | | Scan with docker and retain cache for future runs | `docker run -v :/vulnerableapp -v $PWD/SAISTCache:/app/SAISTCache punksecurity/saist --llm openai filesystem /vulnerableapp` | | Change caching folder | `saist/main.py --llm openai --cache-folder /path/to/cache filesystem /path/to/code` | @@ -106,7 +109,7 @@ saist respects **file include/exclude rules** via two optional files in the root - `build/` will ignore the entire build folder - `*.log` will ignore all log files -You can also provide include/exclude patterns using the command-line arguments `--include` and `--exclude`. +You can also provide include/exclude patterns using the command-line arguments `--include` and `--exclude`. - Patterns provided via command-line arguments are appended to any patterns loaded from the rule files. - Examples: - `--include '**/*.py' --include '**/*.ts'` includes all Python and TypeScript files @@ -132,10 +135,41 @@ docs/ This setup will: - Only scan `.py`, `.ts`, and specific `.js` files - Ignore anything under `tests/` and `docs/` ---- - - -## 📄 PDF report generation +--- + +## 🧠 Analysis Skills + +SAIST can load project-specific analysis skill files from `.saist/skills/*.md`. These files are added to the security review prompt so future scans understand application-specific details such as routing, authentication, authorization, framework conventions, data access, validation boundaries, dependencies, configuration, and security-sensitive workflows. + +Generate an initial set of skill files as a separate run: + +```bash +saist/main.py --llm openai --generate-skills filesystem /path/to/code +``` + +Then review or edit the generated Markdown files and run SAIST normally. Skill files are loaded automatically on future scans: + +```bash +saist/main.py --llm openai filesystem /path/to/code +``` + +Useful options: + +| Option | Description | +|:------|:------------| +| `--skills-path` | Folder containing skill Markdown files. Defaults to `.saist/skills` under the scanned project. | +| `--generate-skills` | Ask the configured LLM to generate skill files and then exit. | +| `--overwrite-skills` | Replace existing skill files during generation. Without this, existing files are preserved. | +| `--disable-skills` | Do not load skill files during analysis. | +| `--skills-max-bytes` | Limit total skill guidance added to analysis prompts. | +| `--skills-sample-files` / `--skills-sample-bytes` | Control how much project context is sampled when generating skills. | + +When skills are loaded, SAIST salts its findings cache with the skill content so updated guidance gets a fresh analysis run. + +--- + + +## 📄 PDF report generation saist allows you to generate PDF reports summarizing your findings, making it easier to share insights with your team. @@ -174,8 +208,12 @@ docker run -v$PWD/code:/code -v$PWD/reporting:/app/reporting punksecurity/saist | `--llm-model` | (Optional) Specific model (e.g., `gpt-4o`) | | `--interactive` | Chat with the LLM after scan | | `--web` | Launch a local web server | -| `--disable-tools` | Disable tool use during file analysis to reduce LLM token usage | -| `--disable-caching` | Disable finding caching during file analysis | +| `--disable-tools` | Disable tool use during file analysis to reduce LLM token usage | +| `--skills-path` | Folder containing SAIST analysis skill Markdown files | +| `--generate-skills` | Generate SAIST analysis skill files and exit | +| `--overwrite-skills` | Replace existing skill files during skill generation | +| `--disable-skills` | Do not load skill files during analysis | +| `--disable-caching` | Disable finding caching during file analysis | | `--skip-line-length-check` | Skip checking files for a maximum line length | | `--max-line-length` | Maximum allowed line length, files with lines longer than this value will be skipped | | `--i, --include` | Pattern to explicitly include | diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3d0ba62 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +pythonpath = saist +testpaths = tests diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..31c406a --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==8.3.5 diff --git a/saist/llm/adapters/faike.py b/saist/llm/adapters/faike.py index c4aa38c..8489a90 100644 --- a/saist/llm/adapters/faike.py +++ b/saist/llm/adapters/faike.py @@ -18,10 +18,9 @@ def __init__(self, base_url: str, model: str = None, api_key: Optional[str] = No async def prompt_structured(self, system_prompt: str, user_prompt: str, response_format: Type[BaseModel], tool_fns: Optional[List[Callable]] = None) -> BaseModel: logger.getChild(self.__class__.__name__).debug("prompt_structured initial response", extra={'prompt': user_prompt}) - # Extract the real filename from user_prompt, allowing future steps to work - filename: str = re.search("(?<=File: )(.*?)(?=\\n)", user_prompt).group(0) - if response_format is Findings: + # Extract the real filename from user_prompt, allowing future steps to work + filename: str = re.search("(?<=File: )(.*?)(?=\\n)", user_prompt).group(0) fake_finding: dict[str: any] = { "file": filename, "snippet": "[]", @@ -34,6 +33,18 @@ async def prompt_structured(self, system_prompt: str, user_prompt: str, response } return Findings(findings=[Finding.model_validate(fake_finding)]) + + if hasattr(response_format, "model_fields") and "skills" in response_format.model_fields: + return response_format.model_validate( + { + "skills": [ + { + "filename": "application-routing.md", + "content": "# Application Routing\n\nGenerated by Faike AI for command-path testing.", + } + ] + } + ) else: #Currently prompt_structured only accepts Findings as a return type logger.error(f"Invalid response_format {response_format} passed to prompt_structured (faike.py)") @@ -47,4 +58,3 @@ async def prompt(self, system_prompt: str, user_prompt: str, tool_fns: Optional[ def generate_agent(self, system_prompt: str = None, tool_fns: Optional[List[Callable]] = None): #Faike LLM: Certified non-existent AI doesn't support interactive mode return None - diff --git a/saist/main.py b/saist/main.py index 114881f..52902d1 100755 --- a/saist/main.py +++ b/saist/main.py @@ -32,6 +32,14 @@ from util.output import print_banner, write_csv from util.poem import poem from util.prompts import prompts +from util.skills import ( + format_analysis_skills, + generate_skill_files, + load_analysis_skills, + project_root_from_args, + resolve_skills_dir, + skills_prompt_digest, +) from web import FindingsServer @@ -44,17 +52,20 @@ logger = logging.getLogger("saist") -async def analyze_single_file(scm: Scm, adapter: BaseLlmAdapter, filename, patch_text, disable_tools: bool) -> Optional[list[Finding]]: +async def analyze_single_file(scm: Scm, adapter: BaseLlmAdapter, filename, patch_text, disable_tools: bool, analysis_skills: str = "") -> Optional[list[Finding]]: """ Analyzes a SINGLE file diff with OpenAI, returning a Findings object or None on error. """ system_prompt = prompts.DETECT + if analysis_skills: + system_prompt = f"{system_prompt}\n\n{analysis_skills}" + logger.debug(f"Processing {filename}") prompt = ( f"\n\nFile: {filename}\n{patch_text}\n" ) try: - return (await adapter.prompt_structured(system_prompt, prompt, Findings, [] if disable_tools else [scm.read_file_contents])).findings + return (await adapter.prompt_structured(system_prompt, prompt, Findings, [] if disable_tools else scm.tool_functions())).findings except Exception as e: logger.error(f"[Error] File '{filename}': {e}") return None @@ -179,11 +190,41 @@ async def main(): print("✨ Poem generation completed.\n") exit(0) + project_root = project_root_from_args(args) + skills_dir = resolve_skills_dir(project_root, args.skills_path) + + if args.generate_skills: + print("🧭 Generating SAIST analysis skills...") + result = await generate_skill_files( + llm=llm, + project_root=project_root, + skills_dir=skills_dir, + max_files=args.skills_sample_files, + max_file_bytes=args.skills_sample_bytes, + overwrite=args.overwrite_skills, + ) + print(f"✅ Sampled {result.sampled_files} files from {project_root}") + print(f"✅ Wrote {len(result.written)} skill files to {result.skills_dir}") + if result.skipped: + print(f"â„šī¸ Skipped {len(result.skipped)} existing skill files. Use --overwrite-skills to replace them.") + return + print("🔎 Initializing SCM adapter...") scm_adapter = _get_scm_adapter(args) scm = Scm(adapter=scm_adapter) print(f"✅ Using SCM: {args.SCM}\n") + analysis_skills = "" + if not args.disable_skills: + loaded_skills = load_analysis_skills(skills_dir, args.skills_max_bytes) + if loaded_skills: + analysis_skills = format_analysis_skills(loaded_skills) + print(f"🧠 Loaded {len(loaded_skills)} SAIST skill files from {skills_dir}\n") + else: + logging.debug(f"No SAIST skill files found under {skills_dir}") + else: + logging.debug("SAIST skill loading disabled") + # 1) Get changed files print("📂 Fetching changed files...") changed_files = scm.get_changed_files() @@ -236,7 +277,7 @@ async def main(): print("🔍 Analyzing files for security issues...") max_workers = min(args.llm_rate_limit, len(app_files)) logging.debug(f"{max_workers=}") - all_findings = await generate_findings(scm, llm, app_files, max_workers, args.disable_tools, args.disable_caching, args.cache_folder) + all_findings = await generate_findings(scm, llm, app_files, max_workers, args.disable_tools, args.disable_caching, args.cache_folder, analysis_skills) if not all_findings: print("✅ No findings reported. Exiting.\n") @@ -367,15 +408,19 @@ async def main(): if args.ci and len(all_findings) > 0: exit(1) -async def process_file(scm: Scm, llm, filename, patch_text, disable_tools, disable_caching, cache_folder): +async def process_file(scm: Scm, llm, filename, patch_text, disable_tools, disable_caching, cache_folder, analysis_skills): start = asyncio.get_event_loop().time() if disable_caching is True: - result = await analyze_single_file(scm, llm, filename, patch_text, disable_tools) + result = await analyze_single_file(scm, llm, filename, patch_text, disable_tools, analysis_skills) else: hash: str = await hash_file(scm, filename) - cache_file = os.path.join(cache_folder, hash + ".json") + cache_filename = hash + ".json" + if analysis_skills: + cache_filename = hash + "-" + skills_prompt_digest(analysis_skills) + ".json" + + cache_file = os.path.join(cache_folder, cache_filename) if not os.path.exists(cache_file): - result = await analyze_single_file(scm, llm, filename, patch_text, disable_tools) + result = await analyze_single_file(scm, llm, filename, patch_text, disable_tools, analysis_skills) store_findings_to_cache_file(filename, result, cache_file) else: result = findings_from_cache_file(cache_file) @@ -385,7 +430,7 @@ async def process_file(scm: Scm, llm, filename, patch_text, disable_tools, disab return result -async def generate_findings(scm, llm, app_files, max_concurrent, disable_tools, disable_caching, cache_folder): +async def generate_findings(scm, llm, app_files, max_concurrent, disable_tools, disable_caching, cache_folder, analysis_skills): if disable_caching is False: if not os.path.exists(cache_folder) or not os.path.isdir(cache_folder): os.makedirs(cache_folder, exist_ok=True) @@ -427,7 +472,7 @@ async def sub_func(*args, **kwargs): tasks = [] overall_task = overall_progress.add_task(f"Analyzing {len(app_files)} files...", total=len(app_files), start=True) # Add a task for filename, patch_text in app_files: - wrapper_func = task_progress_wrapper(process_file, overall_progress, overall_task, file_progress, filename, semaphore)(scm, llm, filename, patch_text, disable_tools, disable_caching, cache_folder) + wrapper_func = task_progress_wrapper(process_file, overall_progress, overall_task, file_progress, filename, semaphore)(scm, llm, filename, patch_text, disable_tools, disable_caching, cache_folder, analysis_skills) tasks.append( wrapper_func ) diff --git a/saist/scm/__init__.py b/saist/scm/__init__.py index 2d6378f..777a1be 100644 --- a/saist/scm/__init__.py +++ b/saist/scm/__init__.py @@ -1,5 +1,4 @@ -from os import PathLike -from typing import TypedDict +from typing import Callable, TypedDict from .adapters import BaseScmAdapter @@ -48,5 +47,33 @@ async def read_file_contents(self, filename: str): """ return await self.adapter.get_file_contents(filename) + async def list_files(self) -> list[str]: + """ + Lists all files available to the scanner, relative to the source root. + """ + return await self.adapter.list_files() + + async def regex_search( + self, + pattern: str, + file_pattern: str = "**/*", + max_results: int = 100, + ) -> list[dict[str, str | int]]: + """ + Searches files available to the scanner using a Python regular expression. + + Args: + pattern: Python regular expression to search for. Inline flags like (?i) are supported. + file_pattern: Optional glob for limiting files, for example **/*.py. + max_results: Maximum number of matches to return. + """ + return await self.adapter.regex_search(pattern, file_pattern, max_results) + + def tool_functions(self) -> list[Callable]: + """ + Returns the SCM helper functions exposed to the LLM. + """ + return [self.read_file_contents, self.list_files, self.regex_search] + def create_review(self, comment, review_comments, request_changes): self.adapter.create_review(comment, review_comments, request_changes) diff --git a/saist/scm/adapters/__init__.py b/saist/scm/adapters/__init__.py index 81fd3e7..04dea17 100644 --- a/saist/scm/adapters/__init__.py +++ b/saist/scm/adapters/__init__.py @@ -1,5 +1,7 @@ from abc import ABCMeta, abstractmethod -from os import PathLike +import fnmatch +from pathlib import PurePosixPath +import re class BaseScmAdapter(metaclass=ABCMeta): @@ -26,3 +28,76 @@ def get_changed_files(self): @abstractmethod async def get_file_contents(self, file_path: str): pass + + async def list_files(self) -> list[str]: + """ + Lists every file path available to this adapter, relative to the project root. + Adapters that cannot enumerate files can return an empty list. + """ + return [] + + async def regex_search( + self, + pattern: str, + file_pattern: str = "**/*", + max_results: int = 100, + ) -> list[dict[str, str | int]]: + """ + Searches available UTF-8 files using a Python regular expression. + + Args: + pattern: Python regular expression to search for. Inline flags like (?i) are supported. + file_pattern: Optional glob for limiting files, for example **/*.py. + max_results: Maximum number of matches to return. + + Returns: + A list of matches with filename, line_number, column, match, and line fields. + """ + if max_results <= 0: + return [] + + try: + regex = re.compile(pattern) + except re.error as e: + return [{"error": f"Invalid regex: {e}", "pattern": pattern}] + + results = [] + for filename in await self.list_files(): + if not self._file_matches_pattern(filename, file_pattern): + continue + + try: + contents = await self.get_file_contents(filename) + except Exception: + continue + + if contents is None: + continue + + for line_number, line in enumerate(contents.splitlines(), start=1): + for match in regex.finditer(line): + results.append( + { + "filename": filename, + "line_number": line_number, + "column": match.start() + 1, + "match": match.group(0), + "line": line, + } + ) + if len(results) >= max_results: + return results + + return results + + @staticmethod + def _file_matches_pattern(filename: str, file_pattern: str) -> bool: + if not file_pattern or file_pattern == "**/*": + return True + + path = PurePosixPath(filename) + patterns = [file_pattern] + if file_pattern.startswith("**/"): + patterns.append(file_pattern[3:]) + + return any(path.match(pattern) or fnmatch.fnmatch(filename, pattern) for pattern in patterns) diff --git a/saist/scm/adapters/filesystem.py b/saist/scm/adapters/filesystem.py index dacdb9d..504dceb 100644 --- a/saist/scm/adapters/filesystem.py +++ b/saist/scm/adapters/filesystem.py @@ -18,17 +18,15 @@ class FilesystemAdapter(BaseScmAdapter): async def get_file_contents(self, filename: str): logger.debug(f"get_file_contents: reading file {filename} under {self.compare_path}") - filename = os.path.join(self.compare_path, filename) try: - if not pathlib.Path(filename).is_relative_to(self.compare_path): - raise Exception(f"Tried to access file outside of the root: {filename}") + file_path = self._resolve_under_root(filename) - async with aiofiles.open(filename, mode='r', encoding='utf-8') as f: + async with aiofiles.open(file_path, mode='r', encoding='utf-8') as f: contents = await f.read() return contents except Exception as e: - logging.warn(f"ERR: {e}") + logging.warning(f"ERR: {e}") def __init__(self, compare_path: PathLike[AnyStr] | str, base_path: Optional[PathLike[AnyStr] | str] = None): self.base_path = base_path @@ -40,6 +38,28 @@ def create_review(self, comment, review_comments, request_changes): def get_changed_files(self) -> list[File]: return list(self._iter_changed_files()) + async def list_files(self) -> list[str]: + """ + Lists all regular files under the comparison path. + Paths are relative to the comparison path and use POSIX separators. + """ + root = self._root_path() + files = [] + + for path in root.rglob("*"): + try: + resolved_path = path.resolve() + except OSError as e: + logger.warning(f"Could not resolve file path {path}: {e}") + continue + + if not resolved_path.is_relative_to(root) or not resolved_path.is_file(): + continue + + files.append(path.relative_to(root).as_posix()) + + return sorted(files) + def _iter_changed_files(self) -> list[File]: logger.debug(f"Iterate changed files: base:{self.base_path}, compare:{self.compare_path}") @@ -80,3 +100,15 @@ def _iter_changed_files(self) -> list[File]: def likely(): # TODO: implement proper logic return True + + def _root_path(self) -> pathlib.Path: + return pathlib.Path(self.compare_path).resolve() + + def _resolve_under_root(self, filename: str) -> pathlib.Path: + root = self._root_path() + file_path = (root / filename).resolve() + + if not file_path.is_relative_to(root): + raise Exception(f"Tried to access file outside of the root: {filename}") + + return file_path diff --git a/saist/scm/adapters/git.py b/saist/scm/adapters/git.py index d0a7ae7..7eeb44d 100644 --- a/saist/scm/adapters/git.py +++ b/saist/scm/adapters/git.py @@ -1,6 +1,7 @@ import io import logging from os import PathLike +from pathlib import PurePosixPath from typing import Optional, Iterator from scm import BaseScmAdapter, File @@ -13,9 +14,18 @@ class GitAdapter(BaseScmAdapter): async def get_file_contents(self, file_path: str): logger.debug(f"file_get_contents: Reading {file_path}") - targetfile = self.compare_commit.tree / file_path - with io.BytesIO(targetfile.data_stream.read()) as f: - return f.read().decode('utf-8') + clean_path = self._clean_repo_path(file_path) + if clean_path is None: + logger.warning(f"get_file_contents: rejected path outside repository: {file_path}") + return None + + try: + targetfile = self.compare_commit.tree / clean_path + with io.BytesIO(targetfile.data_stream.read()) as f: + return f.read().decode('utf-8') + except (KeyError, ValueError, UnicodeDecodeError) as e: + logger.warning(f"get_file_contents: could not read {clean_path}: {e}") + return None def __init__(self, repo_path: Optional[PathLike]=None, base_branch: Optional[str] = None, compare_branch: Optional[str] = None, base_commit: Optional[str] = None, compare_commit: Optional[str] = None): self.repo_path = repo_path @@ -60,18 +70,30 @@ def _repo(self) -> Repo: return repo def _iter_diffs(self) -> Iterator[File]: - diffs = self.compare_commit.diff(self.base_commit, create_patch=True) + diffs = self.base_commit.diff(self.compare_commit, create_patch=True) for diff in diffs: + filename = diff.b_path or diff.a_path match diff.diff: case bytes(data): - yield File(filename=diff.a_path, patch=data.decode('UTF-8')) + yield File(filename=filename, patch=data.decode('UTF-8')) case data: - yield File(filename=diff.a_path, patch=data) + yield File(filename=filename, patch=data) def get_changed_files(self) -> list[File]: return list([f for f in self._iter_diffs() if f['filename'] != None]) + async def list_files(self) -> list[str]: + """ + Lists all blob paths at the comparison commit. + """ + files = [] + for item in self.compare_commit.tree.traverse(): + if item.type == "blob": + files.append(item.path) + + return sorted(files) + def create_review(self, comment, review_comments, request_changes): write_findings(comment,review_comments,request_changes) @@ -79,3 +101,12 @@ def create_review(self, comment, review_comments, request_changes): def likely(): # TODO: determine logic return True + + @staticmethod + def _clean_repo_path(file_path: str) -> Optional[str]: + path = PurePosixPath(str(file_path).replace("\\", "/")) + + if path.is_absolute() or ".." in path.parts: + return None + + return path.as_posix() diff --git a/saist/shell/__init__.py b/saist/shell/__init__.py index ea82968..a072718 100644 --- a/saist/shell/__init__.py +++ b/saist/shell/__init__.py @@ -35,7 +35,17 @@ def __init__(self, llm: BaseLlmAdapter, scm: Scm, findings: list[Finding]): self.findings = findings self.original_findings = findings self.should_stop = False - self.agent = llm.generate_agent(self.PROMPT, [self.stop, self.get_findings, self.update_findings, self.reset_findings, self.reset_chat, scm.read_file_contents]) + self.agent = llm.generate_agent( + self.PROMPT, + [ + self.stop, + self.get_findings, + self.update_findings, + self.reset_findings, + self.reset_chat, + *scm.tool_functions(), + ], + ) self.new_messages = None self.console = Console() diff --git a/saist/util/argparsing.py b/saist/util/argparsing.py index 1cdcace..2dec3bf 100644 --- a/saist/util/argparsing.py +++ b/saist/util/argparsing.py @@ -1,8 +1,9 @@ import argparse from os import linesep, environ, cpu_count -import sys -from shutil import which -from dotenv import load_dotenv +import sys +from shutil import which +from dotenv import load_dotenv +from util.skills import DEFAULT_SKILL_MAX_BYTES, DEFAULT_SKILL_SAMPLE_BYTES, DEFAULT_SKILL_SAMPLE_FILES, DEFAULT_SKILLS_PATH load_dotenv(".env") @@ -45,11 +46,13 @@ def error(self, message): {runtime} --llm openai --llm-model gpt4o git {linesep} > Scan local code folder with ollama and get interactive shell {runtime} --llm ollama --interactive filesystem {linesep} -> Scan local code folder with anthropic and get web server findings -{runtime} --llm anthropic --interactive filesystem {linesep} -{linesep} -""", -) +> Scan local code folder with anthropic and get web server findings +{runtime} --llm anthropic --interactive filesystem {linesep} +> Generate project-specific analysis skills for future scans +{runtime} --llm openai --generate-skills filesystem {linesep} +{linesep} +""", +) class EnvDefault(argparse.Action): def __init__(self, envvar, required=True, default=None, **kwargs): @@ -93,13 +96,13 @@ def __call__(self, parser, namespace, values, option_string=None): git_parser.add_argument("--ref-to-compare", type=str, help = "Git ref to compare to", envvar="SAIST_GIT_COMPARE_REF", action=EnvDefault, default="HEAD" ) -git_parser.add_argument( - "--commit-for-compare", type=str, help = "Git commit to compare from (preferred over REF if set)", - envvar="SAIST_GIT_BASE_COMMIT", action=EnvDefault - ) -git_parser.add_argument("--commit-to-compare", type=str, help = "Git commit to compare to (preferred over REF if set)", - envvar="SAIST_GIT_COMPARE_COMMIT", action=EnvDefault - ) +git_parser.add_argument( + "--commit-for-compare", type=str, help = "Git commit to compare from (preferred over REF if set)", + envvar="SAIST_GIT_BASE_COMMIT", action=EnvDefault, required=False + ) +git_parser.add_argument("--commit-to-compare", type=str, help = "Git commit to compare to (preferred over REF if set)", + envvar="SAIST_GIT_COMPARE_COMMIT", action=EnvDefault, required=False + ) ### GITHUB github_parser = SCM_subparsers.add_parser("github", help = "Scan a github PR") @@ -215,14 +218,49 @@ def __call__(self, parser, namespace, values, option_string=None): action='store_true', required=False ) -parser.add_argument( - "--cache-folder", type=str, help = "Folder name for local caching", - envvar="SAIST_CACHE_FOLDER", action=EnvDefault, required=False, default="SAISTCache" - ) - -parser.add_argument( - "--project-name", type=str, help = "Project name for pdf output", - envvar="SAIST_PROJECT_NAME", action=EnvDefault, required=False, default=None +parser.add_argument( + "--cache-folder", type=str, help = "Folder name for local caching", + envvar="SAIST_CACHE_FOLDER", action=EnvDefault, required=False, default="SAISTCache" + ) + +parser.add_argument( + "--skills-path", type=str, help = "Folder containing SAIST analysis skill Markdown files", + envvar="SAIST_SKILLS_PATH", action=EnvDefault, required=False, default=DEFAULT_SKILLS_PATH + ) + +parser.add_argument( + "--disable-skills", help = "Do not load SAIST analysis skill files during scanning", + required=False, action='store_true' + ) + +parser.add_argument( + "--generate-skills", help = "Generate SAIST analysis skill files for this project and exit", + required=False, action='store_true' + ) + +parser.add_argument( + "--overwrite-skills", help = "Replace existing skill files when used with --generate-skills", + required=False, action='store_true' + ) + +parser.add_argument( + "--skills-max-bytes", type=int, help = "Maximum total bytes of skill guidance to load into analysis prompts", + envvar="SAIST_SKILLS_MAX_BYTES", action=EnvDefault, required=False, default=DEFAULT_SKILL_MAX_BYTES + ) + +parser.add_argument( + "--skills-sample-files", type=int, help = "Maximum number of project files to sample when generating skills", + envvar="SAIST_SKILLS_SAMPLE_FILES", action=EnvDefault, required=False, default=DEFAULT_SKILL_SAMPLE_FILES + ) + +parser.add_argument( + "--skills-sample-bytes", type=int, help = "Maximum bytes to read from each sampled file when generating skills", + envvar="SAIST_SKILLS_SAMPLE_BYTES", action=EnvDefault, required=False, default=DEFAULT_SKILL_SAMPLE_BYTES + ) + +parser.add_argument( + "--project-name", type=str, help = "Project name for pdf output", + envvar="SAIST_PROJECT_NAME", action=EnvDefault, required=False, default=None ) parser.add_argument( @@ -271,10 +309,16 @@ def parse_args(): if args.llm == "ollama" and args.interactive: parser.error(f"You cannot use the interactive shell with ollama currently") - if args.llm == "faike" and args.interactive: - parser.error("Faike LLM: Certified non-existent AI doesn't support interactive mode") - - if args.pdf and which("latexmk") == None: - parser.error("Unable to find 'latexmk' binary in $PATH needed for PDF report building, cannot use --pdf flag") + if args.llm == "faike" and args.interactive: + parser.error("Faike LLM: Certified non-existent AI doesn't support interactive mode") + + if args.generate_skills and args.SCM == "poem": + parser.error("Cannot generate SAIST skills while using the poem command") + + if args.generate_skills and args.disable_skills: + parser.error("Cannot use --generate-skills together with --disable-skills") + + if args.pdf and which("latexmk") == None: + parser.error("Unable to find 'latexmk' binary in $PATH needed for PDF report building, cannot use --pdf flag") return args diff --git a/saist/util/skills.py b/saist/util/skills.py new file mode 100644 index 0000000..d8fc88e --- /dev/null +++ b/saist/util/skills.py @@ -0,0 +1,489 @@ +import hashlib +import logging +import os +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Annotated + +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + +DEFAULT_SKILLS_PATH = ".saist/skills" +DEFAULT_SKILL_MAX_BYTES = 60000 +DEFAULT_SKILL_SAMPLE_FILES = 80 +DEFAULT_SKILL_SAMPLE_BYTES = 12000 + +SKILL_SPECS = [ + ( + "application-routing.md", + "How requests, jobs, events, routes, controllers, handlers, and API endpoints are wired.", + ), + ( + "authentication-and-session.md", + "How users, service accounts, API clients, sessions, tokens, cookies, and identity providers work.", + ), + ( + "authorization-model.md", + "How access control decisions are made, including roles, permissions, policies, ownership checks, and tenancy boundaries.", + ), + ( + "framework-specific-concerns.md", + "Framework conventions and security footguns that matter when reviewing this application.", + ), + ( + "data-model-and-persistence.md", + "Important models, repositories, migrations, query patterns, and places where data integrity or isolation matters.", + ), + ( + "input-validation-and-trust-boundaries.md", + "Where untrusted input enters the application and how validation, parsing, escaping, and serialization are handled.", + ), + ( + "dependency-and-configuration.md", + "Security-sensitive dependencies, configuration patterns, environment variables, deployment assumptions, and feature flags.", + ), + ( + "security-sensitive-flows.md", + "High-risk workflows such as payments, password reset, invitations, admin actions, file upload, webhooks, and background jobs.", + ), +] + +EXCLUDED_DIR_NAMES = { + ".git", + ".hg", + ".svn", + ".mypy_cache", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "SAISTCache", + "__pycache__", + "bin", + "build", + "coverage", + "dist", + "node_modules", + "obj", + "target", + "vendor", +} + +TEXT_EXTENSIONS = { + ".cs", + ".css", + ".env", + ".go", + ".graphql", + ".h", + ".hpp", + ".html", + ".java", + ".js", + ".json", + ".jsx", + ".kt", + ".md", + ".php", + ".py", + ".rb", + ".rs", + ".scala", + ".sh", + ".sql", + ".swift", + ".toml", + ".ts", + ".tsx", + ".xml", + ".yaml", + ".yml", +} + +IMPORTANT_FILENAMES = { + ".env.example", + "app.py", + "application.yml", + "application.yaml", + "build.gradle", + "cargo.toml", + "composer.json", + "docker-compose.yml", + "dockerfile", + "gemfile", + "go.mod", + "main.py", + "manage.py", + "middleware.py", + "package.json", + "pom.xml", + "program.cs", + "pyproject.toml", + "requirements.txt", + "routes.rb", + "settings.py", + "startup.cs", + "urls.py", +} + +IMPORTANT_PATH_TERMS = { + "admin", + "api", + "auth", + "config", + "controller", + "guard", + "handler", + "identity", + "middleware", + "migration", + "model", + "permission", + "policy", + "route", + "schema", + "security", + "service", + "session", + "tenant", + "user", + "validation", + "webhook", +} + + +@dataclass(frozen=True) +class AnalysisSkill: + path: Path + content: str + truncated: bool = False + + +@dataclass(frozen=True) +class SkillGenerationResult: + skills_dir: Path + written: list[Path] + skipped: list[Path] + sampled_files: int + + +class GeneratedSkillFile(BaseModel): + filename: Annotated[ + str, + Field(description="Markdown filename for the generated skill file, such as authorization-model.md"), + ] + content: Annotated[ + str, + Field(description="Durable Markdown instructions for future SAIST security analysis runs"), + ] + + +class GeneratedSkillFiles(BaseModel): + skills: list[GeneratedSkillFile] + + +def project_root_from_args(args) -> Path: + if getattr(args, "SCM", None) in {"filesystem", "git"} and getattr(args, "path", None): + return Path(args.path).expanduser().resolve() + + return Path.cwd().resolve() + + +def resolve_skills_dir(project_root: Path, skills_path: str) -> Path: + configured_path = Path(skills_path).expanduser() + if configured_path.is_absolute(): + return configured_path + + return (project_root / configured_path).resolve() + + +def load_analysis_skills(skills_dir: Path, max_bytes: int = DEFAULT_SKILL_MAX_BYTES) -> list[AnalysisSkill]: + if max_bytes <= 0: + return [] + + if not skills_dir.exists(): + return [] + + if not skills_dir.is_dir(): + logger.warning("Skills path exists but is not a directory: %s", skills_dir) + return [] + + skills: list[AnalysisSkill] = [] + remaining = max_bytes + + for path in sorted(skills_dir.rglob("*.md")): + if not path.is_file(): + continue + + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + logger.warning("Skill file is not valid UTF-8, skipping: %s", path) + continue + + content = content.strip() + if not content: + continue + + truncated = False + encoded_length = len(content.encode("utf-8")) + if encoded_length > remaining: + content = content.encode("utf-8")[:remaining].decode("utf-8", errors="ignore").strip() + truncated = True + + if content: + skills.append(AnalysisSkill(path=path, content=content, truncated=truncated)) + + remaining -= min(encoded_length, remaining) + if remaining <= 0: + break + + return skills + + +def format_analysis_skills(skills: list[AnalysisSkill]) -> str: + if not skills: + return "" + + sections = [ + "Application analysis skills:", + "The following project-specific skill files are guidance for this review. Use them to understand routing, identity, authorization, framework conventions, trust boundaries, and security-sensitive flows. Treat them as context, not proof of a vulnerability. Report only vulnerabilities that are present in the diff being reviewed.", + ] + + for skill in skills: + marker = " (truncated)" if skill.truncated else "" + sections.append(f"\n--- {skill.path.name}{marker} ---\n{skill.content}") + + return "\n".join(sections) + + +def skills_prompt_digest(skills_prompt: str) -> str: + return hashlib.sha256(skills_prompt.encode("utf-8")).hexdigest()[:16] + + +async def generate_skill_files( + llm, + project_root: Path, + skills_dir: Path, + max_files: int = DEFAULT_SKILL_SAMPLE_FILES, + max_file_bytes: int = DEFAULT_SKILL_SAMPLE_BYTES, + overwrite: bool = False, +) -> SkillGenerationResult: + repository_profile, sampled_files = _build_repository_profile( + project_root=project_root, + skills_dir=skills_dir, + max_files=max_files, + max_file_bytes=max_file_bytes, + ) + + existing_skills = load_analysis_skills(skills_dir) + + system_prompt = """ +You are an application security architecture analyst generating durable SAIST skill files. +Skill files teach future security scans how this application works. They are not one-off vulnerability findings. + +Rules: +- Generate concise Markdown files with instructions future reviews can use. +- Use the requested filenames where possible. +- Do not invent facts. If the sampled files do not prove something, say what is unknown and what should be inspected. +- Capture conventions, security boundaries, review heuristics, and framework-specific risks. +- Avoid secrets, credentials, and long code excerpts. +- Focus on how to analyze future diffs in this application. +""" + + user_prompt = f""" +Generate SAIST skill files for this application. + +Requested skill files: +{_format_skill_specs()} + +Existing skill files, if any, should be preserved in spirit and improved from the sampled application context: +{_format_existing_skills(existing_skills)} + +Application context: +{repository_profile} +""" + + generated = await llm.prompt_structured(system_prompt, user_prompt, GeneratedSkillFiles) + + skills_dir.mkdir(parents=True, exist_ok=True) + written: list[Path] = [] + skipped: list[Path] = [] + + for skill in generated.skills: + filename = _safe_skill_filename(skill.filename) + if not filename: + continue + + output_path = skills_dir / filename + content = skill.content.strip() + if not content: + continue + + if output_path.exists() and not overwrite: + skipped.append(output_path) + continue + + output_path.write_text(content + "\n", encoding="utf-8") + written.append(output_path) + + return SkillGenerationResult( + skills_dir=skills_dir, + written=written, + skipped=skipped, + sampled_files=sampled_files, + ) + + +def _format_skill_specs() -> str: + return "\n".join(f"- {filename}: {description}" for filename, description in SKILL_SPECS) + + +def _format_existing_skills(skills: list[AnalysisSkill]) -> str: + if not skills: + return "No existing skill files were found." + + return "\n\n".join(f"--- {skill.path.name} ---\n{skill.content}" for skill in skills) + + +def _build_repository_profile( + project_root: Path, + skills_dir: Path, + max_files: int, + max_file_bytes: int, +) -> tuple[str, int]: + if not project_root.exists() or not project_root.is_dir(): + raise FileNotFoundError(f"Project root does not exist or is not a directory: {project_root}") + + candidates = _rank_candidate_files(project_root, skills_dir) + selected = candidates[:max(0, max_files)] + + inventory = "\n".join(f"- {relative_path}" for _, relative_path, _ in candidates[:300]) + excerpts = [] + + for _, relative_path, absolute_path in selected: + content = _read_sample(absolute_path, max_file_bytes) + if not content: + continue + excerpts.append(f"--- {relative_path} ---\n{content}") + + profile = [ + f"Project root: {project_root}", + "Repository file inventory, ranked for security architecture discovery:", + inventory or "No candidate files were found.", + "Selected file excerpts:", + "\n\n".join(excerpts) or "No readable file excerpts were found.", + ] + + return "\n\n".join(profile), len(selected) + + +def _rank_candidate_files(project_root: Path, skills_dir: Path) -> list[tuple[int, str, Path]]: + candidates: list[tuple[int, str, Path]] = [] + + for current_root, dirnames, filenames in os.walk(project_root): + current_path = Path(current_root) + dirnames[:] = [ + dirname + for dirname in dirnames + if not _should_skip_directory(current_path / dirname, skills_dir) + ] + + for filename in filenames: + absolute_path = current_path / filename + if not absolute_path.is_file() or _is_under(absolute_path, skills_dir): + continue + + try: + relative_path = absolute_path.relative_to(project_root) + except ValueError: + continue + + score = _score_path(relative_path) + if score <= 0: + continue + + candidates.append((score, relative_path.as_posix(), absolute_path)) + + candidates.sort(key=lambda item: (-item[0], item[1])) + return candidates + + +def _should_skip_directory(path: Path, skills_dir: Path) -> bool: + name = path.name + if name in EXCLUDED_DIR_NAMES: + return True + + if name.startswith(".") and name not in {".github"}: + return True + + return _is_under(path, skills_dir) + + +def _score_path(relative_path: Path) -> int: + path_text = relative_path.as_posix().lower() + filename = relative_path.name.lower() + suffix = relative_path.suffix.lower() + + if suffix and suffix not in TEXT_EXTENSIONS: + return 0 + + score = 0 + if filename in IMPORTANT_FILENAMES: + score += 100 + + for term in IMPORTANT_PATH_TERMS: + if term in path_text: + score += 20 + + if suffix in TEXT_EXTENSIONS: + score += 10 + + score -= min(len(relative_path.parts), 10) + return score + + +def _read_sample(path: Path, max_bytes: int) -> str: + if max_bytes <= 0: + return "" + + try: + with path.open("rb") as file: + data = file.read(max_bytes + 1) + except OSError as e: + logger.debug("Unable to read sample file %s: %s", path, e) + return "" + + if b"\x00" in data: + return "" + + truncated = len(data) > max_bytes + text = data[:max_bytes].decode("utf-8", errors="replace").strip() + if truncated: + text += "\n[truncated]" + + return text + + +def _safe_skill_filename(filename: str) -> str: + cleaned = Path(filename).name.lower().strip() + cleaned = re.sub(r"[^a-z0-9._-]+", "-", cleaned) + cleaned = cleaned.strip(".-_") + + if not cleaned: + return "" + + if not cleaned.endswith(".md"): + cleaned += ".md" + + return cleaned + + +def _is_under(path: Path, parent: Path) -> bool: + try: + path.resolve().relative_to(parent.resolve()) + return True + except ValueError: + return False diff --git a/tests/test_argparsing.py b/tests/test_argparsing.py new file mode 100644 index 0000000..d74b3e2 --- /dev/null +++ b/tests/test_argparsing.py @@ -0,0 +1,305 @@ +import pytest + +from util import argparsing + + +def parse_with(monkeypatch, argv): + monkeypatch.setattr(argparsing.sys, "argv", ["saist"] + argv) + return argparsing.parse_args() + + +def test_parse_args_sets_defaults_for_filesystem(monkeypatch, tmp_path): + args = parse_with(monkeypatch, ["--llm", "faike", "filesystem", str(tmp_path)]) + + assert args.SCM == "filesystem" + assert args.path == str(tmp_path) + assert args.path_for_comparison is None + assert args.llm == "faike" + assert args.llm_api_key is None + assert args.llm_model is None + assert args.llm_rate_limit == 10 + assert args.ollama_base_uri == "http://localhost:11434" + assert args.openai_base_uri is None + assert args.interactive is False + assert args.disable_tools is False + assert args.web is False + assert args.web_port == 8080 + assert args.web_host == "127.0.0.1" + assert args.ci is False + assert args.csv is False + assert args.csv_path == "results.csv" + assert args.tex is False + assert args.tex_filename == "report.tex" + assert args.pdf is False + assert args.pdf_filename == "report.pdf" + assert args.disable_caching is False + assert args.cache_folder == "SAISTCache" + assert args.skills_path == ".saist/skills" + assert args.disable_skills is False + assert args.generate_skills is False + assert args.overwrite_skills is False + assert args.skills_max_bytes == 60000 + assert args.skills_sample_files == 80 + assert args.skills_sample_bytes == 12000 + assert args.project_name is None + assert args.skip_line_length_check is False + assert args.max_line_length == 1000 + assert args.include is None + assert args.exclude is None + assert args.dry_run is False + assert args.verbose == 0 + + +def test_parse_args_accepts_all_global_scan_options(monkeypatch, tmp_path): + monkeypatch.setattr(argparsing, "which", lambda binary: "/usr/bin/latexmk") + + args = parse_with( + monkeypatch, + [ + "--llm", + "openai", + "--llm-api-key", + "test-key", + "--llm-model", + "gpt-test", + "--llm-rate-limit", + "3", + "--ollama-base-uri", + "http://ollama.example", + "--openai-base-uri", + "http://openai.example", + "--interactive", + "--disable-tools", + "--web", + "--web-port", + "9999", + "--web-host", + "0.0.0.0", + "--ci", + "--csv", + "--csv-path", + str(tmp_path / "results.csv"), + "--tex", + "--tex-filename", + str(tmp_path / "report.tex"), + "--pdf", + "--pdf-filename", + str(tmp_path / "report.pdf"), + "--disable-caching", + "--cache-folder", + str(tmp_path / "cache"), + "--skills-path", + str(tmp_path / "skills"), + "--disable-skills", + "--skills-max-bytes", + "321", + "--skills-sample-files", + "4", + "--skills-sample-bytes", + "500", + "--project-name", + "Project X", + "--skip-line-length-check", + "--max-line-length", + "222", + "--include", + "**/*.py", + "-i", + "**/*.js", + "--exclude", + "build/", + "-e", + "*.min.js", + "--dry-run", + "-vv", + "filesystem", + str(tmp_path / "app"), + "--path-for-comparison", + str(tmp_path / "base"), + ], + ) + + assert args.SCM == "filesystem" + assert args.path == str(tmp_path / "app") + assert args.path_for_comparison == str(tmp_path / "base") + assert args.llm == "openai" + assert args.llm_api_key == "test-key" + assert args.llm_model == "gpt-test" + assert args.llm_rate_limit == 3 + assert args.ollama_base_uri == "http://ollama.example" + assert args.openai_base_uri == "http://openai.example" + assert args.interactive is True + assert args.disable_tools is True + assert args.web is True + assert args.web_port == 9999 + assert args.web_host == "0.0.0.0" + assert args.ci is True + assert args.csv is True + assert args.csv_path == str(tmp_path / "results.csv") + assert args.tex is True + assert args.tex_filename == str(tmp_path / "report.tex") + assert args.pdf is True + assert args.pdf_filename == str(tmp_path / "report.pdf") + assert args.disable_caching is True + assert args.cache_folder == str(tmp_path / "cache") + assert args.skills_path == str(tmp_path / "skills") + assert args.disable_skills is True + assert args.generate_skills is False + assert args.overwrite_skills is False + assert args.skills_max_bytes == 321 + assert args.skills_sample_files == 4 + assert args.skills_sample_bytes == 500 + assert args.project_name == "Project X" + assert args.skip_line_length_check is True + assert args.max_line_length == 222 + assert args.include == [["**/*.py"], ["**/*.js"]] + assert args.exclude == [["build/"], ["*.min.js"]] + assert args.dry_run is True + assert args.verbose == 2 + + +def test_parse_args_accepts_skill_generation_options(monkeypatch, tmp_path): + args = parse_with( + monkeypatch, + [ + "--llm", + "faike", + "--generate-skills", + "--overwrite-skills", + "--skills-path", + "custom-skills", + "--skills-max-bytes", + "123", + "--skills-sample-files", + "2", + "--skills-sample-bytes", + "3", + "filesystem", + str(tmp_path), + ], + ) + + assert args.llm == "faike" + assert args.SCM == "filesystem" + assert args.path == str(tmp_path) + assert args.generate_skills is True + assert args.overwrite_skills is True + assert args.skills_path == "custom-skills" + assert args.skills_max_bytes == 123 + assert args.skills_sample_files == 2 + assert args.skills_sample_bytes == 3 + + +def test_parse_args_accepts_git_subcommand_options(monkeypatch, tmp_path): + args = parse_with( + monkeypatch, + [ + "--llm", + "faike", + "git", + str(tmp_path), + "--ref-for-compare", + "develop", + "--ref-to-compare", + "feature", + "--commit-for-compare", + "abc123", + "--commit-to-compare", + "def456", + ], + ) + + assert args.SCM == "git" + assert args.path == str(tmp_path) + assert args.ref_for_compare == "develop" + assert args.ref_to_compare == "feature" + assert args.commit_for_compare == "abc123" + assert args.commit_to_compare == "def456" + + +def test_parse_args_sets_git_subcommand_defaults(monkeypatch, tmp_path): + args = parse_with(monkeypatch, ["--llm", "faike", "git", str(tmp_path)]) + + assert args.SCM == "git" + assert args.path == str(tmp_path) + assert args.ref_for_compare == "main" + assert args.ref_to_compare == "HEAD" + assert args.commit_for_compare is None + assert args.commit_to_compare is None + + +def test_parse_args_accepts_github_subcommand_options(monkeypatch): + args = parse_with( + monkeypatch, + [ + "--llm", + "faike", + "github", + "owner/repo", + "--github-token", + "github-token", + "123", + ], + ) + + assert args.SCM == "github" + assert args.repository == "owner/repo" + assert args.github_token == "github-token" + assert args.pr == "123" + + +def test_parse_args_accepts_poem_subcommand(monkeypatch): + args = parse_with(monkeypatch, ["--llm", "faike", "poem"]) + + assert args.SCM == "poem" + + +@pytest.mark.parametrize( + ("argv", "message"), + [ + ( + ["--llm", "openai", "filesystem", "/tmp/project"], + "You must provide an api key", + ), + ( + ["--llm", "faike", "--interactive", "filesystem", "/tmp/project"], + "Faike LLM", + ), + ( + ["--llm", "ollama", "--interactive", "filesystem", "/tmp/project"], + "cannot use the interactive shell with ollama", + ), + ( + ["--llm", "faike", "--generate-skills", "poem"], + "Cannot generate SAIST skills while using the poem command", + ), + ( + ["--llm", "faike", "--generate-skills", "--disable-skills", "filesystem", "/tmp/project"], + "Cannot use --generate-skills together with --disable-skills", + ), + ], +) +def test_parse_args_rejects_invalid_combinations(monkeypatch, capsys, argv, message): + with pytest.raises(SystemExit) as exc_info: + parse_with(monkeypatch, argv) + + assert exc_info.value.code == 2 + assert message in capsys.readouterr().out + + +def test_parse_args_rejects_bedrock_api_key(monkeypatch, capsys): + with pytest.raises(SystemExit) as exc_info: + parse_with(monkeypatch, ["--llm", "bedrock", "--llm-api-key", "nope", "filesystem", "/tmp/project"]) + + assert exc_info.value.code == 2 + assert "Do not provide an API key for bedrock" in capsys.readouterr().out + + +def test_parse_args_rejects_pdf_when_latexmk_is_missing(monkeypatch, capsys): + monkeypatch.setattr(argparsing, "which", lambda binary: None) + + with pytest.raises(SystemExit) as exc_info: + parse_with(monkeypatch, ["--llm", "faike", "--pdf", "filesystem", "/tmp/project"]) + + assert exc_info.value.code == 2 + assert "Unable to find 'latexmk'" in capsys.readouterr().out diff --git a/tests/test_caching_output.py b/tests/test_caching_output.py new file mode 100644 index 0000000..c61059e --- /dev/null +++ b/tests/test_caching_output.py @@ -0,0 +1,71 @@ +import asyncio +import csv +import json + +from models import Finding +from util.caching import findings_from_cache_file, hash_file, store_findings_to_cache_file +from util.output import write_csv + + +def make_finding(**overrides): + values = { + "file": "app.py", + "snippet": "danger()", + "title": "Dangerous call", + "issue": "A dangerous call was introduced.", + "recommendation": "Remove the dangerous call.", + "cwe": "CWE-20", + "priority": 5, + "line_number": 7, + } + values.update(overrides) + return Finding.model_validate(values) + + +def test_hash_file_hashes_scm_file_contents(): + class FakeScm: + async def read_file_contents(self, filename): + assert filename == "app.py" + return "same content\n" + + assert asyncio.run(hash_file(FakeScm(), "app.py")) == "f953bbd204bb867e48a6ff774cffa3dcffd02c6580e8f1d00c37dbbaa743d6c8" + + +def test_findings_cache_round_trip(tmp_path): + cache_file = tmp_path / "finding.json" + finding = make_finding(priority=8) + + store_findings_to_cache_file("app.py", [finding], str(cache_file)) + loaded = findings_from_cache_file(str(cache_file)) + + assert loaded == [finding] + + +def test_findings_cache_returns_empty_list_for_null_findings(tmp_path): + cache_file = tmp_path / "empty.json" + cache_file.write_text(json.dumps({"path": "app.py", "findings": None}), encoding="utf-8") + + assert findings_from_cache_file(str(cache_file)) == [] + + +def test_write_csv_writes_finding_fields(tmp_path): + csv_path = tmp_path / "findings.csv" + finding = make_finding(cwe="CWE-89", priority=9) + + write_csv([finding], str(csv_path)) + + with csv_path.open(newline="") as file: + rows = list(csv.DictReader(file)) + + assert rows == [ + { + "file": "app.py", + "snippet": "danger()", + "title": "Dangerous call", + "issue": "A dangerous call was introduced.", + "recommendation": "Remove the dangerous call.", + "cwe": "CWE-89", + "priority": "9", + "line_number": "7", + } + ] diff --git a/tests/test_filesystem_adapter.py b/tests/test_filesystem_adapter.py new file mode 100644 index 0000000..f45b56d --- /dev/null +++ b/tests/test_filesystem_adapter.py @@ -0,0 +1,93 @@ +import asyncio + +from scm.adapters.filesystem import FilesystemAdapter + + +def test_filesystem_adapter_lists_text_files_and_skips_binary_files(tmp_path): + (tmp_path / "app.py").write_text("print('hello')\n", encoding="utf-8") + (tmp_path / "image.bin").write_bytes(b"\xff\xfe\x00\x00") + + adapter = FilesystemAdapter(compare_path=str(tmp_path)) + changed_files = adapter.get_changed_files() + + assert [file["filename"] for file in changed_files] == ["app.py"] + assert "+print('hello')" in changed_files[0]["patch"] + + +def test_filesystem_adapter_reads_file_contents(tmp_path): + (tmp_path / "app.py").write_text("print('hello')\n", encoding="utf-8") + adapter = FilesystemAdapter(compare_path=str(tmp_path)) + + contents = asyncio.run(adapter.get_file_contents("app.py")) + + assert contents == "print('hello')\n" + + +def test_filesystem_adapter_lists_all_files(tmp_path): + (tmp_path / "app.py").write_text("print('hello')\n", encoding="utf-8") + (tmp_path / "image.bin").write_bytes(b"\xff\xfe\x00\x00") + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "settings.py").write_text("DEBUG = True\n", encoding="utf-8") + + adapter = FilesystemAdapter(compare_path=str(tmp_path)) + + assert asyncio.run(adapter.list_files()) == ["app.py", "image.bin", "nested/settings.py"] + + +def test_filesystem_adapter_regex_searches_text_files(tmp_path): + (tmp_path / "app.py").write_text("SECRET_KEY = 'dev'\nprint(SECRET_KEY)\n", encoding="utf-8") + (tmp_path / "README.md").write_text("SECRET_KEY is documented here\n", encoding="utf-8") + (tmp_path / "image.bin").write_bytes(b"\xff\xfe\x00\x00") + + adapter = FilesystemAdapter(compare_path=str(tmp_path)) + matches = asyncio.run(adapter.regex_search(r"SECRET_KEY", file_pattern="**/*.py")) + + assert matches == [ + { + "filename": "app.py", + "line_number": 1, + "column": 1, + "match": "SECRET_KEY", + "line": "SECRET_KEY = 'dev'", + }, + { + "filename": "app.py", + "line_number": 2, + "column": 7, + "match": "SECRET_KEY", + "line": "print(SECRET_KEY)", + }, + ] + + +def test_filesystem_adapter_regex_search_returns_error_for_invalid_regex(tmp_path): + adapter = FilesystemAdapter(compare_path=str(tmp_path)) + + result = asyncio.run(adapter.regex_search("[")) + + assert result[0]["error"].startswith("Invalid regex:") + assert result[0]["pattern"] == "[" + + +def test_filesystem_adapter_returns_none_for_missing_or_outside_file(tmp_path): + adapter = FilesystemAdapter(compare_path=str(tmp_path)) + + assert asyncio.run(adapter.get_file_contents("missing.py")) is None + assert asyncio.run(adapter.get_file_contents("/etc/passwd")) is None + + +def test_filesystem_adapter_compares_base_and_compare_paths(tmp_path): + base_path = tmp_path / "base" + compare_path = tmp_path / "compare" + base_path.mkdir() + compare_path.mkdir() + (base_path / "app.py").write_text("value = 'old'\n", encoding="utf-8") + (compare_path / "app.py").write_text("value = 'new'\n", encoding="utf-8") + (compare_path / "new_only.py").write_text("ignored by current dircmp implementation\n", encoding="utf-8") + + adapter = FilesystemAdapter(compare_path=str(compare_path), base_path=str(base_path)) + changed_files = adapter.get_changed_files() + + assert [file["filename"] for file in changed_files] == ["app.py"] + assert "-value = 'old'" in changed_files[0]["patch"] + assert "+value = 'new'" in changed_files[0]["patch"] diff --git a/tests/test_filtering.py b/tests/test_filtering.py new file mode 100644 index 0000000..372fe01 --- /dev/null +++ b/tests/test_filtering.py @@ -0,0 +1,46 @@ +from util.filtering import FilterRules + + +def test_filter_rules_apply_include_and_exclude_files(tmp_path): + include_file = tmp_path / "saist.include" + exclude_file = tmp_path / "saist.ignore" + include_file.write_text("src/**/*.py\nREADME.md\n", encoding="utf-8") + exclude_file.write_text("src/generated/\n", encoding="utf-8") + + rules = FilterRules( + include_patterns=None, + exclude_patterns=None, + include_rules_file=include_file, + exclude_rules_file=exclude_file, + ) + + assert rules.filename_included(str(tmp_path / "src" / "app.py")) + assert rules.filename_included(str(tmp_path / "README.md")) + assert not rules.filename_included(str(tmp_path / "src" / "generated" / "client.py")) + assert not rules.filename_included(str(tmp_path / "src" / "app.js")) + + +def test_filter_rules_allow_cli_patterns_to_extend_file_rules(tmp_path): + include_file = tmp_path / "saist.include" + exclude_file = tmp_path / "saist.ignore" + include_file.write_text("src/**/*.py\n", encoding="utf-8") + exclude_file.write_text("", encoding="utf-8") + + rules = FilterRules( + include_patterns=[["tools/**/*.sh"]], + exclude_patterns=[["**/danger.sh"]], + include_rules_file=include_file, + exclude_rules_file=exclude_file, + ) + + assert rules.filename_included(str(tmp_path / "src" / "app.py")) + assert rules.filename_included(str(tmp_path / "tools" / "run.sh")) + assert not rules.filename_included(str(tmp_path / "tools" / "danger.sh")) + + +def test_file_exceeds_line_length_limit_checks_file_and_patch_text(): + rules = FilterRules(include_patterns=None, exclude_patterns=None) + + assert rules.file_exceeds_line_length_limit("short\n", "+short\n", max_line_length=10) is False + assert rules.file_exceeds_line_length_limit("x" * 11, "+short\n", max_line_length=10) is True + assert rules.file_exceeds_line_length_limit("short\n", "+" + "x" * 11, max_line_length=10) is True diff --git a/tests/test_git.py b/tests/test_git.py new file mode 100644 index 0000000..712001e --- /dev/null +++ b/tests/test_git.py @@ -0,0 +1,40 @@ +from util.git import parse_unified_diff + + +def test_parse_unified_diff_maps_added_and_context_lines(): + patch = """diff --git a/app.py b/app.py +index 0000000..1111111 100644 +--- a/app.py ++++ b/app.py +@@ -1,3 +1,4 @@ + import os +-query = "safe" ++query = request.args["q"] ++db.execute("select * from users where name = " + query) + print(query) +""" + + line_map, new_lines_text = parse_unified_diff(patch) + + assert new_lines_text == { + 1: "import os", + 2: 'query = request.args["q"]', + 3: 'db.execute("select * from users where name = " + query)', + 4: "print(query)", + } + assert set(line_map) == {1, 2, 3, 4} + assert line_map[2] < line_map[3] + + +def test_parse_unified_diff_ignores_headers_before_first_hunk(): + line_map, new_lines_text = parse_unified_diff( + """diff --git a/app.py b/app.py +metadata that should not be parsed +@@ -0,0 +1,2 @@ ++first = True ++second = True +""" + ) + + assert new_lines_text == {1: "first = True", 2: "second = True"} + assert set(line_map) == {1, 2} diff --git a/tests/test_git_adapter.py b/tests/test_git_adapter.py new file mode 100644 index 0000000..cd417cf --- /dev/null +++ b/tests/test_git_adapter.py @@ -0,0 +1,68 @@ +import asyncio + +from git import Actor, Repo + +from scm.adapters.git import GitAdapter + + +AUTHOR = Actor("SAIST Tests", "tests@example.com") + + +def commit(repo, message): + return repo.index.commit(message, author=AUTHOR, committer=AUTHOR) + + +def test_git_adapter_reads_and_lists_files_at_compare_commit(tmp_path): + repo = Repo.init(tmp_path) + (tmp_path / "app.py").write_text("print('hello')\n", encoding="utf-8") + (tmp_path / "nested").mkdir() + (tmp_path / "nested" / "settings.py").write_text("DEBUG = True\n", encoding="utf-8") + (tmp_path / "image.bin").write_bytes(b"\xff\xfe\x00\x00") + repo.index.add(["app.py", "nested/settings.py", "image.bin"]) + head = commit(repo, "initial") + + adapter = GitAdapter(repo_path=tmp_path, base_commit=head.hexsha, compare_commit=head.hexsha) + + assert asyncio.run(adapter.list_files()) == ["app.py", "image.bin", "nested/settings.py"] + assert asyncio.run(adapter.get_file_contents("nested/settings.py")) == "DEBUG = True\n" + assert asyncio.run(adapter.get_file_contents("../outside.py")) is None + assert asyncio.run(adapter.get_file_contents("image.bin")) is None + + +def test_git_adapter_regex_searches_compare_commit_files(tmp_path): + repo = Repo.init(tmp_path) + (tmp_path / "app.py").write_text("SECRET_KEY = 'dev'\nprint(SECRET_KEY)\n", encoding="utf-8") + (tmp_path / "README.md").write_text("SECRET_KEY is documented here\n", encoding="utf-8") + repo.index.add(["app.py", "README.md"]) + head = commit(repo, "initial") + + adapter = GitAdapter(repo_path=tmp_path, base_commit=head.hexsha, compare_commit=head.hexsha) + matches = asyncio.run(adapter.regex_search(r"SECRET_KEY", file_pattern="**/*.py", max_results=1)) + + assert matches == [ + { + "filename": "app.py", + "line_number": 1, + "column": 1, + "match": "SECRET_KEY", + "line": "SECRET_KEY = 'dev'", + } + ] + + +def test_git_adapter_get_changed_files_uses_base_to_compare_patch(tmp_path): + repo = Repo.init(tmp_path) + (tmp_path / "app.py").write_text("value = 'old'\n", encoding="utf-8") + repo.index.add(["app.py"]) + base = commit(repo, "base") + + (tmp_path / "app.py").write_text("value = 'new'\n", encoding="utf-8") + repo.index.add(["app.py"]) + compare = commit(repo, "compare") + + adapter = GitAdapter(repo_path=tmp_path, base_commit=base.hexsha, compare_commit=compare.hexsha) + changed_files = adapter.get_changed_files() + + assert [file["filename"] for file in changed_files] == ["app.py"] + assert "-value = 'old'" in changed_files[0]["patch"] + assert "+value = 'new'" in changed_files[0]["patch"] diff --git a/tests/test_main_analysis.py b/tests/test_main_analysis.py new file mode 100644 index 0000000..c63b6e6 --- /dev/null +++ b/tests/test_main_analysis.py @@ -0,0 +1,190 @@ +import asyncio + +import main as saist_main +from models import Finding, Findings +from util.skills import skills_prompt_digest + + +def test_analyze_single_file_includes_analysis_skills_in_system_prompt(): + class CapturingLlm: + def __init__(self): + self.system_prompt = None + self.user_prompt = None + self.tool_fns = None + + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + self.system_prompt = system_prompt + self.user_prompt = user_prompt + self.tool_fns = tool_fns + return Findings(findings=[]) + + class FakeScm: + async def read_file_contents(self, filename): + return "print('hello')\n" + + async def list_files(self): + return ["app.py"] + + async def regex_search(self, pattern, file_pattern="**/*", max_results=100): + return [] + + def tool_functions(self): + return [self.read_file_contents, self.list_files, self.regex_search] + + llm = CapturingLlm() + result = asyncio.run( + saist_main.analyze_single_file( + scm=FakeScm(), + adapter=llm, + filename="app.py", + patch_text="@@ -0,0 +1 @@\n+print('hello')\n", + disable_tools=False, + analysis_skills="Skill: authorization requires tenant ownership checks.", + ) + ) + + assert result == [] + assert "Skill: authorization requires tenant ownership checks." in llm.system_prompt + assert "File: app.py" in llm.user_prompt + assert [tool.__name__ for tool in llm.tool_fns] == [ + "read_file_contents", + "list_files", + "regex_search", + ] + + +def test_process_file_reuses_cache_for_same_skills_and_salts_cache_when_skills_change(tmp_path, monkeypatch): + class CountingLlm: + def __init__(self): + self.calls = 0 + + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + self.calls += 1 + return Findings(findings=[]) + + class FakeScm: + async def read_file_contents(self, filename): + return "print('same file')\n" + + async def no_sleep(delay): + return None + + monkeypatch.setattr(saist_main.asyncio, "sleep", no_sleep) + + llm = CountingLlm() + scm = FakeScm() + cache_dir = tmp_path / "cache" + cache_dir.mkdir() + + for skills in ("routing skill", "routing skill", "auth skill"): + asyncio.run( + saist_main.process_file( + scm=scm, + llm=llm, + filename="app.py", + patch_text="@@ -0,0 +1 @@\n+print('same file')\n", + disable_tools=True, + disable_caching=False, + cache_folder=str(cache_dir), + analysis_skills=skills, + ) + ) + + cache_files = sorted(path.name for path in cache_dir.iterdir()) + assert llm.calls == 2 + assert len(cache_files) == 2 + assert any(skills_prompt_digest("routing skill") in name for name in cache_files) + assert any(skills_prompt_digest("auth skill") in name for name in cache_files) + + +def test_analyze_single_file_returns_none_when_llm_raises(): + class FailingLlm: + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + raise RuntimeError("model unavailable") + + class FakeScm: + async def read_file_contents(self, filename): + return "print('hello')\n" + + result = asyncio.run( + saist_main.analyze_single_file( + scm=FakeScm(), + adapter=FailingLlm(), + filename="app.py", + patch_text="@@ -0,0 +1 @@\n+print('hello')\n", + disable_tools=True, + ) + ) + + assert result is None + + +def test_context_from_finding_returns_context_window(): + class FakeScm: + async def read_file_contents(self, filename): + assert filename == "app.py" + return "\n".join(f"line {index}" for index in range(1, 8)) + + finding = Finding.model_validate( + { + "file": "app.py", + "snippet": "line 4", + "title": "Issue", + "issue": "Issue", + "recommendation": "Fix it.", + "cwe": "CWE-20", + "priority": 4, + "line_number": 4, + } + ) + + context, start, end = asyncio.run(saist_main.context_from_finding(FakeScm(), finding, context_size=2)) + + assert start == 2 + assert end == 6 + assert context == "line 2\nline 3\nline 4\nline 5\nline 6" + + +def test_context_from_finding_returns_none_when_file_read_fails(): + class FailingScm: + async def read_file_contents(self, filename): + raise FileNotFoundError(filename) + + finding = Finding.model_validate( + { + "file": "missing.py", + "snippet": "missing", + "title": "Issue", + "issue": "Issue", + "recommendation": "Fix it.", + "cwe": "CWE-20", + "priority": 4, + "line_number": 1, + } + ) + + assert asyncio.run(saist_main.context_from_finding(FailingScm(), finding)) is None + + +def test_generate_summary_from_findings_returns_fallback_when_llm_raises(): + class FailingLlm: + def prompt(self, system_prompt, user_prompt): + raise RuntimeError("summary unavailable") + + finding = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "Issue", + "issue": "Issue", + "recommendation": "Fix it.", + "cwe": "CWE-20", + "priority": 4, + "line_number": 1, + } + ) + + assert ( + saist_main.generate_summary_from_findings(FailingLlm(), [finding]) + == "Security issues found. Please review the inline comments." + ) diff --git a/tests/test_scm_tools.py b/tests/test_scm_tools.py new file mode 100644 index 0000000..3488209 --- /dev/null +++ b/tests/test_scm_tools.py @@ -0,0 +1,56 @@ +import asyncio + +from scm import Scm +from scm.adapters import BaseScmAdapter + + +class StubAdapter(BaseScmAdapter): + def create_review(self, comment, review_comments, request_changes): + return None + + def get_changed_files(self): + return [] + + async def get_file_contents(self, file_path: str): + return f"contents for {file_path}" + + +class ToolAdapter(StubAdapter): + async def list_files(self): + return ["app.py", "README.md"] + + async def regex_search(self, pattern: str, file_pattern: str = "**/*", max_results: int = 100): + return [{"filename": "app.py", "line_number": 1, "column": 1, "match": pattern, "line": pattern}] + + +def test_scm_exposes_llm_tool_functions_in_order(): + scm = Scm(ToolAdapter()) + + assert [tool.__name__ for tool in scm.tool_functions()] == [ + "read_file_contents", + "list_files", + "regex_search", + ] + + +def test_scm_delegates_file_tools_to_adapter(): + scm = Scm(ToolAdapter()) + + assert asyncio.run(scm.read_file_contents("app.py")) == "contents for app.py" + assert asyncio.run(scm.list_files()) == ["app.py", "README.md"] + assert asyncio.run(scm.regex_search("needle")) == [ + { + "filename": "app.py", + "line_number": 1, + "column": 1, + "match": "needle", + "line": "needle", + } + ] + + +def test_base_adapter_file_tool_stubs_do_not_error(): + scm = Scm(StubAdapter()) + + assert asyncio.run(scm.list_files()) == [] + assert asyncio.run(scm.regex_search("needle")) == [] diff --git a/tests/test_skills.py b/tests/test_skills.py new file mode 100644 index 0000000..f2b40c5 --- /dev/null +++ b/tests/test_skills.py @@ -0,0 +1,143 @@ +import asyncio + +from util.skills import ( + format_analysis_skills, + generate_skill_files, + load_analysis_skills, + project_root_from_args, + resolve_skills_dir, + skills_prompt_digest, +) + + +def test_load_analysis_skills_reads_markdown_in_order_and_respects_byte_limit(tmp_path): + skills_dir = tmp_path / ".saist" / "skills" + skills_dir.mkdir(parents=True) + (skills_dir / "b.md").write_text("second skill", encoding="utf-8") + (skills_dir / "a.md").write_text("first skill with a long body", encoding="utf-8") + (skills_dir / "empty.md").write_text(" ", encoding="utf-8") + + skills = load_analysis_skills(skills_dir, max_bytes=12) + + assert len(skills) == 1 + assert skills[0].path.name == "a.md" + assert skills[0].content == "first skill" + assert skills[0].truncated is True + + +def test_format_analysis_skills_wraps_guidance_with_source_names(tmp_path): + skills_dir = tmp_path / "skills" + skills_dir.mkdir() + (skills_dir / "routing.md").write_text("# Routing\nUse controllers.", encoding="utf-8") + + prompt_text = format_analysis_skills(load_analysis_skills(skills_dir)) + + assert "Application analysis skills" in prompt_text + assert "routing.md" in prompt_text + assert "Use controllers." in prompt_text + assert "Report only vulnerabilities that are present in the diff" in prompt_text + + +def test_project_root_and_skills_dir_resolution(tmp_path): + args = type("Args", (), {"SCM": "filesystem", "path": str(tmp_path)})() + + assert project_root_from_args(args) == tmp_path.resolve() + assert resolve_skills_dir(tmp_path, ".saist/skills") == (tmp_path / ".saist" / "skills").resolve() + assert resolve_skills_dir(tmp_path, str(tmp_path / "absolute")) == tmp_path / "absolute" + + +def test_skills_prompt_digest_changes_with_content(): + assert skills_prompt_digest("auth model") == skills_prompt_digest("auth model") + assert skills_prompt_digest("auth model") != skills_prompt_digest("routing model") + + +def test_generate_skill_files_samples_project_and_sanitizes_filenames(tmp_path): + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / "app.py").write_text("from flask import Flask\napp = Flask(__name__)\n", encoding="utf-8") + (project_root / "package.json").write_text('{"dependencies": {"express": "latest"}}\n', encoding="utf-8") + skills_dir = project_root / ".saist" / "skills" + + class FakeLlm: + def __init__(self): + self.user_prompt = None + + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + self.user_prompt = user_prompt + return response_format.model_validate( + { + "skills": [ + { + "filename": "../Authorization Model!!", + "content": "# Authorization\nCheck tenant boundaries.", + } + ] + } + ) + + llm = FakeLlm() + result = asyncio.run( + generate_skill_files( + llm=llm, + project_root=project_root, + skills_dir=skills_dir, + max_files=5, + max_file_bytes=300, + ) + ) + + output_file = skills_dir / "authorization-model.md" + assert result.skills_dir == skills_dir + assert result.written == [output_file] + assert result.skipped == [] + assert result.sampled_files == 2 + assert output_file.read_text(encoding="utf-8") == "# Authorization\nCheck tenant boundaries.\n" + assert "application-routing.md" in llm.user_prompt + assert "package.json" in llm.user_prompt + + +def test_generate_skill_files_preserves_existing_files_unless_overwrite_is_enabled(tmp_path): + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / "routes.py").write_text("routes = []\n", encoding="utf-8") + skills_dir = project_root / ".saist" / "skills" + skills_dir.mkdir(parents=True) + output_file = skills_dir / "application-routing.md" + output_file.write_text("# Existing\nKeep me.\n", encoding="utf-8") + + class FakeLlm: + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + return response_format.model_validate( + { + "skills": [ + { + "filename": "application-routing.md", + "content": "# New\nReplace me when asked.", + } + ] + } + ) + + skipped = asyncio.run( + generate_skill_files( + llm=FakeLlm(), + project_root=project_root, + skills_dir=skills_dir, + overwrite=False, + ) + ) + assert skipped.written == [] + assert skipped.skipped == [output_file] + assert output_file.read_text(encoding="utf-8") == "# Existing\nKeep me.\n" + + overwritten = asyncio.run( + generate_skill_files( + llm=FakeLlm(), + project_root=project_root, + skills_dir=skills_dir, + overwrite=True, + ) + ) + assert overwritten.written == [output_file] + assert overwritten.skipped == [] + assert output_file.read_text(encoding="utf-8") == "# New\nReplace me when asked.\n" From bee555c413d0742396d343da70f279eaf1d3ec73 Mon Sep 17 00:00:00 2001 From: QA Team Date: Fri, 8 May 2026 21:31:57 +0000 Subject: [PATCH 02/15] remove latex --- .github/workflows/build_pr.yml | 26 +- .github/workflows/build_release.yml | 2 +- Dockerfile | 35 - README.md | 17 +- docker-bake.hcl | 9 +- requirements.txt | 1 + saist/latex/__init__.py | 88 --- saist/latex/tex/brand.tex | 22 - saist/latex/tex/report.tex.jinja | 67 -- saist/latex/tex/style.tex | 49 -- saist/latex/texlive.profile | 9 - saist/main.py | 8 +- saist/reportlab_pdf/__init__.py | 740 ++++++++++++++++++ .../assets}/PunkSecurityLogo.png | Bin saist/reportlab_pdf/fonts/OFL.txt | 93 +++ saist/reportlab_pdf/fonts/PathwayExtreme.ttf | Bin 0 -> 305108 bytes saist/util/argparsing.py | 38 +- tests/test_argparsing.py | 19 +- tests/test_reportlab_pdf.py | 104 +++ 19 files changed, 976 insertions(+), 351 deletions(-) delete mode 100644 saist/latex/__init__.py delete mode 100644 saist/latex/tex/brand.tex delete mode 100644 saist/latex/tex/report.tex.jinja delete mode 100644 saist/latex/tex/style.tex delete mode 100644 saist/latex/texlive.profile create mode 100644 saist/reportlab_pdf/__init__.py rename saist/{latex/tex/image => reportlab_pdf/assets}/PunkSecurityLogo.png (100%) create mode 100644 saist/reportlab_pdf/fonts/OFL.txt create mode 100644 saist/reportlab_pdf/fonts/PathwayExtreme.ttf create mode 100644 tests/test_reportlab_pdf.py diff --git a/.github/workflows/build_pr.yml b/.github/workflows/build_pr.yml index de063bb..1600a06 100644 --- a/.github/workflows/build_pr.yml +++ b/.github/workflows/build_pr.yml @@ -1,4 +1,4 @@ -name: Build and publish release on new tag +name: Build and publish PR image on: pull_request: types: [opened, synchronize, reopened] @@ -20,19 +20,11 @@ jobs: - name: Set up Docker Buildx id: buildx uses: docker/setup-buildx-action@v3 - - name: Build and push SAIST-Lite - run: | - docker buildx build . \ - --push \ - --progress=plain \ - --tag docker.io/punksecurity/saist:lite-pr-${{ github.event.number }} \ - --platform linux/amd64 \ - --target saist - - name: Build and push SAIST - run: | - docker buildx build . \ - --push \ - --progress=plain \ - --tag docker.io/punksecurity/saist:pr-${{ github.event.number }} \ - --platform linux/amd64 \ - --target saist-tex + - name: Build and push SAIST + run: | + docker buildx build . \ + --push \ + --progress=plain \ + --tag docker.io/punksecurity/saist:pr-${{ github.event.number }} \ + --platform linux/amd64 \ + --target saist diff --git a/.github/workflows/build_release.yml b/.github/workflows/build_release.yml index e8079f7..965dd23 100644 --- a/.github/workflows/build_release.yml +++ b/.github/workflows/build_release.yml @@ -28,4 +28,4 @@ jobs: VERSION: ${{ steps.version.outputs.version }} with: push: true - targets: "full,lite" + targets: "release" diff --git a/Dockerfile b/Dockerfile index 02d1193..e64c03b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,43 +19,8 @@ COPY saist . # Exports ENV SAIST_COMMAND "docker run punksecurity/saist" ENV SAIST_CSV_PATH "/app/results.csv" -ENV SAIST_TEX_FILENAME "report.tex" ENV SAIST_PDF_FILENAME "report.pdf" ENV SAIST_WEB_HOST "0.0.0.0" ENV PYTHONUNBUFFERED 1 ENTRYPOINT [ "python3", "/app/main.py" ] CMD [ "-h" ] - -FROM alpine as tex-dl -WORKDIR /tmp -RUN mkdir -p /opt/texlive/bin - -RUN wget https://ftp.math.utah.edu/pub/texlive-utah/bin/aarch64-alpine322.tar.xz && \ - tar xvf aarch64-alpine322.tar.xz && ls -ltra && \ - mv aarch64-alpine322 /opt/texlive/bin/aarch64-linuxmusl - -RUN wget https://ftp.math.utah.edu/pub/texlive-utah/bin/x86_64-alpine322.tar.xz && \ - tar xvf x86_64-alpine322.tar.xz && \ - mv x86_64-alpine322 /opt/texlive/bin/x86_64-linuxmusl - -FROM saist AS saist-tex - -ARG TL_MIRROR="https://texlive.info/CTAN/systems/texlive/tlnet" - -COPY saist/latex/texlive.profile /tmp -COPY --from=tex-dl /opt/texlive/bin /tmp/texlive/bin -RUN apk add --no-cache perl curl fontconfig xz && \ - mkdir -p "/tmp/texlive" && cd "/tmp/texlive" && \ - wget "$TL_MIRROR/install-tl-unx.tar.gz" && \ - tar xzvf ./install-tl-unx.tar.gz && \ - "./install-tl-"*"/install-tl" --location "$TL_MIRROR" --custom-bin=/tmp/texlive/bin/$(uname -m)-linuxmusl -profile "/tmp/texlive.profile" && \ - rm -vf "/opt/texlive/install-tl" && \ - rm -vf "/opt/texlive/install-tl.log" && \ - rm -vrf /tmp/* - -ENV PATH="${PATH}:/opt/texlive/bin/custom" - -ARG TL_PACKAGES="lineno titlesec upquote minted blindtext booktabs fontawesome latexmk parskip xcolor" - -RUN tlmgr update --self && \ - tlmgr install ${TL_PACKAGES} diff --git a/README.md b/README.md index 4239659..9b65393 100644 --- a/README.md +++ b/README.md @@ -173,16 +173,13 @@ When skills are loaded, SAIST salts its findings cache with the skill content so saist allows you to generate PDF reports summarizing your findings, making it easier to share insights with your team. -To create a PDF report, simply use the `--pdf` flag when running the scan. By default, the report will be saved to -`reporting/report.pdf`. You can customize the filename by using the `--pdf-filename` option followed by your desired -filename. - -To add a project name onto the title page of the PDF report, use the `--project-name` option followed by your desired title. - -> It is recommended to use the provided Docker image for generating PDF reports, as it includes the necessary TeX suite, -which can be quite large. This ensures that all dependencies are met and the report is generated properly. - -If not, you need to install latexmk to make it work. +To create a PDF report, use the `--pdf` flag when running the scan. By default, the report will be saved to +`reporting/report.pdf`. You can customize the filename by using the `--pdf-filename` option followed by your desired +filename. + +To add a project name onto the title page of the PDF report, use the `--project-name` option followed by your desired title. + +PDF reports are generated with the built-in ReportLab renderer, so no external document-rendering toolchain is required. ### 🐋 Example (Docker) diff --git a/docker-bake.hcl b/docker-bake.hcl index a929d46..e5f7aca 100644 --- a/docker-bake.hcl +++ b/docker-bake.hcl @@ -11,16 +11,11 @@ target "base" { target "nightly" { inherits = ["base"] tags = ["docker.io/punksecurity/saist:nightly"] -} - -target "lite" { - inherits = ["base"] - tags = ["docker.io/punksecurity/saist:lite-${VERSION}", "docker.io/punksecurity/saist:lite-latest"] target = "saist" } -target "full" { +target "release" { inherits = ["base"] tags = ["docker.io/punksecurity/saist:${VERSION}", "docker.io/punksecurity/saist:latest"] - target = "saist-tex" + target = "saist" } diff --git a/requirements.txt b/requirements.txt index a1eaa77..b0c06f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ rich==14.0.0 flask==3.1.1 ollama==0.5.1 gitignore-parser==0.1.13 +reportlab==4.5.0 diff --git a/saist/latex/__init__.py b/saist/latex/__init__.py deleted file mode 100644 index 051f833..0000000 --- a/saist/latex/__init__.py +++ /dev/null @@ -1,88 +0,0 @@ -from dataclasses import dataclass -from models import FindingContext -from llm.adapters import BaseLlmAdapter -from jinja2 import Environment, FileSystemLoader -import subprocess -import logging -import os -import re -import datetime - -logger = logging.getLogger("saist.latex") - - -# TODO: Container 'awareness' -@dataclass -class Latex: - _DEFAULT_TEX_TEMPLATE = "report.tex.jinja" - _DEFAULT_OUTPUT_DIR = "reporting" - - llm: BaseLlmAdapter - project: str - findings: list[FindingContext] - comment: str - - def run(self, args): - pdf_path = os.path.join(self._DEFAULT_OUTPUT_DIR, args.pdf_filename) - tex_path = os.path.join(self._DEFAULT_OUTPUT_DIR, args.tex_filename) - - self._write_tex(tex_path) - logger.info(f"Written TeX file to: '{tex_path}'") - - if args.pdf: - print("\n📝 Generating PDF report...") - - rc = subprocess.run( - ["latexmk", "-pdf", "-f", "-interaction=nonstopmode", f"-outdir={self._DEFAULT_OUTPUT_DIR}", tex_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ).returncode - - if rc != 0: - logger.error(f"Unable to build PDF '{tex_path}' -> '{pdf_path}'") - exit(1) - - print(f"✨ Written report to '{pdf_path}'\n") - - logger.debug(f"Cleaning up auxiliary files in '{self._DEFAULT_OUTPUT_DIR}'") - subprocess.run(["latexmk", "-c"], cwd=self._DEFAULT_OUTPUT_DIR, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - - def _render_tex(self) -> str: - env = Environment( - loader=FileSystemLoader(os.path.join(os.path.dirname(__file__), "tex")) - ) - env.globals.update(escape_tex=self._escape_tex) - template = env.get_template(self._DEFAULT_TEX_TEMPLATE) - template.globals['now'] = datetime.datetime.now().isoformat(sep=' ', timespec='minutes') - return template.render({"model": self.llm, "project": self.project, "findings": self.findings, "comment": self.comment}) - - def _write_tex(self, tex_path: str): - try: - os.makedirs(os.path.dirname(tex_path), exist_ok=True) - with open(tex_path, "w") as fp: - fp.write(self._render_tex()) - except Exception as e: - logger.error(f"Unable to write TeX file to '{tex_path}': {e}") - exit(1) - - def _escape_tex(self, text: str) -> str: - if not text: - return "" - - specials = { - "&": r"\&", - "%": r"\%", - "$": r"\$", - "_": r"\_", - "{": r"\{", - "}": r"\}", - "#": r"\#", - "~": r"\textasciitilde{}", - "^": r"\^{}", - "\\": r"\textbackslash{}", - } - - def repl(match: re.Match) -> str: - char = match.group(0) - return specials[char] - - pattern = r'.(?<=[&%$_{}#~^\\])' - return re.sub(pattern, repl, text) diff --git a/saist/latex/tex/brand.tex b/saist/latex/tex/brand.tex deleted file mode 100644 index c9d78f0..0000000 --- a/saist/latex/tex/brand.tex +++ /dev/null @@ -1,22 +0,0 @@ -\def\Primary{111B29} -\def\Secondary{0C2540} - -\def\Blue{5BBFCF} -\def\Purple{A25DCE} -\def\Orange{CE985D} -\def\Green{87CE6D} -\def\Red{CE5D5D} - -\def\LogoTitlepage{latex/tex/image/PunkSecurityLogo.png} - -\usepackage[dvipsnames]{xcolor} -\newcommand{\newcolor}[2]{\definecolor{#1}{HTML}{#2}} - -\newcolor{ColorPrimary}{\Primary} -\newcolor{ColorSecondary}{\Secondary} - -\newcolor{ColorBlue}{\Blue} -\newcolor{ColorPurple}{\Purple} -\newcolor{ColorOrange}{\Orange} -\newcolor{ColorGreen}{\Green} -\newcolor{ColorRed}{\Red} \ No newline at end of file diff --git a/saist/latex/tex/report.tex.jinja b/saist/latex/tex/report.tex.jinja deleted file mode 100644 index 2df1518..0000000 --- a/saist/latex/tex/report.tex.jinja +++ /dev/null @@ -1,67 +0,0 @@ -\input{latex/tex/style.tex} - -\extrafloats{1000} - -\begin{document} - -\begin{titlepage} - \centering - \vspace*{5cm} - \includegraphics[width=0.4\textwidth]{\LogoTitlepage}\\[0.5cm] -{\Huge\sffamily\bfseries\color{ColorSecondary}{AI generated code review}\\[0.75em] -\Large\sffamily created by \href{https://github.com/punk-security/SAIST}{\textbf{SAIST}}}\\[0.75em] -{%- if model.model_vendor is not none -%} -\Large\sffamily\color{ColorSecondary}{{ escape_tex(model.model_vendor) }}: -{%- endif -%} -\Large\sffamily\color{ColorSecondary}{{ escape_tex(model.model_name) }} -{%- if project is not none -%} - \\[0.75em] - \Large\sffamily\color{ColorSecondary}{{ escape_tex(project) }}\\[0.75em] -{%- endif -%} - -\vspace{2.5cm} -\centering\Large\sffamily\color{ColorSecondary}{{ now }} - -\end{titlepage} - -\tableofcontents - -\section{Summary} - -{{ escape_tex(comment) }} - -\newpage -\section{Findings} - -{%- for finding in findings if finding.cwe -%} - {%- set title = escape_tex(finding.title) -%} - {%- set issue = escape_tex(finding.issue) -%} - {%- set file = escape_tex(finding.file) -%} - {%- set recommendation = escape_tex(finding.recommendation) -%} -\subsection{ {{ finding.cwe }} - {{ file }} - {{title}} } - -\textbf{Priority:}\; -{%- if finding.priority > 8 -%}\colorbox{ColorRed}{Critical} -{%- elif finding.priority > 7 -%}\colorbox{ColorRed}{High} -{%- elif finding.priority > 4 -%}\colorbox{ColorOrange}{Medium} -{%- else -%}\colorbox{ColorBlue}{Low} -{%- endif -%}\\ - -\textbf{Issue:}\; {{ issue }}\\ - -\textbf{Recommendation:}\; -{%- if finding.recommendation is not none -%} - {{ recommendation }} -{%- else -%} - Not applicable. -{%- endif -%} - -\begin{figure}[!ht] - \begin{minted}[firstnumber={{ finding.context_start }},highlightlines={{ finding.line_number}}]{python} -{{ finding.context }} - \end{minted} - \caption{\textbf{ {{file}} } on line \textbf{ {{ finding.line_number }} }} -\end{figure} -\newpage -{%- endfor -%} -\end{document} diff --git a/saist/latex/tex/style.tex b/saist/latex/tex/style.tex deleted file mode 100644 index a53430e..0000000 --- a/saist/latex/tex/style.tex +++ /dev/null @@ -1,49 +0,0 @@ -\documentclass[titlepage]{article} -\usepackage[utf8]{inputenc} -\usepackage[margin=0.8in]{geometry} -\usepackage[parfill]{parskip} - -\usepackage[english]{babel} -\usepackage{blindtext} - -\usepackage{fontawesome} -\usepackage[T1]{fontenc} -\renewcommand*\familydefault{\sfdefault} - -\usepackage[table]{xcolor} -\usepackage{booktabs, tabularx} - -\usepackage[cache=false]{minted} -\setminted{ - frame=lines, - autogobble, - linenos, - breaklines, - breakanywhere -} - -\usepackage{graphicx} - -\input{latex/tex/brand.tex} - -\usepackage{titlesec} -\titlespacing{\section} - {0pt}{1\baselineskip}{1.2\baselineskip} - -\titleformat{\section} - {\Large\sffamily\bfseries\color{ColorSecondary}} - {\thesection}{.5em}{} - -\titleformat{\subsection} - {\large\sffamily\color{ColorSecondary}} - {\thesubsection}{.7em}{} - -\usepackage[ - colorlinks=true, - linkcolor=ColorSecondary, % internal document links - urlcolor =ColorPurple, % \href links - citecolor=ColorSecondary, % bibliography -]{hyperref} - -\DeclareUnicodeCharacter{2264}{<=} -\DeclareUnicodeCharacter{2265}{>=} diff --git a/saist/latex/texlive.profile b/saist/latex/texlive.profile deleted file mode 100644 index 32e2c29..0000000 --- a/saist/latex/texlive.profile +++ /dev/null @@ -1,9 +0,0 @@ -selected_scheme scheme-basic -instopt_adjustpath 0 -tlpdbopt_install_docfiles 0 -tlpdbopt_install_srcfiles 0 -TEXDIR /opt/texlive/ -TEXMFLOCAL /opt/texlive/texmf-local -TEXMFSYSCONFIG /opt/texlive/texmf-config -TEXMFSYSVAR /opt/texlive/texmf-var -TEXMFHOME ~/.texmf \ No newline at end of file diff --git a/saist/main.py b/saist/main.py index 52902d1..59d5d5b 100755 --- a/saist/main.py +++ b/saist/main.py @@ -5,7 +5,7 @@ from typing import Optional from dotenv import load_dotenv -from latex import Latex +from reportlab_pdf import ReportLabPdf from llm.adapters import BaseLlmAdapter from llm.adapters.anthropic import AnthropicAdapter @@ -386,7 +386,7 @@ async def main(): w = FindingsServer(args.web_host, args.web_port) w.run(enriched_findings) - if args.pdf or args.tex: + if args.pdf: findings_context = [] for finding in all_findings: try: @@ -402,8 +402,8 @@ async def main(): findings_context.append(fc) except: continue - l = Latex(llm, args.project_name, findings_context, comment) - l.run(args) + r = ReportLabPdf(llm, args.project_name, findings_context, comment) + r.run(args) if args.ci and len(all_findings) > 0: exit(1) diff --git a/saist/reportlab_pdf/__init__.py b/saist/reportlab_pdf/__init__.py new file mode 100644 index 0000000..bbe5b24 --- /dev/null +++ b/saist/reportlab_pdf/__init__.py @@ -0,0 +1,740 @@ +from dataclasses import dataclass +from html import escape +import datetime +import logging +import os + +from llm.adapters import BaseLlmAdapter +from models import FindingContext +from reportlab.lib import colors +from reportlab.lib.enums import TA_CENTER +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import inch +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import Image, PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, XPreformatted +from reportlab.platypus.tableofcontents import TableOfContents + +logger = logging.getLogger("saist.reportlab_pdf") + +PUNK_BG_HEX = "#111B29" +PUNK_SECONDARY_HEX = "#0C2540" +PUNK_SECONDARY_LIGHT_HEX = "#2E3848" +PUNK_TEXT_HEX = "#DDEEF2" +PUNK_MUTED_HEX = "#AFC3CB" +PUNK_BLUE_HEX = "#5BBFCF" +PUNK_PURPLE_HEX = "#A25DCE" +PUNK_ORANGE_HEX = "#CE985D" +PUNK_GREEN_HEX = "#87CE5D" +PUNK_RED_HEX = "#CE5D5D" +PUNK_BG = colors.HexColor(PUNK_BG_HEX) +PUNK_SECONDARY = colors.HexColor(PUNK_SECONDARY_HEX) +PUNK_SECONDARY_LIGHT = colors.HexColor(PUNK_SECONDARY_LIGHT_HEX) +PUNK_TEXT = colors.HexColor(PUNK_TEXT_HEX) +PUNK_MUTED = colors.HexColor(PUNK_MUTED_HEX) +PUNK_BLUE = colors.HexColor(PUNK_BLUE_HEX) +PUNK_PURPLE = colors.HexColor(PUNK_PURPLE_HEX) +PUNK_ORANGE = colors.HexColor(PUNK_ORANGE_HEX) +PUNK_GREEN = colors.HexColor(PUNK_GREEN_HEX) +PUNK_RED = colors.HexColor(PUNK_RED_HEX) +PUNK_WHITE = colors.white +PRINT_BG = colors.white +PRINT_TEXT = colors.HexColor("#132033") +PRINT_MUTED = colors.HexColor("#52616F") +PRINT_PANEL = colors.HexColor("#F5F8FA") +PRINT_PANEL_ALT = colors.HexColor("#EEF4F7") +PRINT_BORDER = colors.HexColor("#D8E3E8") +PRINT_CODE_BG = colors.HexColor("#F7FAFC") +PRINT_CODE_HIGHLIGHT = colors.HexColor("#E8F6E8") +RAINBOW_HEX = [PUNK_BLUE_HEX, PUNK_PURPLE_HEX, PUNK_ORANGE_HEX, PUNK_GREEN_HEX, PUNK_RED_HEX] +RAINBOW = [colors.HexColor(color) for color in RAINBOW_HEX] + + +def _font_asset_path(filename: str) -> str: + return os.path.abspath(os.path.join(os.path.dirname(__file__), "fonts", filename)) + + +def _register_pathway_extreme() -> str: + font_path = _font_asset_path("PathwayExtreme.ttf") + if not os.path.exists(font_path): + logger.warning("Pathway Extreme font file missing, falling back to Helvetica") + return "Helvetica" + + try: + pdfmetrics.registerFont(TTFont("PathwayExtreme", font_path)) + return "PathwayExtreme" + except Exception as e: + logger.warning(f"Could not register Pathway Extreme font, falling back to Helvetica: {e}") + return "Helvetica" + + +FONT_REGULAR = _register_pathway_extreme() +FONT_BOLD = FONT_REGULAR if FONT_REGULAR != "Helvetica" else "Helvetica-Bold" +FONT_MONO = "Courier" + + +class SaistDocTemplate(SimpleDocTemplate): + def afterFlowable(self, flowable): + level = getattr(flowable, "_saist_toc_level", None) + if level is not None: + key = getattr(flowable, "_saist_toc_key", None) + if key: + self.canv.bookmarkPage(key) + self.notify("TOCEntry", (level, flowable.getPlainText(), self.page, key)) + + +@dataclass +class ReportLabPdf: + _DEFAULT_OUTPUT_DIR = "reporting" + + llm: BaseLlmAdapter + project: str + findings: list[FindingContext] + comment: str + + def run(self, args): + pdf_path = os.path.join(self._DEFAULT_OUTPUT_DIR, args.pdf_filename) + self.generated_at = datetime.datetime.now().isoformat(sep=" ", timespec="minutes") + self.scm_adapter = self._scm_adapter_from_args(args) + self.target = self._target_from_args(args) + + try: + os.makedirs(os.path.dirname(pdf_path), exist_ok=True) + doc = SaistDocTemplate( + pdf_path, + pagesize=A4, + rightMargin=0.65 * inch, + leftMargin=0.65 * inch, + topMargin=0.65 * inch, + bottomMargin=0.65 * inch, + title=self._document_title(), + author="SAIST", + ) + doc.multiBuild(self._story(), onFirstPage=self._draw_page, onLaterPages=self._draw_page) + except Exception as e: + logger.error(f"Unable to write ReportLab PDF file to '{pdf_path}': {e}") + exit(1) + + print(f"Written ReportLab PDF report to '{pdf_path}'") + + def _story(self) -> list: + styles = self._styles() + story = self._cover_story(styles) + + story.extend( + [ + PageBreak(), + Paragraph("Report contents", styles["Heading1"]), + self._table_of_contents(styles), + PageBreak(), + self._toc_heading("Summary", styles["Heading1"], 0, "summary"), + self._panel([[Paragraph(self._escaped(self.comment or "No summary was generated."), styles["Body"])]]), + Spacer(1, 0.28 * inch), + Paragraph("Issue summary", styles["Heading2"]), + self._issue_summary_table(styles), + PageBreak(), + self._toc_heading("Issues", styles["Heading1"], 0, "issues"), + Paragraph( + self._escaped(f"{len(self.findings)} issues reviewed with code context and remediation guidance."), + styles["Body"], + ), + Spacer(1, 0.18 * inch), + ] + ) + + if not self.findings: + story.append(self._panel([[Paragraph("No issues were provided.", styles["Body"])]])) + return story + + for index, finding in enumerate(self.findings, start=1): + if index > 1: + story.append(PageBreak()) + + story.extend(self._finding_story(index, finding, styles)) + + return story + + def _cover_story(self, styles: dict[str, ParagraphStyle]) -> list: + story = [] + logo_path = self._logo_path() + if logo_path: + logo = Image(logo_path, width=1.15 * inch, height=1.40 * inch) + logo.hAlign = "CENTER" + story.extend([Spacer(1, 0.25 * inch), logo, Spacer(1, 0.20 * inch)]) + else: + story.append(Spacer(1, 0.70 * inch)) + + story.extend( + [ + Paragraph("Punk Security", styles["Title"]), + Spacer(1, 0.12 * inch), + Paragraph(self._cover_title_markup(), styles["CoverSubtitle"]), + Spacer(1, 0.30 * inch), + self._metadata_table(styles), + Spacer(1, 0.18 * inch), + self._disclaimer_panel(styles), + ] + ) + + return story + + def _table_of_contents(self, styles: dict[str, ParagraphStyle]) -> TableOfContents: + toc = TableOfContents() + toc.levelStyles = [styles["TocLevel0"], styles["TocLevel1"]] + toc.dotsMinLevel = 0 + return toc + + def _finding_story(self, index: int, finding: FindingContext, styles: dict[str, ParagraphStyle]) -> list: + priority_label, priority_color = self._priority(finding.priority) + metadata = [ + ["Priority", priority_label], + ["CWE", finding.cwe or "Not specified"], + ["File", finding.file], + ["Line", str(finding.line_number)], + ] + + table = Table(metadata, colWidths=[1.0 * inch, 6.0 * inch], hAlign="LEFT") + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (0, -1), PRINT_PANEL_ALT), + ("BACKGROUND", (1, 1), (1, -1), PRINT_BG), + ("BACKGROUND", (1, 0), (1, 0), priority_color), + ("TEXTCOLOR", (0, 0), (0, -1), PRINT_MUTED), + ("TEXTCOLOR", (1, 1), (-1, -1), PRINT_TEXT), + ("TEXTCOLOR", (1, 0), (1, 0), PUNK_BG if priority_label in {"Low", "Medium"} else PUNK_WHITE), + ("FONTNAME", (0, 0), (0, -1), FONT_BOLD), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("GRID", (0, 0), (-1, -1), 0.25, PRINT_BORDER), + ("LEFTPADDING", (0, 0), (-1, -1), 6), + ("RIGHTPADDING", (0, 0), (-1, -1), 6), + ("TOPPADDING", (0, 0), (-1, -1), 5), + ("BOTTOMPADDING", (0, 0), (-1, -1), 5), + ] + ) + ) + + return [ + Paragraph(self._escaped(f"ISSUE {index:02d}"), styles["Eyebrow"]), + self._toc_heading( + self._escaped(f"{finding.cwe} - {finding.file} - {finding.title}"), + styles["Heading2"], + 1, + f"issue-{index}", + ), + Spacer(1, 0.1 * inch), + table, + Spacer(1, 0.18 * inch), + Paragraph("Issue", styles["Heading3"]), + self._panel([[Paragraph(self._escaped(finding.issue), styles["Body"])]]), + Spacer(1, 0.12 * inch), + Paragraph("Recommendation", styles["Heading3"]), + self._panel([[Paragraph(self._escaped(finding.recommendation or "Not applicable."), styles["Body"])]]), + Spacer(1, 0.18 * inch), + Paragraph("Context", styles["Heading3"]), + self._context_table(finding, styles), + ] + + def _styles(self) -> dict[str, ParagraphStyle]: + styles = getSampleStyleSheet() + styles.add( + ParagraphStyle( + name="Project", + parent=styles["Heading2"], + alignment=TA_CENTER, + textColor=PUNK_WHITE, + spaceAfter=12, + ) + ) + styles.add( + ParagraphStyle( + name="MutedCenter", + parent=styles["BodyText"], + alignment=TA_CENTER, + textColor=PUNK_MUTED, + spaceAfter=6, + ) + ) + styles.add( + ParagraphStyle( + name="Eyebrow", + parent=styles["BodyText"], + fontSize=8, + leading=10, + textColor=PUNK_SECONDARY, + fontName=FONT_BOLD, + spaceAfter=4, + ) + ) + styles.add( + ParagraphStyle( + name="EyebrowCenter", + parent=styles["Eyebrow"], + alignment=TA_CENTER, + textColor=PUNK_GREEN, + spaceAfter=8, + ) + ) + styles.add( + ParagraphStyle( + name="CoverSubtitle", + parent=styles["BodyText"], + alignment=TA_CENTER, + textColor=PUNK_TEXT, + fontName=FONT_BOLD, + fontSize=20, + leading=26, + ) + ) + styles.add( + ParagraphStyle( + name="Body", + parent=styles["BodyText"], + leading=14, + textColor=PRINT_TEXT, + spaceAfter=8, + ) + ) + styles.add( + ParagraphStyle( + name="CodeLine", + parent=styles["Code"], + fontName=FONT_MONO, + fontSize=7.5, + leading=9, + textColor=PRINT_TEXT, + ) + ) + styles.add( + ParagraphStyle( + name="CodeNumber", + parent=styles["CodeLine"], + textColor=PRINT_MUTED, + alignment=TA_CENTER, + ) + ) + styles.add( + ParagraphStyle( + name="CodeLineHighlight", + parent=styles["CodeLine"], + textColor=PRINT_TEXT, + ) + ) + styles.add( + ParagraphStyle( + name="CodeNumberHighlight", + parent=styles["CodeNumber"], + textColor=PRINT_TEXT, + fontName=FONT_MONO, + ) + ) + styles.add( + ParagraphStyle( + name="PanelLabel", + parent=styles["Eyebrow"], + textColor=PUNK_SECONDARY, + ) + ) + styles.add( + ParagraphStyle( + name="MetadataLabel", + parent=styles["BodyText"], + fontName=FONT_BOLD, + fontSize=8, + leading=10, + textColor=PUNK_GREEN, + ) + ) + styles.add( + ParagraphStyle( + name="MetadataValue", + parent=styles["BodyText"], + fontSize=9, + leading=11, + textColor=PUNK_TEXT, + spaceAfter=0, + ) + ) + styles.add( + ParagraphStyle( + name="CoverDisclaimerLabel", + parent=styles["MetadataLabel"], + fontSize=8, + leading=10, + textColor=PUNK_ORANGE, + ) + ) + styles.add( + ParagraphStyle( + name="CoverDisclaimer", + parent=styles["BodyText"], + fontSize=8.5, + leading=11, + textColor=PUNK_MUTED, + spaceAfter=0, + ) + ) + styles.add( + ParagraphStyle( + name="TocLevel0", + parent=styles["BodyText"], + fontName=FONT_BOLD, + fontSize=11, + leading=15, + leftIndent=0, + firstLineIndent=0, + spaceBefore=8, + textColor=PUNK_SECONDARY, + ) + ) + styles.add( + ParagraphStyle( + name="TocLevel1", + parent=styles["TocLevel0"], + fontName=FONT_REGULAR, + fontSize=9, + leading=12, + leftIndent=18, + firstLineIndent=0, + spaceBefore=4, + textColor=PRINT_MUTED, + ) + ) + styles.add( + ParagraphStyle( + name="IssueSummaryHeader", + parent=styles["BodyText"], + fontName=FONT_BOLD, + fontSize=9, + leading=11, + textColor=PUNK_WHITE, + ) + ) + styles.add( + ParagraphStyle( + name="IssueSummaryCell", + parent=styles["Body"], + fontSize=9, + leading=11, + textColor=PRINT_TEXT, + spaceAfter=0, + ) + ) + styles.add( + ParagraphStyle( + name="IssueSummaryId", + parent=styles["IssueSummaryCell"], + fontName=FONT_BOLD, + textColor=PUNK_SECONDARY, + ) + ) + styles.add( + ParagraphStyle( + name="IssueSummarySeverity", + parent=styles["IssueSummaryCell"], + alignment=TA_CENTER, + fontName=FONT_BOLD, + ) + ) + + styles["Title"].alignment = TA_CENTER + styles["Title"].fontSize = 28 + styles["Title"].leading = 32 + styles["Title"].textColor = PUNK_WHITE + styles["Title"].fontName = FONT_BOLD + styles["BodyText"].fontName = FONT_REGULAR + styles["Heading1"].fontName = FONT_BOLD + styles["Heading2"].fontName = FONT_BOLD + styles["Heading3"].fontName = FONT_BOLD + styles["Heading1"].textColor = PUNK_SECONDARY + styles["Heading2"].textColor = PUNK_SECONDARY + styles["Heading3"].textColor = PUNK_SECONDARY + styles["Heading1"].spaceBefore = 12 + styles["Heading1"].spaceAfter = 10 + styles["Heading2"].spaceBefore = 8 + styles["Heading2"].spaceAfter = 8 + styles["Heading3"].spaceBefore = 6 + styles["Heading3"].spaceAfter = 4 + + return styles + + @staticmethod + def _toc_heading(text: str, style: ParagraphStyle, level: int, key: str) -> Paragraph: + paragraph = Paragraph(text, style) + paragraph._saist_toc_level = level + paragraph._saist_toc_key = key + return paragraph + + def _document_title(self) -> str: + if self.project: + return f"SAIST report - {self.project}" + return "SAIST report" + + def _cover_title_markup(self) -> str: + if self.project: + project_line = self._escaped(self.project) + return f"AI generated code review of
{project_line}
by {self._rainbow_saist_markup()}" + return f"AI generated code review
by {self._rainbow_saist_markup()}" + + @staticmethod + def _rainbow_saist_markup() -> str: + return "".join(f'{letter}' for letter, color in zip("SAIST", RAINBOW_HEX)) + + def _model_text(self) -> str: + parts = [self.llm.model_vendor, self.llm.model_name] + return ": ".join(part for part in parts if part) + + @staticmethod + def _scm_adapter_from_args(args) -> str: + return getattr(args, "SCM", None) or "Not specified" + + @staticmethod + def _target_from_args(args) -> str: + scm = getattr(args, "SCM", None) + + if scm in {"filesystem", "git"}: + return getattr(args, "path", None) or "Not specified" + + if scm == "github": + repository = getattr(args, "repository", None) + pr = getattr(args, "pr", None) + if repository and pr: + return f"{repository} pull request #{pr}" + return repository or "Not specified" + + return getattr(args, "path", None) or getattr(args, "repository", None) or "Not specified" + + def _disclaimer_panel(self, styles: dict[str, ParagraphStyle]) -> Table: + table = Table( + [ + [Paragraph("AI-generated review", styles["CoverDisclaimerLabel"])], + [ + Paragraph( + "This report has not been reviewed by Punk Security for technical accuracy.", + styles["CoverDisclaimer"], + ) + ], + ], + colWidths=[6.7 * inch], + hAlign="LEFT", + ) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), PUNK_BG), + ("BOX", (0, 0), (-1, -1), 0.45, PUNK_SECONDARY_LIGHT), + ("LINEABOVE", (0, 0), (-1, 0), 1.0, PUNK_ORANGE), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, 0), 9), + ("BOTTOMPADDING", (0, 0), (-1, 0), 2), + ("TOPPADDING", (0, 1), (-1, 1), 0), + ("BOTTOMPADDING", (0, 1), (-1, 1), 10), + ] + ) + ) + return table + + def _metadata_table(self, styles: dict[str, ParagraphStyle]) -> Table: + rows = [ + ["Date", getattr(self, "generated_at", "Not specified")], + ["Model", self._model_text() or "Not specified"], + ["SCM adapter", getattr(self, "scm_adapter", "Not specified")], + ["Target", getattr(self, "target", "Not specified")], + ] + table_rows = [ + [ + Paragraph(self._escaped(label), styles["MetadataLabel"]), + Paragraph(self._escaped(value), styles["MetadataValue"]), + ] + for label, value in rows + ] + + table = Table(table_rows, colWidths=[1.35 * inch, 5.35 * inch], hAlign="LEFT") + table_style = [ + ("BACKGROUND", (0, 0), (-1, -1), PUNK_BG), + ("BOX", (0, 0), (-1, -1), 0.45, PUNK_SECONDARY_LIGHT), + ("LINEABOVE", (0, 0), (-1, 0), 1.0, PUNK_BLUE), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (0, -1), 12), + ("RIGHTPADDING", (0, 0), (0, -1), 8), + ("LEFTPADDING", (1, 0), (1, -1), 4), + ("RIGHTPADDING", (1, 0), (1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 7), + ("BOTTOMPADDING", (0, 0), (-1, -1), 7), + ] + for row_index in range(len(table_rows) - 1): + table_style.append(("LINEBELOW", (0, row_index), (-1, row_index), 0.25, PUNK_SECONDARY_LIGHT)) + table.setStyle(TableStyle(table_style)) + return table + + def _issue_summary_table(self, styles: dict[str, ParagraphStyle]) -> Table: + rows = [ + [ + Paragraph("Issue ID", styles["IssueSummaryHeader"]), + Paragraph("Title", styles["IssueSummaryHeader"]), + Paragraph("Severity", styles["IssueSummaryHeader"]), + ] + ] + + for index, finding in enumerate(self.findings, start=1): + severity, severity_color = self._priority(finding.priority) + rows.append( + [ + Paragraph(self._escaped(f"ISSUE {index:02d}"), styles["IssueSummaryId"]), + Paragraph(self._escaped(finding.title), styles["IssueSummaryCell"]), + Paragraph(self._escaped(severity), styles["IssueSummarySeverity"]), + ] + ) + + if len(rows) == 1: + rows.append( + [ + Paragraph("-", styles["IssueSummaryCell"]), + Paragraph("No issues were provided.", styles["IssueSummaryCell"]), + Paragraph("-", styles["IssueSummaryCell"]), + ] + ) + + table = Table(rows, colWidths=[1.1 * inch, 4.35 * inch, 1.25 * inch], hAlign="LEFT", repeatRows=1) + table_style = [ + ("BACKGROUND", (0, 0), (-1, 0), PUNK_SECONDARY), + ("TEXTCOLOR", (0, 0), (-1, 0), PUNK_WHITE), + ("FONTNAME", (0, 0), (-1, 0), FONT_BOLD), + ("BACKGROUND", (0, 1), (-1, -1), PRINT_BG), + ("GRID", (0, 0), (-1, -1), 0.25, PRINT_BORDER), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("LEFTPADDING", (0, 0), (-1, -1), 8), + ("RIGHTPADDING", (0, 0), (-1, -1), 8), + ("TOPPADDING", (0, 0), (-1, -1), 7), + ("BOTTOMPADDING", (0, 0), (-1, -1), 7), + ] + + for row_index, finding in enumerate(self.findings, start=1): + _, severity_color = self._priority(finding.priority) + if row_index % 2 == 0: + table_style.append(("BACKGROUND", (0, row_index), (-1, row_index), PRINT_PANEL)) + table_style.extend( + [ + ("BACKGROUND", (2, row_index), (2, row_index), severity_color), + ("TEXTCOLOR", (2, row_index), (2, row_index), PUNK_BG if finding.priority <= 7 else PUNK_WHITE), + ] + ) + + table.setStyle(TableStyle(table_style)) + return table + + def _panel(self, rows: list[list]) -> Table: + table = Table(rows, colWidths=[6.7 * inch], hAlign="LEFT") + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), PRINT_PANEL), + ("BOX", (0, 0), (-1, -1), 0.4, PRINT_BORDER), + ("LEFTPADDING", (0, 0), (-1, -1), 12), + ("RIGHTPADDING", (0, 0), (-1, -1), 12), + ("TOPPADDING", (0, 0), (-1, -1), 10), + ("BOTTOMPADDING", (0, 0), (-1, -1), 10), + ] + ) + ) + return table + + def _context_table(self, finding: FindingContext, styles: dict[str, ParagraphStyle]) -> Table: + lines = finding.context.splitlines() if finding.context else [""] + rows = [] + + for offset, line in enumerate(lines): + line_number = finding.context_start + offset + highlighted = line_number == finding.line_number + rows.append( + [ + str(line_number), + XPreformatted(self._escaped(line) or " ", styles["CodeLineHighlight" if highlighted else "CodeLine"]), + ] + ) + + table = Table(rows, colWidths=[0.70 * inch, 6.00 * inch], hAlign="LEFT", repeatRows=0) + table_style = [ + ("BACKGROUND", (0, 0), (-1, -1), PRINT_CODE_BG), + ("BACKGROUND", (0, 0), (0, -1), PRINT_PANEL_ALT), + ("GRID", (0, 0), (-1, -1), 0.2, PRINT_BORDER), + ("VALIGN", (0, 0), (-1, -1), "TOP"), + ("ALIGN", (0, 0), (0, -1), "RIGHT"), + ("FONTNAME", (0, 0), (0, -1), FONT_MONO), + ("FONTSIZE", (0, 0), (0, -1), 7.5), + ("LEADING", (0, 0), (0, -1), 9), + ("TEXTCOLOR", (0, 0), (0, -1), PRINT_MUTED), + ("LEFTPADDING", (0, 0), (-1, -1), 5), + ("RIGHTPADDING", (0, 0), (-1, -1), 5), + ("TOPPADDING", (0, 0), (-1, -1), 4), + ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ] + + for index, _ in enumerate(lines): + line_number = finding.context_start + index + if line_number == finding.line_number: + table_style.extend( + [ + ("BACKGROUND", (0, index), (-1, index), colors.HexColor("#1D3A2A")), + ("BACKGROUND", (0, index), (-1, index), PRINT_CODE_HIGHLIGHT), + ("TEXTCOLOR", (0, index), (-1, index), PRINT_TEXT), + ("FONTNAME", (0, index), (0, index), "Courier-Bold"), + ] + ) + + table.setStyle(TableStyle(table_style)) + return table + + @staticmethod + def _logo_path() -> str | None: + path = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "assets", + "PunkSecurityLogo.png", + ) + ) + return path if os.path.exists(path) else None + + @staticmethod + def _draw_page(canvas, doc): + width, height = A4 + canvas.saveState() + page_is_cover = doc.page == 1 + canvas.setFillColor(PUNK_SECONDARY if page_is_cover else PRINT_BG) + canvas.rect(0, 0, width, height, stroke=0, fill=1) + canvas.setFillColor(PUNK_SECONDARY) + canvas.rect(0, height - 0.38 * inch, width, 0.38 * inch, stroke=0, fill=1) + ReportLabPdf._draw_rainbow_bar(canvas, width, height - 0.40 * inch) + + if not page_is_cover: + canvas.setFillColor(PRINT_MUTED) + canvas.setFont(FONT_REGULAR, 8) + canvas.drawString(0.65 * inch, height - 0.25 * inch, "SAIST AI generated code review") + canvas.drawRightString(width - 0.65 * inch, 0.35 * inch, f"Page {doc.page}") + ReportLabPdf._draw_rainbow_bar(canvas, width, 0.22 * inch) + + canvas.restoreState() + + @staticmethod + def _draw_rainbow_bar(canvas, width: float, y: float): + segment_width = width / len(RAINBOW) + for index, color in enumerate(RAINBOW): + canvas.setFillColor(color) + canvas.rect(index * segment_width, y, segment_width + 1, 0.035 * inch, stroke=0, fill=1) + + @staticmethod + def _escaped(value) -> str: + return escape(str(value or "")) + + @staticmethod + def _priority(priority: int): + if priority > 8: + return "Critical", PUNK_RED + if priority > 7: + return "High", PUNK_RED + if priority > 4: + return "Medium", PUNK_ORANGE + return "Low", PUNK_BLUE diff --git a/saist/latex/tex/image/PunkSecurityLogo.png b/saist/reportlab_pdf/assets/PunkSecurityLogo.png similarity index 100% rename from saist/latex/tex/image/PunkSecurityLogo.png rename to saist/reportlab_pdf/assets/PunkSecurityLogo.png diff --git a/saist/reportlab_pdf/fonts/OFL.txt b/saist/reportlab_pdf/fonts/OFL.txt new file mode 100644 index 0000000..8fd08fa --- /dev/null +++ b/saist/reportlab_pdf/fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2019 The Pathway Extreme Project Authors (https://github.com/etunni/Pathway-Variable-Font) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/saist/reportlab_pdf/fonts/PathwayExtreme.ttf b/saist/reportlab_pdf/fonts/PathwayExtreme.ttf new file mode 100644 index 0000000000000000000000000000000000000000..d18c67e48e84d687fd50ab456aaff0241c535f1b GIT binary patch literal 305108 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

uf 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/export')\n" + "def export_customer(customer_id):\n" + " require_login()\n" + " return export_customer_records(customer_id)" + ), + "context_start": 85, + "context_end": 88, + } + ), + FindingContext.model_validate( + { + "file": "settings.py", + "snippet": "API_TOKEN = 'dev-token-123'", + "title": "Hardcoded API token", + "issue": "A static API token is stored in source code and could be exposed through the repository.", + "recommendation": "Load secrets from a managed secret store or environment variable.", + "cwe": "CWE-798", + "priority": 5, + "line_number": 12, + "context": ( + "DEBUG = False\n" + "SERVICE_URL = 'https://api.example.test'\n" + "API_TOKEN = 'dev-token-123'\n" + "TIMEOUT_SECONDS = 10" + ), + "context_start": 10, + "context_end": 13, + } + ), + ] + + +def test_reportlab_pdf_writes_pdf_report(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + args = type( + "Args", + (), + { + "pdf_filename": "example-issues.pdf", + "SCM": "filesystem", + "path": "/example/project", + }, + )() + + ReportLabPdf( + llm=FakeLlm(), + project="Example Project", + findings=example_findings(), + comment=STATIC_SUMMARY, + ).run(args) + + pdf_path = tmp_path / "reporting" / "example-issues.pdf" + assert pdf_path.exists() + assert pdf_path.read_bytes().startswith(b"%PDF") + assert pdf_path.stat().st_size > 1000 From 8c848add27168da4fcd5466bc7d724f34a96fbf6 Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 10:28:55 +0000 Subject: [PATCH 03/15] size fixes --- saist/reportlab_pdf/__init__.py | 41 ++++++++++++++++++--------------- tests/test_reportlab_pdf.py | 30 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 19 deletions(-) diff --git a/saist/reportlab_pdf/__init__.py b/saist/reportlab_pdf/__init__.py index bbe5b24..1908e63 100644 --- a/saist/reportlab_pdf/__init__.py +++ b/saist/reportlab_pdf/__init__.py @@ -129,7 +129,7 @@ def _story(self) -> list: self._table_of_contents(styles), PageBreak(), self._toc_heading("Summary", styles["Heading1"], 0, "summary"), - self._panel([[Paragraph(self._escaped(self.comment or "No summary was generated."), styles["Body"])]]), + self._text_panel(self.comment or "No summary was generated.", styles), Spacer(1, 0.28 * inch), Paragraph("Issue summary", styles["Heading2"]), self._issue_summary_table(styles), @@ -144,7 +144,7 @@ def _story(self) -> list: ) if not self.findings: - story.append(self._panel([[Paragraph("No issues were provided.", styles["Body"])]])) + story.append(self._text_panel("No issues were provided.", styles)) return story for index, finding in enumerate(self.findings, start=1): @@ -227,10 +227,10 @@ def _finding_story(self, index: int, finding: FindingContext, styles: dict[str, table, Spacer(1, 0.18 * inch), Paragraph("Issue", styles["Heading3"]), - self._panel([[Paragraph(self._escaped(finding.issue), styles["Body"])]]), + self._text_panel(finding.issue, styles), Spacer(1, 0.12 * inch), Paragraph("Recommendation", styles["Heading3"]), - self._panel([[Paragraph(self._escaped(finding.recommendation or "Not applicable."), styles["Body"])]]), + self._text_panel(finding.recommendation or "Not applicable.", styles), Spacer(1, 0.18 * inch), Paragraph("Context", styles["Heading3"]), self._context_table(finding, styles), @@ -336,6 +336,18 @@ def _styles(self) -> dict[str, ParagraphStyle]: textColor=PUNK_SECONDARY, ) ) + styles.add( + ParagraphStyle( + name="PanelBody", + parent=styles["Body"], + backColor=PRINT_PANEL, + borderColor=PRINT_BORDER, + borderWidth=0.4, + borderPadding=12, + leading=14, + spaceAfter=8, + ) + ) styles.add( ParagraphStyle( name="MetadataLabel", @@ -625,21 +637,8 @@ def _issue_summary_table(self, styles: dict[str, ParagraphStyle]) -> Table: table.setStyle(TableStyle(table_style)) return table - def _panel(self, rows: list[list]) -> Table: - table = Table(rows, colWidths=[6.7 * inch], hAlign="LEFT") - table.setStyle( - TableStyle( - [ - ("BACKGROUND", (0, 0), (-1, -1), PRINT_PANEL), - ("BOX", (0, 0), (-1, -1), 0.4, PRINT_BORDER), - ("LEFTPADDING", (0, 0), (-1, -1), 12), - ("RIGHTPADDING", (0, 0), (-1, -1), 12), - ("TOPPADDING", (0, 0), (-1, -1), 10), - ("BOTTOMPADDING", (0, 0), (-1, -1), 10), - ] - ) - ) - return table + def _text_panel(self, text: str, styles: dict[str, ParagraphStyle]) -> Paragraph: + return Paragraph(self._paragraph_markup(text), styles["PanelBody"]) def _context_table(self, finding: FindingContext, styles: dict[str, ParagraphStyle]) -> Table: lines = finding.context.splitlines() if finding.context else [""] @@ -729,6 +728,10 @@ def _draw_rainbow_bar(canvas, width: float, y: float): def _escaped(value) -> str: return escape(str(value or "")) + def _paragraph_markup(self, value: str) -> str: + escaped = self._escaped(value) + return escaped.replace("\n\n", "

").replace("\n", "
") + @staticmethod def _priority(priority: int): if priority > 8: diff --git a/tests/test_reportlab_pdf.py b/tests/test_reportlab_pdf.py index d42ea5d..c3cbf67 100644 --- a/tests/test_reportlab_pdf.py +++ b/tests/test_reportlab_pdf.py @@ -102,3 +102,33 @@ def test_reportlab_pdf_writes_pdf_report(tmp_path, monkeypatch): assert pdf_path.exists() assert pdf_path.read_bytes().startswith(b"%PDF") assert pdf_path.stat().st_size > 1000 + + +def test_reportlab_pdf_splits_long_summary_across_pages(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + args = type( + "Args", + (), + { + "pdf_filename": "long-summary.pdf", + "SCM": "filesystem", + "path": "/example/project", + }, + )() + long_summary = "\n\n".join( + f"Executive Summary paragraph {index}. " + "The assessment identified several high-impact findings and includes enough detail to span " + "multiple pages without forcing the text into a single unbreakable table cell." + for index in range(70) + ) + + ReportLabPdf( + llm=FakeLlm(), + project="Example Project", + findings=example_findings(), + comment=long_summary, + ).run(args) + + pdf_path = tmp_path / "reporting" / "long-summary.pdf" + assert pdf_path.exists() + assert pdf_path.read_bytes().startswith(b"%PDF") From e51ea80307e49e0b13ee09fc77dae67d77695898 Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 11:00:37 +0000 Subject: [PATCH 04/15] size fixes --- saist/reportlab_pdf/__init__.py | 36 ++++++++++++++++++++------------- tests/test_reportlab_pdf.py | 22 ++++++++++++++++++-- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/saist/reportlab_pdf/__init__.py b/saist/reportlab_pdf/__init__.py index 1908e63..6436caf 100644 --- a/saist/reportlab_pdf/__init__.py +++ b/saist/reportlab_pdf/__init__.py @@ -49,6 +49,11 @@ PRINT_CODE_HIGHLIGHT = colors.HexColor("#E8F6E8") RAINBOW_HEX = [PUNK_BLUE_HEX, PUNK_PURPLE_HEX, PUNK_ORANGE_HEX, PUNK_GREEN_HEX, PUNK_RED_HEX] RAINBOW = [colors.HexColor(color) for color in RAINBOW_HEX] +CONTENT_WIDTH = 6.7 * inch +ISSUE_LABEL_WIDTH = 1.0 * inch +CODE_LINE_NUMBER_WIDTH = 0.62 * inch +FRAME_INNER_WIDTH = A4[0] - (2 * 0.65 * inch) - 12 +CONTENT_RIGHT_INDENT = max(0, FRAME_INNER_WIDTH - CONTENT_WIDTH) def _font_asset_path(filename: str) -> str: @@ -194,7 +199,7 @@ def _finding_story(self, index: int, finding: FindingContext, styles: dict[str, ["Line", str(finding.line_number)], ] - table = Table(metadata, colWidths=[1.0 * inch, 6.0 * inch], hAlign="LEFT") + table = Table(metadata, colWidths=[ISSUE_LABEL_WIDTH, CONTENT_WIDTH - ISSUE_LABEL_WIDTH], hAlign="LEFT") table.setStyle( TableStyle( [ @@ -227,12 +232,14 @@ def _finding_story(self, index: int, finding: FindingContext, styles: dict[str, table, Spacer(1, 0.18 * inch), Paragraph("Issue", styles["Heading3"]), + Spacer(1, 0.08 * inch), self._text_panel(finding.issue, styles), - Spacer(1, 0.12 * inch), + Spacer(1, 0.20 * inch), Paragraph("Recommendation", styles["Heading3"]), + Spacer(1, 0.08 * inch), self._text_panel(finding.recommendation or "Not applicable.", styles), Spacer(1, 0.18 * inch), - Paragraph("Context", styles["Heading3"]), + Paragraph("Affected code", styles["Heading3"]), self._context_table(finding, styles), ] @@ -301,8 +308,8 @@ def _styles(self) -> dict[str, ParagraphStyle]: name="CodeLine", parent=styles["Code"], fontName=FONT_MONO, - fontSize=7.5, - leading=9, + fontSize=6.8, + leading=8, textColor=PRINT_TEXT, ) ) @@ -343,7 +350,8 @@ def _styles(self) -> dict[str, ParagraphStyle]: backColor=PRINT_PANEL, borderColor=PRINT_BORDER, borderWidth=0.4, - borderPadding=12, + borderPadding=8, + rightIndent=CONTENT_RIGHT_INDENT, leading=14, spaceAfter=8, ) @@ -528,7 +536,7 @@ def _disclaimer_panel(self, styles: dict[str, ParagraphStyle]) -> Table: ) ], ], - colWidths=[6.7 * inch], + colWidths=[CONTENT_WIDTH], hAlign="LEFT", ) table.setStyle( @@ -654,7 +662,7 @@ def _context_table(self, finding: FindingContext, styles: dict[str, ParagraphSty ] ) - table = Table(rows, colWidths=[0.70 * inch, 6.00 * inch], hAlign="LEFT", repeatRows=0) + table = Table(rows, colWidths=[CODE_LINE_NUMBER_WIDTH, CONTENT_WIDTH - CODE_LINE_NUMBER_WIDTH], hAlign="LEFT", repeatRows=0) table_style = [ ("BACKGROUND", (0, 0), (-1, -1), PRINT_CODE_BG), ("BACKGROUND", (0, 0), (0, -1), PRINT_PANEL_ALT), @@ -662,13 +670,13 @@ def _context_table(self, finding: FindingContext, styles: dict[str, ParagraphSty ("VALIGN", (0, 0), (-1, -1), "TOP"), ("ALIGN", (0, 0), (0, -1), "RIGHT"), ("FONTNAME", (0, 0), (0, -1), FONT_MONO), - ("FONTSIZE", (0, 0), (0, -1), 7.5), - ("LEADING", (0, 0), (0, -1), 9), + ("FONTSIZE", (0, 0), (0, -1), 6.8), + ("LEADING", (0, 0), (0, -1), 8), ("TEXTCOLOR", (0, 0), (0, -1), PRINT_MUTED), - ("LEFTPADDING", (0, 0), (-1, -1), 5), - ("RIGHTPADDING", (0, 0), (-1, -1), 5), - ("TOPPADDING", (0, 0), (-1, -1), 4), - ("BOTTOMPADDING", (0, 0), (-1, -1), 4), + ("LEFTPADDING", (0, 0), (-1, -1), 4), + ("RIGHTPADDING", (0, 0), (-1, -1), 4), + ("TOPPADDING", (0, 0), (-1, -1), 2), + ("BOTTOMPADDING", (0, 0), (-1, -1), 2), ] for index, _ in enumerate(lines): diff --git a/tests/test_reportlab_pdf.py b/tests/test_reportlab_pdf.py index c3cbf67..7f81be9 100644 --- a/tests/test_reportlab_pdf.py +++ b/tests/test_reportlab_pdf.py @@ -21,8 +21,26 @@ def example_findings(): "file": "app/views.py", "snippet": "db.execute('select * from users where name = ' + username)", "title": "SQL injection through string concatenation", - "issue": "User-controlled input is concatenated directly into a SQL query.", - "recommendation": "Use parameterized queries and validate the expected username format.", + "issue": ( + "User-controlled input is concatenated directly into a SQL query. " + "An attacker could provide crafted input that changes the query structure, reads data for other " + "users, or bypasses application-level checks. This example deliberately uses a longer issue " + "paragraph so the PDF renderer exercises multi-line issue text without letting the panel overlap " + "the section title. The vulnerable construction also makes later review difficult because the " + "query behaviour is hidden inside string assembly rather than expressed through a database API " + "that separates commands from values. If this pattern is copied into nearby handlers, the same " + "weakness can spread across multiple lookup and reporting paths." + ), + "recommendation": ( + "Use parameterized queries for every user-controlled value and keep validation focused on the " + "expected username format before the database call is made. Add a regression test that passes " + "characters commonly used in SQL injection payloads and confirms they are treated as data rather " + "than executable SQL. This longer recommendation verifies that remediation text wraps cleanly in " + "the generated report. Review adjacent database calls for similar string concatenation and move " + "shared query construction into a small helper if the same lookup pattern appears in more than " + "one place. Prefer a fix that is obvious to future maintainers, because defensive query handling " + "is only reliable when it remains easy to spot during code review." + ), "cwe": "CWE-89", "priority": 9, "line_number": 42, From fc9fb1384ebc574064b4c5b0361318788badc799 Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 11:49:36 +0000 Subject: [PATCH 05/15] tweaks --- saist/reportlab_pdf/__init__.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/saist/reportlab_pdf/__init__.py b/saist/reportlab_pdf/__init__.py index 6436caf..59666af 100644 --- a/saist/reportlab_pdf/__init__.py +++ b/saist/reportlab_pdf/__init__.py @@ -528,10 +528,14 @@ def _target_from_args(args) -> str: def _disclaimer_panel(self, styles: dict[str, ParagraphStyle]) -> Table: table = Table( [ - [Paragraph("AI-generated review", styles["CoverDisclaimerLabel"])], + [Paragraph("Disclaimer", styles["CoverDisclaimerLabel"])], [ Paragraph( - "This report has not been reviewed by Punk Security for technical accuracy.", + """ + This report has not been created by the expert testing team at Punk Security. + + It is created by SAIST, an open-source tool developed by Punk Security that uses AI to analyze code and generate findings. The findings and recommendations in this report are generated by an AI model based on the input provided to SAIST. + """, styles["CoverDisclaimer"], ) ], @@ -717,7 +721,7 @@ def _draw_page(canvas, doc): ReportLabPdf._draw_rainbow_bar(canvas, width, height - 0.40 * inch) if not page_is_cover: - canvas.setFillColor(PRINT_MUTED) + canvas.setFillColor(PUNK_WHITE) canvas.setFont(FONT_REGULAR, 8) canvas.drawString(0.65 * inch, height - 0.25 * inch, "SAIST AI generated code review") canvas.drawRightString(width - 0.65 * inch, 0.35 * inch, f"Page {doc.page}") From 965374ed250fdd71abdc553e7244f128fbc0d566 Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 14:18:17 +0000 Subject: [PATCH 06/15] report fixes --- saist/reportlab_pdf/__init__.py | 46 ++++++++++++++++----------------- 1 file changed, 22 insertions(+), 24 deletions(-) diff --git a/saist/reportlab_pdf/__init__.py b/saist/reportlab_pdf/__init__.py index 59666af..42709ef 100644 --- a/saist/reportlab_pdf/__init__.py +++ b/saist/reportlab_pdf/__init__.py @@ -3,6 +3,7 @@ import datetime import logging import os +import textwrap from llm.adapters import BaseLlmAdapter from models import FindingContext @@ -13,7 +14,7 @@ from reportlab.lib.units import inch from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont -from reportlab.platypus import Image, PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, XPreformatted +from reportlab.platypus import Image, PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle from reportlab.platypus.tableofcontents import TableOfContents logger = logging.getLogger("saist.reportlab_pdf") @@ -52,6 +53,7 @@ CONTENT_WIDTH = 6.7 * inch ISSUE_LABEL_WIDTH = 1.0 * inch CODE_LINE_NUMBER_WIDTH = 0.62 * inch +CODE_WRAP_CHARS = 112 FRAME_INNER_WIDTH = A4[0] - (2 * 0.65 * inch) - 12 CONTENT_RIGHT_INDENT = max(0, FRAME_INNER_WIDTH - CONTENT_WIDTH) @@ -223,7 +225,7 @@ def _finding_story(self, index: int, finding: FindingContext, styles: dict[str, return [ Paragraph(self._escaped(f"ISSUE {index:02d}"), styles["Eyebrow"]), self._toc_heading( - self._escaped(f"{finding.cwe} - {finding.file} - {finding.title}"), + self._escaped(f"ISSUE {index:02d} - {finding.title} - {finding.file}"), styles["Heading2"], 1, f"issue-{index}", @@ -306,11 +308,12 @@ def _styles(self) -> dict[str, ParagraphStyle]: styles.add( ParagraphStyle( name="CodeLine", - parent=styles["Code"], + parent=styles["BodyText"], fontName=FONT_MONO, fontSize=6.8, leading=8, textColor=PRINT_TEXT, + splitLongWords=1, ) ) styles.add( @@ -449,14 +452,6 @@ def _styles(self) -> dict[str, ParagraphStyle]: textColor=PUNK_SECONDARY, ) ) - styles.add( - ParagraphStyle( - name="IssueSummarySeverity", - parent=styles["IssueSummaryCell"], - alignment=TA_CENTER, - fontName=FONT_BOLD, - ) - ) styles["Title"].alignment = TA_CENTER styles["Title"].fontSize = 28 @@ -598,17 +593,16 @@ def _issue_summary_table(self, styles: dict[str, ParagraphStyle]) -> Table: [ Paragraph("Issue ID", styles["IssueSummaryHeader"]), Paragraph("Title", styles["IssueSummaryHeader"]), - Paragraph("Severity", styles["IssueSummaryHeader"]), + Paragraph("File", styles["IssueSummaryHeader"]), ] ] for index, finding in enumerate(self.findings, start=1): - severity, severity_color = self._priority(finding.priority) rows.append( [ Paragraph(self._escaped(f"ISSUE {index:02d}"), styles["IssueSummaryId"]), Paragraph(self._escaped(finding.title), styles["IssueSummaryCell"]), - Paragraph(self._escaped(severity), styles["IssueSummarySeverity"]), + Paragraph(self._escaped(finding.file), styles["IssueSummaryCell"]), ] ) @@ -621,7 +615,7 @@ def _issue_summary_table(self, styles: dict[str, ParagraphStyle]) -> Table: ] ) - table = Table(rows, colWidths=[1.1 * inch, 4.35 * inch, 1.25 * inch], hAlign="LEFT", repeatRows=1) + table = Table(rows, colWidths=[1.05 * inch, 3.1 * inch, 2.55 * inch], hAlign="LEFT", repeatRows=1) table_style = [ ("BACKGROUND", (0, 0), (-1, 0), PUNK_SECONDARY), ("TEXTCOLOR", (0, 0), (-1, 0), PUNK_WHITE), @@ -636,15 +630,8 @@ def _issue_summary_table(self, styles: dict[str, ParagraphStyle]) -> Table: ] for row_index, finding in enumerate(self.findings, start=1): - _, severity_color = self._priority(finding.priority) if row_index % 2 == 0: table_style.append(("BACKGROUND", (0, row_index), (-1, row_index), PRINT_PANEL)) - table_style.extend( - [ - ("BACKGROUND", (2, row_index), (2, row_index), severity_color), - ("TEXTCOLOR", (2, row_index), (2, row_index), PUNK_BG if finding.priority <= 7 else PUNK_WHITE), - ] - ) table.setStyle(TableStyle(table_style)) return table @@ -662,7 +649,7 @@ def _context_table(self, finding: FindingContext, styles: dict[str, ParagraphSty rows.append( [ str(line_number), - XPreformatted(self._escaped(line) or " ", styles["CodeLineHighlight" if highlighted else "CodeLine"]), + Paragraph(self._code_markup(line), styles["CodeLineHighlight" if highlighted else "CodeLine"]), ] ) @@ -688,7 +675,6 @@ def _context_table(self, finding: FindingContext, styles: dict[str, ParagraphSty if line_number == finding.line_number: table_style.extend( [ - ("BACKGROUND", (0, index), (-1, index), colors.HexColor("#1D3A2A")), ("BACKGROUND", (0, index), (-1, index), PRINT_CODE_HIGHLIGHT), ("TEXTCOLOR", (0, index), (-1, index), PRINT_TEXT), ("FONTNAME", (0, index), (0, index), "Courier-Bold"), @@ -744,6 +730,18 @@ def _paragraph_markup(self, value: str) -> str: escaped = self._escaped(value) return escaped.replace("\n\n", "

").replace("\n", "
") + def _code_markup(self, value: str) -> str: + line = str(value or " ") + wrapped = textwrap.wrap( + line, + width=CODE_WRAP_CHARS, + break_long_words=True, + break_on_hyphens=False, + replace_whitespace=False, + drop_whitespace=False, + ) or [" "] + return "
".join(self._escaped(part).replace(" ", " ") for part in wrapped) + @staticmethod def _priority(priority: int): if priority > 8: From d327df9a4a66edd4c38cee51be4b9ef00fc89cea Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 14:33:31 +0000 Subject: [PATCH 07/15] fixes --- README.md | 12 +- saist/llm/adapters/faike.py | 7 +- saist/main.py | 358 ++++++++++++++++++++++--------- saist/reportlab_pdf/__init__.py | 28 ++- saist/scm/__init__.py | 6 + saist/scm/adapters/__init__.py | 9 + saist/scm/adapters/filesystem.py | 15 ++ saist/scm/adapters/git.py | 14 ++ saist/scm/adapters/github.py | 24 ++- saist/util/argparsing.py | 16 +- saist/util/prompts.py | 84 +++++--- saist/util/skills.py | 22 +- tests/test_argparsing.py | 7 + tests/test_github_adapter.py | 72 +++++++ tests/test_main_analysis.py | 257 ++++++++++++++++++++++ tests/test_reportlab_pdf.py | 64 ++++++ tests/test_skills.py | 5 +- 17 files changed, 833 insertions(+), 167 deletions(-) create mode 100644 tests/test_github_adapter.py diff --git a/README.md b/README.md index 9b65393..a2cef0a 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,10 @@ export SAIST_LLM_API_KEY=your-api-key | Task | Command | |:-----|:--------| -| Get a DevSecOps poem | `saist/main.py --llm openai poem` | -| Scan a local folder | `saist/main.py --llm deepseek filesystem /path/to/code` | -| Scan a local folder with ollama from within docker| `docker run --network=host -v :/vulnerableapp -v $PWD/reporting:/app/reporting punksecurity/saist --llm ollama --llm-model gemma3:4b fileystem /vulnerableapp` | +| Get a DevSecOps poem | `saist/main.py --llm openai poem` | +| Scan a local folder | `saist/main.py --llm deepseek filesystem /path/to/code` | +| Scan a local folder file-by-file | `saist/main.py --llm deepseek --deep filesystem /path/to/code` | +| Scan a local folder with ollama from within docker| `docker run --network=host -v :/vulnerableapp -v $PWD/reporting:/app/reporting punksecurity/saist --llm ollama --llm-model gemma3:4b fileystem /vulnerableapp` | | Scan a local Git repo | `saist/main.py --llm openai git /path/to/repo` | | Scan a local Git repo (branch diff) | `saist/main.py --llm openai git /path/to/repo --ref-for-compare main --ref-to-compare feature-branch` | | Scan a GitHub PR (and update the PR) | `saist/main.py --llm anthropic github yourorg/yourrepo 1234 --github-token your-token` | @@ -203,9 +204,10 @@ docker run -v$PWD/code:/code -v$PWD/reporting:/app/reporting punksecurity/saist | `--llm` | Select LLM (`anthropic`, `deepseek`, `gemini`, `ollama`, `openai`) | | `--llm-api-key` | API key for your LLM | | `--llm-model` | (Optional) Specific model (e.g., `gpt-4o`) | -| `--interactive` | Chat with the LLM after scan | -| `--web` | Launch a local web server | +| `--interactive` | Chat with the LLM after scan | +| `--web` | Launch a local web server | | `--disable-tools` | Disable tool use during file analysis to reduce LLM token usage | +| `--deep` | For filesystem scans, analyze every file individually. Without this, filesystem scans send a file inventory and let the LLM inspect files with tools, then report file coverage. | | `--skills-path` | Folder containing SAIST analysis skill Markdown files | | `--generate-skills` | Generate SAIST analysis skill files and exit | | `--overwrite-skills` | Replace existing skill files during skill generation | diff --git a/saist/llm/adapters/faike.py b/saist/llm/adapters/faike.py index 8489a90..986b3a1 100644 --- a/saist/llm/adapters/faike.py +++ b/saist/llm/adapters/faike.py @@ -20,7 +20,12 @@ async def prompt_structured(self, system_prompt: str, user_prompt: str, response if response_format is Findings: # Extract the real filename from user_prompt, allowing future steps to work - filename: str = re.search("(?<=File: )(.*?)(?=\\n)", user_prompt).group(0) + filename_match = re.search("(?<=File: )(.*?)(?=\\n)", user_prompt) + if filename_match: + filename = filename_match.group(0) + else: + list_item_match = re.search(r"^- (.+)$", user_prompt, re.MULTILINE) + filename = list_item_match.group(1) if list_item_match else "app.py" fake_finding: dict[str: any] = { "file": filename, "snippet": "[]", diff --git a/saist/main.py b/saist/main.py index 59d5d5b..d443cbe 100755 --- a/saist/main.py +++ b/saist/main.py @@ -2,7 +2,7 @@ import asyncio import logging import os -from typing import Optional +from typing import Callable, Optional from dotenv import load_dotenv from reportlab_pdf import ReportLabPdf @@ -52,11 +52,50 @@ logger = logging.getLogger("saist") + +class CoverageTrackingScm: + def __init__(self, scm: Scm): + self.scm = scm + self.files_read: set[str] = set() + + async def read_file_contents(self, filename: str): + contents = await self.scm.read_file_contents(filename) + if contents is not None: + self.files_read.add(filename) + return contents + + async def list_files(self) -> list[str]: + return await self.scm.list_files() + + async def regex_search( + self, + pattern: str, + file_pattern: str = "**/*", + max_results: int = 100, + ) -> list[dict[str, str | int]]: + results = await self.scm.regex_search(pattern, file_pattern, max_results) + for result in results: + filename = result.get("filename") if isinstance(result, dict) else None + if filename: + self.files_read.add(str(filename)) + return results + + def tool_functions(self) -> list[Callable]: + return [self.read_file_contents, self.list_files, self.regex_search] + + +def scm_detect_prompt(scm: Scm) -> str: + return scm.detect_prompt() if hasattr(scm, "detect_prompt") else FilesystemAdapter.DETECT_PROMPT + + +def scm_summary_prompt(scm: Scm) -> str: + return scm.summary_prompt() if hasattr(scm, "summary_prompt") else FilesystemAdapter.SUMMARY_PROMPT + async def analyze_single_file(scm: Scm, adapter: BaseLlmAdapter, filename, patch_text, disable_tools: bool, analysis_skills: str = "") -> Optional[list[Finding]]: """ Analyzes a SINGLE file diff with OpenAI, returning a Findings object or None on error. """ - system_prompt = prompts.DETECT + system_prompt = prompts.detect(scm_detect_prompt(scm)) if analysis_skills: system_prompt = f"{system_prompt}\n\n{analysis_skills}" @@ -90,11 +129,11 @@ async def context_from_finding(scm: Scm, finding: Finding, context_size: int = 3 return "\n".join(context), start, end -def generate_summary_from_findings(adapter: BaseLlmAdapter, findings: list[Finding]) -> str: +def generate_summary_from_findings(adapter: BaseLlmAdapter, findings: list[Finding], scm_prompt: str = "") -> str: """ Uses OpenAI to generate a summary of all findings to be used as the PR review body. """ - system_prompt = prompts.SUMMARY + system_prompt = prompts.summary(scm_prompt) prompt = "" for f in findings: prompt += f"- **File**: `{f.file}`\n - **Issue**: {f.issue}\n - **Recommendation**: {f.recommendation}\n\n" @@ -105,6 +144,77 @@ def generate_summary_from_findings(adapter: BaseLlmAdapter, findings: list[Findi logger.error(f"[Error generating summary] {e}") return "Security issues found. Please review the inline comments." + +def build_finding_review_body(finding: Finding) -> str: + priority = "LOW" + if finding.priority > 4: + priority = "MEDIUM" + if finding.priority > 7: + priority = "HIGH" + if finding.priority > 8: + priority = "CRITICAL" + + return ( + f"**Security Issue:** {finding.issue}\n\n" + f"**Priority:** {priority}\n\n" + f"**CWE:** {finding.cwe}\n\n" + f"**Recommendation:** {finding.recommendation or 'None provided.'}\n\n" + f"**Snippet**: `{finding.snippet}`\n\n" + ) + + +def build_filesystem_review_comments(findings: list[Finding]) -> list[dict]: + comments = [] + for finding in findings: + if not finding.file or not finding.snippet or not finding.issue: + continue + comments.append( + { + "path": finding.file, + "position": max(1, finding.line_number), + "body": build_finding_review_body(finding), + } + ) + return comments + + +async def generate_findings_with_filesystem_tools( + scm: Scm, + llm: BaseLlmAdapter, + filenames: list[str], + disable_tools: bool, + analysis_skills: str = "", +) -> tuple[list[Finding], set[str]]: + tracked_scm = CoverageTrackingScm(scm) + system_prompt = prompts.detect(scm_detect_prompt(scm)) + if analysis_skills: + system_prompt = f"{system_prompt}\n\n{analysis_skills}" + + file_list = "\n".join(f"- {filename}" for filename in filenames) + user_prompt = f""" +Application file inventory: +{file_list} + +Perform a penetration test style application security review of this codebase. +Use the available tools to inspect files before reporting findings. +Return only findings that are supported by code you inspected. +""" + + result = await llm.prompt_structured( + system_prompt, + user_prompt, + Findings, + [] if disable_tools else tracked_scm.tool_functions(), + ) + return result.findings, tracked_scm.files_read + + +def print_coverage(files_read: set[str], files_in_scope: list[str]): + total = len(files_in_scope) + read_count = len(files_read.intersection(files_in_scope)) + percent = (read_count / total * 100) if total else 0 + print(f"📈 LLM file coverage: {read_count}/{total} files read ({percent:.1f}%)\n") + def _get_scm_adapter(args) -> BaseScmAdapter: if args.SCM == 'github': logger.debug("Using SCM: Github") @@ -214,9 +324,26 @@ async def main(): scm = Scm(adapter=scm_adapter) print(f"✅ Using SCM: {args.SCM}\n") + shallow_filesystem_scan = args.SCM == "filesystem" and not args.deep analysis_skills = "" if not args.disable_skills: loaded_skills = load_analysis_skills(skills_dir, args.skills_max_bytes) + if shallow_filesystem_scan and not loaded_skills: + print("🧭 No SAIST skill files found. Generating application skills for this filesystem scan...") + result = await generate_skill_files( + llm=llm, + project_root=project_root, + skills_dir=skills_dir, + max_files=args.skills_sample_files, + max_file_bytes=args.skills_sample_bytes, + overwrite=False, + ) + print(f"✅ Sampled {result.sampled_files} files from {project_root}") + print(f"✅ Wrote {len(result.written)} skill files to {result.skills_dir}") + if result.skipped: + print(f"â„šī¸ Skipped {len(result.skipped)} existing skill files.") + loaded_skills = load_analysis_skills(skills_dir, args.skills_max_bytes) + if loaded_skills: analysis_skills = format_analysis_skills(loaded_skills) print(f"🧠 Loaded {len(loaded_skills)} SAIST skill files from {skills_dir}\n") @@ -225,123 +352,142 @@ async def main(): else: logging.debug("SAIST skill loading disabled") - # 1) Get changed files - print("📂 Fetching changed files...") - changed_files = scm.get_changed_files() - if not changed_files: - print("âš ī¸ No changed files detected. Exiting.") - return - print(f"✅ Detected {len(changed_files)} changed files\n") - - # 2) Gather only relevant app code diffs - print("🧹 Filtering relevant app code diffs...") - file_line_maps = {} - file_new_lines_text = {} - app_files = [] - filter_rules = FilterRules(args.include, args.exclude) - for f in changed_files: - filename = f["filename"] - patch_text = f.get("patch", "") - if not patch_text: - logging.debug(f"Skipped file {filename} as it contains no patch text") - continue - - if not filter_rules.filename_included(filename): - logging.debug(f"Skipped file {filename} as it is not included in rules") - continue - if not args.skip_line_length_check: - if filter_rules.file_exceeds_line_length_limit(file_content=await scm.read_file_contents(filename), patch_text=patch_text, max_line_length=args.max_line_length): - logging.debug(f"Skipped file {filename} as it contains lines that exceed the maximum line length ({args.max_line_length})") - continue + if shallow_filesystem_scan: + print("📂 Listing application files...") + all_files = await scm.list_files() + app_filenames = [filename for filename in all_files if filter_rules.filename_included(filename)] - line_map, new_lines_text = parse_unified_diff(patch_text) - file_line_maps[filename] = line_map - file_new_lines_text[filename] = new_lines_text - app_files.append((filename, patch_text)) + if not app_filenames: + print("âš ī¸ No app files to analyze. Exiting.") + return - if not app_files: - print("âš ī¸ No app code diffs to analyze. Exiting.") - return - print(f"✅ Prepared {len(app_files)} app files for analysis.\n") + print(f"✅ Prepared {len(app_filenames)} app files for tool-driven analysis.\n") - app_filenames = list((filename for filename,_ in app_files)) - logging.debug(f"Files to process: {app_filenames}") + if args.dry_run: + print("âš ī¸ --dry-run flag passed, exiting without analyzing files.") + exit(0) - if args.dry_run: - print("âš ī¸ --dry-run flag passed, exiting without analyzing files.") - exit(0) + print("🔍 Analyzing application with LLM tool use...") + all_findings, files_read = await generate_findings_with_filesystem_tools( + scm=scm, + llm=llm, + filenames=app_filenames, + disable_tools=args.disable_tools, + analysis_skills=analysis_skills, + ) + print_coverage(files_read, app_filenames) - # 3) Analyze each file in parallel - print("🔍 Analyzing files for security issues...") - max_workers = min(args.llm_rate_limit, len(app_files)) - logging.debug(f"{max_workers=}") - all_findings = await generate_findings(scm, llm, app_files, max_workers, args.disable_tools, args.disable_caching, args.cache_folder, analysis_skills) + if not all_findings: + print("✅ No findings reported. Exiting.\n") + return - if not all_findings: - print("✅ No findings reported. Exiting.\n") - return - print(f"🚨 Analysis complete! Identified {len(all_findings)} potential issues.\n") + all_findings.sort(key=lambda x: x.priority, reverse=True) + review_comments = build_filesystem_review_comments(all_findings) + print(f"🚨 Analysis complete! Identified {len(all_findings)} potential issues.\n") + else: - # 4) Build review comments from snippet-based findings - review_comments = [] - all_findings.sort(key=lambda x: x.priority,reverse=True) - - for item in all_findings: - item.line_number = -1 #set to -1 for filtering. Gets changed later if finding is valid - file_name = item.file - snippet = item.snippet - issue = item.issue - priority = "LOW" - if item.priority > 4: - priority = "MEDIUM" - if item.priority > 7: - priority = "HIGH" - if item.priority > 8: - priority = "CRITICAL" - cwe = item.cwe - recommendation = item.recommendation - - # Basic checks - if not file_name or not snippet or not issue: - continue - if file_name not in file_line_maps: - # Possibly flagged a file that doesn't exist in the PR - continue + # 1) Get changed files + print("📂 Fetching changed files...") + changed_files = scm.get_changed_files() + if not changed_files: + print("âš ī¸ No changed files detected. Exiting.") + return + print(f"✅ Detected {len(changed_files)} changed files\n") + + # 2) Gather only relevant app code diffs + print("🧹 Filtering relevant app code diffs...") + file_line_maps = {} + file_new_lines_text = {} + app_files = [] + + for f in changed_files: + filename = f["filename"] + patch_text = f.get("patch", "") + if not patch_text: + logging.debug(f"Skipped file {filename} as it contains no patch text") + continue + + if not filter_rules.filename_included(filename): + logging.debug(f"Skipped file {filename} as it is not included in rules") + continue - line_map = file_line_maps[file_name] - new_lines_text = file_new_lines_text[file_name] + if not args.skip_line_length_check: + if filter_rules.file_exceeds_line_length_limit(file_content=await scm.read_file_contents(filename), patch_text=patch_text, max_line_length=args.max_line_length): + logging.debug(f"Skipped file {filename} as it contains lines that exceed the maximum line length ({args.max_line_length})") + continue + + line_map, new_lines_text = parse_unified_diff(patch_text) + file_line_maps[filename] = line_map + file_new_lines_text[filename] = new_lines_text + app_files.append((filename, patch_text)) + + if not app_files: + print("âš ī¸ No app code diffs to analyze. Exiting.") + return + print(f"✅ Prepared {len(app_files)} app files for analysis.\n") + + app_filenames = list((filename for filename,_ in app_files)) + logging.debug(f"Files to process: {app_filenames}") + + if args.dry_run: + print("âš ī¸ --dry-run flag passed, exiting without analyzing files.") + exit(0) + + # 3) Analyze each file in parallel + print("🔍 Analyzing files for security issues...") + max_workers = min(args.llm_rate_limit, len(app_files)) + logging.debug(f"{max_workers=}") + all_findings = await generate_findings(scm, llm, app_files, max_workers, args.disable_tools, args.disable_caching, args.cache_folder, analysis_skills) + + if not all_findings: + print("✅ No findings reported. Exiting.\n") + return + print(f"🚨 Analysis complete! Identified {len(all_findings)} potential issues.\n") + + # 4) Build review comments from snippet-based findings + review_comments = [] + all_findings.sort(key=lambda x: x.priority,reverse=True) + + for item in all_findings: + item.line_number = -1 #set to -1 for filtering. Gets changed later if finding is valid + file_name = item.file + snippet = item.snippet + issue = item.issue + + # Basic checks + if not file_name or not snippet or not issue: + continue + if file_name not in file_line_maps: + # Possibly flagged a file that doesn't exist in the PR + continue - # Attempt to find which 'new_line' has the snippet - matched_new_line = None - for ln, code_text in new_lines_text.items(): - if snippet in code_text: - matched_new_line = ln - break + line_map = file_line_maps[file_name] + new_lines_text = file_new_lines_text[file_name] - if not matched_new_line: - # If we can't find the snippet in the patch, skip - continue + # Attempt to find which 'new_line' has the snippet + matched_new_line = None + for ln, code_text in new_lines_text.items(): + if snippet in code_text: + matched_new_line = ln + break - diff_position = line_map[matched_new_line] - body_text = ( - f"**Security Issue:** {issue}\n\n" - f"**Priority:** {priority}\n\n" - f"**CWE:** {cwe}\n\n" - f"**Recommendation:** {recommendation or 'None provided.'}\n\n" - f"**Snippet**: `{snippet}`\n\n" - ) + if not matched_new_line: + # If we can't find the snippet in the patch, skip + continue + + diff_position = line_map[matched_new_line] - review_comments.append({ - "path": file_name, - "position": diff_position - 1, - "body": body_text - }) + review_comments.append({ + "path": file_name, + "position": diff_position - 1, + "body": build_finding_review_body(item) + }) - item.line_number = matched_new_line + item.line_number = matched_new_line - all_findings = list([x for x in all_findings if x.line_number != -1]) + all_findings = list([x for x in all_findings if x.line_number != -1]) if not all_findings: print("No issues detected") @@ -353,7 +499,7 @@ async def main(): all_findings = s.findings - comment = await generate_summary_from_findings(llm, all_findings) + comment = await generate_summary_from_findings(llm, all_findings, scm_summary_prompt(scm)) scm.create_review( comment=comment, review_comments=review_comments, diff --git a/saist/reportlab_pdf/__init__.py b/saist/reportlab_pdf/__init__.py index 42709ef..f053384 100644 --- a/saist/reportlab_pdf/__init__.py +++ b/saist/reportlab_pdf/__init__.py @@ -452,6 +452,14 @@ def _styles(self) -> dict[str, ParagraphStyle]: textColor=PUNK_SECONDARY, ) ) + styles.add( + ParagraphStyle( + name="IssueSummarySeverity", + parent=styles["IssueSummaryCell"], + alignment=TA_CENTER, + fontName=FONT_BOLD, + ) + ) styles["Title"].alignment = TA_CENTER styles["Title"].fontSize = 28 @@ -594,15 +602,18 @@ def _issue_summary_table(self, styles: dict[str, ParagraphStyle]) -> Table: Paragraph("Issue ID", styles["IssueSummaryHeader"]), Paragraph("Title", styles["IssueSummaryHeader"]), Paragraph("File", styles["IssueSummaryHeader"]), + Paragraph("Severity", styles["IssueSummaryHeader"]), ] ] for index, finding in enumerate(self.findings, start=1): + priority_label, _ = self._priority(finding.priority) rows.append( [ Paragraph(self._escaped(f"ISSUE {index:02d}"), styles["IssueSummaryId"]), Paragraph(self._escaped(finding.title), styles["IssueSummaryCell"]), Paragraph(self._escaped(finding.file), styles["IssueSummaryCell"]), + Paragraph(self._escaped(priority_label), styles["IssueSummarySeverity"]), ] ) @@ -612,10 +623,11 @@ def _issue_summary_table(self, styles: dict[str, ParagraphStyle]) -> Table: Paragraph("-", styles["IssueSummaryCell"]), Paragraph("No issues were provided.", styles["IssueSummaryCell"]), Paragraph("-", styles["IssueSummaryCell"]), + Paragraph("-", styles["IssueSummaryCell"]), ] ) - table = Table(rows, colWidths=[1.05 * inch, 3.1 * inch, 2.55 * inch], hAlign="LEFT", repeatRows=1) + table = Table(rows, colWidths=[0.92 * inch, 2.45 * inch, 2.35 * inch, 0.98 * inch], hAlign="LEFT", repeatRows=1) table_style = [ ("BACKGROUND", (0, 0), (-1, 0), PUNK_SECONDARY), ("TEXTCOLOR", (0, 0), (-1, 0), PUNK_WHITE), @@ -627,11 +639,25 @@ def _issue_summary_table(self, styles: dict[str, ParagraphStyle]) -> Table: ("RIGHTPADDING", (0, 0), (-1, -1), 8), ("TOPPADDING", (0, 0), (-1, -1), 7), ("BOTTOMPADDING", (0, 0), (-1, -1), 7), + ("ALIGN", (3, 1), (3, -1), "CENTER"), ] for row_index, finding in enumerate(self.findings, start=1): if row_index % 2 == 0: table_style.append(("BACKGROUND", (0, row_index), (-1, row_index), PRINT_PANEL)) + priority_label, priority_color = self._priority(finding.priority) + table_style.extend( + [ + ("BACKGROUND", (3, row_index), (3, row_index), priority_color), + ( + "TEXTCOLOR", + (3, row_index), + (3, row_index), + PUNK_BG if priority_label in {"Low", "Medium"} else PUNK_WHITE, + ), + ("FONTNAME", (3, row_index), (3, row_index), FONT_BOLD), + ] + ) table.setStyle(TableStyle(table_style)) return table diff --git a/saist/scm/__init__.py b/saist/scm/__init__.py index 777a1be..41536b8 100644 --- a/saist/scm/__init__.py +++ b/saist/scm/__init__.py @@ -75,5 +75,11 @@ def tool_functions(self) -> list[Callable]: """ return [self.read_file_contents, self.list_files, self.regex_search] + def detect_prompt(self) -> str: + return self.adapter.detect_prompt() + + def summary_prompt(self) -> str: + return self.adapter.summary_prompt() + def create_review(self, comment, review_comments, request_changes): self.adapter.create_review(comment, review_comments, request_changes) diff --git a/saist/scm/adapters/__init__.py b/saist/scm/adapters/__init__.py index 04dea17..c22441d 100644 --- a/saist/scm/adapters/__init__.py +++ b/saist/scm/adapters/__init__.py @@ -5,6 +5,15 @@ class BaseScmAdapter(metaclass=ABCMeta): + DETECT_PROMPT = "" + SUMMARY_PROMPT = "" + + def detect_prompt(self) -> str: + return self.DETECT_PROMPT + + def summary_prompt(self) -> str: + return self.SUMMARY_PROMPT + @abstractmethod def create_review(self, comment, review_comments, request_changes): """ diff --git a/saist/scm/adapters/filesystem.py b/saist/scm/adapters/filesystem.py index 504dceb..844c04a 100644 --- a/saist/scm/adapters/filesystem.py +++ b/saist/scm/adapters/filesystem.py @@ -16,6 +16,21 @@ logger = logging.getLogger(__name__) class FilesystemAdapter(BaseScmAdapter): + DETECT_PROMPT = """ +You are performing a penetration test style review across the entire application codebase. +The supplied input is one file from the application. It may be represented as a unified diff from an empty file, but you should treat it as application code in scope for a whole-codebase security assessment. +Use tools aggressively to map routes, controllers, models, middleware, policies, serializers, templates, jobs, and configuration before deciding what is exploitable. +Trace attacker-controlled input from entrypoint to sink and trace authorization decisions from identity source to protected action. +Look for business logic vulnerabilities across files: cross-tenant data access, horizontal/vertical privilege escalation, order/payment/state-machine manipulation, invitation or password-reset abuse, webhook forgery, unsafe admin actions, and background jobs that trust user-controlled state. +Report vulnerabilities that are present in the application, even when the exploit depends on interactions across multiple files. +Avoid one-file lint findings unless that file alone proves a reachable vulnerability. +""" + + SUMMARY_PROMPT = """ +This summary is for a penetration test style review across the entire application codebase. +Summarize exploitable application risks, affected trust boundaries, likely business impact, and the highest-impact fixes. Do not summarize generic best practices. +""" + async def get_file_contents(self, filename: str): logger.debug(f"get_file_contents: reading file {filename} under {self.compare_path}") try: diff --git a/saist/scm/adapters/git.py b/saist/scm/adapters/git.py index 7eeb44d..bbf5b61 100644 --- a/saist/scm/adapters/git.py +++ b/saist/scm/adapters/git.py @@ -12,6 +12,20 @@ logger = logging.getLogger(__name__) class GitAdapter(BaseScmAdapter): + DETECT_PROMPT = """ +You are analyzing a diff of code that needs security review. +The supplied input is a single file's unified diff from a git comparison. +Focus on exploitable vulnerabilities introduced, exposed, or materially changed by this diff. +Use tools to retrieve the full file and related files to validate whether the changed code is reachable, attacker-controlled, and crosses a security boundary. +Report only vulnerabilities anchored to changed lines in the original diff. Do not report pre-existing best-practice issues unless the diff makes them exploitable or materially worse. +For business logic changes, inspect surrounding authorization, state transition, tenancy, payment, invitation, webhook, or admin-flow code before deciding. +""" + + SUMMARY_PROMPT = """ +This summary is for a diff-based code security review. +Summarize exploitable risks introduced or changed by the supplied diff, including business impact and affected security boundaries. Do not summarize generic best practices. +""" + async def get_file_contents(self, file_path: str): logger.debug(f"file_get_contents: Reading {file_path}") clean_path = self._clean_repo_path(file_path) diff --git a/saist/scm/adapters/github.py b/saist/scm/adapters/github.py index 783c14d..3d2d021 100644 --- a/saist/scm/adapters/github.py +++ b/saist/scm/adapters/github.py @@ -8,6 +8,7 @@ from requests import Request from requests.auth import AuthBase +from requests.exceptions import HTTPError from . import BaseScmAdapter @@ -24,6 +25,19 @@ def __call__(self, r: Request): class Github(BaseScmAdapter): API_BASE_URL = os.getenv("GITHUB_API_URL", "https://api.github.com") + DETECT_PROMPT = """ +You are analyzing a diff of code that needs security review. +The supplied input is a single file's unified diff from a GitHub pull request. +Focus on exploitable vulnerabilities introduced, exposed, or materially changed by this pull request. +Use tools to retrieve the full file and related files to validate whether the changed code is reachable, attacker-controlled, and crosses a security boundary. +Report only vulnerabilities anchored to changed lines in the original diff. Do not report pre-existing best-practice issues unless the pull request makes them exploitable or materially worse. +For business logic changes, inspect surrounding authorization, state transition, tenancy, payment, invitation, webhook, or admin-flow code before deciding. +""" + + SUMMARY_PROMPT = """ +This summary is for a GitHub pull request diff-based code security review. +Summarize exploitable risks introduced or changed by the pull request, including business impact and affected security boundaries. Do not summarize generic best practices. +""" def __init__(self, github_token, repo, pr_number): self.github_token = github_token @@ -61,7 +75,13 @@ async def get_file_contents(self, file_path: PathLike[str] | str): clean_url = f"{self.API_BASE_URL}/repos/{self.repo}/contents/{clean_path}?ref={self.commit_sha}" res = self.requests.get(clean_url, headers={"Content-Type": "application/vnd.github.object+json"}) - res.raise_for_status() + try: + res.raise_for_status() + except HTTPError: + if res.status_code == 404: + logger.warning(f"get_file_contents {clean_path}: file does not exist at PR head; skipping.") + return None + raise json_res = res.json() @@ -130,7 +150,7 @@ def get_changed_files(self): files_page = resp.json() if not files_page: break - all_files.extend(files_page) + all_files.extend(file for file in files_page if file.get("status") != "removed") page += 1 return all_files diff --git a/saist/util/argparsing.py b/saist/util/argparsing.py index 4c1a8af..45d87a6 100644 --- a/saist/util/argparsing.py +++ b/saist/util/argparsing.py @@ -157,10 +157,15 @@ def __call__(self, parser, namespace, values, option_string=None): required=False, action='store_true' ) -parser.add_argument( - "--disable-tools", help="Disable usage of tools during code analysis (this is a good cost saving)", - required=False, action="store_true" - ) +parser.add_argument( + "--disable-tools", help="Disable usage of tools during code analysis (this is a good cost saving)", + required=False, action="store_true" + ) + +parser.add_argument( + "--deep", help="For filesystem scans, analyze each file individually instead of using the tool-driven whole-application scan", + required=False, action="store_true" + ) parser.add_argument( "--web", help = "Launch a web server to display findings", @@ -307,4 +312,7 @@ def parse_args(): if args.generate_skills and args.disable_skills: parser.error("Cannot use --generate-skills together with --disable-skills") + if args.SCM == "filesystem" and args.disable_tools and not args.deep: + parser.error("Filesystem scans without --deep require tool use. Remove --disable-tools or add --deep.") + return args diff --git a/saist/util/prompts.py b/saist/util/prompts.py index e77877a..a935c82 100644 --- a/saist/util/prompts.py +++ b/saist/util/prompts.py @@ -1,32 +1,66 @@ -class prompts(): +class prompts: SUMMARY_PRE = """ - You are a senior application security engineer. - Given the following list of findings (issue descriptions and recommendations) - Write an informative summary suitable, and include headings - Group similar issues, and prioritize by severity. - Do not use any markdown - Return only the summary, no other text - """ +You are a senior application security engineer. +Write an informative executive summary suitable for a security review report. +Group similar issues, prioritize by severity, and make the business risk clear. +Do not use markdown. +Return only the summary, no other text. +""" + SUMMARY_POST = """ - findings: - """ +Findings: +""" + DETECT_PRE = """ - You are a security reviewer analyzing a single file's diff from a Pull Request. - Look for issues in the OWASP top ten. Identify as many as you can. - Report multiple issues per line as seperate findings. - When you detect a vulnerability get the full file by retrieving its contents, use this for context. - You can also retrieve other files for context as needed. - Only report a vulnerability if exists in the original diff. - Do not report vulnerabilities that exist only in tool output - Provide a vulnerability priority between 1 and 9. 9 is most critical - Map each finding to a Common Weakness Enumeration ID (CWE). - """ - DETECT_POST = """" - Below is the diff for this single file. It starts with 'File: ' followed by the unified diff.\n" - """ +You are a senior application security engineer. +Your job is to find exploitable vulnerabilities, not to produce a best-practice checklist. +Only report a finding when the supplied code supports a realistic attack path, privilege abuse path, data exposure path, or integrity impact. +Prefer business logic flaws and classic vulnerability classes over style, maintainability, hardening, or generic defense-in-depth advice. + +High-value issues include: +- Broken access control, tenant isolation failures, IDOR, missing ownership checks, unsafe role transitions, workflow bypasses, and confused-deputy flows. +- Authentication/session flaws, token misuse, password reset/invitation/account recovery abuse, MFA bypasses, and unsafe trust in client-controlled identity. +- Injection flaws such as SQL/NoSQL/LDAP/OS/template expression injection, unsafe deserialization, SSRF, path traversal, file upload abuse, command execution, and XSS where the sink is reachable. +- Secret exposure, insecure cryptography, dangerous framework configuration, webhook/signature verification failures, race conditions, payment/order/state-machine abuse, and unsafe admin or background-job actions. + +Do not report: +- Missing tests, missing logging, missing rate limits, missing security headers, missing CSP, missing validation, or missing error handling unless the code shows a concrete exploit path and reachable impact. +- Hypothetical risks that require unknown routes, unknown permissions, or assumptions not supported by code. +- Issues that only exist in tool output or in generated examples. + +For every finding, be specific about the attacker-controlled input or actor, the vulnerable code path, the security boundary crossed, and the impact. +If the evidence is weak, inspect more code with tools. If it is still weak, do not report it. +Report multiple vulnerabilities on the same line as separate findings only when they are distinct exploit paths. +Use the available tools to retrieve full files and related files when that context is needed. +Only report issues that are supported by the supplied code and context. +Provide a vulnerability priority between 1 and 9. 9 is most critical. +Map each finding to a Common Weakness Enumeration ID (CWE). +""" + + DETECT_POST = """ +Input format: +File: + +""" + + def detect(self, scm_prompt: str = "") -> str: + return "\n\n".join( + part.strip() + for part in [self.DETECT_PRE, scm_prompt, self.DETECT_POST] + if part and part.strip() + ) + + def summary(self, scm_prompt: str = "") -> str: + return "\n\n".join( + part.strip() + for part in [self.SUMMARY_PRE, scm_prompt, self.SUMMARY_POST] + if part and part.strip() + ) + @property def SUMMARY(self): - return self.SUMMARY_PRE + self.SUMMARY_POST + return self.summary() + @property def DETECT(self): - return self.DETECT_PRE + self.DETECT_POST + return self.detect() diff --git a/saist/util/skills.py b/saist/util/skills.py index d8fc88e..ab68749 100644 --- a/saist/util/skills.py +++ b/saist/util/skills.py @@ -16,14 +16,6 @@ DEFAULT_SKILL_SAMPLE_BYTES = 12000 SKILL_SPECS = [ - ( - "application-routing.md", - "How requests, jobs, events, routes, controllers, handlers, and API endpoints are wired.", - ), - ( - "authentication-and-session.md", - "How users, service accounts, API clients, sessions, tokens, cookies, and identity providers work.", - ), ( "authorization-model.md", "How access control decisions are made, including roles, permissions, policies, ownership checks, and tenancy boundaries.", @@ -32,22 +24,10 @@ "framework-specific-concerns.md", "Framework conventions and security footguns that matter when reviewing this application.", ), - ( - "data-model-and-persistence.md", - "Important models, repositories, migrations, query patterns, and places where data integrity or isolation matters.", - ), ( "input-validation-and-trust-boundaries.md", "Where untrusted input enters the application and how validation, parsing, escaping, and serialization are handled.", ), - ( - "dependency-and-configuration.md", - "Security-sensitive dependencies, configuration patterns, environment variables, deployment assumptions, and feature flags.", - ), - ( - "security-sensitive-flows.md", - "High-risk workflows such as payments, password reset, invitations, admin actions, file upload, webhooks, and background jobs.", - ), ] EXCLUDED_DIR_NAMES = { @@ -249,7 +229,7 @@ def format_analysis_skills(skills: list[AnalysisSkill]) -> str: sections = [ "Application analysis skills:", - "The following project-specific skill files are guidance for this review. Use them to understand routing, identity, authorization, framework conventions, trust boundaries, and security-sensitive flows. Treat them as context, not proof of a vulnerability. Report only vulnerabilities that are present in the diff being reviewed.", + "The following project-specific skill files are guidance for this review. Use them to understand routing, identity, authorization, framework conventions, trust boundaries, and security-sensitive flows. Treat them as context, not proof of a vulnerability. Use this context to validate exploitability and business impact, not to report generic best-practice advice.", ] for skill in skills: diff --git a/tests/test_argparsing.py b/tests/test_argparsing.py index d2ab6c4..9d8be17 100644 --- a/tests/test_argparsing.py +++ b/tests/test_argparsing.py @@ -22,6 +22,7 @@ def test_parse_args_sets_defaults_for_filesystem(monkeypatch, tmp_path): assert args.openai_base_uri is None assert args.interactive is False assert args.disable_tools is False + assert args.deep is False assert args.web is False assert args.web_port == 8080 assert args.web_host == "127.0.0.1" @@ -66,6 +67,7 @@ def test_parse_args_accepts_all_global_scan_options(monkeypatch, tmp_path): "http://openai.example", "--interactive", "--disable-tools", + "--deep", "--web", "--web-port", "9999", @@ -123,6 +125,7 @@ def test_parse_args_accepts_all_global_scan_options(monkeypatch, tmp_path): assert args.openai_base_uri == "http://openai.example" assert args.interactive is True assert args.disable_tools is True + assert args.deep is True assert args.web is True assert args.web_port == 9999 assert args.web_host == "0.0.0.0" @@ -268,6 +271,10 @@ def test_parse_args_accepts_poem_subcommand(monkeypatch): ["--llm", "faike", "--generate-skills", "--disable-skills", "filesystem", "/tmp/project"], "Cannot use --generate-skills together with --disable-skills", ), + ( + ["--llm", "faike", "--disable-tools", "filesystem", "/tmp/project"], + "Filesystem scans without --deep require tool use", + ), ], ) def test_parse_args_rejects_invalid_combinations(monkeypatch, capsys, argv, message): diff --git a/tests/test_github_adapter.py b/tests/test_github_adapter.py new file mode 100644 index 0000000..7f33cb7 --- /dev/null +++ b/tests/test_github_adapter.py @@ -0,0 +1,72 @@ +import asyncio +import base64 + +from scm.adapters.github import Github + + +class FakeResponse: + def __init__(self, payload=None, status_code=200): + self.payload = payload + self.status_code = status_code + + def json(self): + return self.payload + + def raise_for_status(self): + if self.status_code >= 400: + import requests + + raise requests.exceptions.HTTPError(response=self) + + +class FakeSession: + def __init__(self, responses): + self.responses = list(responses) + self.urls = [] + + def get(self, url, **kwargs): + self.urls.append(url) + return self.responses.pop(0) + + +def github_with_session(session): + adapter = Github.__new__(Github) + adapter.requests = session + adapter.repo = "owner/repo" + adapter.pr_number = "123" + adapter.commit_sha = "abc123" + return adapter + + +def test_github_changed_files_excludes_removed_files(): + session = FakeSession( + [ + FakeResponse( + [ + {"filename": "app.py", "status": "modified", "patch": "@@ -1 +1 @@"}, + {"filename": "deleted.py", "status": "removed", "patch": "@@ -1 +0 @@"}, + ] + ), + FakeResponse([]), + ] + ) + adapter = github_with_session(session) + + changed_files = adapter.get_changed_files() + + assert changed_files == [{"filename": "app.py", "status": "modified", "patch": "@@ -1 +1 @@"}] + + +def test_github_get_file_contents_returns_none_for_missing_pr_head_file(): + session = FakeSession([FakeResponse(status_code=404)]) + adapter = github_with_session(session) + + assert asyncio.run(adapter.get_file_contents("deleted.py")) is None + + +def test_github_get_file_contents_decodes_base64_content(): + content = base64.b64encode(b"print('hello')\n").decode() + session = FakeSession([FakeResponse({"encoding": "base64", "content": content})]) + adapter = github_with_session(session) + + assert asyncio.run(adapter.get_file_contents("app.py")) == "print('hello')\n" diff --git a/tests/test_main_analysis.py b/tests/test_main_analysis.py index c63b6e6..b4a38f4 100644 --- a/tests/test_main_analysis.py +++ b/tests/test_main_analysis.py @@ -1,7 +1,11 @@ import asyncio +from types import SimpleNamespace import main as saist_main from models import Finding, Findings +from scm.adapters.filesystem import FilesystemAdapter +from scm.adapters.git import GitAdapter as RealGitAdapter +from scm.adapters.github import Github as RealGithub from util.skills import skills_prompt_digest @@ -31,6 +35,9 @@ async def regex_search(self, pattern, file_pattern="**/*", max_results=100): def tool_functions(self): return [self.read_file_contents, self.list_files, self.regex_search] + def detect_prompt(self): + return FilesystemAdapter.DETECT_PROMPT + llm = CapturingLlm() result = asyncio.run( saist_main.analyze_single_file( @@ -51,6 +58,229 @@ def tool_functions(self): "list_files", "regex_search", ] + assert "not to produce a best-practice checklist" in llm.system_prompt + assert "penetration test style review across the entire application codebase" in llm.system_prompt + assert "cross-tenant data access" in llm.system_prompt + + +def test_analyze_single_file_uses_git_diff_prompt_for_git_adapter(): + class CapturingLlm: + def __init__(self): + self.system_prompt = None + + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + self.system_prompt = system_prompt + return Findings(findings=[]) + + class GitAdapter: + def tool_functions(self): + return [] + + def detect_prompt(self): + return RealGitAdapter.DETECT_PROMPT + + llm = CapturingLlm() + result = asyncio.run( + saist_main.analyze_single_file( + scm=GitAdapter(), + adapter=llm, + filename="app.py", + patch_text="@@ -1 +1 @@\n-old\n+new\n", + disable_tools=False, + ) + ) + + assert result == [] + assert "analyzing a diff of code that needs security review" in llm.system_prompt + assert "git comparison" in llm.system_prompt + assert "anchored to changed lines" in llm.system_prompt + assert "pre-existing best-practice issues" in llm.system_prompt + assert "penetration test style review" not in llm.system_prompt + + +def test_analyze_single_file_uses_github_pull_request_prompt(): + class CapturingLlm: + def __init__(self): + self.system_prompt = None + + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + self.system_prompt = system_prompt + return Findings(findings=[]) + + class Github: + def tool_functions(self): + return [] + + def detect_prompt(self): + return RealGithub.DETECT_PROMPT + + llm = CapturingLlm() + result = asyncio.run( + saist_main.analyze_single_file( + scm=Github(), + adapter=llm, + filename="app.py", + patch_text="@@ -1 +1 @@\n-old\n+new\n", + disable_tools=False, + ) + ) + + assert result == [] + assert "GitHub pull request" in llm.system_prompt + assert "Report only vulnerabilities anchored to changed lines" in llm.system_prompt + assert "business logic changes" in llm.system_prompt + + +def test_filesystem_tool_analysis_sends_file_inventory_and_tracks_coverage(): + class CapturingLlm: + def __init__(self): + self.system_prompt = None + self.user_prompt = None + self.tool_names = None + + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + self.system_prompt = system_prompt + self.user_prompt = user_prompt + self.tool_names = [tool.__name__ for tool in tool_fns] + read_file_contents = next(tool for tool in tool_fns if tool.__name__ == "read_file_contents") + regex_search = next(tool for tool in tool_fns if tool.__name__ == "regex_search") + await read_file_contents("app.py") + await regex_search("SECRET", "**/*.py", 10) + return Findings( + findings=[ + Finding( + file="app.py", + snippet="SECRET", + title="Secret", + issue="Issue", + recommendation="Fix it.", + cwe="CWE-798", + priority=6, + line_number=1, + ) + ] + ) + + class FakeScm: + async def read_file_contents(self, filename): + return "SECRET = 'dev'\n" + + async def list_files(self): + return ["app.py", "settings.py"] + + async def regex_search(self, pattern, file_pattern="**/*", max_results=100): + return [{"filename": "settings.py", "line_number": 1, "column": 1, "match": "SECRET", "line": "SECRET = 'dev'"}] + + def detect_prompt(self): + return FilesystemAdapter.DETECT_PROMPT + + llm = CapturingLlm() + findings, files_read = asyncio.run( + saist_main.generate_findings_with_filesystem_tools( + scm=FakeScm(), + llm=llm, + filenames=["app.py", "settings.py"], + disable_tools=False, + analysis_skills="Skill guidance", + ) + ) + + assert [finding.file for finding in findings] == ["app.py"] + assert files_read == {"app.py", "settings.py"} + assert "Application file inventory" in llm.user_prompt + assert "- app.py" in llm.user_prompt + assert "- settings.py" in llm.user_prompt + assert "penetration test style review across the entire application codebase" in llm.system_prompt + assert "Trace attacker-controlled input from entrypoint to sink" in llm.system_prompt + assert "Skill guidance" in llm.system_prompt + assert llm.tool_names == ["read_file_contents", "list_files", "regex_search"] + + +def test_print_coverage_reports_read_percentage(capsys): + saist_main.print_coverage({"app.py"}, ["app.py", "settings.py"]) + + assert "LLM file coverage: 1/2 files read (50.0%)" in capsys.readouterr().out + + +def test_filesystem_shallow_scan_generates_missing_skills(tmp_path, monkeypatch): + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / "app.py").write_text("print('hello')\n", encoding="utf-8") + + class FakeLlm: + model_name = "fake-model" + + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + return response_format.model_validate( + { + "skills": [ + { + "filename": "authorization-model.md", + "content": "# Authorization\nGenerated for test.", + } + ] + } + ) + + class EmptyFilesystemAdapter: + def get_changed_files(self): + return [] + + async def get_file_contents(self, filename): + return "" + + async def list_files(self): + return [] + + async def regex_search(self, pattern, file_pattern="**/*", max_results=100): + return [] + + def create_review(self, comment, review_comments, request_changes): + raise AssertionError("review should not be created when no files are listed") + + args = SimpleNamespace( + SCM="filesystem", + path=str(project_root), + path_for_comparison=None, + llm="faike", + llm_model=None, + verbose=0, + generate_skills=False, + skills_path=".saist/skills", + skills_sample_files=5, + skills_sample_bytes=300, + overwrite_skills=False, + disable_skills=False, + skills_max_bytes=60000, + deep=False, + include=None, + exclude=None, + dry_run=False, + disable_tools=False, + skip_line_length_check=False, + max_line_length=1000, + llm_rate_limit=1, + disable_caching=True, + cache_folder=str(tmp_path / "cache"), + interactive=False, + csv=False, + web=False, + pdf=False, + ci=False, + project_name=None, + ) + + async def fake_get_llm_adapter(parsed_args): + return FakeLlm() + + monkeypatch.setattr(saist_main, "parse_args", lambda: args) + monkeypatch.setattr(saist_main, "_get_llm_adapter", fake_get_llm_adapter) + monkeypatch.setattr(saist_main, "_get_scm_adapter", lambda parsed_args: EmptyFilesystemAdapter()) + + asyncio.run(saist_main.main()) + + skill_file = project_root / ".saist" / "skills" / "authorization-model.md" + assert skill_file.read_text(encoding="utf-8") == "# Authorization\nGenerated for test.\n" def test_process_file_reuses_cache_for_same_skills_and_salts_cache_when_skills_change(tmp_path, monkeypatch): @@ -188,3 +418,30 @@ def prompt(self, system_prompt, user_prompt): saist_main.generate_summary_from_findings(FailingLlm(), [finding]) == "Security issues found. Please review the inline comments." ) + + +def test_generate_summary_uses_scm_specific_prompt(): + class CapturingLlm: + def __init__(self): + self.system_prompt = None + + def prompt(self, system_prompt, user_prompt): + self.system_prompt = system_prompt + return "summary" + + finding = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "Issue", + "issue": "Issue", + "recommendation": "Fix it.", + "cwe": "CWE-20", + "priority": 4, + "line_number": 1, + } + ) + + llm = CapturingLlm() + assert saist_main.generate_summary_from_findings(llm, [finding], RealGithub.SUMMARY_PROMPT) == "summary" + assert "GitHub pull request diff-based code security review" in llm.system_prompt diff --git a/tests/test_reportlab_pdf.py b/tests/test_reportlab_pdf.py index 7f81be9..b56163e 100644 --- a/tests/test_reportlab_pdf.py +++ b/tests/test_reportlab_pdf.py @@ -150,3 +150,67 @@ def test_reportlab_pdf_splits_long_summary_across_pages(tmp_path, monkeypatch): pdf_path = tmp_path / "reporting" / "long-summary.pdf" assert pdf_path.exists() assert pdf_path.read_bytes().startswith(b"%PDF") + + +def test_reportlab_pdf_handles_long_code_lines_and_long_index_values(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + args = type( + "Args", + (), + { + "pdf_filename": "long-code-lines.pdf", + "SCM": "filesystem", + "path": "/example/project", + }, + )() + long_line = "token = '" + ("abcdef1234567890" * 30) + "'" + findings = example_findings() + findings[0].title = "Long code line remains readable in the generated report" + findings[0].file = "app/security/reports/very/deeply/nested/module_with_a_long_filename.py" + findings[0].context = "def load_token():\n" + long_line + "\nreturn token" + findings[0].context_start = 1 + findings[0].context_end = 3 + findings[0].line_number = 2 + + ReportLabPdf( + llm=FakeLlm(), + project="Example Project", + findings=findings, + comment=STATIC_SUMMARY, + ).run(args) + + pdf_path = tmp_path / "reporting" / "long-code-lines.pdf" + assert pdf_path.exists() + assert pdf_path.read_bytes().startswith(b"%PDF") + + +def test_issue_summary_uses_issue_title_file_and_severity_columns(): + report = ReportLabPdf( + llm=FakeLlm(), + project="Example Project", + findings=example_findings(), + comment=STATIC_SUMMARY, + ) + styles = report._styles() + table = report._issue_summary_table(styles) + + headers = [cell.getPlainText() for cell in table._cellvalues[0]] + assert headers == ["Issue ID", "Title", "File", "Severity"] + severity_cells = [row[3].getPlainText() for row in table._cellvalues[1:]] + assert severity_cells == ["Critical", "High", "Medium"] + + +def test_code_markup_wraps_long_lines_and_context_highlight_stays_light(): + report = ReportLabPdf( + llm=FakeLlm(), + project="Example Project", + findings=example_findings(), + comment=STATIC_SUMMARY, + ) + long_line = "token = '" + ("abcdef1234567890" * 30) + "'" + + assert "
" in report._code_markup(long_line) + + context_table = report._context_table(example_findings()[0], report._styles()) + background_colours = [command[3] for command in context_table._bkgrndcmds if command[0] == "BACKGROUND"] + assert all(str(colour) != "Color(.113725,.227451,.164706,1)" for colour in background_colours) diff --git a/tests/test_skills.py b/tests/test_skills.py index f2b40c5..cfa49e7 100644 --- a/tests/test_skills.py +++ b/tests/test_skills.py @@ -35,7 +35,8 @@ def test_format_analysis_skills_wraps_guidance_with_source_names(tmp_path): assert "Application analysis skills" in prompt_text assert "routing.md" in prompt_text assert "Use controllers." in prompt_text - assert "Report only vulnerabilities that are present in the diff" in prompt_text + assert "Use this context to validate exploitability and business impact" in prompt_text + assert "not to report generic best-practice advice" in prompt_text def test_project_root_and_skills_dir_resolution(tmp_path): @@ -92,7 +93,7 @@ async def prompt_structured(self, system_prompt, user_prompt, response_format, t assert result.skipped == [] assert result.sampled_files == 2 assert output_file.read_text(encoding="utf-8") == "# Authorization\nCheck tenant boundaries.\n" - assert "application-routing.md" in llm.user_prompt + assert "authorization-model.md" in llm.user_prompt assert "package.json" in llm.user_prompt From d3bd8b9647be9137fb31310e35243134be2fa5d8 Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 19:15:21 +0000 Subject: [PATCH 08/15] add dedupe --- README.md | 8 +- requirements.txt | 4 +- saist/llm/adapters/__init__.py | 47 ++++++- saist/llm/adapters/anthropic.py | 3 +- saist/llm/adapters/bedrock.py | 4 +- saist/llm/adapters/deepseek.py | 4 +- saist/llm/adapters/faike.py | 3 +- saist/llm/adapters/gemini.py | 3 +- saist/llm/adapters/ollama.py | 4 +- saist/llm/adapters/openai.py | 3 +- saist/main.py | 110 ++++++++++++---- saist/util/argparsing.py | 51 ++++++-- tests/test_argparsing.py | 34 +++++ tests/test_llm_adapters.py | 42 ++++++ tests/test_main_analysis.py | 219 ++++++++++++++++++++++++++++++++ 15 files changed, 484 insertions(+), 55 deletions(-) create mode 100644 tests/test_llm_adapters.py diff --git a/README.md b/README.md index a2cef0a..4d365c2 100644 --- a/README.md +++ b/README.md @@ -201,13 +201,15 @@ docker run -v$PWD/code:/code -v$PWD/reporting:/app/reporting punksecurity/saist | Option | Description | |:------|:------------| -| `--llm` | Select LLM (`anthropic`, `deepseek`, `gemini`, `ollama`, `openai`) | -| `--llm-api-key` | API key for your LLM | -| `--llm-model` | (Optional) Specific model (e.g., `gpt-4o`) | +| `--llm` | Select LLM (`anthropic`, `deepseek`, `gemini`, `ollama`, `openai`) | +| `--llm-api-key` | API key for your LLM | +| `--llm-model` | (Optional) Specific model (e.g., `gpt-4o`) | +| `--thinking` | Pydantic AI thinking effort: `minimal`, `low`, `medium`, `high`, `xhigh`, or `disabled` | | `--interactive` | Chat with the LLM after scan | | `--web` | Launch a local web server | | `--disable-tools` | Disable tool use during file analysis to reduce LLM token usage | | `--deep` | For filesystem scans, analyze every file individually. Without this, filesystem scans send a file inventory and let the LLM inspect files with tools, then report file coverage. | +| `--iterations` | Number of tool-driven filesystem scan passes to run when `--deep` is not set. Defaults to `1`; concurrency is capped by `--llm-rate-limit`. | | `--skills-path` | Folder containing SAIST analysis skill Markdown files | | `--generate-skills` | Generate SAIST analysis skill files and exit | | `--overwrite-skills` | Replace existing skill files during skill generation | diff --git a/requirements.txt b/requirements.txt index b0c06f0..7c00b80 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ -pydantic==2.11.7 +pydantic==2.12.5 requests==2.32.4 GitPython==3.1.44 python-dotenv==1.1.1 -pydantic-ai==0.3.7 +pydantic-ai==1.93.0 aiofiles==24.1.0 rich==14.0.0 flask==3.1.1 diff --git a/saist/llm/adapters/__init__.py b/saist/llm/adapters/__init__.py index b6f32ab..17fdef7 100644 --- a/saist/llm/adapters/__init__.py +++ b/saist/llm/adapters/__init__.py @@ -2,35 +2,70 @@ from typing import Callable, List, Optional, Type from pydantic_ai import Agent, Tool - -from typing import Type +from pydantic_ai.settings import ModelSettings from pydantic import BaseModel logger = logging.getLogger("saist.llm.adapters") +THINKING_CHOICES = ("minimal", "low", "medium", "high", "xhigh", "disabled") +DISABLED_THINKING = "disabled" + + +def pydantic_ai_supports_thinking() -> bool: + return "thinking" in getattr(ModelSettings, "__annotations__", {}) + + class BaseLlmAdapter: model_options = None model_vendor = '' model_name = '' + def __init__(self, thinking: str = "medium"): + self.thinking = thinking + def get_model_options(self): - return {'temperature': 0.0} | self.model_options if self.model_options is not None else {} + options = {"temperature": 0.0} + if self.model_options is not None: + options.update(self.model_options) + + if pydantic_ai_supports_thinking(): + options["thinking"] = False if self.thinking == DISABLED_THINKING else self.thinking + elif self.thinking != DISABLED_THINKING: + logger.getChild(self.__class__.__name__).debug( + "Installed pydantic-ai version does not support model_settings.thinking; ignoring setting." + ) + + return options + + async def _run_agent(self, agent: Agent, user_prompt: str): + model_settings = self.get_model_options() + try: + return await agent.run(user_prompt=user_prompt, model_settings=model_settings) + except Exception as e: + if "thinking" not in str(e).lower() or "thinking" not in model_settings: + raise + + fallback_settings = {key: value for key, value in model_settings.items() if key != "thinking"} + logger.getChild(self.__class__.__name__).warning( + "Model or provider rejected model_settings.thinking; retrying without it." + ) + return await agent.run(user_prompt=user_prompt, model_settings=fallback_settings) async def prompt_structured(self, system_prompt: str, user_prompt: str, response_format: Type[BaseModel], tool_fns: Optional[List[Callable]] = None) -> BaseModel: tools = [Tool(fn) for fn in tool_fns] if tool_fns else [] agent = Agent(self.model, output_type = response_format, tools=tools, system_prompt=system_prompt) - response = await agent.run( user_prompt=user_prompt, model_settings=self.get_model_options()) + response = await self._run_agent(agent, user_prompt) logger.getChild(self.__class__.__name__).debug("prompt_structured response", extra={'response_data': response.output, 'prompt': user_prompt}) return response.output async def prompt(self, system_prompt: str, user_prompt: str, tool_fns: Optional[List[Callable]] = None) -> str | None: tools = [Tool(fn) for fn in tool_fns] if tool_fns else [] agent = Agent(self.model, system_prompt=system_prompt, tools=tools) - response = await agent.run(user_prompt=user_prompt, model_settings=self.get_model_options()) + response = await self._run_agent(agent, user_prompt) logger.getChild(self.__class__.__name__).debug("prompt response", extra={'response_data': response.output, 'prompt': user_prompt}) return response.output def generate_agent(self, system_prompt: str = None, tool_fns: Optional[List[Callable]] = None): tools = [Tool(fn) for fn in tool_fns] if tool_fns else [] - return Agent(self.model, system_prompt=system_prompt, tools=tools) + return Agent(self.model, system_prompt=system_prompt, tools=tools, model_settings=self.get_model_options()) diff --git a/saist/llm/adapters/anthropic.py b/saist/llm/adapters/anthropic.py index 20afd78..cd52aa4 100644 --- a/saist/llm/adapters/anthropic.py +++ b/saist/llm/adapters/anthropic.py @@ -6,7 +6,8 @@ from pydantic_ai.providers.anthropic import AnthropicProvider class AnthropicAdapter(BaseLlmAdapter): - def __init__(self, model: str = None, api_key: Optional[str] = None): + def __init__(self, model: str = None, api_key: Optional[str] = None, thinking: str = "medium"): + super().__init__(thinking=thinking) if model is None: model = "claude-3-7-sonnet-latest" self.model = AnthropicModel( diff --git a/saist/llm/adapters/bedrock.py b/saist/llm/adapters/bedrock.py index 006d1c5..35d7603 100644 --- a/saist/llm/adapters/bedrock.py +++ b/saist/llm/adapters/bedrock.py @@ -5,7 +5,8 @@ from pydantic_ai.models.bedrock import BedrockConverseModel class BedrockAdapter(BaseLlmAdapter): - def __init__(self, model: str = None, api_key: Optional[str] = None): + def __init__(self, model: str = None, api_key: Optional[str] = None, thinking: str = "medium"): + super().__init__(thinking=thinking) if api_key: raise ValueError("Do not provide API keys for AWS - use ENV variables") if model is None: @@ -14,4 +15,3 @@ def __init__(self, model: str = None, api_key: Optional[str] = None): self.model_name = self.model.model_name self.model_vendor = 'Bedrock' - diff --git a/saist/llm/adapters/deepseek.py b/saist/llm/adapters/deepseek.py index 211092e..d809404 100644 --- a/saist/llm/adapters/deepseek.py +++ b/saist/llm/adapters/deepseek.py @@ -6,7 +6,8 @@ from pydantic_ai.providers.deepseek import DeepSeekProvider class DeepseekAdapter(BaseLlmAdapter): - def __init__(self, model: str = None, api_key: Optional[str] = None): + def __init__(self, model: str = None, api_key: Optional[str] = None, thinking: str = "medium"): + super().__init__(thinking=thinking) if model is None: model = "deepseek-chat" self.model = OpenAIModel( @@ -15,4 +16,3 @@ def __init__(self, model: str = None, api_key: Optional[str] = None): ) self.model_name = self.model.model_name self.model_vendor = 'DeepSeek' - diff --git a/saist/llm/adapters/faike.py b/saist/llm/adapters/faike.py index 986b3a1..37674e8 100644 --- a/saist/llm/adapters/faike.py +++ b/saist/llm/adapters/faike.py @@ -10,7 +10,8 @@ logger = logging.getLogger(__name__) class FaikeAdapter(BaseLlmAdapter): - def __init__(self, base_url: str, model: str = None, api_key: Optional[str] = None): + def __init__(self, base_url: str, model: str = None, api_key: Optional[str] = None, thinking: str = "medium"): + super().__init__(thinking=thinking) self.model = model self.model_name = self.model self.model_vendor = 'Fake AI LLM' diff --git a/saist/llm/adapters/gemini.py b/saist/llm/adapters/gemini.py index 40de292..a5e097f 100644 --- a/saist/llm/adapters/gemini.py +++ b/saist/llm/adapters/gemini.py @@ -6,7 +6,8 @@ from pydantic_ai.providers.google_gla import GoogleGLAProvider class GeminiAdapter(BaseLlmAdapter): - def __init__(self, model: str = None, api_key: Optional[str] = None): + def __init__(self, model: str = None, api_key: Optional[str] = None, thinking: str = "medium"): + super().__init__(thinking=thinking) if model is None: model = "gemini-2.5-pro-exp-03-25" self.model = GeminiModel( diff --git a/saist/llm/adapters/ollama.py b/saist/llm/adapters/ollama.py index c0e409d..1d5a2f2 100644 --- a/saist/llm/adapters/ollama.py +++ b/saist/llm/adapters/ollama.py @@ -10,7 +10,8 @@ logger = logging.getLogger(__name__) class OllamaAdapter(BaseLlmAdapter): - def __init__(self, base_url: str, model: str = None, api_key: Optional[str] = None): + def __init__(self, base_url: str, model: str = None, api_key: Optional[str] = None, thinking: str = "medium"): + super().__init__(thinking=thinking) if model is None: model = "llama3:latest" self.model = model @@ -68,4 +69,3 @@ async def prompt(self, system_prompt: str, user_prompt: str, tool_fns: Optional[ def generate_agent(self, system_prompt: str = None, tool_fns: Optional[List[Callable]] = None): #TODO: OLLAMA AGENT FOR SHELL return None - diff --git a/saist/llm/adapters/openai.py b/saist/llm/adapters/openai.py index 6bca158..51b95ab 100644 --- a/saist/llm/adapters/openai.py +++ b/saist/llm/adapters/openai.py @@ -6,7 +6,8 @@ from pydantic_ai.providers.openai import OpenAIProvider class OpenAiAdapter(BaseLlmAdapter): - def __init__(self, model: str = None, api_key: Optional[str] = None): + def __init__(self, model: str = None, api_key: Optional[str] = None, thinking: str = "medium"): + super().__init__(thinking=thinking) if model is None: model = "gpt-4o" self.model = OpenAIModel( diff --git a/saist/main.py b/saist/main.py index d443cbe..55e1ead 100755 --- a/saist/main.py +++ b/saist/main.py @@ -178,6 +178,38 @@ def build_filesystem_review_comments(findings: list[Finding]) -> list[dict]: return comments +def build_diff_review_comments(findings: list[Finding], file_line_maps: dict) -> list[dict]: + comments = [] + for finding in findings: + diff_position = file_line_maps[finding.file][finding.line_number] + comments.append( + { + "path": finding.file, + "position": diff_position - 1, + "body": build_finding_review_body(finding), + } + ) + return comments + + +def dedupe_findings(findings: list[Finding]) -> list[Finding]: + deduped: dict[tuple[str, int, str], Finding] = {} + order: list[tuple[str, int, str]] = [] + + for finding in findings: + key = (finding.file, finding.line_number, finding.cwe) + existing = deduped.get(key) + if existing is None: + deduped[key] = finding + order.append(key) + continue + + if finding.priority > existing.priority: + deduped[key] = finding + + return [deduped[key] for key in order] + + async def generate_findings_with_filesystem_tools( scm: Scm, llm: BaseLlmAdapter, @@ -209,6 +241,37 @@ async def generate_findings_with_filesystem_tools( return result.findings, tracked_scm.files_read +async def generate_findings_with_filesystem_tools_iterations( + scm: Scm, + llm: BaseLlmAdapter, + filenames: list[str], + disable_tools: bool, + analysis_skills: str, + iterations: int, + max_concurrent: int, +) -> tuple[list[Finding], set[str]]: + semaphore = asyncio.Semaphore(max(1, max_concurrent)) + + async def run_iteration(): + async with semaphore: + return await generate_findings_with_filesystem_tools( + scm=scm, + llm=llm, + filenames=filenames, + disable_tools=disable_tools, + analysis_skills=analysis_skills, + ) + + results = await asyncio.gather(*(run_iteration() for _ in range(iterations))) + all_findings = [] + files_read = set() + for findings, iteration_files_read in results: + all_findings.extend(findings) + files_read.update(iteration_files_read) + + return all_findings, files_read + + def print_coverage(files_read: set[str], files_in_scope: list[str]): total = len(files_in_scope) read_count = len(files_read.intersection(files_in_scope)) @@ -243,28 +306,29 @@ def _get_scm_adapter(args) -> BaseScmAdapter: async def _get_llm_adapter(args) -> BaseLlmAdapter: model = args.llm_model + thinking = args.thinking if args.llm == 'anthropic': - llm = AnthropicAdapter( api_key = args.llm_api_key, model=model) + llm = AnthropicAdapter(api_key=args.llm_api_key, model=model, thinking=thinking) logger.debug(f"Using LLM: anthropic Model: {llm.model_name}") elif args.llm == 'bedrock': - llm = BedrockAdapter( api_key = args.llm_api_key, model=model) + llm = BedrockAdapter(api_key=args.llm_api_key, model=model, thinking=thinking) logger.debug(f"Using LLM: AWS bedrock Model: {llm.model_name}") elif args.llm == 'deepseek': - llm = DeepseekAdapter(api_key = args.llm_api_key, model=model) + llm = DeepseekAdapter(api_key=args.llm_api_key, model=model, thinking=thinking) logger.debug(f"Using LLM: deepseek Model: {llm.model_name}") elif args.llm == 'openai': - llm = OpenAiAdapter(api_key = args.llm_api_key, model=model) + llm = OpenAiAdapter(api_key=args.llm_api_key, model=model, thinking=thinking) logger.debug(f"Using LLM: openai Model: {llm.model_name}") elif args.llm == 'gemini': - llm = GeminiAdapter(api_key = args.llm_api_key, model=model) + llm = GeminiAdapter(api_key=args.llm_api_key, model=model, thinking=thinking) logger.debug(f"Using LLM: gemini Model: {llm.model_name}") elif args.llm == 'ollama': - llm = OllamaAdapter(api_key = args.llm_api_key, base_url=args.ollama_base_uri, model=model) + llm = OllamaAdapter(api_key=args.llm_api_key, base_url=args.ollama_base_uri, model=model, thinking=thinking) await llm.initialize() logger.debug(f"Using LLM: ollama Model: {llm.model_name}") elif args.llm == 'faike': - llm = FaikeAdapter("", "Fake LLM") + llm = FaikeAdapter("", "Fake LLM", thinking=thinking) logger.debug("Using LLM: Faike AI") else: raise Exception("Could not determine a suitable LLM to use") @@ -353,6 +417,7 @@ async def main(): logging.debug("SAIST skill loading disabled") filter_rules = FilterRules(args.include, args.exclude) + review_comments = [] if shallow_filesystem_scan: print("📂 Listing application files...") @@ -369,13 +434,15 @@ async def main(): print("âš ī¸ --dry-run flag passed, exiting without analyzing files.") exit(0) - print("🔍 Analyzing application with LLM tool use...") - all_findings, files_read = await generate_findings_with_filesystem_tools( + print(f"🔍 Analyzing application with LLM tool use ({args.iterations} iteration{'s' if args.iterations != 1 else ''})...") + all_findings, files_read = await generate_findings_with_filesystem_tools_iterations( scm=scm, llm=llm, filenames=app_filenames, disable_tools=args.disable_tools, analysis_skills=analysis_skills, + iterations=args.iterations, + max_concurrent=args.llm_rate_limit, ) print_coverage(files_read, app_filenames) @@ -383,9 +450,6 @@ async def main(): print("✅ No findings reported. Exiting.\n") return - all_findings.sort(key=lambda x: x.priority, reverse=True) - review_comments = build_filesystem_review_comments(all_findings) - print(f"🚨 Analysis complete! Identified {len(all_findings)} potential issues.\n") else: # 1) Get changed files @@ -444,10 +508,8 @@ async def main(): if not all_findings: print("✅ No findings reported. Exiting.\n") return - print(f"🚨 Analysis complete! Identified {len(all_findings)} potential issues.\n") # 4) Build review comments from snippet-based findings - review_comments = [] all_findings.sort(key=lambda x: x.priority,reverse=True) for item in all_findings: @@ -463,7 +525,6 @@ async def main(): # Possibly flagged a file that doesn't exist in the PR continue - line_map = file_line_maps[file_name] new_lines_text = file_new_lines_text[file_name] # Attempt to find which 'new_line' has the snippet @@ -477,22 +538,25 @@ async def main(): # If we can't find the snippet in the patch, skip continue - diff_position = line_map[matched_new_line] - - review_comments.append({ - "path": file_name, - "position": diff_position - 1, - "body": build_finding_review_body(item) - }) - item.line_number = matched_new_line all_findings = list([x for x in all_findings if x.line_number != -1]) + + all_findings = dedupe_findings(all_findings) + all_findings.sort(key=lambda x: x.priority, reverse=True) + if not all_findings: print("No issues detected") exit(0) + print(f"🚨 Analysis complete! Identified {len(all_findings)} potential issues.\n") + + if shallow_filesystem_scan: + review_comments = build_filesystem_review_comments(all_findings) + else: + review_comments = build_diff_review_comments(all_findings, file_line_maps) + if args.interactive: s = Shell(llm, scm, all_findings) await s.run() diff --git a/saist/util/argparsing.py b/saist/util/argparsing.py index 45d87a6..85c58c9 100644 --- a/saist/util/argparsing.py +++ b/saist/util/argparsing.py @@ -2,6 +2,7 @@ from os import linesep, environ, cpu_count import sys from dotenv import load_dotenv +from llm.adapters import THINKING_CHOICES from util.skills import DEFAULT_SKILL_MAX_BYTES, DEFAULT_SKILL_SAMPLE_BYTES, DEFAULT_SKILL_SAMPLE_FILES, DEFAULT_SKILLS_PATH load_dotenv(".env") @@ -137,15 +138,35 @@ def __call__(self, parser, namespace, values, option_string=None): envvar="SAIST_LLM_MODEL", action=EnvDefault, required=False ) -parser.add_argument( - "--llm-rate-limit", help = "Max requests per second", envvar="SAIST_LLM_RATE_LIMIT", - action=EnvDefault, required=False, type=int, default = 10 - ) - -parser.add_argument( - "--ollama-base-uri", type=str, help = "Base uri of ollama", - envvar="SAIST_OLLAMA_BASE_URI", action=EnvDefault, default = "http://localhost:11434" - ) +parser.add_argument( + "--llm-rate-limit", help = "Max requests per second", envvar="SAIST_LLM_RATE_LIMIT", + action=EnvDefault, required=False, type=int, default = 10 + ) + +parser.add_argument( + "--iterations", + help="Number of tool-driven filesystem analysis passes to run when --deep is not set", + envvar="SAIST_ITERATIONS", + action=EnvDefault, + required=False, + type=int, + default=1, +) + +parser.add_argument( + "--thinking", + help="LLM thinking effort for providers supported by pydantic-ai", + choices=THINKING_CHOICES, + envvar="SAIST_THINKING", + action=EnvDefault, + required=False, + default="medium", +) + +parser.add_argument( + "--ollama-base-uri", type=str, help = "Base uri of ollama", + envvar="SAIST_OLLAMA_BASE_URI", action=EnvDefault, default = "http://localhost:11434" + ) parser.add_argument( "--openai-base-uri", type=str, help = "Base uri of openai to use any compatable service", @@ -288,8 +309,8 @@ def __call__(self, parser, namespace, values, option_string=None): help="-v for verbose, -vv for extra verbose", ) -def parse_args(): - args = parser.parse_args() +def parse_args(): + args = parser.parse_args() if args.llm == "bedrock" and args.llm_api_key: parser.error(f"Do not provide an API key for bedrock, use AWS ENV variables https://docs.aws.amazon.com/cli/v1/userguide/cli-configure-envvars.html") @@ -315,4 +336,12 @@ def parse_args(): if args.SCM == "filesystem" and args.disable_tools and not args.deep: parser.error("Filesystem scans without --deep require tool use. Remove --disable-tools or add --deep.") + if args.iterations < 1: + parser.error("--iterations must be at least 1") + + if args.iterations > 1 and (args.SCM != "filesystem" or args.deep): + sys.stdout.write( + f" âš ī¸ warning: --iterations only applies to filesystem scans without --deep; ignoring --iterations={args.iterations}.{linesep}" + ) + return args diff --git a/tests/test_argparsing.py b/tests/test_argparsing.py index 9d8be17..5914970 100644 --- a/tests/test_argparsing.py +++ b/tests/test_argparsing.py @@ -18,6 +18,8 @@ def test_parse_args_sets_defaults_for_filesystem(monkeypatch, tmp_path): assert args.llm_api_key is None assert args.llm_model is None assert args.llm_rate_limit == 10 + assert args.iterations == 1 + assert args.thinking == "medium" assert args.ollama_base_uri == "http://localhost:11434" assert args.openai_base_uri is None assert args.interactive is False @@ -61,6 +63,10 @@ def test_parse_args_accepts_all_global_scan_options(monkeypatch, tmp_path): "gpt-test", "--llm-rate-limit", "3", + "--iterations", + "10", + "--thinking", + "high", "--ollama-base-uri", "http://ollama.example", "--openai-base-uri", @@ -121,6 +127,8 @@ def test_parse_args_accepts_all_global_scan_options(monkeypatch, tmp_path): assert args.llm_api_key == "test-key" assert args.llm_model == "gpt-test" assert args.llm_rate_limit == 3 + assert args.iterations == 10 + assert args.thinking == "high" assert args.ollama_base_uri == "http://ollama.example" assert args.openai_base_uri == "http://openai.example" assert args.interactive is True @@ -184,6 +192,32 @@ def test_parse_args_accepts_skill_generation_options(monkeypatch, tmp_path): assert args.skills_sample_bytes == 3 +def test_parse_args_accepts_disabled_thinking(monkeypatch, tmp_path): + args = parse_with(monkeypatch, ["--llm", "faike", "--thinking", "disabled", "filesystem", str(tmp_path)]) + + assert args.thinking == "disabled" + + +def test_parse_args_rejects_zero_iterations(monkeypatch, tmp_path): + with pytest.raises(SystemExit): + parse_with(monkeypatch, ["--llm", "faike", "--iterations", "0", "filesystem", str(tmp_path)]) + + +@pytest.mark.parametrize( + "argv", + [ + ["--llm", "faike", "--iterations", "3", "--deep", "filesystem", "/tmp/project"], + ["--llm", "faike", "--iterations", "3", "git", "/tmp/project"], + ["--llm", "faike", "--iterations", "3", "github", "owner/repo", "--github-token", "token", "123"], + ], +) +def test_parse_args_warns_when_iterations_are_ignored(monkeypatch, capsys, argv): + args = parse_with(monkeypatch, argv) + + assert args.iterations == 3 + assert "--iterations only applies to filesystem scans without --deep" in capsys.readouterr().out + + def test_parse_args_accepts_git_subcommand_options(monkeypatch, tmp_path): args = parse_with( monkeypatch, diff --git a/tests/test_llm_adapters.py b/tests/test_llm_adapters.py new file mode 100644 index 0000000..615586f --- /dev/null +++ b/tests/test_llm_adapters.py @@ -0,0 +1,42 @@ +import asyncio + +from llm import adapters +from llm.adapters import BaseLlmAdapter +from llm.adapters.faike import FaikeAdapter +from models import Findings + + +def test_model_options_include_pydantic_thinking_level_when_supported(monkeypatch): + monkeypatch.setattr(adapters, "pydantic_ai_supports_thinking", lambda: True) + adapter = BaseLlmAdapter(thinking="high") + + assert adapter.get_model_options()["thinking"] == "high" + + +def test_model_options_map_disabled_thinking_to_false(monkeypatch): + monkeypatch.setattr(adapters, "pydantic_ai_supports_thinking", lambda: True) + adapter = BaseLlmAdapter(thinking="disabled") + + assert adapter.get_model_options()["thinking"] is False + + +def test_model_options_omit_thinking_when_pydantic_ai_does_not_support_it(monkeypatch): + monkeypatch.setattr(adapters, "pydantic_ai_supports_thinking", lambda: False) + adapter = BaseLlmAdapter(thinking="xhigh") + + assert "thinking" not in adapter.get_model_options() + + +def test_faike_adapter_accepts_thinking_without_using_it(): + adapter = FaikeAdapter("", "Fake LLM", thinking="low") + + result = asyncio.run( + adapter.prompt_structured( + "system", + "File: app.py\n@@ -0,0 +1 @@\n+[]\n", + Findings, + ) + ) + + assert adapter.thinking == "low" + assert result.findings[0].file == "app.py" diff --git a/tests/test_main_analysis.py b/tests/test_main_analysis.py index b4a38f4..9b865a2 100644 --- a/tests/test_main_analysis.py +++ b/tests/test_main_analysis.py @@ -1,4 +1,5 @@ import asyncio +import time from types import SimpleNamespace import main as saist_main @@ -196,12 +197,228 @@ def detect_prompt(self): assert llm.tool_names == ["read_file_contents", "list_files", "regex_search"] +def test_filesystem_tool_analysis_iterations_respect_concurrency_limit(): + class CountingLlm: + def __init__(self): + self.calls = 0 + self.active = 0 + self.max_active = 0 + + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + self.calls += 1 + self.active += 1 + self.max_active = max(self.max_active, self.active) + await asyncio.sleep(0.01) + self.active -= 1 + return Findings( + findings=[ + Finding( + file="app.py", + snippet="SECRET", + title=f"Secret {self.calls}", + issue=f"Issue {self.calls}", + recommendation="Fix it.", + cwe="CWE-798", + priority=6, + line_number=1, + ) + ] + ) + + class FakeScm: + async def read_file_contents(self, filename): + return "SECRET = 'dev'\n" + + async def list_files(self): + return ["app.py"] + + async def regex_search(self, pattern, file_pattern="**/*", max_results=100): + return [] + + def detect_prompt(self): + return FilesystemAdapter.DETECT_PROMPT + + llm = CountingLlm() + findings, files_read = asyncio.run( + saist_main.generate_findings_with_filesystem_tools_iterations( + scm=FakeScm(), + llm=llm, + filenames=["app.py"], + disable_tools=False, + analysis_skills="", + iterations=5, + max_concurrent=2, + ) + ) + + assert llm.calls == 5 + assert llm.max_active == 2 + assert len(findings) == 5 + assert files_read == set() + + +def test_filesystem_tool_analysis_iterations_run_concurrently(): + class SlowLlm: + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + await asyncio.sleep(0.05) + return Findings(findings=[]) + + class FakeScm: + async def read_file_contents(self, filename): + return "" + + async def list_files(self): + return ["app.py"] + + async def regex_search(self, pattern, file_pattern="**/*", max_results=100): + return [] + + def detect_prompt(self): + return FilesystemAdapter.DETECT_PROMPT + + started = time.perf_counter() + asyncio.run( + saist_main.generate_findings_with_filesystem_tools_iterations( + scm=FakeScm(), + llm=SlowLlm(), + filenames=["app.py"], + disable_tools=False, + analysis_skills="", + iterations=3, + max_concurrent=3, + ) + ) + elapsed = time.perf_counter() - started + + assert elapsed < 0.12 + + def test_print_coverage_reports_read_percentage(capsys): saist_main.print_coverage({"app.py"}, ["app.py", "settings.py"]) assert "LLM file coverage: 1/2 files read (50.0%)" in capsys.readouterr().out +def test_dedupe_findings_keeps_highest_priority_for_same_file_line_and_cwe(): + low = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "Low duplicate", + "issue": "Lower severity issue", + "recommendation": "Fix it.", + "cwe": "CWE-20", + "priority": 4, + "line_number": 10, + } + ) + high = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "High duplicate", + "issue": "Higher severity issue", + "recommendation": "Fix it now.", + "cwe": "CWE-20", + "priority": 8, + "line_number": 10, + } + ) + different_line = Finding.model_validate( + { + "file": "app.py", + "snippet": "other_danger()", + "title": "Different line", + "issue": "Different issue", + "recommendation": "Fix this too.", + "cwe": "CWE-20", + "priority": 5, + "line_number": 11, + } + ) + + deduped = saist_main.dedupe_findings([low, high, different_line]) + + assert deduped == [high, different_line] + + +def test_get_llm_adapter_applies_thinking_option_to_adapter(): + args = SimpleNamespace( + llm="faike", + llm_model="Fake LLM", + llm_api_key=None, + thinking="xhigh", + ollama_base_uri="http://localhost:11434", + ) + + adapter = asyncio.run(saist_main._get_llm_adapter(args)) + + assert adapter.thinking == "xhigh" + + +def test_dedupe_findings_keeps_first_for_same_priority(): + first = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "First duplicate", + "issue": "First issue", + "recommendation": "First fix.", + "cwe": "CWE-20", + "priority": 7, + "line_number": 10, + } + ) + second = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "Second duplicate", + "issue": "Second issue", + "recommendation": "Second fix.", + "cwe": "CWE-20", + "priority": 7, + "line_number": 10, + } + ) + + assert saist_main.dedupe_findings([first, second]) == [first] + + +def test_diff_review_comments_are_built_after_dedupe(): + first = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "First duplicate", + "issue": "First issue", + "recommendation": "First fix.", + "cwe": "CWE-20", + "priority": 5, + "line_number": 10, + } + ) + second = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "Second duplicate", + "issue": "Second issue", + "recommendation": "Second fix.", + "cwe": "CWE-20", + "priority": 9, + "line_number": 10, + } + ) + + deduped = saist_main.dedupe_findings([first, second]) + comments = saist_main.build_diff_review_comments(deduped, {"app.py": {10: 14}}) + + assert len(comments) == 1 + assert comments[0]["position"] == 13 + assert "Second issue" in comments[0]["body"] + + def test_filesystem_shallow_scan_generates_missing_skills(tmp_path, monkeypatch): project_root = tmp_path / "project" project_root.mkdir() @@ -244,6 +461,7 @@ def create_review(self, comment, review_comments, request_changes): path_for_comparison=None, llm="faike", llm_model=None, + thinking="medium", verbose=0, generate_skills=False, skills_path=".saist/skills", @@ -260,6 +478,7 @@ def create_review(self, comment, review_comments, request_changes): skip_line_length_check=False, max_line_length=1000, llm_rate_limit=1, + iterations=3, disable_caching=True, cache_folder=str(tmp_path / "cache"), interactive=False, From 45e300faaae684ee466f40a8e2c38e126d0fbfcd Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 19:20:19 +0000 Subject: [PATCH 09/15] fix temporario bug --- requirements.txt | 2 +- saist/main.py | 55 +++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/requirements.txt b/requirements.txt index 7c00b80..2ab0fd9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ pydantic==2.12.5 requests==2.32.4 GitPython==3.1.44 python-dotenv==1.1.1 -pydantic-ai==1.93.0 +pydantic-ai-slim[anthropic,bedrock,google,openai]==1.93.0 aiofiles==24.1.0 rich==14.0.0 flask==3.1.1 diff --git a/saist/main.py b/saist/main.py index 55e1ead..5095649 100755 --- a/saist/main.py +++ b/saist/main.py @@ -252,17 +252,56 @@ async def generate_findings_with_filesystem_tools_iterations( ) -> tuple[list[Finding], set[str]]: semaphore = asyncio.Semaphore(max(1, max_concurrent)) - async def run_iteration(): + overall_progress = Progress( + TextColumn("[bold blue]{task.description}"), + BarColumn(), + MofNCompleteColumn(), + TimeElapsedColumn(), + TimeRemainingColumn(), + ) + + iteration_progress = Progress( + SpinnerColumn(), + TextColumn("[blue]{task.description}"), + transient=True, + ) + + progress_group = Group( + overall_progress, + iteration_progress, + ) + + async def run_iteration(iteration: int, overall_task): async with semaphore: - return await generate_findings_with_filesystem_tools( - scm=scm, - llm=llm, - filenames=filenames, - disable_tools=disable_tools, - analysis_skills=analysis_skills, + iteration_task = iteration_progress.add_task( + description=f"Iteration {iteration}/{iterations}...", + transient=True, ) + try: + return await generate_findings_with_filesystem_tools( + scm=scm, + llm=llm, + filenames=filenames, + disable_tools=disable_tools, + analysis_skills=analysis_skills, + ) + finally: + iteration_progress.remove_task(iteration_task) + iteration_progress.refresh() + overall_progress.update(overall_task, advance=1) + + with Live(progress_group): + overall_task = overall_progress.add_task( + f"Running {iterations} tool-driven analysis iteration{'s' if iterations != 1 else ''}...", + total=iterations, + start=True, + ) + try: + results = await asyncio.gather(*(run_iteration(iteration, overall_task) for iteration in range(1, iterations + 1))) + finally: + overall_progress.stop() + iteration_progress.stop() - results = await asyncio.gather(*(run_iteration() for _ in range(iterations))) all_findings = [] files_read = set() for findings, iteration_files_read in results: From b007cd9e89100a4f91c0b3f3a44ac0f6d5b12c7f Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 19:31:15 +0000 Subject: [PATCH 10/15] fix temporario bug --- saist/llm/adapters/openai.py | 4 ++-- tests/test_llm_adapters.py | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/saist/llm/adapters/openai.py b/saist/llm/adapters/openai.py index 51b95ab..79fe0e2 100644 --- a/saist/llm/adapters/openai.py +++ b/saist/llm/adapters/openai.py @@ -2,7 +2,7 @@ from llm.adapters import BaseLlmAdapter -from pydantic_ai.models.openai import OpenAIModel +from pydantic_ai.models.openai import OpenAIResponsesModel from pydantic_ai.providers.openai import OpenAIProvider class OpenAiAdapter(BaseLlmAdapter): @@ -10,7 +10,7 @@ def __init__(self, model: str = None, api_key: Optional[str] = None, thinking: s super().__init__(thinking=thinking) if model is None: model = "gpt-4o" - self.model = OpenAIModel( + self.model = OpenAIResponsesModel( model, provider = OpenAIProvider( api_key=api_key ) ) diff --git a/tests/test_llm_adapters.py b/tests/test_llm_adapters.py index 615586f..34f95ce 100644 --- a/tests/test_llm_adapters.py +++ b/tests/test_llm_adapters.py @@ -3,7 +3,9 @@ from llm import adapters from llm.adapters import BaseLlmAdapter from llm.adapters.faike import FaikeAdapter +from llm.adapters.openai import OpenAiAdapter from models import Findings +from pydantic_ai.models.openai import OpenAIResponsesModel def test_model_options_include_pydantic_thinking_level_when_supported(monkeypatch): @@ -40,3 +42,11 @@ def test_faike_adapter_accepts_thinking_without_using_it(): assert adapter.thinking == "low" assert result.findings[0].file == "app.py" + + +def test_openai_adapter_uses_responses_model_for_thinking_support(): + adapter = OpenAiAdapter(model="gpt-5-mini", api_key="test-key", thinking="high") + + assert isinstance(adapter.model, OpenAIResponsesModel) + assert adapter.model_name == "gpt-5-mini" + assert adapter.get_model_options()["thinking"] == "high" From e1624231718e95a09115b329b8e8b631dccaaffa Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 19:36:07 +0000 Subject: [PATCH 11/15] add azure --- README.md | 4 +++- saist/llm/adapters/azure_foundry.py | 30 ++++++++++++++++++++++++ saist/main.py | 10 ++++++++ saist/util/argparsing.py | 36 +++++++++++++++++++++-------- tests/test_argparsing.py | 20 ++++++++++++++++ tests/test_llm_adapters.py | 16 +++++++++++++ tests/test_main_analysis.py | 18 +++++++++++++++ 7 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 saist/llm/adapters/azure_foundry.py diff --git a/README.md b/README.md index 4d365c2..6047ddd 100644 --- a/README.md +++ b/README.md @@ -201,10 +201,12 @@ docker run -v$PWD/code:/code -v$PWD/reporting:/app/reporting punksecurity/saist | Option | Description | |:------|:------------| -| `--llm` | Select LLM (`anthropic`, `deepseek`, `gemini`, `ollama`, `openai`) | +| `--llm` | Select LLM (`anthropic`, `azure-foundry`, `bedrock`, `deepseek`, `gemini`, `ollama`, `openai`) | | `--llm-api-key` | API key for your LLM | | `--llm-model` | (Optional) Specific model (e.g., `gpt-4o`) | | `--thinking` | Pydantic AI thinking effort: `minimal`, `low`, `medium`, `high`, `xhigh`, or `disabled` | +| `--azure-openai-endpoint` | Azure AI Foundry or Azure OpenAI endpoint. Can also be set with `AZURE_OPENAI_ENDPOINT`; `/openai/v1/` endpoints use the Responses API without `api-version`. | +| `--azure-openai-api-version` | Azure OpenAI API version for non-v1 endpoints. Can also be set with `OPENAI_API_VERSION`. | | `--interactive` | Chat with the LLM after scan | | `--web` | Launch a local web server | | `--disable-tools` | Disable tool use during file analysis to reduce LLM token usage | diff --git a/saist/llm/adapters/azure_foundry.py b/saist/llm/adapters/azure_foundry.py new file mode 100644 index 0000000..72686f9 --- /dev/null +++ b/saist/llm/adapters/azure_foundry.py @@ -0,0 +1,30 @@ +from typing import Optional + +from llm.adapters import BaseLlmAdapter + +from pydantic_ai.models.openai import OpenAIResponsesModel +from pydantic_ai.providers.azure import AzureProvider + + +class AzureFoundryAdapter(BaseLlmAdapter): + def __init__( + self, + model: str = None, + api_key: Optional[str] = None, + azure_endpoint: Optional[str] = None, + api_version: Optional[str] = None, + thinking: str = "medium", + ): + super().__init__(thinking=thinking) + if model is None: + model = "gpt-5-mini" + self.model = OpenAIResponsesModel( + model, + provider=AzureProvider( + azure_endpoint=azure_endpoint, + api_key=api_key, + api_version=api_version, + ), + ) + self.model_name = self.model.model_name + self.model_vendor = "Azure AI Foundry" diff --git a/saist/main.py b/saist/main.py index 5095649..8a8c622 100755 --- a/saist/main.py +++ b/saist/main.py @@ -9,6 +9,7 @@ from llm.adapters import BaseLlmAdapter from llm.adapters.anthropic import AnthropicAdapter +from llm.adapters.azure_foundry import AzureFoundryAdapter from llm.adapters.bedrock import BedrockAdapter from llm.adapters.deepseek import DeepseekAdapter from llm.adapters.faike import FaikeAdapter @@ -350,6 +351,15 @@ async def _get_llm_adapter(args) -> BaseLlmAdapter: if args.llm == 'anthropic': llm = AnthropicAdapter(api_key=args.llm_api_key, model=model, thinking=thinking) logger.debug(f"Using LLM: anthropic Model: {llm.model_name}") + elif args.llm == 'azure-foundry': + llm = AzureFoundryAdapter( + api_key=args.llm_api_key, + model=model, + azure_endpoint=args.azure_openai_endpoint, + api_version=args.azure_openai_api_version, + thinking=thinking, + ) + logger.debug(f"Using LLM: Azure AI Foundry Model: {llm.model_name}") elif args.llm == 'bedrock': llm = BedrockAdapter(api_key=args.llm_api_key, model=model, thinking=thinking) logger.debug(f"Using LLM: AWS bedrock Model: {llm.model_name}") diff --git a/saist/util/argparsing.py b/saist/util/argparsing.py index 85c58c9..a4ccd05 100644 --- a/saist/util/argparsing.py +++ b/saist/util/argparsing.py @@ -122,7 +122,7 @@ def __call__(self, parser, namespace, values, option_string=None): parser.add_argument( "--llm", type=str, - choices=["anthropic", "bedrock", "deepseek", "gemini", "ollama", "openai", "faike"], + choices=["anthropic", "azure-foundry", "bedrock", "deepseek", "gemini", "ollama", "openai", "faike"], required=True, action=EnvDefault, envvar="SAIST_LLM" @@ -168,12 +168,30 @@ def __call__(self, parser, namespace, values, option_string=None): envvar="SAIST_OLLAMA_BASE_URI", action=EnvDefault, default = "http://localhost:11434" ) -parser.add_argument( - "--openai-base-uri", type=str, help = "Base uri of openai to use any compatable service", - envvar="SAIST_OPENAI_BASE_URI", action=EnvDefault, required=False - ) - -parser.add_argument( +parser.add_argument( + "--openai-base-uri", type=str, help = "Base uri of openai to use any compatable service", + envvar="SAIST_OPENAI_BASE_URI", action=EnvDefault, required=False + ) + +parser.add_argument( + "--azure-openai-endpoint", + type=str, + help="Azure AI Foundry or Azure OpenAI endpoint (can be set with AZURE_OPENAI_ENDPOINT)", + envvar="AZURE_OPENAI_ENDPOINT", + action=EnvDefault, + required=False, +) + +parser.add_argument( + "--azure-openai-api-version", + type=str, + help="Azure OpenAI API version for non-v1 endpoints (can be set with OPENAI_API_VERSION)", + envvar="OPENAI_API_VERSION", + action=EnvDefault, + required=False, +) + +parser.add_argument( "--interactive", help = "Spawn an interactive prompt with the LLM at the end", required=False, action='store_true' ) @@ -318,8 +336,8 @@ def parse_args(): if args.llm == "bedrock" and args.interactive: parser.error("Sorry, we dont support interactive mode with bedrock as AWS tool calling is a bit broken") - if args.llm not in [ "ollama", "bedrock", "faike" ] and args.llm_api_key is None: - parser.error(f"You must provide an api key with --llm-api-key if using {args.llm}") + if args.llm not in [ "azure-foundry", "ollama", "bedrock", "faike" ] and args.llm_api_key is None: + parser.error(f"You must provide an api key with --llm-api-key if using {args.llm}") if args.llm == "ollama" and args.interactive: parser.error(f"You cannot use the interactive shell with ollama currently") diff --git a/tests/test_argparsing.py b/tests/test_argparsing.py index 5914970..739ec39 100644 --- a/tests/test_argparsing.py +++ b/tests/test_argparsing.py @@ -22,6 +22,8 @@ def test_parse_args_sets_defaults_for_filesystem(monkeypatch, tmp_path): assert args.thinking == "medium" assert args.ollama_base_uri == "http://localhost:11434" assert args.openai_base_uri is None + assert args.azure_openai_endpoint is None + assert args.azure_openai_api_version is None assert args.interactive is False assert args.disable_tools is False assert args.deep is False @@ -71,6 +73,10 @@ def test_parse_args_accepts_all_global_scan_options(monkeypatch, tmp_path): "http://ollama.example", "--openai-base-uri", "http://openai.example", + "--azure-openai-endpoint", + "https://example.openai.azure.com/openai/v1/", + "--azure-openai-api-version", + "preview", "--interactive", "--disable-tools", "--deep", @@ -131,6 +137,8 @@ def test_parse_args_accepts_all_global_scan_options(monkeypatch, tmp_path): assert args.thinking == "high" assert args.ollama_base_uri == "http://ollama.example" assert args.openai_base_uri == "http://openai.example" + assert args.azure_openai_endpoint == "https://example.openai.azure.com/openai/v1/" + assert args.azure_openai_api_version == "preview" assert args.interactive is True assert args.disable_tools is True assert args.deep is True @@ -198,6 +206,18 @@ def test_parse_args_accepts_disabled_thinking(monkeypatch, tmp_path): assert args.thinking == "disabled" +def test_parse_args_accepts_azure_foundry_without_generic_api_key(monkeypatch, tmp_path): + monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com/openai/v1/") + monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key") + + args = parse_with(monkeypatch, ["--llm", "azure-foundry", "--llm-model", "gpt-5-mini", "filesystem", str(tmp_path)]) + + assert args.llm == "azure-foundry" + assert args.llm_model == "gpt-5-mini" + assert args.llm_api_key is None + assert args.azure_openai_endpoint is None + + def test_parse_args_rejects_zero_iterations(monkeypatch, tmp_path): with pytest.raises(SystemExit): parse_with(monkeypatch, ["--llm", "faike", "--iterations", "0", "filesystem", str(tmp_path)]) diff --git a/tests/test_llm_adapters.py b/tests/test_llm_adapters.py index 34f95ce..4ad025d 100644 --- a/tests/test_llm_adapters.py +++ b/tests/test_llm_adapters.py @@ -2,6 +2,7 @@ from llm import adapters from llm.adapters import BaseLlmAdapter +from llm.adapters.azure_foundry import AzureFoundryAdapter from llm.adapters.faike import FaikeAdapter from llm.adapters.openai import OpenAiAdapter from models import Findings @@ -50,3 +51,18 @@ def test_openai_adapter_uses_responses_model_for_thinking_support(): assert isinstance(adapter.model, OpenAIResponsesModel) assert adapter.model_name == "gpt-5-mini" assert adapter.get_model_options()["thinking"] == "high" + + +def test_azure_foundry_adapter_uses_responses_model_and_azure_provider(): + adapter = AzureFoundryAdapter( + model="gpt-5-mini", + api_key="test-key", + azure_endpoint="https://example.openai.azure.com/openai/v1/", + thinking="high", + ) + + assert isinstance(adapter.model, OpenAIResponsesModel) + assert adapter.model_name == "gpt-5-mini" + assert adapter.model_vendor == "Azure AI Foundry" + assert adapter.model.provider.name == "azure" + assert adapter.get_model_options()["thinking"] == "high" diff --git a/tests/test_main_analysis.py b/tests/test_main_analysis.py index 9b865a2..0468625 100644 --- a/tests/test_main_analysis.py +++ b/tests/test_main_analysis.py @@ -356,6 +356,24 @@ def test_get_llm_adapter_applies_thinking_option_to_adapter(): assert adapter.thinking == "xhigh" +def test_get_llm_adapter_builds_azure_foundry_adapter(): + args = SimpleNamespace( + llm="azure-foundry", + llm_model="gpt-5-mini", + llm_api_key="azure-key", + azure_openai_endpoint="https://example.openai.azure.com/openai/v1/", + azure_openai_api_version=None, + thinking="high", + ollama_base_uri="http://localhost:11434", + ) + + adapter = asyncio.run(saist_main._get_llm_adapter(args)) + + assert adapter.model_vendor == "Azure AI Foundry" + assert adapter.model_name == "gpt-5-mini" + assert adapter.thinking == "high" + + def test_dedupe_findings_keeps_first_for_same_priority(): first = Finding.model_validate( { From d526d0a54d83275db59fa4cfebccb856e2563692 Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 20:40:00 +0000 Subject: [PATCH 12/15] add validation steps to report --- saist/llm/adapters/faike.py | 1 + saist/main.py | 15 ++++++++++++++- saist/models.py | 9 ++++++++- saist/reportlab_pdf/__init__.py | 10 ++++++++++ saist/util/output.py | 12 +++++++----- saist/util/prompts.py | 1 + saist/web/template.html | 31 +++++++++++++++++++------------ tests/test_caching_output.py | 2 ++ tests/test_main_analysis.py | 25 +++++++++++++++++++++++++ tests/test_reportlab_pdf.py | 24 ++++++++++++++++++++++++ 10 files changed, 111 insertions(+), 19 deletions(-) diff --git a/saist/llm/adapters/faike.py b/saist/llm/adapters/faike.py index 37674e8..3ee5249 100644 --- a/saist/llm/adapters/faike.py +++ b/saist/llm/adapters/faike.py @@ -33,6 +33,7 @@ async def prompt_structured(self, system_prompt: str, user_prompt: str, response "title": "Fake Issue #1234", "issue": "Fake Issue", "recommendation": "Do Nothing", + "validation_steps": ["Inspect the fake finding in app.py.", "Confirm no real validation is required for Faike."], "cwe": "CWE-NAN", "priority": 0, "line_number": 1, diff --git a/saist/main.py b/saist/main.py index 8a8c622..b00077d 100755 --- a/saist/main.py +++ b/saist/main.py @@ -137,7 +137,13 @@ def generate_summary_from_findings(adapter: BaseLlmAdapter, findings: list[Findi system_prompt = prompts.summary(scm_prompt) prompt = "" for f in findings: - prompt += f"- **File**: `{f.file}`\n - **Issue**: {f.issue}\n - **Recommendation**: {f.recommendation}\n\n" + validation_steps = "\n".join(f" - {step}" for step in f.validation_steps) or " - Not provided" + prompt += ( + f"- **File**: `{f.file}`\n" + f" - **Issue**: {f.issue}\n" + f" - **Recommendation**: {f.recommendation}\n" + f" - **Validation steps**:\n{validation_steps}\n\n" + ) try: return adapter.prompt(system_prompt, prompt) @@ -160,10 +166,17 @@ def build_finding_review_body(finding: Finding) -> str: f"**Priority:** {priority}\n\n" f"**CWE:** {finding.cwe}\n\n" f"**Recommendation:** {finding.recommendation or 'None provided.'}\n\n" + f"**Validation Steps:**\n{format_validation_steps(finding.validation_steps)}\n\n" f"**Snippet**: `{finding.snippet}`\n\n" ) +def format_validation_steps(validation_steps: list[str]) -> str: + if not validation_steps: + return "None provided." + return "\n".join(f"{index}. {step}" for index, step in enumerate(validation_steps, start=1)) + + def build_filesystem_review_comments(findings: list[Finding]) -> list[dict]: comments = [] for finding in findings: diff --git a/saist/models.py b/saist/models.py index 0c80b8d..74f7706 100644 --- a/saist/models.py +++ b/saist/models.py @@ -8,6 +8,13 @@ class Finding(BaseModel): title: Annotated[str, Field(description= "a short title describing the issue") ] issue: str recommendation: str + validation_steps: Annotated[ + list[str], + Field( + default_factory=list, + description="concrete steps a reviewer can follow to validate the issue is exploitable", + ), + ] cwe: Annotated[str, Field(description= "CWE id, should conform to CWE-XX or CWE-XXX where X is a number") ] priority: int line_number: int @@ -27,4 +34,4 @@ class FindingEnriched(Finding): class FindingContext(Finding): context: str context_start: int - context_end: int \ No newline at end of file + context_end: int diff --git a/saist/reportlab_pdf/__init__.py b/saist/reportlab_pdf/__init__.py index f053384..b260497 100644 --- a/saist/reportlab_pdf/__init__.py +++ b/saist/reportlab_pdf/__init__.py @@ -241,6 +241,10 @@ def _finding_story(self, index: int, finding: FindingContext, styles: dict[str, Spacer(1, 0.08 * inch), self._text_panel(finding.recommendation or "Not applicable.", styles), Spacer(1, 0.18 * inch), + Paragraph("Validation steps", styles["Heading3"]), + Spacer(1, 0.08 * inch), + self._text_panel(self._validation_steps_text(finding.validation_steps), styles), + Spacer(1, 0.18 * inch), Paragraph("Affected code", styles["Heading3"]), self._context_table(finding, styles), ] @@ -665,6 +669,12 @@ def _issue_summary_table(self, styles: dict[str, ParagraphStyle]) -> Table: def _text_panel(self, text: str, styles: dict[str, ParagraphStyle]) -> Paragraph: return Paragraph(self._paragraph_markup(text), styles["PanelBody"]) + @staticmethod + def _validation_steps_text(validation_steps: list[str]) -> str: + if not validation_steps: + return "Not provided." + return "\n".join(f"{index}. {step}" for index, step in enumerate(validation_steps, start=1)) + def _context_table(self, finding: FindingContext, styles: dict[str, ParagraphStyle]) -> Table: lines = finding.context.splitlines() if finding.context else [""] rows = [] diff --git a/saist/util/output.py b/saist/util/output.py index c0a231e..0222201 100644 --- a/saist/util/output.py +++ b/saist/util/output.py @@ -33,8 +33,10 @@ def write_csv(findings: Iterable[Finding], csv_path: str): fieldnames = list(Finding.model_json_schema()["properties"].keys()) with open(csv_path, "w", newline="") as fp: - writer = csv.DictWriter(fp, fieldnames=fieldnames) - writer.writeheader() - for finding in findings: - writer.writerow(finding.model_dump()) - print(f"Written files to {csv_path}") + writer = csv.DictWriter(fp, fieldnames=fieldnames) + writer.writeheader() + for finding in findings: + row = finding.model_dump() + row["validation_steps"] = json.dumps(row.get("validation_steps", [])) + writer.writerow(row) + print(f"Written files to {csv_path}") diff --git a/saist/util/prompts.py b/saist/util/prompts.py index a935c82..af2d24e 100644 --- a/saist/util/prompts.py +++ b/saist/util/prompts.py @@ -29,6 +29,7 @@ class prompts: - Issues that only exist in tool output or in generated examples. For every finding, be specific about the attacker-controlled input or actor, the vulnerable code path, the security boundary crossed, and the impact. +For every finding, include concrete validation steps that a human reviewer can follow to reproduce or confirm the issue. These steps should identify the relevant entrypoint, required actor or permissions, input or request to try, expected vulnerable behavior, and the safe evidence that confirms impact. If the evidence is weak, inspect more code with tools. If it is still weak, do not report it. Report multiple vulnerabilities on the same line as separate findings only when they are distinct exploit paths. Use the available tools to retrieve full files and related files when that context is needed. diff --git a/saist/web/template.html b/saist/web/template.html index d9afd92..83905a0 100644 --- a/saist/web/template.html +++ b/saist/web/template.html @@ -236,10 +236,11 @@

File Contents

rowData = enrichedRaw.map(item => ({ index: x++, file: item.file, - issue: item.issue, - snippet: item.snippet, - recommendation: item.recommendation, - cwe: item.cwe, + issue: item.issue, + snippet: item.snippet, + recommendation: item.recommendation, + validation_steps: (item.validation_steps || []).join('\\n'), + cwe: item.cwe, priority: item.priority, line_number: item.line_number, file_contents: item.file_contents @@ -294,14 +295,20 @@

File Contents

tooltipField: "issue", flex: 2 }, - { - headerName: "Recommendation", - field: "recommendation", - tooltipField: "recommendation", - flex: 2 - }, - { - headerName: "CWE", + { + headerName: "Recommendation", + field: "recommendation", + tooltipField: "recommendation", + flex: 2 + }, + { + headerName: "Validation Steps", + field: "validation_steps", + tooltipField: "validation_steps", + flex: 2 + }, + { + headerName: "CWE", field: "cwe", width: 100, flex: 1 diff --git a/tests/test_caching_output.py b/tests/test_caching_output.py index c61059e..e0d2c49 100644 --- a/tests/test_caching_output.py +++ b/tests/test_caching_output.py @@ -14,6 +14,7 @@ def make_finding(**overrides): "title": "Dangerous call", "issue": "A dangerous call was introduced.", "recommendation": "Remove the dangerous call.", + "validation_steps": ["Call the affected endpoint.", "Confirm the dangerous behavior is reachable."], "cwe": "CWE-20", "priority": 5, "line_number": 7, @@ -64,6 +65,7 @@ def test_write_csv_writes_finding_fields(tmp_path): "title": "Dangerous call", "issue": "A dangerous call was introduced.", "recommendation": "Remove the dangerous call.", + "validation_steps": '["Call the affected endpoint.", "Confirm the dangerous behavior is reachable."]', "cwe": "CWE-89", "priority": "9", "line_number": "7", diff --git a/tests/test_main_analysis.py b/tests/test_main_analysis.py index 0468625..6c1061c 100644 --- a/tests/test_main_analysis.py +++ b/tests/test_main_analysis.py @@ -62,6 +62,7 @@ def detect_prompt(self): assert "not to produce a best-practice checklist" in llm.system_prompt assert "penetration test style review across the entire application codebase" in llm.system_prompt assert "cross-tenant data access" in llm.system_prompt + assert "include concrete validation steps" in llm.system_prompt def test_analyze_single_file_uses_git_diff_prompt_for_git_adapter(): @@ -155,6 +156,7 @@ async def prompt_structured(self, system_prompt, user_prompt, response_format, t title="Secret", issue="Issue", recommendation="Fix it.", + validation_steps=["Search for SECRET and confirm it is committed."], cwe="CWE-798", priority=6, line_number=1, @@ -218,6 +220,7 @@ async def prompt_structured(self, system_prompt, user_prompt, response_format, t title=f"Secret {self.calls}", issue=f"Issue {self.calls}", recommendation="Fix it.", + validation_steps=["Confirm the issue is reachable."], cwe="CWE-798", priority=6, line_number=1, @@ -682,3 +685,25 @@ def prompt(self, system_prompt, user_prompt): llm = CapturingLlm() assert saist_main.generate_summary_from_findings(llm, [finding], RealGithub.SUMMARY_PROMPT) == "summary" assert "GitHub pull request diff-based code security review" in llm.system_prompt + + +def test_review_body_includes_validation_steps(): + finding = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "Issue", + "issue": "Issue", + "recommendation": "Fix it.", + "validation_steps": ["Call the endpoint.", "Confirm data crosses tenant boundaries."], + "cwe": "CWE-20", + "priority": 4, + "line_number": 1, + } + ) + + body = saist_main.build_finding_review_body(finding) + + assert "**Validation Steps:**" in body + assert "1. Call the endpoint." in body + assert "2. Confirm data crosses tenant boundaries." in body diff --git a/tests/test_reportlab_pdf.py b/tests/test_reportlab_pdf.py index b56163e..e650a17 100644 --- a/tests/test_reportlab_pdf.py +++ b/tests/test_reportlab_pdf.py @@ -41,6 +41,11 @@ def example_findings(): "one place. Prefer a fix that is obvious to future maintainers, because defensive query handling " "is only reliable when it remains easy to spot during code review." ), + "validation_steps": [ + "Find the lookup_user route or caller and confirm username is controlled by the requester.", + "Submit a username containing a harmless SQL metacharacter and confirm the generated query changes behavior.", + "Verify the same lookup succeeds after replacing concatenation with a parameterized query.", + ], "cwe": "CWE-89", "priority": 9, "line_number": 42, @@ -61,6 +66,10 @@ def example_findings(): "title": "Missing tenant authorization check", "issue": "The export endpoint reads customer records without checking that the requester owns the tenant.", "recommendation": "Enforce a tenant ownership check before exporting customer data.", + "validation_steps": [ + "Authenticate as a user from one tenant.", + "Request another tenant's customer export id and confirm records are returned.", + ], "cwe": "CWE-862", "priority": 8, "line_number": 88, @@ -81,6 +90,7 @@ def example_findings(): "title": "Hardcoded API token", "issue": "A static API token is stored in source code and could be exposed through the repository.", "recommendation": "Load secrets from a managed secret store or environment variable.", + "validation_steps": ["Inspect the committed settings file and confirm the token value is present."], "cwe": "CWE-798", "priority": 5, "line_number": 12, @@ -200,6 +210,20 @@ def test_issue_summary_uses_issue_title_file_and_severity_columns(): assert severity_cells == ["Critical", "High", "Medium"] +def test_finding_story_includes_validation_steps_section(): + report = ReportLabPdf( + llm=FakeLlm(), + project="Example Project", + findings=example_findings(), + comment=STATIC_SUMMARY, + ) + story = report._finding_story(1, example_findings()[0], report._styles()) + story_text = "\n".join(getattr(item, "getPlainText", lambda: "")() for item in story) + + assert "Validation steps" in story_text + assert "1. Find the lookup_user route" in story_text + + def test_code_markup_wraps_long_lines_and_context_highlight_stays_light(): report = ReportLabPdf( llm=FakeLlm(), From 95f28ae5113654b4c43587a4c49bbb112424c58c Mon Sep 17 00:00:00 2001 From: QA Team Date: Sat, 9 May 2026 21:03:15 +0000 Subject: [PATCH 13/15] awesome webs --- saist/web/template.html | 1697 +++++++++++++++++++++++++++------------ 1 file changed, 1175 insertions(+), 522 deletions(-) diff --git a/saist/web/template.html b/saist/web/template.html index 83905a0..85f48a9 100644 --- a/saist/web/template.html +++ b/saist/web/template.html @@ -1,524 +1,1177 @@ - - - - - - SAIST - Findings - - - - - - - - - - - - - -
- -
-

SAIST - Findings

-

You can save this page locally for later using "Right Click + Save As..."

-
- -
-
- -
- -
- - - - - + + + +
+
+
+

Punk Security

+

SAIST Findings

+

AI generated code review findings with validation steps, affected code, and local triage status. Status changes are stored in this browser.

+
+
+
Total0
+
Open0
+
False positives0
+
+
+ +
+
+ + + + + +
+
+ +
+
+ +
+
+ + + 0 selected +
+ Tip: right-click a cell to filter, copy, or mark a row. +
+ +
+
+ +
+
+ + + + - - + onFilterChanged: () => { + populateFilterPanel(); + updateMetrics(); + }, + onRowSelected: () => updateSelectedText(), + onCellValueChanged: (params) => { + if (params.colDef.field === "status") { + rowDataOverlay[keyOfRow(params.data)] = params.data.status; + persistOverlay(); + populateFilterPanel(); + updateMetrics(); + } + }, + onRowClicked: (params) => { + if (shouldIgnoreRowOpen(params.event)) return; + openModal(params.data.index); + }, + onCellContextMenu: createContextMenu + }; + + let gridApi; + + document.addEventListener("DOMContentLoaded", () => { + const gridDiv = document.getElementById("myGrid"); + gridApi = agGrid.createGrid(gridDiv, gridOptions); + populateColumnPanel(); + populateFilterPanel(); + updateMetrics(); + updateSelectedText(); + }); + + function persistOverlay() { + window.localStorage.setItem(overlayKey, JSON.stringify(rowDataOverlay)); + } + + function updateSelectedText() { + if (!gridApi) return; + const selectedRows = gridApi.getSelectedRows ? gridApi.getSelectedRows() : []; + document.getElementById("selectedText").innerText = `${selectedRows.length} selected out of ${gridApi.getDisplayedRowCount()} visible`; + } + + function updateMetrics() { + const total = rowData.length; + let falsePositive = 0; + let open = 0; + rowData.forEach(row => { + if (row.status === "False Positive") falsePositive++; + if (row.status !== "False Positive" && row.status !== "Confirmed") open++; + }); + document.getElementById("metric-total").innerText = total; + document.getElementById("metric-open").innerText = open; + document.getElementById("metric-fp").innerText = falsePositive; + } + + function applyStatusToRows(rows, status) { + rows.forEach(row => { + row.status = status; + rowDataOverlay[keyOfRow(row)] = status; + }); + persistOverlay(); + gridApi.applyTransaction({ update: rows }); + gridApi.refreshCells({ force: true }); + gridApi.onFilterChanged(); + populateFilterPanel(); + updateMetrics(); + } + + function onActionButtonClick() { + const status = document.getElementById("actionDropDown").value; + const selectedRows = gridApi.getSelectedRows(); + applyStatusToRows(selectedRows, status); + updateSelectedText(); + } + + function markRowStatus(row, status) { + applyStatusToRows([row], status); + } + + function resetFPList() { + const updates = []; + gridApi.forEachNode(rowNode => { + if (rowNode.data.status === "False Positive") { + rowNode.data.status = "New"; + rowDataOverlay[keyOfRow(rowNode.data)] = "New"; + updates.push(rowNode.data); + } + }); + persistOverlay(); + gridApi.applyTransaction({ update: updates }); + gridApi.onFilterChanged(); + } + + function onSearchBoxChange() { + const value = document.getElementById("searchbar").value; + if (gridApi.setGridOption) { + gridApi.setGridOption("quickFilterText", value); + } else if (gridApi.setQuickFilter) { + gridApi.setQuickFilter(value); + } + } + + function setFilterTo(column, value) { + const model = gridApi.getFilterModel(); + model[column] = { + filterType: "text", + type: "equals", + filter: value + }; + gridApi.setFilterModel(model); + gridApi.onFilterChanged(); + } + + function removeFilterFrom(column) { + const model = gridApi.getFilterModel(); + delete model[column]; + gridApi.setFilterModel(model); + gridApi.onFilterChanged(); + } + + function ResetFilters() { + gridApi.setFilterModel({}); + onSearchBoxChange(); + gridApi.onFilterChanged(); + } + + function setColumnVisible(field, visible) { + if (gridApi.setColumnsVisible) { + gridApi.setColumnsVisible([field], visible); + } else if (gridApi.applyColumnState) { + gridApi.applyColumnState({ state: [{ colId: field, hide: !visible }] }); + } else if (gridApi.columnApi && gridApi.columnApi.setColumnVisible) { + gridApi.columnApi.setColumnVisible(field, visible); + } + } + + function isColumnVisible(field) { + if (gridApi.getColumn) { + const column = gridApi.getColumn(field); + return column ? column.isVisible() : true; + } + if (gridApi.columnApi && gridApi.columnApi.getColumn) { + const column = gridApi.columnApi.getColumn(field); + return column ? column.isVisible() : true; + } + return true; + } + + function populateColumnPanel() { + const panel = document.getElementById("column-panel-content"); + panel.innerHTML = ""; + columnDefs.filter(column => column.field).forEach(column => { + const label = document.createElement("label"); + label.className = "column-toggle"; + + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = isColumnVisible(column.field); + input.onchange = () => setColumnVisible(column.field, input.checked); + + const text = document.createElement("span"); + text.innerText = column.headerName || column.field; + + label.appendChild(input); + label.appendChild(text); + panel.appendChild(label); + }); + } + + function shouldIgnoreRowOpen(event) { + if (!event || !event.target) return false; + return Boolean(event.target.closest("button, input, select, label, .ag-selection-checkbox")); + } + + function populateFilterPanel() { + if (!gridApi) return; + updateSelectedText(); + const model = gridApi.getFilterModel(); + const filterPanel = document.getElementById("filter-panel-content"); + filterPanel.innerHTML = ""; + ["status", "severity", "file", "cwe"].forEach(columnName => { + const column = columnDefs.find(x => x.field === columnName); + const valueCount = {}; + gridApi.forEachNodeAfterFilter(row => { + const value = row.data[columnName] || "Unknown"; + valueCount[value] = (valueCount[value] || 0) + 1; + }); + const keys = Object.keys(valueCount).sort((a, b) => valueCount[b] - valueCount[a]).slice(0, 10); + const header = document.createElement("h3"); + header.innerText = `By ${column.headerName || columnName}`; + filterPanel.appendChild(header); + + const all = document.createElement("a"); + all.href = "#"; + all.className = `filterlink ${model[columnName] ? "" : "activefilterlink"}`; + all.innerText = "All"; + all.onclick = event => { + event.preventDefault(); + removeFilterFrom(columnName); + }; + filterPanel.appendChild(all); + + keys.forEach(key => { + const item = document.createElement("a"); + item.href = "#"; + item.className = `filterlink ${model[columnName] && model[columnName].filter === key ? "activefilterlink" : ""}`; + item.innerText = `${key} (${valueCount[key]})`; + item.onclick = event => { + event.preventDefault(); + setFilterTo(columnName, key); + }; + filterPanel.appendChild(item); + }); + }); + } + + function createContextMenu(params) { + if (!params.colDef.field) return; + document.querySelectorAll(".contextmenu").forEach(x => x.remove()); + const contextmenu = document.createElement("div"); + document.body.appendChild(contextmenu); + contextmenu.onmouseleave = () => contextmenu.remove(); + contextmenu.classList.add("contextmenu"); + contextmenu.style.left = `${params.event.pageX}px`; + contextmenu.style.top = `${params.event.pageY}px`; + + const ul = document.createElement("ul"); + addMenuItem(ul, "Filter on cell value", () => setFilterTo(params.colDef.field, params.value)); + addMenuItem(ul, "Reset column filter", () => removeFilterFrom(params.colDef.field)); + addMenuItem(ul, "Copy cell", () => navigator.clipboard.writeText(params.value || "")); + addMenuItem(ul, "Mark row false positive", () => markRowStatus(params.node.data, "False Positive")); + addMenuItem(ul, "Mark row confirmed", () => markRowStatus(params.node.data, "Confirmed")); + addMenuItem(ul, "Mark row investigating", () => markRowStatus(params.node.data, "Investigating")); + contextmenu.appendChild(ul); + } + + function addMenuItem(ul, label, action) { + const li = document.createElement("li"); + li.innerText = label; + li.onclick = () => { + action(); + document.querySelectorAll(".contextmenu").forEach(x => x.remove()); + }; + ul.appendChild(li); + } + + function exportCSV() { + gridApi.exportDataAsCsv({ + fileName: "saist-findings.csv", + exportedRows: "all", + columnKeys: ["status", "severity", "priority", "file", "line_number", "title", "issue", "recommendation", "validation_steps", "cwe", "snippet"], + processHeaderCallback: params => params.column.getColDef().field + }); + } + + function parseCSV(csvString) { + const rows = []; + let row = []; + let value = ""; + let quoted = false; + for (let i = 0; i < csvString.length; i++) { + const char = csvString[i]; + const next = csvString[i + 1]; + if (quoted && char === '"' && next === '"') { + value += '"'; + i++; + } else if (char === '"') { + quoted = !quoted; + } else if (!quoted && char === ",") { + row.push(value); + value = ""; + } else if (!quoted && (char === "\n" || char === "\r")) { + if (char === "\r" && next === "\n") i++; + row.push(value); + if (row.some(cell => cell !== "")) rows.push(row); + row = []; + value = ""; + } else { + value += char; + } + } + row.push(value); + if (row.some(cell => cell !== "")) rows.push(row); + return rows; + } + + function importCSV(input) { + const file = input.files[0]; + if (!file) return; + const fileReader = new FileReader(); + fileReader.readAsText(file); + fileReader.onload = event => { + const table = parseCSV(event.target.result); + const headers = table.shift() || []; + const statusIndex = headers.indexOf("status"); + const keyIndexes = ["file", "line_number", "cwe", "title"].map(header => headers.indexOf(header)); + if (statusIndex < 0 || keyIndexes.some(index => index < 0)) return; + + const imported = {}; + table.forEach(row => { + const key = `${row[keyIndexes[0]]}|${row[keyIndexes[1]]}|${row[keyIndexes[2]]}|${row[keyIndexes[3]]}`; + imported[key] = row[statusIndex]; + }); + + const updates = []; + gridApi.forEachNode(node => { + const key = keyOfRow(node.data); + if (imported[key]) { + node.data.status = imported[key]; + rowDataOverlay[key] = imported[key]; + updates.push(node.data); + } + }); + persistOverlay(); + gridApi.applyTransaction({ update: updates }); + gridApi.onFilterChanged(); + input.value = ""; + }; + } + + function escapeHtml(value) { + const element = document.createElement("div"); + element.innerText = value || ""; + return element.innerHTML; + } + + function renderValidationSteps(row) { + const steps = String(row.validation_steps || "") + .split("\n") + .map(step => step.trim()) + .filter(Boolean); + if (!steps.length) return "

No validation steps supplied.

"; + return `
    ${steps.map(step => `
  1. ${escapeHtml(step)}
  2. `).join("")}
`; + } + + function renderAffectedCode(row) { + const lines = (row.file_contents || row.snippet || "").split("\n"); + return `${lines.map((line, idx) => { + const lineNumber = idx + 1; + const isHighlight = row.line_number === lineNumber; + return ` + + + `; + }).join("")}
${lineNumber}${escapeHtml(line)}
`; + } + + function openModal(index) { + const row = rowData.find(x => x.index === index); + if (!row) return; + const modalTitle = document.getElementById("modalTitle"); + modalTitle.innerText = row.title || "Finding"; + document.getElementById("modalMeta").innerHTML = ` + ${escapeHtml(row.severity)} + ${escapeHtml(row.status)} + ${escapeHtml(row.file)}:${escapeHtml(String(row.line_number))} + ${escapeHtml(row.cwe)} + `; + + document.getElementById("modalContent").innerHTML = ` +
+
+

Issue

+

${escapeHtml(row.issue)}

+
+
+

Recommendation

+

${escapeHtml(row.recommendation)}

+
+
+

Validation Steps

+ ${renderValidationSteps(row)} +
+
+ Affected Code + ${renderAffectedCode(row)} +
+
+ `; + document.getElementById("snippetModal").classList.add("open"); + setTimeout(() => { + const lineElem = document.getElementById(`line-${row.line_number}`); + if (lineElem) lineElem.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 40); + } + + function closeModal() { + document.getElementById("snippetModal").classList.remove("open"); + } + + window.onclick = event => { + const modal = document.getElementById("snippetModal"); + if (event.target === modal) closeModal(); + }; + + + From 58c2761d928fef7e93d5e35dd42b6ed7eae03fea Mon Sep 17 00:00:00 2001 From: QA Team Date: Sun, 10 May 2026 07:44:39 +0000 Subject: [PATCH 14/15] fixes --- saist/llm/adapters/__init__.py | 8 +- saist/main.py | 47 +++++++++--- saist/scm/adapters/filesystem.py | 15 +++- saist/util/caching.py | 43 ++++++++++- tests/test_caching_output.py | 40 +++++++++- tests/test_filesystem_adapter.py | 14 ++++ tests/test_llm_adapters.py | 18 ++++- tests/test_main_analysis.py | 124 ++++++++++++++++++++++++++++++- 8 files changed, 290 insertions(+), 19 deletions(-) diff --git a/saist/llm/adapters/__init__.py b/saist/llm/adapters/__init__.py index 17fdef7..786eb8f 100644 --- a/saist/llm/adapters/__init__.py +++ b/saist/llm/adapters/__init__.py @@ -10,6 +10,7 @@ THINKING_CHOICES = ("minimal", "low", "medium", "high", "xhigh", "disabled") DISABLED_THINKING = "disabled" +SAMPLING_PARAMETERS = {"temperature"} def pydantic_ai_supports_thinking() -> bool: @@ -30,7 +31,12 @@ def get_model_options(self): options.update(self.model_options) if pydantic_ai_supports_thinking(): - options["thinking"] = False if self.thinking == DISABLED_THINKING else self.thinking + if self.thinking == DISABLED_THINKING: + options["thinking"] = False + else: + options["thinking"] = self.thinking + for parameter in SAMPLING_PARAMETERS: + options.pop(parameter, None) elif self.thinking != DISABLED_THINKING: logger.getChild(self.__class__.__name__).debug( "Installed pydantic-ai version does not support model_settings.thinking; ignoring setting." diff --git a/saist/main.py b/saist/main.py index b00077d..7a646d0 100755 --- a/saist/main.py +++ b/saist/main.py @@ -207,11 +207,11 @@ def build_diff_review_comments(findings: list[Finding], file_line_maps: dict) -> def dedupe_findings(findings: list[Finding]) -> list[Finding]: - deduped: dict[tuple[str, int, str], Finding] = {} - order: list[tuple[str, int, str]] = [] + deduped: dict[tuple[str, int], Finding] = {} + order: list[tuple[str, int]] = [] for finding in findings: - key = (finding.file, finding.line_number, finding.cwe) + key = (finding.file, finding.line_number) existing = deduped.get(key) if existing is None: deduped[key] = finding @@ -263,8 +263,12 @@ async def generate_findings_with_filesystem_tools_iterations( analysis_skills: str, iterations: int, max_concurrent: int, + disable_caching: bool = True, + cache_folder: str = ".cache", ) -> tuple[list[Finding], set[str]]: semaphore = asyncio.Semaphore(max(1, max_concurrent)) + if disable_caching is False: + os.makedirs(cache_folder, exist_ok=True) overall_progress = Progress( TextColumn("[bold blue]{task.description}"), @@ -285,6 +289,31 @@ async def generate_findings_with_filesystem_tools_iterations( iteration_progress, ) + async def run_uncached_iteration(iteration: int): + return await generate_findings_with_filesystem_tools( + scm=scm, + llm=llm, + filenames=filenames, + disable_tools=disable_tools, + analysis_skills=analysis_skills, + ) + + async def run_cached_iteration(iteration: int): + cache_hash = await hash_files(scm, filenames, extra=analysis_skills) + cache_file = os.path.join(cache_folder, f"{iteration}-{cache_hash}.json") + if os.path.exists(cache_file): + return filesystem_tool_findings_from_cache_file(cache_file) + + findings, files_read = await run_uncached_iteration(iteration) + store_filesystem_tool_findings_to_cache_file( + iteration=iteration, + filenames=filenames, + findings=findings, + files_read=files_read, + cache_file=cache_file, + ) + return findings, files_read + async def run_iteration(iteration: int, overall_task): async with semaphore: iteration_task = iteration_progress.add_task( @@ -292,13 +321,9 @@ async def run_iteration(iteration: int, overall_task): transient=True, ) try: - return await generate_findings_with_filesystem_tools( - scm=scm, - llm=llm, - filenames=filenames, - disable_tools=disable_tools, - analysis_skills=analysis_skills, - ) + if disable_caching: + return await run_uncached_iteration(iteration) + return await run_cached_iteration(iteration) finally: iteration_progress.remove_task(iteration_task) iteration_progress.refresh() @@ -505,6 +530,8 @@ async def main(): analysis_skills=analysis_skills, iterations=args.iterations, max_concurrent=args.llm_rate_limit, + disable_caching=args.disable_caching, + cache_folder=args.cache_folder, ) print_coverage(files_read, app_filenames) diff --git a/saist/scm/adapters/filesystem.py b/saist/scm/adapters/filesystem.py index 844c04a..86f6a6f 100644 --- a/saist/scm/adapters/filesystem.py +++ b/saist/scm/adapters/filesystem.py @@ -36,12 +36,19 @@ async def get_file_contents(self, filename: str): try: file_path = self._resolve_under_root(filename) - async with aiofiles.open(file_path, mode='r', encoding='utf-8') as f: - contents = await f.read() + async with aiofiles.open(file_path, mode="rb") as f: + data = await f.read() - return contents + return data.decode("utf-8") + except UnicodeDecodeError: + logger.debug(f"get_file_contents: file is not valid UTF-8, skipping: {filename}") + return None + except (FileNotFoundError, IsADirectoryError): + logger.debug(f"get_file_contents: file does not exist or is not readable: {filename}") + return None except Exception as e: - logging.warning(f"ERR: {e}") + logger.warning(f"get_file_contents: could not read {filename}: {e}") + return None def __init__(self, compare_path: PathLike[AnyStr] | str, base_path: Optional[PathLike[AnyStr] | str] = None): self.base_path = base_path diff --git a/saist/util/caching.py b/saist/util/caching.py index 7e02df5..265bfde 100644 --- a/saist/util/caching.py +++ b/saist/util/caching.py @@ -7,6 +7,24 @@ async def hash_file(scm: Scm, filename: str) -> str: file: str = await scm.read_file_contents(filename) return hashlib.sha256(file.encode()).hexdigest() +async def hash_files(scm: Scm, filenames: list[str], extra: str = "") -> str: + hasher = hashlib.sha256() + if extra: + hasher.update(extra.encode("utf-8")) + hasher.update(b"\0") + + for filename in sorted(filenames): + hasher.update(filename.encode("utf-8")) + hasher.update(b"\0") + file_contents = await scm.read_file_contents(filename) + if file_contents is None: + hasher.update(b"") + else: + hasher.update(file_contents.encode("utf-8")) + hasher.update(b"\0") + + return hasher.hexdigest() + def finding_from_json_cache(json_dict: dict[str, any]) -> Finding: return Finding.model_validate(json_dict) @@ -23,4 +41,27 @@ def store_findings_to_cache_file(filename: str, findings: list[Finding], cache_f "findings": findings, } with open(cache_file, "w", encoding="utf-8") as cf: - json.dump(cache_dict, cf, cls=FindingJSONEncoder) \ No newline at end of file + json.dump(cache_dict, cf, cls=FindingJSONEncoder) + +def filesystem_tool_findings_from_cache_file(cache_file: str) -> tuple[list[Finding], set[str]]: + with open(cache_file, "r", encoding="utf-8") as file: + cache_json = json.load(file) + findings = cache_json.get("findings") or [] + files_read = cache_json.get("files_read") or [] + return [finding_from_json_cache(json_dict) for json_dict in findings], set(files_read) + +def store_filesystem_tool_findings_to_cache_file( + iteration: int, + filenames: list[str], + findings: list[Finding], + files_read: set[str], + cache_file: str, +): + cache_dict = { + "path": f"filesystem-tool-iteration-{iteration}", + "files": sorted(filenames), + "files_read": sorted(files_read), + "findings": findings, + } + with open(cache_file, "w", encoding="utf-8") as cf: + json.dump(cache_dict, cf, cls=FindingJSONEncoder) diff --git a/tests/test_caching_output.py b/tests/test_caching_output.py index e0d2c49..cd1f2d7 100644 --- a/tests/test_caching_output.py +++ b/tests/test_caching_output.py @@ -3,7 +3,14 @@ import json from models import Finding -from util.caching import findings_from_cache_file, hash_file, store_findings_to_cache_file +from util.caching import ( + filesystem_tool_findings_from_cache_file, + findings_from_cache_file, + hash_file, + hash_files, + store_filesystem_tool_findings_to_cache_file, + store_findings_to_cache_file, +) from util.output import write_csv @@ -32,6 +39,19 @@ async def read_file_contents(self, filename): assert asyncio.run(hash_file(FakeScm(), "app.py")) == "f953bbd204bb867e48a6ff774cffa3dcffd02c6580e8f1d00c37dbbaa743d6c8" +def test_hash_files_includes_file_names_contents_and_extra_context(): + class FakeScm: + async def read_file_contents(self, filename): + return {"app.py": "same content\n", "binary.gz": None}[filename] + + first = asyncio.run(hash_files(FakeScm(), ["binary.gz", "app.py"], extra="iteration prompt")) + second = asyncio.run(hash_files(FakeScm(), ["app.py", "binary.gz"], extra="iteration prompt")) + different_extra = asyncio.run(hash_files(FakeScm(), ["app.py", "binary.gz"], extra="other prompt")) + + assert first == second + assert first != different_extra + + def test_findings_cache_round_trip(tmp_path): cache_file = tmp_path / "finding.json" finding = make_finding(priority=8) @@ -42,6 +62,24 @@ def test_findings_cache_round_trip(tmp_path): assert loaded == [finding] +def test_filesystem_tool_findings_cache_round_trip(tmp_path): + cache_file = tmp_path / "shallow.json" + finding = make_finding(priority=8) + + store_filesystem_tool_findings_to_cache_file( + iteration=2, + filenames=["app.py", "settings.py"], + findings=[finding], + files_read={"settings.py"}, + cache_file=str(cache_file), + ) + + loaded_findings, files_read = filesystem_tool_findings_from_cache_file(str(cache_file)) + + assert loaded_findings == [finding] + assert files_read == {"settings.py"} + + def test_findings_cache_returns_empty_list_for_null_findings(tmp_path): cache_file = tmp_path / "empty.json" cache_file.write_text(json.dumps({"path": "app.py", "findings": None}), encoding="utf-8") diff --git a/tests/test_filesystem_adapter.py b/tests/test_filesystem_adapter.py index f45b56d..fdedaaa 100644 --- a/tests/test_filesystem_adapter.py +++ b/tests/test_filesystem_adapter.py @@ -1,4 +1,5 @@ import asyncio +import logging from scm.adapters.filesystem import FilesystemAdapter @@ -23,6 +24,18 @@ def test_filesystem_adapter_reads_file_contents(tmp_path): assert contents == "print('hello')\n" +def test_filesystem_adapter_returns_none_for_gzip_or_binary_file_without_err_log(tmp_path, caplog): + (tmp_path / "archive.gz").write_bytes(b"\x1f\x8b\x08\x00not text") + adapter = FilesystemAdapter(compare_path=str(tmp_path)) + + with caplog.at_level(logging.WARNING): + contents = asyncio.run(adapter.get_file_contents("archive.gz")) + + assert contents is None + assert "ERR:" not in caplog.text + assert "codec can't decode" not in caplog.text + + def test_filesystem_adapter_lists_all_files(tmp_path): (tmp_path / "app.py").write_text("print('hello')\n", encoding="utf-8") (tmp_path / "image.bin").write_bytes(b"\xff\xfe\x00\x00") @@ -38,6 +51,7 @@ def test_filesystem_adapter_regex_searches_text_files(tmp_path): (tmp_path / "app.py").write_text("SECRET_KEY = 'dev'\nprint(SECRET_KEY)\n", encoding="utf-8") (tmp_path / "README.md").write_text("SECRET_KEY is documented here\n", encoding="utf-8") (tmp_path / "image.bin").write_bytes(b"\xff\xfe\x00\x00") + (tmp_path / "archive.gz").write_bytes(b"\x1f\x8b\x08\x00SECRET_KEY") adapter = FilesystemAdapter(compare_path=str(tmp_path)) matches = asyncio.run(adapter.regex_search(r"SECRET_KEY", file_pattern="**/*.py")) diff --git a/tests/test_llm_adapters.py b/tests/test_llm_adapters.py index 4ad025d..3aefea3 100644 --- a/tests/test_llm_adapters.py +++ b/tests/test_llm_adapters.py @@ -14,13 +14,29 @@ def test_model_options_include_pydantic_thinking_level_when_supported(monkeypatc adapter = BaseLlmAdapter(thinking="high") assert adapter.get_model_options()["thinking"] == "high" + assert "temperature" not in adapter.get_model_options() + + +def test_model_options_drop_custom_sampling_parameters_when_thinking_enabled(monkeypatch): + monkeypatch.setattr(adapters, "pydantic_ai_supports_thinking", lambda: True) + adapter = BaseLlmAdapter(thinking="medium") + adapter.model_options = {"temperature": 0.7, "timeout": 30} + + options = adapter.get_model_options() + + assert options["thinking"] == "medium" + assert options["timeout"] == 30 + assert "temperature" not in options def test_model_options_map_disabled_thinking_to_false(monkeypatch): monkeypatch.setattr(adapters, "pydantic_ai_supports_thinking", lambda: True) adapter = BaseLlmAdapter(thinking="disabled") - assert adapter.get_model_options()["thinking"] is False + options = adapter.get_model_options() + + assert options["thinking"] is False + assert options["temperature"] == 0.0 def test_model_options_omit_thinking_when_pydantic_ai_does_not_support_it(monkeypatch): diff --git a/tests/test_main_analysis.py b/tests/test_main_analysis.py index 6c1061c..7c93371 100644 --- a/tests/test_main_analysis.py +++ b/tests/test_main_analysis.py @@ -296,13 +296,104 @@ def detect_prompt(self): assert elapsed < 0.12 +def test_filesystem_tool_analysis_iterations_cache_each_iteration(tmp_path): + class CountingLlm: + def __init__(self): + self.calls = 0 + + async def prompt_structured(self, system_prompt, user_prompt, response_format, tool_fns=None): + self.calls += 1 + return Findings( + findings=[ + Finding( + file="app.py", + snippet="SECRET", + title=f"Secret {self.calls}", + issue=f"Issue {self.calls}", + recommendation="Fix it.", + validation_steps=["Confirm the issue is reachable."], + cwe="CWE-798", + priority=6, + line_number=self.calls, + ) + ] + ) + + class FakeScm: + async def read_file_contents(self, filename): + return "SECRET = 'dev'\n" + + async def list_files(self): + return ["app.py"] + + async def regex_search(self, pattern, file_pattern="**/*", max_results=100): + return [] + + def detect_prompt(self): + return FilesystemAdapter.DETECT_PROMPT + + llm = CountingLlm() + scm = FakeScm() + cache_dir = tmp_path / "cache" + + findings, _ = asyncio.run( + saist_main.generate_findings_with_filesystem_tools_iterations( + scm=scm, + llm=llm, + filenames=["app.py"], + disable_tools=False, + analysis_skills="", + iterations=2, + max_concurrent=2, + disable_caching=False, + cache_folder=str(cache_dir), + ) + ) + assert llm.calls == 2 + assert [finding.line_number for finding in findings] == [1, 2] + assert sorted(path.name.split("-", 1)[0] for path in cache_dir.iterdir()) == ["1", "2"] + + cached_findings, _ = asyncio.run( + saist_main.generate_findings_with_filesystem_tools_iterations( + scm=scm, + llm=llm, + filenames=["app.py"], + disable_tools=False, + analysis_skills="", + iterations=2, + max_concurrent=2, + disable_caching=False, + cache_folder=str(cache_dir), + ) + ) + assert llm.calls == 2 + assert [finding.line_number for finding in cached_findings] == [1, 2] + + extended_findings, _ = asyncio.run( + saist_main.generate_findings_with_filesystem_tools_iterations( + scm=scm, + llm=llm, + filenames=["app.py"], + disable_tools=False, + analysis_skills="", + iterations=3, + max_concurrent=2, + disable_caching=False, + cache_folder=str(cache_dir), + ) + ) + assert llm.calls == 3 + assert [finding.line_number for finding in extended_findings] == [1, 2, 3] + assert sorted(path.name.split("-", 1)[0] for path in cache_dir.iterdir()) == ["1", "2", "3"] + + def test_print_coverage_reports_read_percentage(capsys): saist_main.print_coverage({"app.py"}, ["app.py", "settings.py"]) assert "LLM file coverage: 1/2 files read (50.0%)" in capsys.readouterr().out -def test_dedupe_findings_keeps_highest_priority_for_same_file_line_and_cwe(): +def test_dedupe_findings_keeps_highest_priority_for_same_file_and_line(): low = Finding.model_validate( { "file": "app.py", @@ -406,6 +497,37 @@ def test_dedupe_findings_keeps_first_for_same_priority(): assert saist_main.dedupe_findings([first, second]) == [first] +def test_dedupe_findings_ignores_cwe_for_same_file_and_line(): + first = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "First duplicate", + "issue": "First issue", + "recommendation": "First fix.", + "cwe": "CWE-20", + "priority": 6, + "line_number": 10, + } + ) + higher_priority_different_cwe = Finding.model_validate( + { + "file": "app.py", + "snippet": "danger()", + "title": "Second duplicate", + "issue": "Second issue", + "recommendation": "Second fix.", + "cwe": "CWE-89", + "priority": 8, + "line_number": 10, + } + ) + + assert saist_main.dedupe_findings([first, higher_priority_different_cwe]) == [ + higher_priority_different_cwe + ] + + def test_diff_review_comments_are_built_after_dedupe(): first = Finding.model_validate( { From 164a31c5d6b94e84f8fb062a1853226a911babab Mon Sep 17 00:00:00 2001 From: QA Team Date: Sun, 10 May 2026 22:07:58 +0000 Subject: [PATCH 15/15] openai fixes --- README.md | 1 + saist/llm/adapters/openai.py | 10 ++++++++-- saist/main.py | 7 ++++++- saist/util/argparsing.py | 2 +- tests/test_argparsing.py | 18 ++++++++++++++++++ tests/test_llm_adapters.py | 10 ++++++++++ tests/test_main_analysis.py | 16 ++++++++++++++++ 7 files changed, 60 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 6047ddd..f820a55 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,7 @@ docker run -v$PWD/code:/code -v$PWD/reporting:/app/reporting punksecurity/saist | `--llm-api-key` | API key for your LLM | | `--llm-model` | (Optional) Specific model (e.g., `gpt-4o`) | | `--thinking` | Pydantic AI thinking effort: `minimal`, `low`, `medium`, `high`, `xhigh`, or `disabled` | +| `--openai-base-uri` | Base URI for OpenAI-compatible services. Can also be set with `SAIST_OPENAI_BASE_URI`. | | `--azure-openai-endpoint` | Azure AI Foundry or Azure OpenAI endpoint. Can also be set with `AZURE_OPENAI_ENDPOINT`; `/openai/v1/` endpoints use the Responses API without `api-version`. | | `--azure-openai-api-version` | Azure OpenAI API version for non-v1 endpoints. Can also be set with `OPENAI_API_VERSION`. | | `--interactive` | Chat with the LLM after scan | diff --git a/saist/llm/adapters/openai.py b/saist/llm/adapters/openai.py index 79fe0e2..b992513 100644 --- a/saist/llm/adapters/openai.py +++ b/saist/llm/adapters/openai.py @@ -6,13 +6,19 @@ from pydantic_ai.providers.openai import OpenAIProvider class OpenAiAdapter(BaseLlmAdapter): - def __init__(self, model: str = None, api_key: Optional[str] = None, thinking: str = "medium"): + def __init__( + self, + model: str = None, + api_key: Optional[str] = None, + base_url: Optional[str] = None, + thinking: str = "medium", + ): super().__init__(thinking=thinking) if model is None: model = "gpt-4o" self.model = OpenAIResponsesModel( model, - provider = OpenAIProvider( api_key=api_key ) + provider=OpenAIProvider(api_key=api_key, base_url=base_url), ) self.model_name = self.model.model_name self.model_vendor = 'OpenAI' diff --git a/saist/main.py b/saist/main.py index 7a646d0..6d425e2 100755 --- a/saist/main.py +++ b/saist/main.py @@ -405,7 +405,12 @@ async def _get_llm_adapter(args) -> BaseLlmAdapter: llm = DeepseekAdapter(api_key=args.llm_api_key, model=model, thinking=thinking) logger.debug(f"Using LLM: deepseek Model: {llm.model_name}") elif args.llm == 'openai': - llm = OpenAiAdapter(api_key=args.llm_api_key, model=model, thinking=thinking) + llm = OpenAiAdapter( + api_key=args.llm_api_key, + model=model, + base_url=args.openai_base_uri, + thinking=thinking, + ) logger.debug(f"Using LLM: openai Model: {llm.model_name}") elif args.llm == 'gemini': llm = GeminiAdapter(api_key=args.llm_api_key, model=model, thinking=thinking) diff --git a/saist/util/argparsing.py b/saist/util/argparsing.py index a4ccd05..8933437 100644 --- a/saist/util/argparsing.py +++ b/saist/util/argparsing.py @@ -169,7 +169,7 @@ def __call__(self, parser, namespace, values, option_string=None): ) parser.add_argument( - "--openai-base-uri", type=str, help = "Base uri of openai to use any compatable service", + "--openai-base-uri", "--open-ai-baseuri", type=str, help = "Base uri of openai to use any compatable service", envvar="SAIST_OPENAI_BASE_URI", action=EnvDefault, required=False ) diff --git a/tests/test_argparsing.py b/tests/test_argparsing.py index 739ec39..f9b2e52 100644 --- a/tests/test_argparsing.py +++ b/tests/test_argparsing.py @@ -206,6 +206,24 @@ def test_parse_args_accepts_disabled_thinking(monkeypatch, tmp_path): assert args.thinking == "disabled" +def test_parse_args_accepts_open_ai_baseuri_alias(monkeypatch, tmp_path): + args = parse_with( + monkeypatch, + [ + "--llm", + "openai", + "--llm-api-key", + "test-key", + "--open-ai-baseuri", + "http://openai.example/v1", + "filesystem", + str(tmp_path), + ], + ) + + assert args.openai_base_uri == "http://openai.example/v1" + + def test_parse_args_accepts_azure_foundry_without_generic_api_key(monkeypatch, tmp_path): monkeypatch.setenv("AZURE_OPENAI_ENDPOINT", "https://example.openai.azure.com/openai/v1/") monkeypatch.setenv("AZURE_OPENAI_API_KEY", "azure-key") diff --git a/tests/test_llm_adapters.py b/tests/test_llm_adapters.py index 3aefea3..ec048d0 100644 --- a/tests/test_llm_adapters.py +++ b/tests/test_llm_adapters.py @@ -69,6 +69,16 @@ def test_openai_adapter_uses_responses_model_for_thinking_support(): assert adapter.get_model_options()["thinking"] == "high" +def test_openai_adapter_uses_custom_base_url(): + adapter = OpenAiAdapter( + model="gpt-5-mini", + api_key="test-key", + base_url="http://openai.example/v1", + ) + + assert str(adapter.model.provider.client.base_url) == "http://openai.example/v1/" + + def test_azure_foundry_adapter_uses_responses_model_and_azure_provider(): adapter = AzureFoundryAdapter( model="gpt-5-mini", diff --git a/tests/test_main_analysis.py b/tests/test_main_analysis.py index 7c93371..962e27b 100644 --- a/tests/test_main_analysis.py +++ b/tests/test_main_analysis.py @@ -468,6 +468,22 @@ def test_get_llm_adapter_builds_azure_foundry_adapter(): assert adapter.thinking == "high" +def test_get_llm_adapter_passes_openai_base_uri(): + args = SimpleNamespace( + llm="openai", + llm_model="gpt-5-mini", + llm_api_key="test-key", + openai_base_uri="http://openai.example/v1", + thinking="medium", + ollama_base_uri="http://localhost:11434", + ) + + adapter = asyncio.run(saist_main._get_llm_adapter(args)) + + assert adapter.model_vendor == "OpenAI" + assert str(adapter.model.provider.client.base_url) == "http://openai.example/v1/" + + def test_dedupe_findings_keeps_first_for_same_priority(): first = Finding.model_validate( {