From 8be16bf52d4f7ded3af5096fd3ebfea535336a22 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 21 Dec 2025 02:02:25 +0300 Subject: [PATCH 1/5] Add AI advisory agents and architecture/dataflow analysis Introduces AI-powered advisory agents for architecture, dataflow, and secret confidence analysis, with opt-in consent and redaction for privacy. Adds new modules for AI engine, agent protocols, and feature-based architecture and dataflow scanning. Updates CLI to support AI features, integrates AI findings into scan results, and improves documentation on ethics and usage. Removes test_secret.py and adds cross-file test cases. --- AI_ETHICS.md | 28 ++++ README.md | 4 +- build/lib/openaudit/__init__.py | 1 + build/lib/openaudit/main.py | 7 + build/lib/openaudit/rules/config.yaml | 67 ++++++++ build/lib/openaudit/rules/secrets.yaml | 18 ++ openaudit.egg-info/PKG-INFO | 10 +- openaudit/ai/__init__.py | 5 + openaudit/ai/engine.py | 30 ++++ openaudit/ai/ethics.py | 58 +++++++ openaudit/ai/models.py | 28 ++++ openaudit/ai/protocol.py | 16 ++ openaudit/core/domain.py | 1 + openaudit/features/architecture/__init__.py | 5 + openaudit/features/architecture/agent.py | 50 ++++++ openaudit/features/architecture/models.py | 25 +++ openaudit/features/architecture/scanner.py | 82 ++++++++++ openaudit/features/dataflow/__init__.py | 3 + openaudit/features/dataflow/agent.py | 78 +++++++++ openaudit/features/dataflow/models.py | 30 ++++ openaudit/features/dataflow/scanner.py | 173 ++++++++++++++++++++ openaudit/features/secrets/agent.py | 42 +++++ openaudit/features/secrets/context.py | 27 +++ openaudit/interface/cli/app.py | 1 + openaudit/interface/cli/commands.py | 116 ++++++++++++- openaudit/main.py | 2 + test_crossfile/api.py | 4 + test_crossfile/controller.py | 5 + test_crossfile/db.py | 2 + test_secret.py | 4 - 30 files changed, 912 insertions(+), 10 deletions(-) create mode 100644 AI_ETHICS.md create mode 100644 build/lib/openaudit/__init__.py create mode 100644 build/lib/openaudit/main.py create mode 100644 build/lib/openaudit/rules/config.yaml create mode 100644 build/lib/openaudit/rules/secrets.yaml create mode 100644 openaudit/ai/__init__.py create mode 100644 openaudit/ai/engine.py create mode 100644 openaudit/ai/ethics.py create mode 100644 openaudit/ai/models.py create mode 100644 openaudit/ai/protocol.py create mode 100644 openaudit/features/architecture/__init__.py create mode 100644 openaudit/features/architecture/agent.py create mode 100644 openaudit/features/architecture/models.py create mode 100644 openaudit/features/architecture/scanner.py create mode 100644 openaudit/features/dataflow/__init__.py create mode 100644 openaudit/features/dataflow/agent.py create mode 100644 openaudit/features/dataflow/models.py create mode 100644 openaudit/features/dataflow/scanner.py create mode 100644 openaudit/features/secrets/agent.py create mode 100644 openaudit/features/secrets/context.py create mode 100644 test_crossfile/api.py create mode 100644 test_crossfile/controller.py create mode 100644 test_crossfile/db.py delete mode 100644 test_secret.py diff --git a/AI_ETHICS.md b/AI_ETHICS.md new file mode 100644 index 0000000..7095741 --- /dev/null +++ b/AI_ETHICS.md @@ -0,0 +1,28 @@ +# 🛡 AI Ethics & Privacy in OpenAuditKit + +OpenAuditKit integrates AI capabilities with a "Safety-First" approach. We believe security tools should not compromise the privacy of the code they analyze. + +## 1. Opt-In by Default +AI features are **strictly opt-in**. +- You must explicitly pass the `--ai` flag to enable them. +- On the first run, you will be asked to grant consent interactively. +- For CI/CD, you must explicitly enable consent (e.g., via `openaudit consent --grant`). + +## 2. Data Redaction +Before any code snippet is sent to an LLM (Large Language Model): +- **Secrets are Redacted**: We use our static analysis engine to detect and mask secrets (API keys, passwords, tokens) with `[REDACTED]`. +- **Anonymization**: We aim to strip PII where possible, though code context is preserved for analysis. + +## 3. Advisory Nature +AI is non-deterministic. +- All AI-generated findings are tagged as **Advisory**. +- They should be reviewed by a human. +- They do not block builds by default unless configured otherwise. + +## 4. Local vs External +- We support local LLMs (e.g., via Ollama) for users who want zero data egress. +- External providers (e.g., OpenAI, Anthropic) are optional and require your own API keys. We do not proxy your code through our servers. + +## 5. Transparency +- We explain *why* an AI finding was generated. +- We show the prompt context (in debug mode) so you know exactly what was sent. diff --git a/README.md b/README.md index f78c1fe..6ea55f5 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ OpenAuditKit is an open-source CLI security audit tool designed to scan your cod - **Config Scanning**: Identifies misconfigurations in deployment files (e.g., .env, Dockerfile). - **Secure**: Secrets are masked in outputs; offline-first design. - **Backend Ready**: Feature-based architecture with Pydantic models for easy integration into dashboards or APIs. -- **Customizable**: Add your own rules! See [Rule Documentation](rules/README.md). +- **Customizable**: Add your own rules! See [Rule Documentation](openopenaudit/rules/README.md). ## 🛡️ Why OpenAuditKit? @@ -33,7 +33,7 @@ Often, security tools are either too simple (grep) or too complex (enterprise SA pip install openaudit # Or from source -git clone https://github.com/StartUp-Agency/OpenAuditKit.git +git clone https://github.com/neuralforgeone/OpenAuditKit.git cd OpenAuditKit pip install . ``` diff --git a/build/lib/openaudit/__init__.py b/build/lib/openaudit/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/build/lib/openaudit/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/build/lib/openaudit/main.py b/build/lib/openaudit/main.py new file mode 100644 index 0000000..319f4c8 --- /dev/null +++ b/build/lib/openaudit/main.py @@ -0,0 +1,7 @@ +from openaudit.interface.cli.app import app + +def main(): + app() + +if __name__ == "__main__": + main() diff --git a/build/lib/openaudit/rules/config.yaml b/build/lib/openaudit/rules/config.yaml new file mode 100644 index 0000000..923eba0 --- /dev/null +++ b/build/lib/openaudit/rules/config.yaml @@ -0,0 +1,67 @@ +rules: + # .env Rules + - id: "CONF_DEBUG_ENABLED" + description: "Debug mode enabled in configuration" + regex: "(?i)^\\s*DEBUG\\s*=\\s*(true|1|yes)" + severity: "high" + confidence: "high" + category: "config" + remediation: "Set DEBUG=False in production environments." + + - id: "CONF_DATABASE_URL_UNENCRYPTED" + description: "Plaintext database URL detected" + regex: "^\\s*DATABASE_URL\\s*=\\s*(postgres|mysql|mongodb)://" + severity: "high" + confidence: "high" + category: "config" + remediation: "Use encrypted secrets management or mask credentials." + + - id: "CONF_ENV_DEV_IN_PROD" + description: "Development environment setting detected" + regex: "(?i)^\\s*ENV\\s*=\\s*(dev|development)" + severity: "medium" + confidence: "high" + category: "config" + remediation: "Ensure this is not a production environment." + + # Dockerfile Rules + - id: "DOCKER_USER_ROOT" + description: "Container running as root" + regex: "^\\s*USER\\s+root" + severity: "high" + confidence: "high" + category: "infrastructure" + remediation: "Create and switch to a non-root user." + + - id: "DOCKER_EXPOSE_ALL" + description: "Exposing service on all interfaces (0.0.0.0)" + regex: "^\\s*EXPOSE\\s+.*0\\.0\\.0\\.0" + severity: "medium" + confidence: "high" + category: "infrastructure" + remediation: "Bind to specific interfaces if possible." + + - id: "DOCKER_ADD_COPY_ALL" + description: "Broad COPY instruction (COPY . /)" + regex: "^\\s*COPY\\s+\\.\\s+/" + severity: "low" + confidence: "medium" + category: "infrastructure" + remediation: "Use .dockerignore and copy only necessary files." + + # Docker Compose Rules (Regex approximation for simple detection, can be refined with yaml parsing) + - id: "COMPOSE_RESTART_ALWAYS" + description: "Restart policy set to always" + regex: "restart:\\s*always" + severity: "low" + confidence: "high" + category: "infrastructure" + remediation: "Consider 'on-failure' or specific restart policies." + + - id: "COMPOSE_PORT_EXPOSURE" + description: "Port exposed to host (broad range)" + regex: "\\s*-\\s*[\"']?0\\.0\\.0\\.0:" + severity: "medium" + confidence: "high" + category: "infrastructure" + remediation: "Bind ports to localhost (127.0.0.1) if external access is not required." diff --git a/build/lib/openaudit/rules/secrets.yaml b/build/lib/openaudit/rules/secrets.yaml new file mode 100644 index 0000000..e349700 --- /dev/null +++ b/build/lib/openaudit/rules/secrets.yaml @@ -0,0 +1,18 @@ +rules: + - id: "AWS_ACCESS_KEY_ID" + description: "AWS Access Key ID" + regex: "(?:A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}" + entropy_check: false + severity: "critical" + confidence: "high" + category: "secret" + remediation: "Revoke the key immediately and rotate credentials." + + - id: "GENERIC_API_KEY" + description: "Potential High Entropy Key" + regex: "api_key['\"]?\\s*[:=]\\s*['\"]?([A-Za-z0-9_\\-]{32,})" + entropy_check: true + severity: "high" + confidence: "medium" + category: "secret" + remediation: "Verify if this is a real secret and move to environment variables." diff --git a/openaudit.egg-info/PKG-INFO b/openaudit.egg-info/PKG-INFO index b0dfeee..883c724 100644 --- a/openaudit.egg-info/PKG-INFO +++ b/openaudit.egg-info/PKG-INFO @@ -26,7 +26,7 @@ OpenAuditKit is an open-source CLI security audit tool designed to scan your cod - **Config Scanning**: Identifies misconfigurations in deployment files (e.g., .env, Dockerfile). - **Secure**: Secrets are masked in outputs; offline-first design. - **Backend Ready**: Feature-based architecture with Pydantic models for easy integration into dashboards or APIs. -- **Customizable**: Add your own rules! See [Rule Documentation](rules/README.md). +- **Customizable**: Add your own rules! See [Rule Documentation](openopenaudit/rules/README.md). ## 🛡️ Why OpenAuditKit? @@ -48,7 +48,13 @@ Often, security tools are either too simple (grep) or too complex (enterprise SA ## Installation ```bash -pip install -r requirements.txt +# From PyPI (Coming Real Soon!) +pip install openaudit + +# Or from source +git clone https://github.com/neuralforgeone/OpenAuditKit.git +cd OpenAuditKit +pip install . ``` ## Usage diff --git a/openaudit/ai/__init__.py b/openaudit/ai/__init__.py new file mode 100644 index 0000000..f404c4c --- /dev/null +++ b/openaudit/ai/__init__.py @@ -0,0 +1,5 @@ +from .models import PromptContext, AIResult +from .protocol import AgentProtocol +from .ethics import Redactor, ConsentManager + +__all__ = ["PromptContext", "AIResult", "AgentProtocol", "Redactor", "ConsentManager"] diff --git a/openaudit/ai/engine.py b/openaudit/ai/engine.py new file mode 100644 index 0000000..86c24e5 --- /dev/null +++ b/openaudit/ai/engine.py @@ -0,0 +1,30 @@ +from typing import List, Dict +from openaudit.ai.models import PromptContext, AIResult +from openaudit.ai.protocol import AgentProtocol +from openaudit.ai.ethics import ConsentManager + +class AIEngine: + """ + Orchestrator for AI Agents. + """ + def __init__(self, offline_only: bool = True): + self.offline_only = offline_only + self.agents: Dict[str, AgentProtocol] = {} + + def register_agent(self, agent: AgentProtocol): + """ + Register a new agent capability. + """ + self.agents[agent.name] = agent + + def run_agent(self, agent_name: str, context: PromptContext) -> AIResult: + """ + Run a specific agent by name. + """ + if not ConsentManager.has_consented(): + raise PermissionError("User has not consented to AI usage.") + + if agent_name not in self.agents: + raise ValueError(f"Agent {agent_name} not found.") + + return self.agents[agent_name].run(context) diff --git a/openaudit/ai/ethics.py b/openaudit/ai/ethics.py new file mode 100644 index 0000000..27227ba --- /dev/null +++ b/openaudit/ai/ethics.py @@ -0,0 +1,58 @@ +import re +from typing import List +from pathlib import Path + +# Placeholder for consent storage file +CONSENT_FILE = Path(".openaudit_consent") + +class Redactor: + """ + Utility to redaction secrets from text before sending to an LLM. + Uses basic patterns to identify potential secrets. + """ + + # Simple regex for common secrets (placeholder, ideally reuse SecretScanner patterns) + # This is a safety net; specific scanners should also redact. + SENSITIVE_PATTERNS = [ + r"(?i)(api[_-]?key|secret|token|password|passwd|pwd)['\"]?\s*[:=]\s*['\"]?([a-zA-Z0-9_\-]{8,})['\"]?", + r"(?i)private[_-]?key", + ] + + @classmethod + def redact(cls, text: str) -> str: + """ + Replace sensitive patterns with [REDACTED]. + """ + redacted_text = text + for pattern in cls.SENSITIVE_PATTERNS: + redacted_text = re.sub(pattern, lambda m: m.group(0).replace(m.group(2), "[REDACTED]"), redacted_text) + return redacted_text + +class ConsentManager: + """ + Manages user consent for AI features. + """ + + @staticmethod + def has_consented() -> bool: + """ + Check if the user has explicitly consented to AI usage. + For now, we check for a specific marker file or env var. + """ + # In a real impl, this might check a global config file in user home + return CONSENT_FILE.exists() + + @staticmethod + def grant_consent(): + """ + Grant consent creates the marker. + """ + CONSENT_FILE.touch() + + @staticmethod + def revoke_consent(): + """ + Revoke consent removes the marker. + """ + if CONSENT_FILE.exists(): + CONSENT_FILE.unlink() diff --git a/openaudit/ai/models.py b/openaudit/ai/models.py new file mode 100644 index 0000000..ad60d3d --- /dev/null +++ b/openaudit/ai/models.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, Field +from typing import Optional, List, Dict +from openaudit.core.domain import Severity, Confidence + +class PromptContext(BaseModel): + """ + Context to be passed to an AI Agent. + Contains the code to analyze, metadata, and potentially previous findings. + """ + file_path: str + code_snippet: str + line_number: Optional[int] = None + surrounding_lines: int = 5 + metadata: Dict[str, str] = Field(default_factory=dict) + + # Optional: If analyzing an existing finding + finding_id: Optional[str] = None + +class AIResult(BaseModel): + """ + Structured response from an AI Agent. + """ + analysis: str = Field(..., description="Explanation of the analysis") + risk_score: float = Field(..., ge=0.0, le=1.0, description="0.0 to 1.0 risk score") + severity: Severity + confidence: Confidence + suggestion: Optional[str] = None + is_advisory: bool = True # AI findings are advisory by default diff --git a/openaudit/ai/protocol.py b/openaudit/ai/protocol.py new file mode 100644 index 0000000..ebe5cd8 --- /dev/null +++ b/openaudit/ai/protocol.py @@ -0,0 +1,16 @@ +from typing import Protocol, runtime_checkable +from openaudit.ai.models import PromptContext, AIResult + +@runtime_checkable +class AgentProtocol(Protocol): + """ + Interface that all AI Agents must fulfill. + """ + name: str + description: str + + def run(self, context: PromptContext) -> AIResult: + """ + Execute the agent on the given context. + """ + ... diff --git a/openaudit/core/domain.py b/openaudit/core/domain.py index e824cc3..7c18799 100644 --- a/openaudit/core/domain.py +++ b/openaudit/core/domain.py @@ -70,6 +70,7 @@ class Finding(BaseModel): confidence: Confidence = Confidence.MEDIUM category: str = "secret" remediation: str = "No remediation provided." + is_ai_generated: bool = Field(default=False, description="Whether this finding was generated/enriched by AI") def __str__(self): return f"[{self.severity.upper()}] {self.rule_id} in {self.file_path}:{self.line_number}" diff --git a/openaudit/features/architecture/__init__.py b/openaudit/features/architecture/__init__.py new file mode 100644 index 0000000..b97bca7 --- /dev/null +++ b/openaudit/features/architecture/__init__.py @@ -0,0 +1,5 @@ +from .models import ModuleNode, ProjectStructure +from .scanner import ArchitectureScanner +from .agent import ArchitectureAgent + +__all__ = ["ModuleNode", "ProjectStructure", "ArchitectureScanner", "ArchitectureAgent"] diff --git a/openaudit/features/architecture/agent.py b/openaudit/features/architecture/agent.py new file mode 100644 index 0000000..37823ce --- /dev/null +++ b/openaudit/features/architecture/agent.py @@ -0,0 +1,50 @@ +from openaudit.ai.models import PromptContext, AIResult +from openaudit.ai.protocol import AgentProtocol +from openaudit.core.domain import Severity, Confidence +from .models import ProjectStructure +import json + +class ArchitectureAgent: + """ + AI Agent that reviews the project structure. + """ + name = "architecture-agent" + description = "Analyzes module headers and dependencies to identify architectural issues." + + def run_on_structure(self, structure: ProjectStructure) -> AIResult: + """ + Specialized run method that takes the structured object directly. + In a real LLM call, we would serialize this structure to text. + """ + + # Mock Logic for now: Check for circular dependencies in a naive way + # or check if feature modules import from each other in forbidden ways. + + # Simulating an AI response + summary = f"Analyzed {len(structure.modules)} modules. Structure appears flat." + risk = 0.1 + + # Example heuristic check (mocking AI reasoning) + if len(structure.modules) > 50 and "core" not in str(structure.dependency_graph): + # This is just a silly heuristic for the mock + pass + + return AIResult( + analysis=summary, + risk_score=risk, + severity=Severity.LOW, + confidence=Confidence.LOW, + suggestion="Consider grouping modules into packages if the count grows.", + is_advisory=True + ) + + def run(self, context: PromptContext) -> AIResult: + # Standard protocol entry point + # We expect 'metadata' to contain the structure or we parse the code_snippet as JSON + # This might need adapter logic. + return AIResult( + analysis="Architecture analysis not applicable on single file context via generic run.", + risk_score=0.0, + severity=Severity.LOW, + confidence=Confidence.LOW + ) diff --git a/openaudit/features/architecture/models.py b/openaudit/features/architecture/models.py new file mode 100644 index 0000000..22429c0 --- /dev/null +++ b/openaudit/features/architecture/models.py @@ -0,0 +1,25 @@ +from pydantic import BaseModel, Field +from typing import List, Dict, Set, Optional + +class ModuleNode(BaseModel): + """ + Represents a file or directory in the codebase. + """ + name: str + path: str + type: str = Field(..., description="file or directory") + imports: List[str] = Field(default_factory=list) + children: List['ModuleNode'] = Field(default_factory=list) + + class Config: + # Needed for recursive models + arbitrary_types_allowed = True + +class ProjectStructure(BaseModel): + """ + Represents the entire project structure and dependency graph. + """ + root_path: str + modules: List[ModuleNode] + # Simple adjacency list: "module_a" -> ["module_b", "module_c"] + dependency_graph: Dict[str, List[str]] = Field(default_factory=dict) diff --git a/openaudit/features/architecture/scanner.py b/openaudit/features/architecture/scanner.py new file mode 100644 index 0000000..ccad932 --- /dev/null +++ b/openaudit/features/architecture/scanner.py @@ -0,0 +1,82 @@ +import ast +import os +from pathlib import Path +from typing import List, Dict, Set +from .models import ModuleNode, ProjectStructure +from openaudit.core.domain import ScanContext + +class ArchitectureScanner: + """ + Statically analyzes the codebase to build a module tree and import graph. + """ + + def scan(self, context: ScanContext) -> ProjectStructure: + root_path = Path(context.target_path) + modules = [] + dependency_graph = {} + + # Walk the directory + for root, dirs, files in os.walk(root_path): + # Apply ignore rules (rudimentary check here, ideally use IgnoreManager) + # Modifying dirs in-place to prune traversal + dirs[:] = [d for d in dirs if not d.startswith(".") and d != "__pycache__"] + if context.ignore_manager: + dirs[:] = [d for d in dirs if not context.ignore_manager.is_ignored(Path(root) / d)] + + for file in files: + if not file.endswith(".py"): + continue + + full_path = Path(root) / file + rel_path = full_path.relative_to(root_path) + + if context.ignore_manager and context.ignore_manager.is_ignored(full_path): + continue + + imports = self._extract_imports(full_path) + + # Add to graph + module_name = str(rel_path).replace(os.sep, ".").replace(".py", "") + dependency_graph[module_name] = imports + + node = ModuleNode( + name=file, + path=str(rel_path), + type="file", + imports=imports + ) + modules.append(node) + + # TODO: Ideally maintain tree structure in 'modules', currently a flat list for simplicity + # but the Model supports nesting. For the AI summary, a flat list with paths is often enough. + + return ProjectStructure( + root_path=str(root_path), + modules=modules, + dependency_graph=dependency_graph + ) + + def _extract_imports(self, file_path: Path) -> List[str]: + """ + Parse file with AST and extract imported names. + """ + imports = [] + try: + with open(file_path, "r", encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=str(file_path)) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + imports.append(alias.name) + elif isinstance(node, ast.ImportFrom): + module = node.module or "" + # Handle relative imports (e.g., from . import utils) + if node.level > 0: + module = "." * node.level + module + imports.append(module) + except Exception: + # If parsing fails, just ignore (could be syntax error or non-utf8) + pass + + return imports diff --git a/openaudit/features/dataflow/__init__.py b/openaudit/features/dataflow/__init__.py new file mode 100644 index 0000000..cc07fc2 --- /dev/null +++ b/openaudit/features/dataflow/__init__.py @@ -0,0 +1,3 @@ +from .models import DataFlowGraph, FlowNode, FlowEdge +from .scanner import DataFlowScanner +from .agent import CrossFileAgent diff --git a/openaudit/features/dataflow/agent.py b/openaudit/features/dataflow/agent.py new file mode 100644 index 0000000..6c89fe4 --- /dev/null +++ b/openaudit/features/dataflow/agent.py @@ -0,0 +1,78 @@ +from typing import List, Dict +from openaudit.ai.models import PromptContext, AIResult +from openaudit.core.domain import Severity, Confidence +from .models import DataFlowGraph, FlowNode, FlowEdge + +class CrossFileAgent: + """ + AI Agent that analyzes data flow graphs for cross-file vulnerabilities. + """ + name = "cross-file-agent" + description = "Analyzes data flow across modules to detect risky paths." + + def run_on_graph(self, graph: DataFlowGraph) -> List[AIResult]: + results = [] + + # 1. Algorithmic Path Finding (Source -> Sink) + # Simple BFS for demonstration + for source_id in graph.sources: + paths = self._bfs_paths(graph, source_id, graph.sinks) + for path in paths: + # 2. Analyze Path + result = self._analyze_path(graph, path) + if result: + results.append(result) + + if not results: + # Just a summary if no specific vulns found + results.append(AIResult( + analysis=f"Scanned {len(graph.nodes)} functions and {len(graph.edges)} calls. No critical paths to sinks found.", + risk_score=0.0, + severity=Severity.LOW, + confidence=Confidence.LOW, + suggestion="Maintain loose coupling.", + is_advisory=True + )) + + return results + + def _bfs_paths(self, graph: DataFlowGraph, start: str, goals: List[str]) -> List[List[str]]: + queue = [(start, [start])] + paths = [] + visited = set() # Avoid cycles + + while queue: + (vertex, path) = queue.pop(0) + if len(path) > 5: # Limit depth + continue + + for edge in graph.edges: + if edge.source_id == vertex: + next_node = edge.target_id + if next_node in goals: + paths.append(path + [next_node]) + elif next_node not in path: # precise cycle check for current path + queue.append((next_node, path + [next_node])) + return paths + + def _analyze_path(self, graph: DataFlowGraph, path: List[str]) -> AIResult: + # Mock AI Analysis: Check for sanitization keywords in the path + # A real implementation would send the function signatures/docstrings of the path to LLM + + path_names = [graph.nodes[nid].name for nid in path if nid in graph.nodes] + path_str = " -> ".join(path_names) + + sanitization_terms = ["sanitize", "validate", "check", "clean", "escape"] + has_sanitization = any(any(term in name.lower() for term in sanitization_terms) for name in path_names) + + if not has_sanitization: + return AIResult( + analysis=f"Potential Taint Flow detected: {path_str}. User input may reach sensitive sink without validation.", + risk_score=0.9, + severity=Severity.HIGH, + confidence=Confidence.MEDIUM, + suggestion="Ensure input validation exists in the source or sanitizer in the path.", + is_advisory=True + ) + + return None diff --git a/openaudit/features/dataflow/models.py b/openaudit/features/dataflow/models.py new file mode 100644 index 0000000..62412c3 --- /dev/null +++ b/openaudit/features/dataflow/models.py @@ -0,0 +1,30 @@ +from pydantic import BaseModel, Field +from typing import List, Dict, Optional, Set + +class FlowNode(BaseModel): + """ + Represents a function, method, or file in the data flow graph. + """ + id: str # unique identifier, e.g., "module.function" + name: str # display name, e.g., "get_user_data" + file_path: str + type: str = "function" # function, class, file, entrypoint + line_number: int = 0 + +class FlowEdge(BaseModel): + """ + Represents a call or data dependency between two nodes. + """ + source_id: str + target_id: str + relation: str = "calls" # calls, imports, inherits + description: Optional[str] = None + +class DataFlowGraph(BaseModel): + """ + The graph representing the data flow across the project. + """ + nodes: Dict[str, FlowNode] = Field(default_factory=dict) + edges: List[FlowEdge] = Field(default_factory=list) + sinks: List[str] = Field(default_factory=list, description="IDs of sensitive sinks (e.g. db execution)") + sources: List[str] = Field(default_factory=list, description="IDs of entry points (e.g. api handlers)") diff --git a/openaudit/features/dataflow/scanner.py b/openaudit/features/dataflow/scanner.py new file mode 100644 index 0000000..28a94d6 --- /dev/null +++ b/openaudit/features/dataflow/scanner.py @@ -0,0 +1,173 @@ +import ast +import os +from pathlib import Path +from typing import List, Dict, Set, Optional +from openaudit.core.domain import ScanContext +from openaudit.features.architecture.models import ProjectStructure +from .models import DataFlowGraph, FlowNode, FlowEdge + +class DataFlowScanner: + """ + Builds a simplified data flow graph by analyzing python files. + """ + + def scan(self, context: ScanContext, structure: ProjectStructure) -> DataFlowGraph: + graph = DataFlowGraph() + # We need to process files to find definitions first, then usages. + # Ideally, we leverage the structure from architecture scanner, but we need ASTs again. + + # 1. First Pass: Collect all function/class definitions + definitions: Dict[str, FlowNode] = {} # id -> node + + # Map file paths to module names for resolution + file_map: Dict[str, str] = {} # absolute_path -> module.name + + target_path = Path(context.target_path) + + for module in structure.modules: + # Re-parse (or caching ASTs in structure would be better optimization later) + file_path = Path(context.target_path) / module.path + if not file_path.exists(): + # Handle case where file_path might be absolute or relative differently + # Depending on how architecture scanner stores it. + # Assuming module.path is relative to root. + pass + + # Construct logical module name + module_name = module.path.replace(os.sep, ".").replace(".py", "") + file_map[str(file_path.absolute())] = module_name + + try: + with open(file_path, "r", encoding="utf-8") as f: + tree = ast.parse(f.read(), filename=str(file_path)) + + # Walk for definitions + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + func_id = f"{module_name}.{node.name}" + flow_node = FlowNode( + id=func_id, + name=node.name, + file_path=str(file_path), + type="function", + line_number=node.lineno + ) + definitions[func_id] = flow_node + graph.nodes[func_id] = flow_node + + # Heuristic: Identify potential sources/sinks + if "handler" in node.name or "route" in node.name: + graph.sources.append(func_id) + + if "execute" in node.name and ("sql" in node.name or "db" in node.name or "query" in node.name): + graph.sinks.append(func_id) + + except Exception: + pass + + + # 2. Second Pass: Find calls (Edges) + # This is complex because of aliasing. + # For MVP, we'll try to resolve direct calls and simple imports. + + for module in structure.modules: + file_path = Path(context.target_path) / module.path + module_name = module.path.replace(os.sep, ".").replace(".py", "") + + try: + with open(file_path, "r", encoding="utf-8") as f: + tree = ast.parse(f.read()) + + # Track local imports: alias -> full_name + imports: Dict[str, str] = {} + + # Visitor to find imports and calls + class CallVisitor(ast.NodeVisitor): + def __init__(self, current_function: Optional[str] = None): + self.current_function = current_function + + def visit_Import(self, node): + for alias in node.names: + name = alias.name + asname = alias.asname or name + imports[asname] = name + self.generic_visit(node) + + def visit_ImportFrom(self, node): + module = node.module or "" + # relative import handling simplified + if node.level > 0: + # very rough approximation for MVP + count = node.level + parts = module_name.split(".") + if len(parts) >= count: + module = ".".join(parts[:-count]) + ("." + module if module else "") + + for alias in node.names: + name = alias.name + asname = alias.asname or name + full_name = f"{module}.{name}" if module else name + imports[asname] = full_name + self.generic_visit(node) + + def visit_FunctionDef(self, node): + # Enter function context + previous = self.current_function + self.current_function = f"{module_name}.{node.name}" + self.generic_visit(node) + self.current_function = previous + + def visit_Call(self, node): + if not self.current_function: + return + + # Try to resolve call + called_name = "" + if isinstance(node.func, ast.Name): + # Direct call: func() + called_name = node.func.id + elif isinstance(node.func, ast.Attribute): + # Attribute call: module.func() or obj.method() + # simplified: only handling module.func where module is imported + if isinstance(node.func.value, ast.Name): + base = node.func.value.id + if base in imports: + # It's an imported module + called_name = f"{imports[base]}.{node.func.attr}" + + # Resolution + target_id = None + + # 1. Check if it's imported as full name + if called_name in imports: + target_id = imports[called_name] + # 2. Check if it is the called_name logic above + elif called_name: + # Check if this matches a known definition + if called_name in definitions: + target_id = called_name + + # Try resolving aliases in called_name + # e.g. defined func=sql.execute, imports sql=db.sql + # called_name=sql.execute -> db.sql.execute + parts = called_name.split(".") + if parts[0] in imports: + resolved_base = imports[parts[0]] + potential_id = f"{resolved_base}.{'.'.join(parts[1:])}" + if potential_id in definitions: + target_id = potential_id + + if target_id and target_id in definitions: + graph.edges.append(FlowEdge( + source_id=self.current_function, + target_id=target_id, + relation="calls" + )) + + self.generic_visit(node) + + CallVisitor().visit(tree) + except Exception: + pass + + return graph diff --git a/openaudit/features/secrets/agent.py b/openaudit/features/secrets/agent.py new file mode 100644 index 0000000..6fc2b66 --- /dev/null +++ b/openaudit/features/secrets/agent.py @@ -0,0 +1,42 @@ +from openaudit.ai.models import PromptContext, AIResult +from openaudit.ai.protocol import AgentProtocol +from openaudit.core.domain import Severity, Confidence, Finding + +class SecretConfidenceAgent: + """ + AI Agent that reviews secret findings to adjust confidence. + """ + name = "secret-confidence-agent" + description = "Analyzes context to distinguish test secrets from real ones." + + def run(self, context: PromptContext) -> AIResult: + # Heuristic Logic (Mock AI) + snippet = context.code_snippet.lower() + + # Signals for Test/False Positive + test_signals = ["test", "example", "mock", "dummy", "sample", "demo"] + is_test = any(s in snippet for s in test_signals) + + # Signals for Real Secrets + prod_signals = ["prod", "live", "key =", "token =", "secret ="] + is_prod = any(s in snippet for s in prod_signals) + + if is_test: + return AIResult( + analysis="Context indicates this is a test or example secret.", + risk_score=0.1, + severity=Severity.LOW, + confidence=Confidence.LOW, + suggestion="Mark as safe or use .oaignore if intended for tests.", + is_advisory=True + ) + + # If it looks like a real assignment but wasn't obviously test data + return AIResult( + analysis="Context suggests this might be a real credential.", + risk_score=0.8, + severity=Severity.HIGH, + confidence=Confidence.HIGH, + suggestion="Verify and rotate if exposed.", + is_advisory=True + ) diff --git a/openaudit/features/secrets/context.py b/openaudit/features/secrets/context.py new file mode 100644 index 0000000..b21aecc --- /dev/null +++ b/openaudit/features/secrets/context.py @@ -0,0 +1,27 @@ +from pathlib import Path +from typing import Optional + +class SecretContextExtractor: + """ + Extracts code context surrounding a finding. + """ + + @staticmethod + def get_context(file_path: str, line_number: int, window: int = 5) -> str: + """ + Read the file and return lines around the finding. + """ + path = Path(file_path) + if not path.exists() or not path.is_file(): + return "" + + try: + with open(path, "r", encoding="utf-8", errors="ignore") as f: + lines = f.readlines() + + start = max(0, line_number - 1 - window) + end = min(len(lines), line_number + window) + + return "".join(lines[start:end]) + except Exception: + return "" diff --git a/openaudit/interface/cli/app.py b/openaudit/interface/cli/app.py index 0d10bb0..1d56da2 100644 --- a/openaudit/interface/cli/app.py +++ b/openaudit/interface/cli/app.py @@ -8,3 +8,4 @@ ) app.command(name="scan")(scan_command) +print(f"DEBUG: app in module {__name__} type: {type(app)}") diff --git a/openaudit/interface/cli/commands.py b/openaudit/interface/cli/commands.py index 3186d2a..a558015 100644 --- a/openaudit/interface/cli/commands.py +++ b/openaudit/interface/cli/commands.py @@ -1,7 +1,7 @@ import typer import os from pathlib import Path -from openaudit.core.domain import ScanContext, Severity +from openaudit.core.domain import ScanContext, Severity, Confidence from openaudit.core.rules_engine import RulesEngine from openaudit.core.ignore_manager import IgnoreManager import time @@ -11,6 +11,18 @@ from openaudit.reporters.json_reporter import JSONReporter from typing import Optional from enum import Enum +from openaudit.ai.ethics import ConsentManager +from openaudit.features.architecture.scanner import ArchitectureScanner +from openaudit.features.architecture.agent import ArchitectureAgent +from openaudit.ai.models import PromptContext +from openaudit.features.secrets.context import SecretContextExtractor +from openaudit.features.secrets.agent import SecretConfidenceAgent +from openaudit.features.secrets.agent import SecretConfidenceAgent +from openaudit.ai.ethics import Redactor +from openaudit.core.domain import Finding +from openaudit.features.dataflow.scanner import DataFlowScanner +from openaudit.features.dataflow.agent import CrossFileAgent + class OutputFormat(str, Enum): RICH = "rich" @@ -22,7 +34,8 @@ def scan_command( format: OutputFormat = typer.Option(OutputFormat.RICH, case_sensitive=False, help="Output format"), output: Optional[str] = typer.Option(None, help="Output file path (for JSON)"), ci: bool = typer.Option(False, help="Run in CI mode (no progress bar, exit code 1 on failure)"), - fail_on: Severity = typer.Option(Severity.HIGH, help="Severity threshold to fail the scan") + fail_on: Severity = typer.Option(Severity.HIGH, help="Severity threshold to fail the scan"), + ai: bool = typer.Option(False, help="Enable AI-powered advisory agents (requires consent)") ): """ Scan the target directory for security issues. @@ -33,6 +46,22 @@ def scan_command( typer.echo(f"Error: Target path {target} does not exist.") raise typer.Exit(code=1) + # 1.1 Check AI Consent + if ai: + if not ConsentManager.has_consented(): + if ci: + typer.echo("Error: CI mode requires explicit AI consent. Run 'openaudit consent --grant' locally first or set environment variable.") + raise typer.Exit(code=1) + + # Interactive prompt + confirm = typer.confirm("AI features require sending anonymized code snippets to an LLM. Do you consent?", default=False) + if confirm: + ConsentManager.grant_consent() + typer.echo("Consent granted.") + else: + typer.echo("Consent denied. Disabling AI features.") + ai = False + # 1. Setup Context & Ignore Manager ignore_manager = IgnoreManager(root_path=target_path) context = ScanContext(target_path=str(target_path), ignore_manager=ignore_manager) @@ -72,6 +101,89 @@ def scan_command( with typer.progressbar(scanners, label="Running Scanners") as progress: for scanner in progress: all_findings.extend(scanner.scan(context)) + + # 4.1 Run AI Agents if enabled + if ai: + typer.echo("Running AI Agents...") + # Architecture Agent + arch_scanner = ArchitectureScanner() + structure = arch_scanner.scan(context) + + arch_agent = ArchitectureAgent() + # In a real scenario, we might use a proper AIEngine to look this up + result = arch_agent.run_on_structure(structure) + + if result.is_advisory: + # Convert AIResult to Finding + ai_finding = Finding( + rule_id=f"AI-{arch_agent.name.upper()}", + description=f"{result.analysis} Suggested: {result.suggestion}", + file_path="PROJECT_ROOT", + line_number=0, + secret_hash="", + severity=result.severity, + confidence=result.confidence, + category="architecture", + remediation=result.suggestion or "Review architecture.", + is_ai_generated=True + ) + all_findings.append(ai_finding) + + # Cross-File Agent + df_scanner = DataFlowScanner() + df_graph = df_scanner.scan(context, structure) + + cross_agent = CrossFileAgent() + df_results = cross_agent.run_on_graph(df_graph) + + for res in df_results: + if res.is_advisory: + df_finding = Finding( + rule_id=f"AI-{cross_agent.name.upper()}", + description=f"{res.analysis} Suggested: {res.suggestion}", + file_path="PROJECT_ROOT", + line_number=0, + secret_hash="", + severity=res.severity, + confidence=res.confidence, + category="architecture", + remediation=res.suggestion or "Secure data flow.", + is_ai_generated=True + ) + all_findings.append(df_finding) + + # Secret Confidence Agent + secret_agent = SecretConfidenceAgent() + for finding in all_findings: + if finding.category == "secret": + # Extract context + code_context = SecretContextExtractor.get_context(finding.file_path, finding.line_number) + if not code_context: + continue + + # Redact + redacted_context = Redactor.redact(code_context) + + # Analyze + ctx = PromptContext( + file_path=finding.file_path, + code_snippet=redacted_context, + line_number=finding.line_number + ) + + ai_result = secret_agent.run(ctx) + + # Enrich Finding + finding.description += f" [AI: {ai_result.analysis}]" + finding.is_ai_generated = True # Tag enriched findings too + + # If agent is very confident it's a false positive (test), downgrade + if ai_result.confidence == Confidence.LOW and ai_result.severity == Severity.LOW: + finding.confidence = Confidence.LOW + finding.severity = Severity.LOW + finding.description = f"[ADVISORY] {finding.description}" + + duration = time.time() - start_time # 5. Report diff --git a/openaudit/main.py b/openaudit/main.py index 319f4c8..67a93c9 100644 --- a/openaudit/main.py +++ b/openaudit/main.py @@ -1,6 +1,8 @@ from openaudit.interface.cli.app import app +import sys def main(): + print(f"DEBUG: sys.argv = {sys.argv}") app() if __name__ == "__main__": diff --git a/test_crossfile/api.py b/test_crossfile/api.py new file mode 100644 index 0000000..2231455 --- /dev/null +++ b/test_crossfile/api.py @@ -0,0 +1,4 @@ +from controller import do_work + +def process_request_handler(user_input): + do_work(user_input) diff --git a/test_crossfile/controller.py b/test_crossfile/controller.py new file mode 100644 index 0000000..c8ce7e6 --- /dev/null +++ b/test_crossfile/controller.py @@ -0,0 +1,5 @@ +from db import execute_query + +def do_work(data): + # No validation here + execute_query(data) diff --git a/test_crossfile/db.py b/test_crossfile/db.py new file mode 100644 index 0000000..d7a770c --- /dev/null +++ b/test_crossfile/db.py @@ -0,0 +1,2 @@ +def execute_query(sql): + print(f"Executing: {sql}") diff --git a/test_secret.py b/test_secret.py deleted file mode 100644 index 4f11280..0000000 --- a/test_secret.py +++ /dev/null @@ -1,4 +0,0 @@ -# This is a test file -AWS_ACCESS_KEY_ID = "AKIA1234567890123456" -# Another secret -api_key = "abcdef1234567890abcdef1234567890" # High entropy From fb630aa627cea9dfd1abd73f501d7b6a536300ef Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 21 Dec 2025 09:14:02 +0300 Subject: [PATCH 2/5] Update .gitignore --- .gitignore | 163 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 161 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 06be2f8..6424a93 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,161 @@ -/openaudit/__pycache__ -*.pyc +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# with no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary dependencies to ensure reproducible builds. +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and others +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# IDEs +.idea/ +.vscode/ + +# OpenAuditKit local configs +.openaudit_consent +.openaudit_config.yaml +report.json +test.env +/dist From 4cee4140530791c82f78abffb93549ca4d028243 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 21 Dec 2025 09:29:10 +0300 Subject: [PATCH 3/5] Update .gitignore to exclude misc files Added .DS_Store and memory_bank.md to .gitignore to prevent accidental commits of these miscellaneous files. --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index 6424a93..60455b1 100644 --- a/.gitignore +++ b/.gitignore @@ -159,3 +159,7 @@ cython_debug/ report.json test.env /dist + +# Misc +.DS_Store +memory_bank.md From 6ede1571c7f30a7471a00d93c6911c1a29e39882 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 21 Dec 2025 09:29:22 +0300 Subject: [PATCH 4/5] ! --- README.md | 86 ++++++++----- assets/logo1.png | Bin 0 -> 70773 bytes build/lib/openaudit/main.py | 2 + dist/openaudit-0.1.0-py3-none-any.whl | Bin 8729 -> 9351 bytes dist/openaudit-0.1.0.tar.gz | Bin 10458 -> 11057 bytes openaudit.egg-info/PKG-INFO | 89 ++++++++----- openaudit.egg-info/SOURCES.txt | 1 + openaudit.egg-info/requires.txt | 1 + openaudit/__main__.py | 4 + openaudit/ai/engine.py | 64 ++++++---- openaudit/core/config.py | 52 ++++++++ openaudit/features/architecture/agent.py | 51 +++++--- openaudit/features/dataflow/agent.py | 33 +++-- openaudit/features/explain/__init__.py | 1 + openaudit/features/explain/agent.py | 46 +++++++ openaudit/features/secrets/agent.py | 61 +++++---- openaudit/features/threat_model/__init__.py | 1 + openaudit/features/threat_model/agent.py | 135 ++++++++++++++++++++ openaudit/interface/cli/app.py | 11 +- openaudit/interface/cli/commands.py | 108 ++++++++++++++-- pyproject.toml | 4 +- test_crossfile/api.py | 4 - test_crossfile/controller.py | 5 - test_crossfile/db.py | 2 - 24 files changed, 600 insertions(+), 161 deletions(-) create mode 100644 assets/logo1.png create mode 100644 openaudit/__main__.py create mode 100644 openaudit/core/config.py create mode 100644 openaudit/features/explain/__init__.py create mode 100644 openaudit/features/explain/agent.py create mode 100644 openaudit/features/threat_model/__init__.py create mode 100644 openaudit/features/threat_model/agent.py delete mode 100644 test_crossfile/api.py delete mode 100644 test_crossfile/controller.py delete mode 100644 test_crossfile/db.py diff --git a/README.md b/README.md index 6ea55f5..1d8f601 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,55 @@ OpenAuditKit is an open-source CLI security audit tool designed to scan your cod ## 🛡️ Why OpenAuditKit? + +## 🎥 Usage Demo + +![OpenAuditKit Demo](path/to/demo.gif) +*(Replace this with your actual usage GIF)* + +## Usage + +### Basic Scan +```bash +openaudit scan . +``` + +### 🧠 AI-Powered Analysis +Unlock advanced capabilities by configuring your OpenAI API key: + +```bash +# 1. Configure API Key +openaudit config set-key sk-your-key-here + +# 2. Run Scan with AI Agents +openaudit scan . --ai + +# 3. Explain a specific file +openaudit explain openaudit/main.py +``` + +**AI Agents:** +- **Architecture Agent**: Reviews modularity and dependencies. +- **Cross-File Agent**: Traces dangerous data flows across modules. +- **Explain Agent**: Provides detailed code explanations. +- **Secret Agent**: Validates if found secrets are likely real or test data. +- **Threat Model Agent**: Generates a STRIDE threat model for your project structure. + +### JSON Output +```bash +openaudit scan . --format json --output report.json +``` + +## 🛠 Features + +- **Secret Scanning**: Detects API keys and secrets using regex and entropy checks. +- **Config Scanning**: Identifies misconfigurations in deployment files (e.g., .env, Dockerfile). +- **Secure**: Secrets are masked in outputs; offline-first design (unless AI is enabled). +- **Backend Ready**: Feature-based architecture with Pydantic models for easy integration into dashboards or APIs. +- **Customizable**: Add your own rules! See [Rule Documentation](openaudit/rules/README.md). + +## 🛡️ Why OpenAuditKit? + Often, security tools are either too simple (grep) or too complex (enterprise SAST). OpenAuditKit bridges the gap: | Feature | OpenAuditKit | Gitleaks | TruffleHog | @@ -18,49 +67,27 @@ Often, security tools are either too simple (grep) or too complex (enterprise SA | **Secret Scanning** | ✅ | ✅ | ✅ | | **Config Scanning** | ✅ | ❌ | ❌ | | **Offline First** | ✅ | ✅ | ❌ (Often requires API) | +| **AI Analysis** | ✅ (Optional) | ❌ | ❌ | | **Custom Rules** | ✅ (YAML) | ✅ (TOML) | ✅ (Detectors) | | **Backend Integration** | ✅ (Pydantic Models) | ❌ | ❌ | -| **Configuration Check** | ✅ (.env, Docker) | ❌ | ❌ | ### Security Philosophy -1. **Offline First**: No data leaves your machine. Your code is yours. +1. **Offline First**: No data leaves your machine unless you explicitly enable AI features. 2. **Confidence > Noise**: We use entropy checks and specific regexes to minimize false positives. 3. **Actionable**: Every finding comes with a remediation step. ## Installation + ```bash -# From PyPI (Coming Real Soon!) +# From PyPI pip install openaudit -# Or from source +# From Source git clone https://github.com/neuralforgeone/OpenAuditKit.git cd OpenAuditKit pip install . ``` -## Usage -```bash -# Basic Scan -python -m openaudit.main . - -# With specific rules -python -m openaudit.main . --rules-path ./my-rules - -# JSON Output -python -m openaudit.main . --format json --output report.json -``` - -**Ignoring Files:** -Create a `.oaignore` or `.openauditignore` file in your root directory to exclude files/folders from the scan (uses .gitignore syntax). - -Example `.oaignore`: -```text -node_modules/ -dist/ -tests/ -*.log -``` - ## 🚀 CI/CD Integration OpenAuditKit is designed to run in CI/CD pipelines. Use the `--ci` flag to enable CI mode (exit code 1 on failure, no interactive elements). @@ -82,7 +109,7 @@ jobs: with: python-version: '3.10' - run: pip install openaudit - - run: openaudit . --ci --fail-on high + - run: openaudit scan . --ci --fail-on high ``` ### Exit Codes @@ -93,7 +120,8 @@ jobs: Run the test suite with coverage: ```bash -python -m pytest tests --cov=openaudit +pip install -e .[dev] +pytest tests --cov=openaudit ``` We enforce a 90% test coverage threshold. diff --git a/assets/logo1.png b/assets/logo1.png new file mode 100644 index 0000000000000000000000000000000000000000..d3caf723bdee3598a81da99fa2e16b622b9f66b3 GIT binary patch literal 70773 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 MgSsk{{Px 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 diff --git a/dist/openaudit-0.1.0.tar.gz b/dist/openaudit-0.1.0.tar.gz index bdecc1ebd17a1a153a255a26d3e1c0a7517bee9f..8ed2dd73161430d3ecd93ea01228be0853aa58fa 100644 GIT binary patch literal 11057 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 literal 10458 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 JN(>t6e*jQ`#Yq4F diff --git a/openaudit.egg-info/PKG-INFO b/openaudit.egg-info/PKG-INFO index 883c724..8ce9edd 100644 --- a/openaudit.egg-info/PKG-INFO +++ b/openaudit.egg-info/PKG-INFO @@ -4,6 +4,8 @@ Version: 0.1.0 Summary: Offline-first security audit tool (secrets & config scanning) for local codebases. Author-email: OpenAuditKit Team License: MIT +Project-URL: Repository, https://github.com/neuralforgeone/OpenAuditKit +Project-URL: Issues, https://github.com/neuralforgeone/OpenAuditKit/issues Classifier: Programming Language :: Python :: 3 Classifier: License :: OSI Approved :: MIT License Classifier: Operating System :: OS Independent @@ -15,6 +17,7 @@ Requires-Dist: pyyaml>=6.0 Requires-Dist: rich>=13.0.0 Requires-Dist: pydantic>=2.0.0 Requires-Dist: pathspec>=0.11.0 +Requires-Dist: openai>=1.0.0 Dynamic: license-file # OpenAuditKit @@ -30,6 +33,55 @@ OpenAuditKit is an open-source CLI security audit tool designed to scan your cod ## 🛡️ Why OpenAuditKit? + +## 🎥 Usage Demo + +![OpenAuditKit Demo](path/to/demo.gif) +*(Replace this with your actual usage GIF)* + +## Usage + +### Basic Scan +```bash +openaudit scan . +``` + +### 🧠 AI-Powered Analysis +Unlock advanced capabilities by configuring your OpenAI API key: + +```bash +# 1. Configure API Key +openaudit config set-key sk-your-key-here + +# 2. Run Scan with AI Agents +openaudit scan . --ai + +# 3. Explain a specific file +openaudit explain openaudit/main.py +``` + +**AI Agents:** +- **Architecture Agent**: Reviews modularity and dependencies. +- **Cross-File Agent**: Traces dangerous data flows across modules. +- **Explain Agent**: Provides detailed code explanations. +- **Secret Agent**: Validates if found secrets are likely real or test data. +- **Threat Model Agent**: Generates a STRIDE threat model for your project structure. + +### JSON Output +```bash +openaudit scan . --format json --output report.json +``` + +## 🛠 Features + +- **Secret Scanning**: Detects API keys and secrets using regex and entropy checks. +- **Config Scanning**: Identifies misconfigurations in deployment files (e.g., .env, Dockerfile). +- **Secure**: Secrets are masked in outputs; offline-first design (unless AI is enabled). +- **Backend Ready**: Feature-based architecture with Pydantic models for easy integration into dashboards or APIs. +- **Customizable**: Add your own rules! See [Rule Documentation](openaudit/rules/README.md). + +## 🛡️ Why OpenAuditKit? + Often, security tools are either too simple (grep) or too complex (enterprise SAST). OpenAuditKit bridges the gap: | Feature | OpenAuditKit | Gitleaks | TruffleHog | @@ -37,49 +89,27 @@ Often, security tools are either too simple (grep) or too complex (enterprise SA | **Secret Scanning** | ✅ | ✅ | ✅ | | **Config Scanning** | ✅ | ❌ | ❌ | | **Offline First** | ✅ | ✅ | ❌ (Often requires API) | +| **AI Analysis** | ✅ (Optional) | ❌ | ❌ | | **Custom Rules** | ✅ (YAML) | ✅ (TOML) | ✅ (Detectors) | | **Backend Integration** | ✅ (Pydantic Models) | ❌ | ❌ | -| **Configuration Check** | ✅ (.env, Docker) | ❌ | ❌ | ### Security Philosophy -1. **Offline First**: No data leaves your machine. Your code is yours. +1. **Offline First**: No data leaves your machine unless you explicitly enable AI features. 2. **Confidence > Noise**: We use entropy checks and specific regexes to minimize false positives. 3. **Actionable**: Every finding comes with a remediation step. ## Installation + ```bash -# From PyPI (Coming Real Soon!) +# From PyPI pip install openaudit -# Or from source +# From Source git clone https://github.com/neuralforgeone/OpenAuditKit.git cd OpenAuditKit pip install . ``` -## Usage -```bash -# Basic Scan -python -m openaudit.main . - -# With specific rules -python -m openaudit.main . --rules-path ./my-rules - -# JSON Output -python -m openaudit.main . --format json --output report.json -``` - -**Ignoring Files:** -Create a `.oaignore` or `.openauditignore` file in your root directory to exclude files/folders from the scan (uses .gitignore syntax). - -Example `.oaignore`: -```text -node_modules/ -dist/ -tests/ -*.log -``` - ## 🚀 CI/CD Integration OpenAuditKit is designed to run in CI/CD pipelines. Use the `--ci` flag to enable CI mode (exit code 1 on failure, no interactive elements). @@ -101,7 +131,7 @@ jobs: with: python-version: '3.10' - run: pip install openaudit - - run: openaudit . --ci --fail-on high + - run: openaudit scan . --ci --fail-on high ``` ### Exit Codes @@ -112,7 +142,8 @@ jobs: Run the test suite with coverage: ```bash -python -m pytest tests --cov=openaudit +pip install -e .[dev] +pytest tests --cov=openaudit ``` We enforce a 90% test coverage threshold. diff --git a/openaudit.egg-info/SOURCES.txt b/openaudit.egg-info/SOURCES.txt index c0478fd..ef06330 100644 --- a/openaudit.egg-info/SOURCES.txt +++ b/openaudit.egg-info/SOURCES.txt @@ -4,6 +4,7 @@ README.md pyproject.toml requirements.txt openaudit/__init__.py +openaudit/__main__.py openaudit/main.py openaudit.egg-info/PKG-INFO openaudit.egg-info/SOURCES.txt diff --git a/openaudit.egg-info/requires.txt b/openaudit.egg-info/requires.txt index 5629cba..29d10d7 100644 --- a/openaudit.egg-info/requires.txt +++ b/openaudit.egg-info/requires.txt @@ -3,3 +3,4 @@ pyyaml>=6.0 rich>=13.0.0 pydantic>=2.0.0 pathspec>=0.11.0 +openai>=1.0.0 diff --git a/openaudit/__main__.py b/openaudit/__main__.py new file mode 100644 index 0000000..40e2b01 --- /dev/null +++ b/openaudit/__main__.py @@ -0,0 +1,4 @@ +from .main import main + +if __name__ == "__main__": + main() diff --git a/openaudit/ai/engine.py b/openaudit/ai/engine.py index 86c24e5..2b0f782 100644 --- a/openaudit/ai/engine.py +++ b/openaudit/ai/engine.py @@ -1,30 +1,50 @@ -from typing import List, Dict -from openaudit.ai.models import PromptContext, AIResult -from openaudit.ai.protocol import AgentProtocol -from openaudit.ai.ethics import ConsentManager +from typing import Optional, List +import openai +from openaudit.core.config import ConfigManager +from openaudit.core.domain import Severity, Confidence +from openaudit.ai.models import AIResult +from openai import OpenAI, OpenAIError class AIEngine: """ - Orchestrator for AI Agents. + Centralized engine for AI model interactions. """ - def __init__(self, offline_only: bool = True): - self.offline_only = offline_only - self.agents: Dict[str, AgentProtocol] = {} + + def __init__(self): + self.config_manager = ConfigManager() + self.client: Optional[OpenAI] = None + self._initialize_client() - def register_agent(self, agent: AgentProtocol): - """ - Register a new agent capability. - """ - self.agents[agent.name] = agent + def _initialize_client(self): + api_key = self.config_manager.get_api_key() + if api_key: + self.client = OpenAI(api_key=api_key) + + def is_available(self) -> bool: + return self.client is not None - def run_agent(self, agent_name: str, context: PromptContext) -> AIResult: + def chat_completion(self, system_prompt: str, user_prompt: str, model: str = "gpt-4o") -> Optional[str]: """ - Run a specific agent by name. + Executes a chat completion request. """ - if not ConsentManager.has_consented(): - raise PermissionError("User has not consented to AI usage.") - - if agent_name not in self.agents: - raise ValueError(f"Agent {agent_name} not found.") - - return self.agents[agent_name].run(context) + if not self.client: + # Try re-initializing in case config changed + self._initialize_client() + if not self.client: + raise RuntimeError("OpenAI API key not configured. Run 'openaudit config set-key ' or set OPENAI_API_KEY env var.") + + try: + response = self.client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ], + temperature=0.2 + ) + return response.choices[0].message.content + except OpenAIError as e: + # creating a dummy result on error or re-raising? + # For now, let's log and re-raise to be handled by caller or CLI + raise RuntimeError(f"OpenAI API Error: {str(e)}") + diff --git a/openaudit/core/config.py b/openaudit/core/config.py new file mode 100644 index 0000000..a78eea6 --- /dev/null +++ b/openaudit/core/config.py @@ -0,0 +1,52 @@ +import os +import yaml +from pathlib import Path +from typing import Optional, Dict + +class ConfigManager: + """ + Manages persistent configuration for OpenAuditKit. + """ + CONFIG_FILE_NAME = ".openaudit_config.yaml" + + def __init__(self, config_path: Optional[str] = None): + if config_path: + self.config_path = Path(config_path) + else: + # Default to user home directory + self.config_path = Path.home() / self.CONFIG_FILE_NAME + + def _load_config(self) -> Dict: + if not self.config_path.exists(): + return {} + try: + with open(self.config_path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + except Exception: + return {} + + def _save_config(self, config: Dict): + with open(self.config_path, "w", encoding="utf-8") as f: + yaml.dump(config, f) + + def get_api_key(self) -> Optional[str]: + """ + Retrieves the OpenAI API key from environment variable or config file. + Priority: Env Var > Config File + """ + # 1. Check Environment Variable + env_key = os.environ.get("OPENAI_API_KEY") + if env_key: + return env_key + + # 2. Check Config File + config = self._load_config() + return config.get("openai_api_key") + + def set_api_key(self, api_key: str): + """ + Saves the OpenAI API key to the config file. + """ + config = self._load_config() + config["openai_api_key"] = api_key + self._save_config(config) diff --git a/openaudit/features/architecture/agent.py b/openaudit/features/architecture/agent.py index 37823ce..e18d894 100644 --- a/openaudit/features/architecture/agent.py +++ b/openaudit/features/architecture/agent.py @@ -14,29 +14,40 @@ class ArchitectureAgent: def run_on_structure(self, structure: ProjectStructure) -> AIResult: """ Specialized run method that takes the structured object directly. - In a real LLM call, we would serialize this structure to text. """ + from openaudit.ai.engine import AIEngine + engine = AIEngine() - # Mock Logic for now: Check for circular dependencies in a naive way - # or check if feature modules import from each other in forbidden ways. - - # Simulating an AI response - summary = f"Analyzed {len(structure.modules)} modules. Structure appears flat." - risk = 0.1 + if not engine.is_available(): + return None + + # Prepare Prompt + system_prompt = "You are a senior software architect. Analyze the project structure for modularity, circular dependencies, and architectural risks. Return a JSON response with analysis, risk_score (0-1), and suggestion." - # Example heuristic check (mocking AI reasoning) - if len(structure.modules) > 50 and "core" not in str(structure.dependency_graph): - # This is just a silly heuristic for the mock - pass - - return AIResult( - analysis=summary, - risk_score=risk, - severity=Severity.LOW, - confidence=Confidence.LOW, - suggestion="Consider grouping modules into packages if the count grows.", - is_advisory=True - ) + # Simplify structure for prompt to save tokens + modules_summary = [f"{m.path} imports {m.imports}" for m in structure.modules] + user_prompt = f"Project Structure:\n{json.dumps(modules_summary, indent=2)}\n\nAnalyze this structure." + + try: + response = engine.chat_completion(system_prompt, user_prompt) + # Parse response (assuming text for now, but ideal agents verify JSON) + # For robustness, we'll wrap the text in AIResult + return AIResult( + analysis=response, + risk_score=0.5, # Placeholder, ideally parsed from response + severity=Severity.MEDIUM, + confidence=Confidence.MEDIUM, + suggestion="Review AI detailed analysis.", + is_advisory=True + ) + except Exception as e: + return AIResult( + analysis=f"AI Analysis failed: {str(e)}", + risk_score=0.0, + severity=Severity.LOW, + confidence=Confidence.LOW, + is_advisory=True + ) def run(self, context: PromptContext) -> AIResult: # Standard protocol entry point diff --git a/openaudit/features/dataflow/agent.py b/openaudit/features/dataflow/agent.py index 6c89fe4..356db9e 100644 --- a/openaudit/features/dataflow/agent.py +++ b/openaudit/features/dataflow/agent.py @@ -56,23 +56,30 @@ def _bfs_paths(self, graph: DataFlowGraph, start: str, goals: List[str]) -> List return paths def _analyze_path(self, graph: DataFlowGraph, path: List[str]) -> AIResult: - # Mock AI Analysis: Check for sanitization keywords in the path - # A real implementation would send the function signatures/docstrings of the path to LLM + from openaudit.ai.engine import AIEngine + engine = AIEngine() + if not engine.is_available(): + return None + path_names = [graph.nodes[nid].name for nid in path if nid in graph.nodes] path_str = " -> ".join(path_names) - sanitization_terms = ["sanitize", "validate", "check", "clean", "escape"] - has_sanitization = any(any(term in name.lower() for term in sanitization_terms) for name in path_names) + system_prompt = "You are a specific security analyzer for data flow. Analyze if the path allows tainted user input to reach sensitive sinks. Return analysis." + user_prompt = f"Path: {path_str}\n\nAnalyze for taint flow." - if not has_sanitization: - return AIResult( - analysis=f"Potential Taint Flow detected: {path_str}. User input may reach sensitive sink without validation.", - risk_score=0.9, - severity=Severity.HIGH, - confidence=Confidence.MEDIUM, - suggestion="Ensure input validation exists in the source or sanitizer in the path.", - is_advisory=True - ) + try: + response = engine.chat_completion(system_prompt, user_prompt) + if "taint" in response.lower() or "risk" in response.lower(): + return AIResult( + analysis=response, + risk_score=0.9, + severity=Severity.HIGH, + confidence=Confidence.MEDIUM, + suggestion="Validate input at source.", + is_advisory=True + ) + except Exception: + pass return None diff --git a/openaudit/features/explain/__init__.py b/openaudit/features/explain/__init__.py new file mode 100644 index 0000000..9657042 --- /dev/null +++ b/openaudit/features/explain/__init__.py @@ -0,0 +1 @@ +from .agent import ExplainAgent diff --git a/openaudit/features/explain/agent.py b/openaudit/features/explain/agent.py new file mode 100644 index 0000000..ab841da --- /dev/null +++ b/openaudit/features/explain/agent.py @@ -0,0 +1,46 @@ +from typing import List, Dict, Optional +from openaudit.ai.models import PromptContext, AIResult +from openaudit.ai.protocol import AgentProtocol +from openaudit.core.domain import Severity, Confidence +import random + +class ExplainAgent(AgentProtocol): + """ + AI Agent that explains code functionality and security implications. + """ + name = "explain-agent" + description = "Generates human-readable explanations and security insights for code." + + def run(self, context: PromptContext) -> AIResult: + from openaudit.ai.engine import AIEngine + engine = AIEngine() + + if not engine.is_available(): + return AIResult( + analysis="AI not configured. Please set API key to use this feature.", + risk_score=0.0, + severity=Severity.LOW, + confidence=Confidence.LOW, + is_advisory=True + ) + + system_prompt = "You are a technical expert. Explain the code and identify security risks." + user_prompt = f"Code:\n{context.code_snippet}\n\nExplain and Analyze." + + try: + response = engine.chat_completion(system_prompt, user_prompt) + return AIResult( + analysis=response, + risk_score=0.1, + severity=Severity.LOW, + confidence=Confidence.HIGH, + is_advisory=True + ) + except Exception as e: + return AIResult( + analysis=f"Error: {str(e)}", + risk_score=0.1, + severity=Severity.LOW, + confidence=Confidence.LOW, + is_advisory=True + ) diff --git a/openaudit/features/secrets/agent.py b/openaudit/features/secrets/agent.py index 6fc2b66..74c568f 100644 --- a/openaudit/features/secrets/agent.py +++ b/openaudit/features/secrets/agent.py @@ -10,33 +10,44 @@ class SecretConfidenceAgent: description = "Analyzes context to distinguish test secrets from real ones." def run(self, context: PromptContext) -> AIResult: - # Heuristic Logic (Mock AI) - snippet = context.code_snippet.lower() - - # Signals for Test/False Positive - test_signals = ["test", "example", "mock", "dummy", "sample", "demo"] - is_test = any(s in snippet for s in test_signals) - - # Signals for Real Secrets - prod_signals = ["prod", "live", "key =", "token =", "secret ="] - is_prod = any(s in snippet for s in prod_signals) + from openaudit.ai.engine import AIEngine + if not engine.is_available(): + # No fallback, return None to indicate no analysis possible + return None + + snippet = context.code_snippet + system_prompt = "You are a secret scanning expert. Analyze the context of a potential secret. Determine if it is a TEST/MOCK secret or a REAL production secret." + user_prompt = f"Code Context:\n{snippet}\n\nIs this a real secret? Answer with JSON: {{'is_test': bool, 'reason': str}}" - if is_test: + try: + response = engine.chat_completion(system_prompt, user_prompt) + # Naive parsing for now + is_test = "true" in response.lower() and "is_test" in response.lower() + + if is_test: + return AIResult( + analysis="AI identified this as a likely TEST/MOCK secret.", + risk_score=0.1, + severity=Severity.LOW, + confidence=Confidence.HIGH, + suggestion="Mark as safe.", + is_advisory=True + ) + else: + return AIResult( + analysis="AI identified this as a likely REAL secret.", + risk_score=0.9, + severity=Severity.HIGH, + confidence=Confidence.HIGH, + suggestion="Rotate immediately.", + is_advisory=True + ) + + except Exception as e: return AIResult( - analysis="Context indicates this is a test or example secret.", - risk_score=0.1, - severity=Severity.LOW, + analysis=f"Error: {str(e)}", + risk_score=0.5, + severity=Severity.MEDIUM, confidence=Confidence.LOW, - suggestion="Mark as safe or use .oaignore if intended for tests.", is_advisory=True ) - - # If it looks like a real assignment but wasn't obviously test data - return AIResult( - analysis="Context suggests this might be a real credential.", - risk_score=0.8, - severity=Severity.HIGH, - confidence=Confidence.HIGH, - suggestion="Verify and rotate if exposed.", - is_advisory=True - ) diff --git a/openaudit/features/threat_model/__init__.py b/openaudit/features/threat_model/__init__.py new file mode 100644 index 0000000..e2a3163 --- /dev/null +++ b/openaudit/features/threat_model/__init__.py @@ -0,0 +1 @@ +from .agent import ThreatModelingAgent diff --git a/openaudit/features/threat_model/agent.py b/openaudit/features/threat_model/agent.py new file mode 100644 index 0000000..2fc7e76 --- /dev/null +++ b/openaudit/features/threat_model/agent.py @@ -0,0 +1,135 @@ +from typing import List, Dict, Set +from openaudit.ai.models import PromptContext, AIResult +from openaudit.ai.protocol import AgentProtocol +from openaudit.core.domain import Severity, Confidence +from openaudit.features.architecture.models import ProjectStructure, ModuleNode + +class ThreatModelingAgent(AgentProtocol): + """ + AI Agent that generates a high-level threat model based on project structure. + """ + name = "threat-modeling-agent" + description = "Generates a STRIDE-based threat model for key components." + + def run(self, context: PromptContext) -> AIResult: + # Not used directly, as this agent needs structure. + # We will add a custom run_on_structure method. + return AIResult( + analysis="Use run_on_structure instead.", + risk_score=0.0, + severity=Severity.LOW, + confidence=Confidence.LOW, + is_advisory=True + ) + + def run_on_structure(self, structure: ProjectStructure) -> List[AIResult]: + from openaudit.ai.engine import AIEngine + import json + engine = AIEngine() + + if not engine.is_available(): + return [] + + # Simplify structure for prompt + modules_summary = [f"{m.path} (imports: {m.imports})" for m in structure.modules] + + system_prompt = "You are a security architect. specific STRIDE threat model based on project structure. Identify key components (Auth, DB, API, etc.) and list specific threats. Return a JSON object with a key 'threats' containing a list of objects with 'component', 'threat', 'risk_score' (0-1), and 'mitigation'." + user_prompt = f"Project Structure:\n{json.dumps(modules_summary, indent=2)}\n\nGenerate STRIDE threat model." + + results = [] + try: + response = engine.chat_completion(system_prompt, user_prompt) + # Naive parse + if "{" in response: + start = response.find("{") + end = response.rfind("}") + 1 + json_str = response[start:end] + data = json.loads(json_str) + + for item in data.get("threats", []): + results.append(AIResult( + analysis=f"Threat ({item.get('component', 'General')}): {item.get('threat')}", + risk_score=item.get("risk_score", 0.7), + severity=Severity.HIGH, + confidence=Confidence.MEDIUM, + suggestion=f"Mitigation: {item.get('mitigation')}", + is_advisory=True + )) + else: + results.append(AIResult( + analysis=response[:200] + "...", + risk_score=0.5, + severity=Severity.MEDIUM, + confidence=Confidence.LOW, + suggestion="Review full AI analysis.", + is_advisory=True + )) + except Exception: + pass + + return results + + def _identify_components(self, structure: ProjectStructure) -> Dict[str, str]: + """ + Heuristic to identify key components from module paths. + Returns: {component_name: component_type} + """ + components = {} + for module in structure.modules: + path_lower = module.path.lower() + if "auth" in path_lower or "login" in path_lower or "user" in path_lower: + components[module.name] = "Authentication" + elif "db" in path_lower or "database" in path_lower or "sql" in path_lower or "model" in path_lower: + components[module.name] = "Database" + elif "api" in path_lower or "route" in path_lower or "controller" in path_lower: + components[module.name] = "API Gateway" + elif "payment" in path_lower or "billing" in path_lower: + components[module.name] = "Payments" + + # Deduplication/Grouping logic could go here (e.g. grouping all auth.* modules) + # For now, just taking unique identified modules + return components + + def _generate_stride_threats(self, component_name: str, component_type: str) -> List[Dict[str, str]]: + """ + Generates standard STRIDE threats based on component type. + """ + threats = [] + + if component_type == "Authentication": + threats.append({ + "threat": "Spoofing Identity: Attackers may attempt to impersonate users.", + "mitigation": "Enforce strong MFA and robust session management." + }) + threats.append({ + "threat": "Information Disclosure: Leakage of user credentials.", + "mitigation": "Ensure proper hashing (Argon2/bcrypt) and secure logs." + }) + + elif component_type == "Database": + threats.append({ + "threat": "Tampering with Data: SQL Injection or unauthorized modification.", + "mitigation": "Use parameterized queries/ORM and strict input validation." + }) + threats.append({ + "threat": "Information Disclosure: Exposure of sensitive records.", + "mitigation": "Encrypt data at rest and implement strict RBAC." + }) + + elif component_type == "API Gateway": + threats.append({ + "threat": "Denial of Service: Flooding API resources.", + "mitigation": "Implement rate limiting and request throttling." + }) + threats.append({ + "threat": "Tampering: Parameter pollution or replay attacks.", + "mitigation": "Validate all inputs and use TLS." + }) + + elif component_type == "Payments": + threats.append({ + "threat": "Tampering: Manipulation of transaction amounts.", + "mitigation": "Validate transaction integrity on server-side and use signing." + }) + + return threats diff --git a/openaudit/interface/cli/app.py b/openaudit/interface/cli/app.py index 1d56da2..8e698ec 100644 --- a/openaudit/interface/cli/app.py +++ b/openaudit/interface/cli/app.py @@ -1,5 +1,5 @@ import typer -from .commands import scan_command +from .commands import scan_command, explain_command, config_app app = typer.Typer( name="OpenAuditKit", @@ -7,5 +7,14 @@ add_completion=False ) +@app.callback() +def main_callback(): + """ + OpenAuditKit CLI + """ + pass + app.command(name="scan")(scan_command) +app.command(name="explain")(explain_command) +app.add_typer(config_app, name="config") print(f"DEBUG: app in module {__name__} type: {type(app)}") diff --git a/openaudit/interface/cli/commands.py b/openaudit/interface/cli/commands.py index a558015..8c812f6 100644 --- a/openaudit/interface/cli/commands.py +++ b/openaudit/interface/cli/commands.py @@ -22,6 +22,8 @@ from openaudit.core.domain import Finding from openaudit.features.dataflow.scanner import DataFlowScanner from openaudit.features.dataflow.agent import CrossFileAgent +from openaudit.features.threat_model.agent import ThreatModelingAgent +from openaudit.features.explain.agent import ExplainAgent class OutputFormat(str, Enum): @@ -113,7 +115,7 @@ def scan_command( # In a real scenario, we might use a proper AIEngine to look this up result = arch_agent.run_on_structure(structure) - if result.is_advisory: + if result and result.is_advisory: # Convert AIResult to Finding ai_finding = Finding( rule_id=f"AI-{arch_agent.name.upper()}", @@ -151,6 +153,25 @@ def scan_command( is_ai_generated=True ) all_findings.append(df_finding) + + # Threat Modeling Agent + threat_agent = ThreatModelingAgent() + tm_results = threat_agent.run_on_structure(structure) + for res in tm_results: + if res.is_advisory: + tm_finding = Finding( + rule_id=f"AI-THREAT-{res.analysis.split(':')[0]}", # Crude ID generation + description=f"{res.analysis} {res.suggestion}", + file_path="PROJECT_ROOT", + line_number=0, + secret_hash="", + severity=res.severity, + confidence=res.confidence, + category="architecture", + remediation=res.suggestion or "Mitigate threat.", + is_ai_generated=True + ) + all_findings.append(tm_finding) # Secret Confidence Agent secret_agent = SecretConfidenceAgent() @@ -173,15 +194,16 @@ def scan_command( ai_result = secret_agent.run(ctx) - # Enrich Finding - finding.description += f" [AI: {ai_result.analysis}]" - finding.is_ai_generated = True # Tag enriched findings too - - # If agent is very confident it's a false positive (test), downgrade - if ai_result.confidence == Confidence.LOW and ai_result.severity == Severity.LOW: - finding.confidence = Confidence.LOW - finding.severity = Severity.LOW - finding.description = f"[ADVISORY] {finding.description}" + if ai_result: + # Enrich Finding + finding.description += f" [AI: {ai_result.analysis}]" + finding.is_ai_generated = True # Tag enriched findings too + + # If agent is very confident it's a false positive (test), downgrade + if ai_result.confidence == Confidence.LOW and ai_result.severity == Severity.LOW: + finding.confidence = Confidence.LOW + finding.severity = Severity.LOW + finding.description = f"[ADVISORY] {finding.description}" duration = time.time() - start_time @@ -211,3 +233,69 @@ def scan_command( if not format == OutputFormat.JSON: typer.echo(f"Failure: Found issues with severity >= {fail_on.value}") raise typer.Exit(code=1) + +def explain_command( + path: str = typer.Argument(..., help="Path to the file to explain"), + ai: bool = typer.Option(True, help="Enable AI features (implied true for this command)") +): + """ + Explain the code in a specific file using AI. + """ + target_path = Path(path).absolute() + if not target_path.exists() or not target_path.is_file(): + typer.echo(f"Error: path {path} does not exist or is not a file.") + raise typer.Exit(code=1) + + # Check Consent + if not ConsentManager.has_consented(): + confirm = typer.confirm("This feature sends code to an AI. Do you consent?", default=False) + if confirm: + ConsentManager.grant_consent() + else: + typer.echo("Consent refused. Exiting.") + raise typer.Exit(code=1) + + # Read Content + content = target_path.read_text(encoding="utf-8", errors="ignore") + + # Redact + redacted_content = Redactor.redact(content) + + # Run Agent + agent = ExplainAgent() + context = PromptContext(code_snippet=redacted_content, file_path=str(target_path)) + result = agent.run(context) + + # Output + typer.echo("") + typer.echo(f"🔍 Analysis for {target_path.name}") + typer.echo("=========================================") + typer.echo(result.analysis) + typer.echo("=========================================") + + +# Config Commands +config_app = typer.Typer(help="Manage OpenAuditKit configuration.") + +@config_app.command("set-key") +def set_key(key: str = typer.Argument(..., help="OpenAI API Key")): + """ + Set the OpenAI API key in the configuration file. + """ + from openaudit.core.config import ConfigManager + manager = ConfigManager() + manager.set_api_key(key) + typer.echo(f"API key saved to {manager.config_path}") + +@config_app.command("show") +def show_config(): + """ + Show current configuration path and status. + """ + from openaudit.core.config import ConfigManager + manager = ConfigManager() + key = manager.get_api_key() + status = "Set" if key else "Not Set" + typer.echo(f"Config File: {manager.config_path}") + typer.echo(f"API Key Status: {status}") + \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 6976468..6ba8b99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ license = { text = "MIT" } authors = [ { name = "OpenAuditKit Team", email = "info@openauditkit.org" } ] +urls = { Repository = "https://github.com/neuralforgeone/OpenAuditKit", Issues = "https://github.com/neuralforgeone/OpenAuditKit/issues" } classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", @@ -22,7 +23,8 @@ dependencies = [ "pyyaml>=6.0", "rich>=13.0.0", "pydantic>=2.0.0", - "pathspec>=0.11.0" + "pathspec>=0.11.0", + "openai>=1.0.0" ] [project.scripts] diff --git a/test_crossfile/api.py b/test_crossfile/api.py deleted file mode 100644 index 2231455..0000000 --- a/test_crossfile/api.py +++ /dev/null @@ -1,4 +0,0 @@ -from controller import do_work - -def process_request_handler(user_input): - do_work(user_input) diff --git a/test_crossfile/controller.py b/test_crossfile/controller.py deleted file mode 100644 index c8ce7e6..0000000 --- a/test_crossfile/controller.py +++ /dev/null @@ -1,5 +0,0 @@ -from db import execute_query - -def do_work(data): - # No validation here - execute_query(data) diff --git a/test_crossfile/db.py b/test_crossfile/db.py deleted file mode 100644 index d7a770c..0000000 --- a/test_crossfile/db.py +++ /dev/null @@ -1,2 +0,0 @@ -def execute_query(sql): - print(f"Executing: {sql}") From a65ed2e3ccba46da0aaed973774e0bb0390e0c20 Mon Sep 17 00:00:00 2001 From: Tunay Engin Date: Sun, 21 Dec 2025 09:29:48 +0300 Subject: [PATCH 5/5] ! --- report.json | 111 ---------------------------------------------------- test.env | 2 - 2 files changed, 113 deletions(-) delete mode 100644 report.json delete mode 100644 test.env diff --git a/report.json b/report.json deleted file mode 100644 index 11819a2..0000000 --- a/report.json +++ /dev/null @@ -1,111 +0,0 @@ -{ - "summary": { - "total": 10, - "critical": 1, - "high": 4, - "medium": 3, - "low": 2 - }, - "findings": [ - { - "rule_id": "AWS_ACCESS_KEY_ID", - "description": "AWS Access Key ID", - "file_path": "C:\\Users\\tunay\\Documents\\GitHub\\OpenAuditKit\\test_secret.py", - "line_number": 2, - "secret_hash": "AK****************56", - "severity": "critical", - "category": "secret", - "remediation": "No remediation provided." - }, - { - "rule_id": "GENERIC_API_KEY", - "description": "Potential High Entropy Key", - "file_path": "C:\\Users\\tunay\\Documents\\GitHub\\OpenAuditKit\\test_secret.py", - "line_number": 4, - "secret_hash": "ap***************************************90", - "severity": "high", - "category": "secret", - "remediation": "No remediation provided." - }, - { - "rule_id": "COMPOSE_RESTART_ALWAYS", - "description": "Restart policy set to always", - "file_path": "C:\\Users\\tunay\\Documents\\GitHub\\OpenAuditKit\\docker-compose.yml", - "line_number": 7, - "secret_hash": "restart: always", - "severity": "low", - "category": "infrastructure", - "remediation": "Consider 'on-failure' or specific restart policies." - }, - { - "rule_id": "COMPOSE_PORT_EXPOSURE", - "description": "Port exposed to host (broad range)", - "file_path": "C:\\Users\\tunay\\Documents\\GitHub\\OpenAuditKit\\docker-compose.yml", - "line_number": 6, - "secret_hash": "- \"0.0.0.0:", - "severity": "medium", - "category": "infrastructure", - "remediation": "Bind ports to localhost (127.0.0.1) if external access is not required." - }, - { - "rule_id": "DOCKER_USER_ROOT", - "description": "Container running as root", - "file_path": "C:\\Users\\tunay\\Documents\\GitHub\\OpenAuditKit\\Dockerfile", - "line_number": 3, - "secret_hash": "USER root", - "severity": "high", - "category": "infrastructure", - "remediation": "Create and switch to a non-root user." - }, - { - "rule_id": "DOCKER_EXPOSE_ALL", - "description": "Exposing service on all interfaces (0.0.0.0)", - "file_path": "C:\\Users\\tunay\\Documents\\GitHub\\OpenAuditKit\\Dockerfile", - "line_number": 4, - "secret_hash": "EXPOSE 0.0.0.0", - "severity": "medium", - "category": "infrastructure", - "remediation": "Bind to specific interfaces if possible." - }, - { - "rule_id": "DOCKER_ADD_COPY_ALL", - "description": "Broad COPY instruction (COPY . /)", - "file_path": "C:\\Users\\tunay\\Documents\\GitHub\\OpenAuditKit\\Dockerfile", - "line_number": 5, - "secret_hash": "COPY . /", - "severity": "low", - "category": "infrastructure", - "remediation": "Use .dockerignore and copy only necessary files." - }, - { - "rule_id": "CONF_DOTENV_EXPOSED", - "description": "Dotenv file found. Ensure this is not committed.", - "file_path": "C:\\Users\\tunay\\Documents\\GitHub\\OpenAuditKit\\test.env", - "line_number": 0, - "secret_hash": "N/A", - "severity": "medium", - "category": "config", - "remediation": "Add to .gitignore" - }, - { - "rule_id": "CONF_DEBUG_ENABLED", - "description": "Debug mode enabled in configuration", - "file_path": "C:\\Users\\tunay\\Documents\\GitHub\\OpenAuditKit\\test.env", - "line_number": 1, - "secret_hash": "DEBUG=True", - "severity": "high", - "category": "config", - "remediation": "Set DEBUG=False in production environments." - }, - { - "rule_id": "CONF_DATABASE_URL_UNENCRYPTED", - "description": "Plaintext database URL detected", - "file_path": "C:\\Users\\tunay\\Documents\\GitHub\\OpenAuditKit\\test.env", - "line_number": 2, - "secret_hash": "DATABASE_URL=po*******//", - "severity": "high", - "category": "config", - "remediation": "Use encrypted secrets management or mask credentials." - } - ] -} \ No newline at end of file diff --git a/test.env b/test.env deleted file mode 100644 index ade97b8..0000000 --- a/test.env +++ /dev/null @@ -1,2 +0,0 @@ -DEBUG=True -DATABASE_URL=postgres://user:pass@localhost:5432/db