pal-code 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- pal_code-0.1.0.dist-info/top_level.txt +1 -0
pal/terminal/security.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Pal's security layer. The checks follow the vibe-security checklist (Chris Raroque, MIT) where it applies to a coding CLI:
|
|
2
|
+
|
|
3
|
+
- keys never leave the machine: secret files are never sent to a model, key-shaped strings are hidden first (`guard_outgoing`);
|
|
4
|
+
- the model's output is untrusted: code it proposes is checked before anything is written (`review_code`), and text it prints
|
|
5
|
+
cannot drive the terminal (`clean_terminal`);
|
|
6
|
+
- the project can be audited on demand (`audit_folder`): secrets in files, `.env` outside `.gitignore`, and the usual
|
|
7
|
+
mistakes AI-written code makes (client-side keys, eval, shell and SQL built from strings, unsafe HTML, trusting a price
|
|
8
|
+
or role sent by the browser, open CORS, debug left on, TLS checks off).
|
|
9
|
+
|
|
10
|
+
Everything is pattern matching: it finds the common cases quickly and cannot prove code is safe. Findings say so.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fnmatch
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from pathlib import Path, PurePosixPath
|
|
20
|
+
|
|
21
|
+
SEVERITIES = ("critical", "high", "medium", "low")
|
|
22
|
+
|
|
23
|
+
# -- secrets ---------------------------------------------------------------------------------------------------------------
|
|
24
|
+
SECRET_PATTERNS: list[tuple[str, re.Pattern]] = [
|
|
25
|
+
("private key", re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY(?: BLOCK)?-----")),
|
|
26
|
+
("Stripe secret key", re.compile(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}")),
|
|
27
|
+
("Anthropic key", re.compile(r"\bsk-ant-[A-Za-z0-9_\-]{20,}")),
|
|
28
|
+
("OpenAI-style key", re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_\-]{20,}")),
|
|
29
|
+
("AWS access key", re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b")),
|
|
30
|
+
("GitHub token", re.compile(r"\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})")),
|
|
31
|
+
("GitLab token", re.compile(r"\bglpat-[A-Za-z0-9_\-]{20,}")),
|
|
32
|
+
("Slack token", re.compile(r"\bxox[baprs]-[A-Za-z0-9\-]{10,}")),
|
|
33
|
+
("Google API key", re.compile(r"\bAIza[0-9A-Za-z_\-]{35}\b")),
|
|
34
|
+
("password in a connection string", re.compile(r"\b[a-z][a-z0-9+.\-]{1,20}://[^\s:/@'\"]+:[^\s@/'\"]{3,}@[^\s'\"]+", re.I)),
|
|
35
|
+
("hardcoded secret", re.compile(r"(?i)\b[\w.\-]*(?:secret|password|passwd|api[_-]?key|access[_-]?token|auth[_-]?token|private[_-]?key|service[_-]?role)[\w\-]*"
|
|
36
|
+
r"\s*[:=]\s*[\"'](?![\"'])(?!\s*(?:your|xxx|changeme|example|placeholder|<|\$\{|process\.env|os\.environ))[^\"'\s]{8,}[\"']")),
|
|
37
|
+
]
|
|
38
|
+
SECRET_FILE_GLOBS = (".env", ".env.*", "*.env", "id_rsa", "id_dsa", "id_ecdsa", "id_ed25519", "*.pem", "*.key", "*.p12", "*.pfx", "*.keystore", "*.jks",
|
|
39
|
+
"credentials.json", "service-account*.json", "*serviceaccount*.json", ".npmrc", ".pypirc", ".netrc", "secrets.*", ".git-credentials")
|
|
40
|
+
PLACEHOLDER_FILES = (".env.example", ".env.sample", ".env.template")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def is_secret_file(path: str) -> bool:
|
|
44
|
+
"""A file that holds keys by its very name: never sent to a model, never written by one."""
|
|
45
|
+
name = PurePosixPath(path.replace("\\", "/")).name.lower()
|
|
46
|
+
if name in PLACEHOLDER_FILES:
|
|
47
|
+
return False
|
|
48
|
+
return any(fnmatch.fnmatch(name, g) for g in SECRET_FILE_GLOBS)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_secrets(text: str) -> list[tuple[int, str]]:
|
|
52
|
+
"""[(line number, what it looks like)] for every key-shaped string."""
|
|
53
|
+
out = []
|
|
54
|
+
for i, line in enumerate(text.splitlines(), 1):
|
|
55
|
+
for kind, rx in SECRET_PATTERNS:
|
|
56
|
+
if rx.search(line):
|
|
57
|
+
out.append((i, kind))
|
|
58
|
+
break
|
|
59
|
+
return out
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def redact_secrets(text: str) -> tuple[str, int]:
|
|
63
|
+
"""The text with every key-shaped string replaced by [hidden secret], and how many were hidden."""
|
|
64
|
+
count = 0
|
|
65
|
+
|
|
66
|
+
def hide(m: re.Match) -> str:
|
|
67
|
+
nonlocal count
|
|
68
|
+
count += 1
|
|
69
|
+
return "[hidden secret]"
|
|
70
|
+
for kind, rx in SECRET_PATTERNS:
|
|
71
|
+
if kind == "private key":
|
|
72
|
+
text, n = re.subn(r"-----BEGIN [A-Z ]*PRIVATE KEY[A-Z ]*-----.*?(?:-----END [A-Z ]*PRIVATE KEY[A-Z ]*-----|\Z)", "[hidden private key]", text, flags=re.S)
|
|
73
|
+
count += n
|
|
74
|
+
else:
|
|
75
|
+
text = rx.sub(hide, text)
|
|
76
|
+
return text, count
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def guard_outgoing(name: str, text: str) -> tuple[str | None, str | None]:
|
|
80
|
+
"""(the text to send, or None to send nothing; a warning for the person, or None)."""
|
|
81
|
+
if is_secret_file(name):
|
|
82
|
+
return None, f"@{name}: not sent: it looks like a secrets file (keys stay on this computer)"
|
|
83
|
+
clean, n = redact_secrets(text)
|
|
84
|
+
return clean, (f"@{name}: {n} secret{'s' if n != 1 else ''} hidden before sending" if n else None)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# -- terminal safety -------------------------------------------------------------------------------------------------------
|
|
88
|
+
_ANSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[PX^_][^\x1b]*\x1b\\|\x1b.")
|
|
89
|
+
_CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f\x80-\x9f]")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def clean_terminal(text: str) -> str:
|
|
93
|
+
"""Text a model wrote, made safe to print: no escape sequences (colours, cursor moves, window titles, links, clipboard writes)
|
|
94
|
+
and no control characters, so a reply or a file cannot rewrite what the screen shows or act on the terminal."""
|
|
95
|
+
return _CONTROL.sub("", _ANSI.sub("", text.replace("\r\n", "\n")))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# -- reviewing code ----------------------------------------------------------------------------------------------------------
|
|
99
|
+
@dataclass
|
|
100
|
+
class Finding:
|
|
101
|
+
severity: str
|
|
102
|
+
path: str
|
|
103
|
+
line: int
|
|
104
|
+
title: str
|
|
105
|
+
why: str
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
_CLIENT = (".js", ".jsx", ".ts", ".tsx", ".mjs", ".vue", ".svelte", ".html", ".htm")
|
|
109
|
+
_PY = (".py",)
|
|
110
|
+
RULES: list[tuple[str, str, re.Pattern, tuple[str, ...], str]] = [
|
|
111
|
+
# severity, title, pattern, file kinds ('' = any), what an attacker could do
|
|
112
|
+
("critical", "Secret key in a client-side variable", re.compile(r"\b(?:NEXT_PUBLIC|VITE|EXPO_PUBLIC|REACT_APP)_\w*(?:SECRET|PRIVATE|SERVICE|PASSWORD|SK_|TOKEN)\w*"), (),
|
|
113
|
+
"these prefixes are copied into the browser or app bundle, so anyone can read the key and use it at your expense"),
|
|
114
|
+
("critical", "Supabase service_role key in front-end code", re.compile(r"service_role", re.I), _CLIENT,
|
|
115
|
+
"the service_role key bypasses all row-level security: anyone who opens the page can read or delete every row"),
|
|
116
|
+
("high", "AI or payment key used directly in front-end code", re.compile(r"(?:api\.openai\.com|api\.anthropic\.com|generativelanguage\.googleapis\.com|api\.stripe\.com)"), _CLIENT,
|
|
117
|
+
"calling the provider from the browser exposes the key; call it from your server instead"),
|
|
118
|
+
("high", "eval of dynamic code", re.compile(r"(?<![\w.])(?:eval|exec)\s*\((?!\s*[\"')])|new\s+Function\s*\("), _PY + _CLIENT,
|
|
119
|
+
"if any part of the string comes from a user or a model, they can run their own code"),
|
|
120
|
+
("high", "Shell command built from a string", re.compile(r"shell\s*=\s*True|os\.system\s*\(|os\.popen\s*\(|child_process[\s\S]{0,40}\bexec(?:Sync)?\s*\(\s*[`'\"].*?(?:\$\{|\"\s*\+|'\s*\+)"), (),
|
|
121
|
+
"a value with ; or && in it runs extra commands on your machine or server"),
|
|
122
|
+
("high", "SQL built from strings", re.compile(r"(?i)(?:execute|query|raw|exec)\s*\(\s*(?:f[\"']|[\"'][^\"']*(?:select|insert|update|delete)\b[^\"']*[\"']\s*(?:%|\+|\.format))"
|
|
123
|
+
r"|`\s*(?:select|insert|update|delete)\b[^`]*\$\{"), (),
|
|
124
|
+
"SQL injection: a crafted value can read, change or delete any data; use parameters (?, %s, $1) instead"),
|
|
125
|
+
("high", "HTML inserted from a variable", re.compile(r"\.innerHTML\s*[+]?=\s*(?![\"'`]\s*[;<]?[\"'`]?\s*;)|dangerouslySetInnerHTML|document\.write\s*\(|v-html\s*="), _CLIENT,
|
|
126
|
+
"cross-site scripting: if the text comes from a user or a model, it can run scripts in your users' browsers"),
|
|
127
|
+
("high", "Price, amount or role taken from the request", re.compile(r"(?i)(?:req\.body|request\.(?:json|form|get_json\(\)|data)|body)\s*(?:\.|\[\s*[\"'])(?:price|amount|total|unit_amount|role|is_?admin|plan|credits)\b"), (),
|
|
128
|
+
"never trust the client: anyone can send price=0.01 or role=admin; look the value up on the server"),
|
|
129
|
+
("high", "TLS certificate checks turned off", re.compile(r"verify\s*=\s*False|rejectUnauthorized\s*:\s*false|NODE_TLS_REJECT_UNAUTHORIZED\s*=\s*['\"]?0|InsecureSkipVerify\s*:\s*true"), (),
|
|
130
|
+
"anyone on the network can read or change the traffic"),
|
|
131
|
+
("high", "Unsafe deserialisation", re.compile(r"pickle\.loads?\s*\(|yaml\.load\s*\((?![^)]*Loader\s*=\s*yaml\.(?:Safe|C?Safe)Loader)|marshal\.loads\s*\("), _PY,
|
|
132
|
+
"loading untrusted data this way runs code hidden in it"),
|
|
133
|
+
("medium", "JWT read without checking its signature", re.compile(r"verify_signature[\"']?\s*:\s*False|jwt\.decode\([^)]*verify\s*=\s*False|algorithms\s*=\s*\[\s*[\"']none[\"']"), (),
|
|
134
|
+
"anyone can forge a token and log in as anyone"),
|
|
135
|
+
("medium", "CORS open to every site", re.compile(r"Access-Control-Allow-Origin[\"']?\s*[,:]\s*[\"']\*|origin\s*:\s*[\"']\*[\"']|allow_origins\s*=\s*\[\s*[\"']\*[\"']\s*\]|CORS\([^)]*origins\s*=\s*[\"']\*"), (),
|
|
136
|
+
"any website can call your API from a visitor's browser; list the origins you trust"),
|
|
137
|
+
("medium", "Debug mode left on", re.compile(r"\.run\([^)]*debug\s*=\s*True|^\s*DEBUG\s*=\s*True\b", re.M), _PY,
|
|
138
|
+
"debug pages show code and settings, and Flask's debugger can run code; switch it off outside development"),
|
|
139
|
+
("low", "Random numbers that are not secure", re.compile(r"Math\.random\(\)|random\.(?:random|randint|choice)\("), (),
|
|
140
|
+
"fine for games; for tokens, codes or passwords use secrets (Python) or crypto.getRandomValues (JavaScript)"),
|
|
141
|
+
]
|
|
142
|
+
_COMMENT = re.compile(r"^\s*(?:#|//|\*|/\*|<!--)")
|
|
143
|
+
_LOW_ONLY_IF = re.compile(r"(?i)token|password|secret|otp|code|session|salt|nonce")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def review_code(path: str, text: str) -> list[Finding]:
|
|
147
|
+
"""Findings for one file's text: secrets first, then the code rules that apply to this kind of file."""
|
|
148
|
+
out: list[Finding] = []
|
|
149
|
+
suffix = PurePosixPath(path).suffix.lower()
|
|
150
|
+
is_placeholder = PurePosixPath(path).name.lower() in PLACEHOLDER_FILES
|
|
151
|
+
for at, kind in find_secrets(text):
|
|
152
|
+
out.append(Finding("high" if is_placeholder else "critical", path, at, f"Hardcoded {kind}",
|
|
153
|
+
"keep real keys out of files: read them from the environment, and rotate any key that was ever committed"
|
|
154
|
+
+ (" (an example file must hold placeholders only)" if is_placeholder else "")))
|
|
155
|
+
lines = text.splitlines()
|
|
156
|
+
for sev, title, rx, kinds, why in RULES:
|
|
157
|
+
if kinds and suffix not in kinds:
|
|
158
|
+
continue
|
|
159
|
+
for i, line in enumerate(lines, 1):
|
|
160
|
+
if _COMMENT.match(line): # a comment that mentions eval() is not a call
|
|
161
|
+
continue
|
|
162
|
+
if rx.search(line):
|
|
163
|
+
if sev == "low" and not _LOW_ONLY_IF.search(line):
|
|
164
|
+
continue
|
|
165
|
+
out.append(Finding(sev, path, i, title, why))
|
|
166
|
+
break # one finding per rule per file is enough to act on
|
|
167
|
+
return out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def worst(findings: list[Finding]) -> str:
|
|
171
|
+
return min((f.severity for f in findings), key=SEVERITIES.index, default="")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# -- auditing a folder -------------------------------------------------------------------------------------------------------
|
|
175
|
+
SKIP_DIRS = frozenset({".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", ".next", ".idea", ".vscode", "coverage"})
|
|
176
|
+
TEXT_SUFFIXES = frozenset({".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".vue", ".svelte", ".html", ".htm", ".json", ".yml", ".yaml", ".toml", ".ini", ".cfg",
|
|
177
|
+
".env", ".sh", ".rb", ".php", ".go", ".rs", ".java", ".kt", ".cs", ".sql", ".md", ".txt", ".example", ".sample"})
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _ignored_by_gitignore(root: Path, name: str) -> bool:
|
|
181
|
+
gi = root / ".gitignore"
|
|
182
|
+
if not gi.is_file():
|
|
183
|
+
return False
|
|
184
|
+
for raw in gi.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
185
|
+
pat = raw.strip().lstrip("/")
|
|
186
|
+
if pat and not pat.startswith(("#", "!")) and fnmatch.fnmatch(name, pat.rstrip("/")):
|
|
187
|
+
return True
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _tracked_by_git(root: Path, rel: str) -> bool:
|
|
192
|
+
git = shutil.which("git")
|
|
193
|
+
if not git or not (root / ".git").exists():
|
|
194
|
+
return False
|
|
195
|
+
try:
|
|
196
|
+
return subprocess.run([git, "-C", str(root), "ls-files", "--error-unmatch", rel], capture_output=True, timeout=5).returncode == 0
|
|
197
|
+
except (OSError, subprocess.SubprocessError):
|
|
198
|
+
return False
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def audit_folder(root: Path, max_files: int = 400, max_bytes: int = 300_000) -> tuple[list[Finding], int]:
|
|
202
|
+
"""(findings, files checked) for the folder. Reads files only; runs nothing except an optional `git ls-files` to see if a secrets file is committed."""
|
|
203
|
+
root = Path(root).resolve()
|
|
204
|
+
findings: list[Finding] = []
|
|
205
|
+
checked = 0
|
|
206
|
+
for p in sorted(root.rglob("*")):
|
|
207
|
+
rel = p.relative_to(root)
|
|
208
|
+
if any(part in SKIP_DIRS for part in rel.parts) or not p.is_file() or p.is_symlink():
|
|
209
|
+
continue
|
|
210
|
+
name, rel_s = p.name, rel.as_posix()
|
|
211
|
+
if is_secret_file(name):
|
|
212
|
+
if _tracked_by_git(root, rel_s):
|
|
213
|
+
findings.append(Finding("critical", rel_s, 0, "Secrets file committed to git", "everyone with the repository has these keys; rotate them, then remove the file from git history"))
|
|
214
|
+
elif not _ignored_by_gitignore(root, name):
|
|
215
|
+
findings.append(Finding("high", rel_s, 0, "Secrets file not in .gitignore", f"add {name} to .gitignore before your next commit, or the keys go to the repository"))
|
|
216
|
+
continue # its content is not read: nothing in it is shown or sent
|
|
217
|
+
if p.suffix.lower() not in TEXT_SUFFIXES and name not in PLACEHOLDER_FILES:
|
|
218
|
+
continue
|
|
219
|
+
try:
|
|
220
|
+
if p.stat().st_size > max_bytes:
|
|
221
|
+
continue
|
|
222
|
+
text = p.read_text(encoding="utf-8")
|
|
223
|
+
except (OSError, UnicodeDecodeError):
|
|
224
|
+
continue
|
|
225
|
+
findings.extend(review_code(rel_s, text))
|
|
226
|
+
checked += 1
|
|
227
|
+
if checked >= max_files:
|
|
228
|
+
break
|
|
229
|
+
findings.sort(key=lambda f: (SEVERITIES.index(f.severity), f.path, f.line))
|
|
230
|
+
return findings, checked
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
AUDIT_PROMPT = """Review this project for security problems, the way a careful reviewer of AI-written code would. Report only real, exploitable issues, worst first
|
|
234
|
+
(Critical, High, Medium, Low). For each: the file and line, the problem, what an attacker could actually do, and the fix in a few lines.
|
|
235
|
+
Check: secrets in code or in client-side variables; database access without per-user rules (Supabase RLS, Firebase rules); routes and actions
|
|
236
|
+
that do not check who is logged in; prices, roles or plans trusted from the client; payment webhooks without signature checks; missing rate
|
|
237
|
+
limits on login and AI endpoints; AI keys in front-end code and no usage caps; model output rendered as HTML or run as code; SQL or shell
|
|
238
|
+
built from strings; open CORS; debug or source maps left on in production.
|
|
239
|
+
The automatic scanner already found the items below; confirm or dismiss them, then add what it cannot see. Do not write files."""
|