Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
edc47e2
test(scanner): define immutable scan path context core
seonghobae Aug 7, 2026
ed5b28f
test(scanner): define one-context large-scan integration
seonghobae Aug 7, 2026
55df0ca
test(scanner): define comment-only authentication deferral rule
seonghobae Aug 7, 2026
b220b84
test(release): define scanner path-context evidence contract
seonghobae Aug 7, 2026
45a87cf
test(scanner): correct cross-platform separator fixture
seonghobae Aug 7, 2026
bd1736d
feat(scanner): add immutable scan-root path context core
seonghobae Aug 7, 2026
fa87472
fix(scanner): scope auth deferral findings to real comments
seonghobae Aug 7, 2026
5e4281f
docs(scanner): record path-context benchmark and comment boundary
seonghobae Aug 7, 2026
9f1ab01
docs(changelog): record scan path context and rule precision
seonghobae Aug 7, 2026
2e2d188
perf(scanner): export reusable path context
seonghobae Aug 9, 2026
774b0d8
perf(scanner): reuse one scan path context
seonghobae Aug 9, 2026
e0633b2
fix(scanner): avoid duplicate auth deferral findings
seonghobae Aug 9, 2026
734d88c
test(scanner): assert shared path context
seonghobae Aug 9, 2026
5140222
test(scanner): cover path context boundaries
seonghobae Aug 9, 2026
3e7cec7
ci(scanner): enforce exact path context coverage
seonghobae Aug 9, 2026
0883690
test(scanner): cover nested auth deferral comment
seonghobae Aug 11, 2026
7a9bd51
fix(scanner): deduplicate nested auth deferrals
seonghobae Aug 11, 2026
15f0237
test(paths): match literal pathlib type name
seonghobae Aug 11, 2026
81ffbbd
test(authz): require ordered approved authentication
seonghobae Aug 11, 2026
607f36c
fix(authz): enforce authentication before data access
seonghobae Aug 11, 2026
dd69eeb
docs: record authentication rule hardening
seonghobae Aug 11, 2026
29c8556
Merge branch 'develop' into perf/scanner-path-context-893
opencode-agent[bot] Aug 11, 2026
8109d1a
test: anchor authz rule contract to repository path
seonghobae Aug 11, 2026
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
33 changes: 33 additions & 0 deletions .github/workflows/scan-path-context-coverage.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Scan path context coverage

on:
push:
branches: [develop, main]
pull_request:
branches: [develop, main]

permissions:
contents: read

jobs:
exact-coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.13'
- name: Install hash-locked tests
run: >-
python -m pip install --disable-pip-version-check --no-cache-dir
--require-hashes -r requirements-test.txt
- name: Verify exact scan-path context coverage and integration
run: |
python -m scripts.ci.verify_module_coverage \
--module appguardrail_core/scan_paths.py \
--test tests/test_scan_path_context_core.py \
--test tests/test_scan_path_context_integration.py \
--test tests/test_auth_deferral_comment_rule.py \
--test tests/test_scan_path_context_release_contract.py
6 changes: 6 additions & 0 deletions CHANGELOG.d/893-scan-path-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Changed

- Added an immutable reusable scan-root path context so a representative 10,000-file scan reduces root file/directory classification from 10,000 operations to 1 while preserving standalone, single-file, dotfile, separator, `str` subclass, and `Path` behavior without making an unverified wall-clock speedup claim.
- Scoped authentication-deferral findings to real Python, JavaScript, and TypeScript line or bounded block comments, preventing executable multiline `*` expressions and credential-removal hardening code from becoming HIGH findings.
- Restricted route authentication detection to approved APIs and result symbols with order-aware data-access checks, so arbitrary awaits cannot satisfy authentication and authentication performed only after protected data access remains CRITICAL.
- Added exact statement-coverage, deterministic operation-count, modular MSA/naruon, documentation, and regression contracts for the path-context core.
3 changes: 3 additions & 0 deletions appguardrail_core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@
extract_public_references,
validate_rule_metadata,
)
from appguardrail_core.scan_paths import ScanPathContext, build_scan_path_context


ReportContext = _reports.ReportContext
Expand Down Expand Up @@ -187,6 +188,7 @@ def render_buyer_diligence_report(
"SchemaInspection",
"SchemaMigrationError",
"SchemaMigrationResult",
"ScanPathContext",
"StackProfile",
"StalePurgePreview",
"build_buyer_evidence_pack",
Expand All @@ -195,6 +197,7 @@ def render_buyer_diligence_report(
"build_org_inventory",
"build_purge_preview",
"build_rule_metadata",
"build_scan_path_context",
"buyer_evidence_pack_to_dict",
"classify_pr_gate",
"collect_openssf_evidence",
Expand Down
99 changes: 99 additions & 0 deletions appguardrail_core/scan_paths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Build immutable scan-root path context for standalone and batch scanners.

The scanner repeatedly needs the same root classification and string boundary
while processing repository files. This module performs that work once, keeps
it immutable, and preserves the established single-file and directory-relative
path semantics without importing the CLI.
"""

from __future__ import annotations

import os
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class ScanPathContext:
"""One immutable path-classification snapshot shared by a scan batch.

Attributes:
base_path: The caller-supplied scan root.
resolved_base_path: The root used for relative display paths. Directory
scans retain their supplied resolved root; single-file scans retain
the historical current-working-directory root.
resolved_base_path_str: Cached string form of ``resolved_base_path``.
resolved_base_path_prefix: Cached root plus exactly one platform path
separator, preventing prefix collisions such as ``repo`` and
``repository``.
base_path_is_file: Whether the scan root represents one file.
"""

base_path: Path
resolved_base_path: Path
resolved_base_path_str: str
resolved_base_path_prefix: str
base_path_is_file: bool

def relative_candidate(self, file_path: Path) -> str:
"""Return the established pre-sanitization display candidate for a file.

Children of a directory root become relative strings. A file equal to
the resolved root becomes ``"."``. Paths outside a directory root stay
absolute, while a single-file scan falls back to the filename.
"""
file_path_str = str(file_path)
if file_path_str == self.resolved_base_path_str:
return "."
if file_path_str.startswith(self.resolved_base_path_prefix):
return file_path_str[len(self.resolved_base_path_prefix) :]
if self.base_path_is_file:
return file_path.name
return file_path_str


def build_scan_path_context(
base_path: Path,
*,
base_path_is_file: bool | None = None,
) -> ScanPathContext:
"""Classify one scan root and cache its reusable relative-path boundary.

Batch callers should pass an already observed ``base_path_is_file`` value so
this function performs no additional filesystem classification. Standalone
callers may omit it and pay exactly one ``Path.is_file()`` call.

Args:
base_path: Scan root represented as ``pathlib.Path``.
base_path_is_file: Optional previously computed file classification.

Returns:
An immutable context safe to share across every file in one scan.

Raises:
TypeError: If ``base_path`` is not ``Path`` or the optional
classification is not a real Boolean.
"""
if not isinstance(base_path, Path):
raise TypeError("base_path must be a pathlib.Path")
if base_path_is_file is not None and not isinstance(base_path_is_file, bool):
raise TypeError("base_path_is_file must be a Boolean when provided")

is_file = base_path.is_file() if base_path_is_file is None else base_path_is_file
resolved_base_path = Path(".").resolve() if is_file else base_path
resolved_base_path_str = str(resolved_base_path)
resolved_base_path_prefix = (
resolved_base_path_str
if resolved_base_path_str.endswith(os.sep)
else resolved_base_path_str + os.sep
)
return ScanPathContext(
base_path=base_path,
resolved_base_path=resolved_base_path,
resolved_base_path_str=resolved_base_path_str,
resolved_base_path_prefix=resolved_base_path_prefix,
base_path_is_file=is_file,
)


__all__ = ["ScanPathContext", "build_scan_path_context"]
78 changes: 78 additions & 0 deletions docs/scanner-path-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Scanner path-context reuse and authentication-comment precision

AppGuardrail scans large repositories by streaming files through `_scan_file`. The scan root does not change during that operation, so its file/directory classification, relative-path root, string form, and separator-safe prefix are immutable scan-level data rather than file-level data.

The reusable `appguardrail_core.scan_paths` module exposes that contract independently of the CLI for standalone integrations, organization services, `naruon`, and other modular MSA consumers.

## Architecture

```mermaid
flowchart LR
A[Resolved scan root] --> B[One file or directory classification]
B --> C[Immutable ScanPathContext]
C --> D1[File 1 scan]
C --> D2[File 2 scan]
C --> D3[File N scan]
D1 --> E[Shared relative-path semantics]
D2 --> E
D3 --> E
```

`cmd_scan` observes `scan_path.is_file()` once, builds one frozen context, and passes the same object to every `_scan_file` call. A standalone `_scan_file` caller that does not provide a context retains a safe fallback and performs one classification for that call.

The context preserves the existing behavior:

- directory children are represented relative to the resolved root;
- a path equal to the resolved root is represented as `.`;
- paths outside a directory root retain their full string form;
- a single-file scan falls back to the filename and uses the current working directory as its relative root;
- the root prefix always contains one platform separator, so `repo` does not falsely match `repository`;
- plain strings, `str` subclasses, `Path` values, dotfiles, and platform-specific separators retain their public contracts.

## Deterministic benchmark evidence

The performance gate uses an operation-count benchmark instead of a timing threshold. Wall-clock microbenchmarks in shared CI are affected by runner load, filesystem cache, operating system scheduling, and virtualized storage. The test therefore makes **no wall-clock speedup claim**.

For a representative stream of **10,000** files:

| Root operation | Previous placement | New placement |
|---|---:|---:|
| Root file/directory classification | once inside each `_scan_file` call | once in `cmd_scan` |
| Observed classification count | **10,000** | **1** |
| Context object construction | 10,000 | 1 |
| Context identity across file calls | not applicable | exactly one shared frozen object |

The deterministic result is therefore **10,000 → 1** scan-root classification operations for that workload. This demonstrates removal of redundant metadata decisions without claiming a machine-independent elapsed-time percentage. Operators can run repository-specific profiling separately when deciding whether filesystem latency makes the optimization material in their environment.

## Authentication-deferral rule boundary

The `todo-skip-auth` rule is intended to detect comments that explicitly defer authentication or security work. It must not classify executable hardening code, multiplication expressions, or arbitrary source text as a HIGH finding.

The packaged rule now recognizes:

- Python `#` line comments;
- JavaScript and TypeScript `//` line comments; and
- bounded `/* ... */` block comments, including conventional leading `*` decoration.

A standalone `*` prefix is not treated as a comment. Executable multiline expressions such as `* todo * auth` therefore remain code rather than security-comment evidence. Bounded block-comment expressions stop at the first closing delimiter and avoid scanning an unbounded file as one comment.

## Verification

The protected workflow verifies:

- exact 100% statement coverage for `appguardrail_core/scan_paths.py`;
- frozen-context construction, validation, prefix collision, single-file, directory, and cross-platform contracts;
- the same exact context object across a 2,000-file stream;
- one root classification across a 10,000-file operation-count benchmark;
- one fallback build for standalone `_scan_file` callers;
- `str` subclass behavior in language detection and display paths;
- positive Python, JavaScript, and block-comment findings; and
- negative executable-expression and credential-removal cases.

## References

Python Software Foundation. (2026a). *os—Miscellaneous operating system interfaces* (Python 3.13 documentation). https://docs.python.org/3.13/library/os.html

Python Software Foundation. (2026b). *pathlib—Object-oriented filesystem paths* (Python 3.13 documentation). https://docs.python.org/3.13/library/pathlib.html

Python Software Foundation. (2026c). *re—Regular expression operations* (Python 3.13 documentation). https://docs.python.org/3.13/library/re.html
84 changes: 30 additions & 54 deletions scanner/cli/appguardrail.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
supported_report_types,
)
from appguardrail_core.rules import build_rule_metadata
from appguardrail_core.scan_paths import ScanPathContext, build_scan_path_context

__version__ = "0.1.1"

Expand Down Expand Up @@ -371,16 +372,6 @@ def _console_print(*values, **kwargs) -> None:
"message": "Firebase/Firestore rule allows unrestricted read/write access. Add authentication and ownership checks. [OWASP A01:2021 - Broken Access Control]",
"extensions": [".rules"],
},
{
"id": "todo-skip-auth",
"pattern": re.compile(
r"(?i)(?:todo|fixme|hack|temp)[^\n]{0,50}(?:auth|security|permission|check|protect)",
re.MULTILINE,
),
"severity": "HIGH",
"message": "Comment suggests auth/security check was deferred. Verify this is not deployed to production. [OWASP A01:2021 - Broken Access Control]",
"extensions": [".ts", ".tsx", ".js", ".jsx", ".py"],
},
{
"id": "dangerous-cors",
"pattern": re.compile(r"Access-Control-Allow-Origin['\",\s]*[*]", re.MULTILINE),
Expand Down Expand Up @@ -1420,15 +1411,25 @@ def cmd_scan(args):
files_scanned = 0
scanned_files = []

if scan_path.is_file():
scan_path_is_file = scan_path.is_file()
path_context = build_scan_path_context(
scan_path,
base_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)

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,
path_context=path_context,
)
findings.extend(file_findings)

profile = detect_stack_profile(scanned_files)
Expand Down Expand Up @@ -2901,14 +2902,20 @@ def _run_codegraph_index(scan_path: Path):
return _run_codegraph_command([codegraph, "status"], workdir, "status")


def _scan_file(file_path: Path, base_path: Path):
"""Scan a single file and return a list of findings."""
findings = []
def _scan_file(
file_path: Path,
base_path: Path,
*,
path_context: ScanPathContext | None = None,
):
"""Scan one file using an optional immutable batch path context.

# ⚡ 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()
Direct callers may omit ``path_context`` and retain the historical safe
fallback. Batch callers should build one context and reuse it for every
file so root classification and normalized prefix construction happen once.
"""
findings = []
context = path_context or build_scan_path_context(base_path)

# ⚡ 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 @@ -2936,15 +2943,6 @@ def _scan_file(file_path: Path, base_path: Path):
rel_path_for_filters = None
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:
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
Expand All @@ -2964,19 +2962,9 @@ def _scan_file(file_path: Path, base_path: Path):
) in applicable_rules:
if include_paths or exclude_paths:
if rel_path_for_filters is None:
if file_path_str_cache == resolved_base_path_str:
rel_path_for_filters = "."
elif file_path_str_cache.startswith(resolved_base_path_prefix):
rel_path_for_filters = file_path_str_cache[
len(resolved_base_path_prefix) :
]
else:
rel_path_for_filters = (
file_path.name
if base_path.is_file()
else file_path_str_cache
)
rel_path_for_filters = _display_path(rel_path_for_filters)
rel_path_for_filters = _display_path(
context.relative_candidate(file_path)
)
if not _path_allowed_by_rule(
rel_path_for_filters, include_paths, exclude_paths
):
Expand All @@ -2989,20 +2977,8 @@ def _scan_file(file_path: Path, base_path: Path):

for match in finditer(content):
if rel_path_str is None:
if file_path_str_cache == resolved_base_path_str:
rel_path_for_output = "."
elif file_path_str_cache.startswith(resolved_base_path_prefix):
rel_path_for_output = file_path_str_cache[
len(resolved_base_path_prefix) :
]
else:
rel_path_for_output = (
file_path.name
if base_path.is_file()
else file_path_str_cache
)
rel_path_str = _sanitize_terminal_output(
_display_path(rel_path_for_output)
_display_path(context.relative_candidate(file_path))
)

start_idx = match.start()
Expand Down
Loading
Loading