Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
**Action:** Always defer expensive path computations (like converting paths to relative or string sanitization) until *after* the fast-path condition (like a regex match) triggers. This drastically cuts down on unnecessary string operations for clean files.
## 2024-06-20 - Regex File Scanning Optimization
**Learning:** Python's `for line in f:` combined with running multiple regex checks per line introduces huge interpreter overhead for file scanning utilities.
**Action:** Use `.read()` and `.finditer(content)` for the whole file, which pushes the tight iteration loops down to the C-compiled regex engine. Recover line numbers with string `.count('\n')` only when a match is found to achieve massive performance gains (~20-30% reduction in scan time on large text corpuses).
**Action:** Use `.read()` and `.finditer(content)` for the whole file, which pushes the tight iteration loops down to the C-compiled regex engine. Recover line numbers with string `.count('
')` only when a match is found to achieve massive performance gains (~20-30% reduction in scan time on large text corpuses).
Comment on lines +18 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

줄바꿈 문자열 표기를 복구하십시오.

Line 18-19와 Line 45-48에서 '\n'"\n"가 실제 줄바꿈으로 분리되었습니다. 현재 예제는 줄바꿈 문자를 인자로 전달하는 코드를 정확히 표시하지 못합니다. 각 예제를 한 줄의 이스케이프된 문자열 리터럴로 작성하십시오.

Also applies to: 45-48

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.jules/bolt.md around lines 18 - 19, Restore the newline literals in the
examples at the referenced sections so they appear as single-line escaped
strings, using '\n' or "\n" rather than embedding an actual line break inside
the literal. Preserve the surrounding .read(), .finditer(content), and
line-number recovery guidance unchanged.


## 2024-06-21 - Python Regex vs String Lookup Overhead
**Learning:** In Python, a combined massive regular expression (e.g., `re.compile("...|...|...", re.IGNORECASE)`) or iterating over multiple compiled regex objects with `finditer()` is surprisingly slower on large texts than a simple substring pre-filter using `content.lower()` and `any(k in content for k in keywords)`. In `VibeSec`, `finditer` on a clean 10MB file took ~1.5s, `re.search` with a combined regex took ~2.6s, while `in` operator substring searching completed in ~0.1s (a 10x+ speedup). The C-compiled string operations bypass regular expression engine overhead completely.
Expand All @@ -41,8 +42,10 @@
**Learning:** Inside tight loops like rule match processing, repeatedly invoking `base_path.is_dir()` and `Path(".").resolve()` is extremely expensive because they trigger synchronous `stat()` system calls on the disk.
**Action:** Always hoist constant path resolutions (like determining the base directory) outside of loops and hot paths. Store the resolved reference once and reuse it to avoid recursive I/O overhead.
## 2026-07-01 - O(N*M) Line Counting Optimization
**Learning:** In `scanner/cli/appguardrail.py`, the `_scan_file` loop calculates line numbers by calling `count_newlines("\n", 0, start_idx)` for *every* regex match. In files with many matches, this repeatedly scans the string from the beginning, resulting in O(N*M) performance (where N is file length and M is matches). This is a massive bottleneck.
**Action:** Since `re.finditer` yields matches strictly in order, always calculate line numbers progressively using a tracking variable `current_line` and `current_pos`. Update `current_line += count_newlines("\n", current_pos, start_idx)`. This makes the line calculation strictly O(N), bringing up to a 15x speedup for files with many hits.
**Learning:** In `scanner/cli/appguardrail.py`, the `_scan_file` loop calculates line numbers by calling `count_newlines("
", 0, start_idx)` for *every* regex match. In files with many matches, this repeatedly scans the string from the beginning, resulting in O(N*M) performance (where N is file length and M is matches). This is a massive bottleneck.
**Action:** Since `re.finditer` yields matches strictly in order, always calculate line numbers progressively using a tracking variable `current_line` and `current_pos`. Update `current_line += count_newlines("
", current_pos, start_idx)`. This makes the line calculation strictly O(N), bringing up to a 15x speedup for files with many hits.

## 2026-07-02 - Remove `re.search` fast-path pre-check
**Learning:** Python's `re.finditer` evaluates lazily by allocating a lightweight C-level `ScannerObject`. Using `re.search` as a fast-path pre-check before `re.finditer` is an anti-pattern that addresses a non-existent bottleneck and degrades performance for matched paths by evaluating the regex twice.
Expand Down Expand Up @@ -73,3 +76,10 @@
## 2024-05-19 - Pathlib Instantiation in Hot Loops
**Learning:** Blindly instantiating `pathlib.Path` objects in hot loops (like file discovery loops or display formatters such as `detect_language_axes` and `_display_path`) creates measurable performance bottlenecks due to object allocation and potential system calls. When checking file extensions or processing path strings, Python's native string methods like `str.rfind()` and `str.replace()` are vastly more efficient.
**Action:** Replace `pathlib.Path` usage with fast C-level string operations (`replace("\\", "/")`, `rfind()`, `split()`) in performance-critical areas, particularly when traversing thousands of files, formatting paths, or extracting file extensions.
## 2026-08-05 - File scanning path stat optimization
**Learning:** Rechecking a constant scan root for every file adds avoidable filesystem metadata calls, while standalone `_scan_file` callers still need a safe one-time fallback.
**Action:** Compute the scan-root file classification and normalized prefix once in `cmd_scan`, pass them into `_scan_file`, and let direct callers compute the same values once per call.

## 2026-08-05 - File path string operations optimization
**Learning:** Native string basename and suffix extraction avoids temporary normalized strings, but a public `str | Path` API must continue to accept `str` subclasses.
**Action:** Use `isinstance(file_path, str)` for the public contract and `rfind()` for slash and dot boundaries without allocating a replaced path string.
6 changes: 6 additions & 0 deletions CHANGELOG.d/877-scan-path-performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Changed

- Reduced repeated scan-root file classification and relative-path allocation in large repository scans while retaining a one-time fallback for standalone `_scan_file` callers.
- Preserved the public `str | Path` contract, including `str` subclasses, while using allocation-light basename and suffix parsing in language detection.
- Restricted bearer-authenticated control-plane uploads and redirects to public HTTPS, rejected transport downgrades, and removed sensitive authorization headers from cross-origin redirects.
- Limited authentication-deferral findings to source comments so executable hardening such as removing `Authorization` headers is not misclassified as deferred authentication work.
32 changes: 31 additions & 1 deletion appguardrail_core/controlplane.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,10 +275,40 @@ def is_bad_ip(ip) -> bool:


class SafeRedirectHandler(urllib.request.HTTPRedirectHandler):
"""Reject unsafe redirects and prevent cross-origin credential forwarding."""

def redirect_request(self, req, fp, code, msg, headers, newurl):
"""Build one safe redirected request with bounded credential scope."""
if not _is_safe_url(newurl):
raise urllib.error.URLError("Unsafe redirect target")
return super().redirect_request(req, fp, code, msg, headers, newurl)

redirected = super().redirect_request(req, fp, code, msg, headers, newurl)
Comment on lines 282 to +285

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 5 'def _is_safe_url|SafeRedirectHandler|build_opener|opener\.open|socket\.getaddrinfo|create_connection' \
  appguardrail_core/controlplane.py scanner/cli/appguardrail.py

Repository: ContextualWisdomLab/appguardrail

Length of output: 6551


SSRF (CWE-918): Server-Side Request Forgery (SSRF)

Reachability: External · Exploitability: Moderate

Reachability path
● Entry
  tests/test_bolt_review_regressions.py
│
▼
● Hop
  scanner/cli/appguardrail.py:1701
  _push_findings: POST normalized findings to a control-plane /api/v1/scans endpoint.
│
▼
● Sink
  appguardrail_core/controlplane.py

검증된 DNS 주소에 연결을 고정하십시오.

_is_safe_url(newurl)은 사전 DNS 응답만 검사합니다. 이후 urllib가 호스트 이름을 다시 해석하므로 DNS rebinding으로 내부 주소에 연결할 수 있습니다. 검증된 IP를 실제 연결에 사용하거나 egress 정책으로 내부 주소 연결을 차단하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@appguardrail_core/controlplane.py` around lines 282 - 285, Update the
redirect handling around _is_safe_url and redirect_request so validation and the
subsequent connection use the same resolved, verified IP address rather than
resolving newurl again. Preserve rejection of unsafe targets, and ensure
redirects cannot reach internal addresses through DNS rebinding; alternatively
enforce an equivalent egress block for private/internal destinations.

if redirected is None or req is None:
return redirected

original = urlparse(req.full_url)
target = urlparse(newurl)
has_sensitive_header = req.has_header("Authorization") or req.has_header(
"Proxy-Authorization"
)
if not has_sensitive_header:
return redirected
if original.scheme.lower() != "https" or target.scheme.lower() != "https":
raise urllib.error.URLError("Authenticated redirects require HTTPS")

def origin(parsed):
scheme = parsed.scheme.lower()
port = parsed.port or (443 if scheme == "https" else 80)
return scheme, (parsed.hostname or "").lower(), port

try:
cross_origin = origin(original) != origin(target)
except ValueError as exc:
raise urllib.error.URLError("Unsafe redirect target") from exc
if cross_origin:
redirected.remove_header("Authorization")
redirected.remove_header("Proxy-Authorization")
Comment on lines +291 to +310

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

python - <<'PY'
from urllib.request import Request

request = Request(
    "https://api.example.com/scans",
    headers={"Proxy-Authorization": "Basic secret"},
)

print(request.header_items())
assert not request.has_header("Proxy-Authorization")
assert any(
    name.lower() == "proxy-authorization" for name, _ in request.header_items()
)
PY

Repository: ContextualWisdomLab/appguardrail

Length of output: 212


Sensitive Data Exposure (CWE-200): Exposure of Sensitive Information to an Unauthorized Actor

Reachability: Internal · Exploitability: Moderate

Reachability path
● Entry
  tests/test_bolt_review_regressions.py
│
▼
● Hop
  scanner/cli/appguardrail.py:1701
  _push_findings: POST normalized findings to a control-plane /api/v1/scans endpoint.
│
▼
● Sink
  appguardrail_core/controlplane.py

Proxy-Authorization 헤더를 대소문자와 무관하게 검사하고 제거하십시오.

urllib.request.Request는 헤더를 Proxy-authorization으로 저장합니다. 현재 호출은 해당 헤더를 감지하거나 제거하지 못하므로, 교차 출처 리디렉션이 프록시 자격 증명을 전달할 수 있습니다. header_items()에서 헤더 이름을 소문자로 비교하고, 제거할 때 열거된 실제 헤더 이름을 사용하십시오. 회귀 테스트도 header_items()를 기준으로 두 민감한 헤더가 없는지 검사하십시오.

📍 Affects 2 files
  • appguardrail_core/controlplane.py#L291-L310 (this comment)
  • tests/test_bolt_review_regressions.py#L46-L65
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@appguardrail_core/controlplane.py` around lines 291 - 310, Update the
redirect handling around has_sensitive_header and the cross-origin cleanup to
inspect redirected.header_items() case-insensitively, detecting both
Authorization and Proxy-Authorization regardless of urllib’s stored casing, and
remove each sensitive header using its enumerated actual name. In
tests/test_bolt_review_regressions.py lines 46-65, update the regression
assertion to inspect header_items() and verify that neither sensitive header
remains after a cross-origin redirect.

return redirected


def _send_alert(
Expand Down
9 changes: 5 additions & 4 deletions appguardrail_core/language.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,13 +94,14 @@ def detect_language_axes(files: Iterable[str | Path]) -> set[str]:
"""Return language axes found in a scan target without requiring user flags."""
languages: set[str] = set()
for file_path in files:
if isinstance(file_path, Path):
# Preserve the public str | Path contract, including str subclasses.
if not isinstance(file_path, str):
name = file_path.name
suffix = file_path.suffix.lower()
else:
file_path_posix = file_path.replace("\\", "/")
idx = file_path_posix.rfind("/")
name = file_path_posix[idx + 1 :] if idx != -1 else file_path_posix
# ⚡ Bolt: Avoid string allocations like replace('\\', '/') and use fast C-level string operations
idx = max(file_path.rfind("/"), file_path.rfind("\\"))
name = file_path[idx + 1 :] if idx != -1 else file_path

dot_idx = name.rfind(".")
suffix = name[dot_idx:].lower() if dot_idx > 0 else ""
Expand Down
128 changes: 84 additions & 44 deletions scanner/cli/appguardrail.py
Original file line number Diff line number Diff line change
Expand Up @@ -1169,7 +1169,7 @@ def _load_packaged_regex_rules():

def _display_path(path: str | Path) -> str:
"""Return a stable, slash-separated path for CLI output and reports."""
return path.as_posix() if isinstance(path, Path) else path.replace("\\", "/")
return path.replace("\\", "/") if isinstance(path, str) else path.as_posix()


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1415,15 +1415,31 @@ def cmd_scan(args):
files_scanned = 0
scanned_files = []

if scan_path.is_file():
scan_path_is_file = scan_path.is_file()
if scan_path_is_file:
files_to_scan = [scan_path]
else:
files_to_scan = _collect_files(scan_path)

resolved_base_path = Path(".").resolve() if scan_path_is_file else scan_path
resolved_base_path_str = str(resolved_base_path)
resolved_base_path_prefix = (
resolved_base_path_str + os.sep
if not resolved_base_path_str.endswith(os.sep)
else resolved_base_path_str
)

for file_path in files_to_scan:
scanned_files.append(file_path)
files_scanned += 1
file_findings = _scan_file(file_path, scan_path)
file_findings = _scan_file(
file_path,
scan_path,
resolved_base_path,
resolved_base_path_str,
resolved_base_path_prefix,
scan_path_is_file,
)
findings.extend(file_findings)

profile = detect_stack_profile(scanned_files)
Expand Down Expand Up @@ -1670,6 +1686,18 @@ def is_bad_ip(ip) -> bool:
return True



def _is_secure_control_plane_url(url: str) -> bool:
"""Return whether a bearer-token destination is public HTTPS."""
import urllib.parse

try:
parsed = urllib.parse.urlparse(url)
except ValueError:
return False
return parsed.scheme.lower() == "https" and _is_safe_url(url)


def _push_findings(url, findings):
"""POST normalized findings to a control-plane /api/v1/scans endpoint."""
import urllib.request
Expand All @@ -1681,9 +1709,9 @@ def _push_findings(url, findings):
file=sys.stderr,
)
return
if not _is_safe_url(url):
if not _is_secure_control_plane_url(url):
_console_print(
f"⚠️ --push URL must be a valid http/https URL and not point to internal infrastructure, got {url}",
f"⚠️ --push URL must be a public HTTPS URL, got {url}",
file=sys.stderr,
)
return
Expand All @@ -1704,11 +1732,9 @@ def _push_findings(url, findings):
)
try:
opener = urllib.request.build_opener(SafeRedirectHandler())
with (
opener.open( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
req, timeout=15
) as resp
): # noqa: S310 - Safe URL scheme validated
with opener.open( # nosemgrep: python.lang.security.audit.dynamic-urllib-use-detected.dynamic-urllib-use-detected
req, timeout=15
) as resp: # noqa: S310 - Safe URL scheme validated
body = json.loads(resp.read() or b"{}")
drift = body.get("new_blocking")
extra = f", {drift} newly deploy-blocking" if drift else ""
Expand Down Expand Up @@ -2666,20 +2692,22 @@ def _run_semgrep_scan(scan_path: Path, config: str = "auto"):

config = config or "auto"
try:
process = subprocess.run( # noqa: S603 - Semgrep path resolved with shutil.which
[
semgrep,
"scan",
"--config",
config,
"--json",
str(scan_path),
],
shell=False,
capture_output=True,
text=True,
check=False,
timeout=600,
process = (
subprocess.run( # noqa: S603 - Semgrep path resolved with shutil.which
[
semgrep,
"scan",
"--config",
config,
"--json",
str(scan_path),
],
shell=False,
capture_output=True,
text=True,
check=False,
timeout=600,
)
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError("Semgrep scan timed out.") from exc
Expand Down Expand Up @@ -2746,13 +2774,15 @@ def _run_zap_baseline(target_url: str):
with tempfile.TemporaryDirectory() as tmpdir:
report_path = Path(tmpdir) / "zap-baseline.json"
try:
process = subprocess.run( # noqa: S603 - ZAP path resolved with shutil.which
[zap, "-t", target_url, "-J", str(report_path), "-I"],
shell=False,
capture_output=True,
text=True,
check=False,
timeout=900,
process = (
subprocess.run( # noqa: S603 - ZAP path resolved with shutil.which
[zap, "-t", target_url, "-J", str(report_path), "-I"],
shell=False,
capture_output=True,
text=True,
check=False,
timeout=900,
)
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError("ZAP baseline scan timed out.") from exc
Expand Down Expand Up @@ -2842,14 +2872,30 @@ def _run_codegraph_index(scan_path: Path):
return _run_codegraph_command([codegraph, "status"], workdir, "status")


def _scan_file(file_path: Path, base_path: Path):
def _scan_file(
file_path: Path,
base_path: Path,
resolved_base_path: Path | None = None,
resolved_base_path_str: str | None = None,
resolved_base_path_prefix: str | None = None,
base_path_is_file: bool | None = None,
):
"""Scan a single file and return a list of findings."""
findings = []

# ⚡ Bolt: Hoist expensive relative_to base_path resolution outside of loops.
# Path.is_dir() and Path.resolve() invoke stat() system calls. Doing this inside
# the finding iteration loop for every match was causing massive I/O overhead.
resolved_base_path = base_path if base_path.is_dir() else Path(".").resolve()
# Callers scanning many files precompute this immutable path context once.
if base_path_is_file is None:
base_path_is_file = base_path.is_file()
if resolved_base_path is None:
resolved_base_path = Path(".").resolve() if base_path_is_file else base_path
if resolved_base_path_str is None:
resolved_base_path_str = str(resolved_base_path)
if resolved_base_path_prefix is None:
resolved_base_path_prefix = (
resolved_base_path_str + os.sep
if not resolved_base_path_str.endswith(os.sep)
else resolved_base_path_str
)

# ⚡ Bolt: Optimize stat calls by using os.lstat instead of Path objects
# Impact: Combines symlink, file type, and size checks into a single stat call
Expand Down Expand Up @@ -2878,12 +2924,6 @@ def _scan_file(file_path: Path, base_path: Path):
build_finding = _build_finding

# Pre-compute string values to replace slow Path.relative_to() calls
resolved_base_path_str = str(resolved_base_path)
resolved_base_path_prefix = (
resolved_base_path_str + os.sep
if not resolved_base_path_str.endswith(os.sep)
else resolved_base_path_str
)
file_path_str_cache = str(file_path)

try:
Expand Down Expand Up @@ -2914,7 +2954,7 @@ def _scan_file(file_path: Path, base_path: Path):
else:
rel_path_for_filters = (
file_path.name
if base_path.is_file()
if base_path_is_file
else file_path_str_cache
)
rel_path_for_filters = _display_path(rel_path_for_filters)
Expand All @@ -2939,7 +2979,7 @@ def _scan_file(file_path: Path, base_path: Path):
else:
rel_path_for_output = (
file_path.name
if base_path.is_file()
if base_path_is_file
else file_path_str_cache
)
rel_path_str = _sanitize_terminal_output(
Expand Down
6 changes: 3 additions & 3 deletions scanner/rules/authz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ rules:

- id: todo-skip-auth
patterns:
- pattern-regex: '(?i)(?:todo|fixme|hack|temp|temporary).{0,50}(?:auth|security|permission|check|protect)'
- pattern-regex: '(?i)(?:skip|bypass|disable|remove).{0,30}(?:auth|authentication|authorization|security)'
- pattern-regex: '(?i)//\s*(?:disable|mock|fake)\s*(?:auth|security)'
- pattern-regex: '(?im)^\s*(?://|#|/\*+|\*)\s*(?:todo|fixme|hack|temp|temporary)\b[^\n]{0,50}\b(?:auth|security|permission|check|protect)\b'
- pattern-regex: '(?im)^\s*(?://|#|/\*+|\*)\s*(?:skip|bypass|disable|remove)\b[^\n]{0,30}\b(?:auth|authentication|authorization|security)\b'
- pattern-regex: '(?im)^\s*(?://|#|/\*+|\*)\s*(?:disable|mock|fake)\s+(?:auth|security)\b'
Comment on lines +59 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

블록 주석의 * 접두사를 실제 주석 문맥으로 제한하십시오.

Line 59의 독립된 \* 대안은 블록 주석 내부인지 확인하지 않습니다. 예를 들어 유효한 Python 식의 * todo * auth 연속 줄도 todo-skip-auth 규칙에 일치합니다. Line 60도 같은 문제가 있습니다.

이 경우 실행 코드에 HIGH finding이 생성됩니다. * 접두사는 /*로 시작한 블록 내부에서만 허용하거나, 주석을 먼저 추출한 뒤 규칙을 적용하십시오. 실행 가능한 여러 줄 식이 finding을 만들지 않는 회귀 테스트와 /* ... * TODO ... */ 양성 테스트를 추가하십시오.

As per coding guidelines, “Treat AppGuardrail critical/high findings in app code as deploy blockers.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scanner/rules/authz.yml` around lines 59 - 61, Update the comment-prefix
handling in the two relevant pattern-regex rules so a standalone * prefix only
matches within a /* ... */ block comment, preventing executable multiline
expressions such as * todo * auth from producing HIGH findings; alternatively
extract comments before applying these rules. Add regression coverage confirming
executable expressions do not match and /* ... * TODO ... */ block comments do
match.

Source: Coding guidelines

message: |
Comment suggests authentication or security check was intentionally skipped
or deferred. This is a common pattern in AI-generated code. Review and ensure
Expand Down
2 changes: 1 addition & 1 deletion tests/test_appguardrail.py
Original file line number Diff line number Diff line change
Expand Up @@ -769,7 +769,7 @@ def fake_collect_files(_base_path):
events.append(f"yield:{file_path.name}")
yield file_path

def fake_scan_file(file_path, _base_path):
def fake_scan_file(file_path, _base_path, *args, **kwargs):
events.append(f"scan:{file_path.name}")
return []

Expand Down
Loading
Loading