Skip to content
Merged
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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ All notable changes to CodeSnake are documented here. The format follows

## [Unreleased]

### Fixed

- **`.gitignore` no longer hides committed files from a directory walk.** git's own
rule is that an ignore pattern has no effect on a tracked file; CodeSnake applied
it regardless, so a pull request could add a file, ignore it, `git add -f` it, and
`codesnake check src/` would never see it — exiting 0 with the file in the tree.
Tracked files are now exempt from ignore filtering, and an ignored directory
holding a tracked file is walked rather than pruned. Untracked ignored files, such
as generated output, are still skipped. No flag is needed, and CI gates written
before this release are covered on upgrade.

### Added

- **`--no-ignore`** disables `.gitignore` handling entirely, covering *untracked*
ignored files as well — useful for linting generated code or scanning a tree that
is not a git repository. Venvs, caches, and `.git` stay skipped. Explicit file
arguments were never gitignore-filtered.

## [1.2.1] - 2026-09-01

First release published to PyPI. The package itself is byte-identical to 1.2.0 —
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,13 @@ adoption path is in [docs/INTEGRATIONS.md](docs/INTEGRATIONS.md#adopting-codesna
## Usage

```bash
# Files or directories (walks *.py, skips venvs, caches, and .gitignore)
# Files or directories (walks *.py; skips venvs, caches, and untracked .gitignore matches)
codesnake check src/codesnake/checker.py test/example_bad_code.py
codesnake check src/

# Also analyze untracked files that .gitignore hides, e.g. generated output
codesnake check --no-ignore src/

# Same thing without the subcommand
codesnake src/

Expand Down Expand Up @@ -110,11 +113,12 @@ codesnake config -o .codesnake.json
| `--no-color` | Disable ANSI color (`NO_COLOR` also works) |
| `--bandit` | Merge Bandit results when the `bandit` executable is installed |
| `--staged` | Check `git diff --cached` Python files only |
| `--no-ignore` | When walking directories, ignore `.gitignore` entirely, including for untracked files (venvs, caches, and `.git` are still skipped). Tracked files are analyzed either way. |
| `--baseline FILE` | Hide issues whose fingerprint is already in the baseline |
| `--update-baseline FILE` | Write the current finding set as a baseline |
| `-j`, `--jobs N` | Worker processes (default: auto — one per CPU once 8+ files are checked; `1` disables) |

`--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).
`--staged` needs no file arguments and works from any directory inside the repository (paths from git are resolved against the repo root). With no staged `.py` files it exits **0**. Directory walks skip `.gitignore` matches only for files git does not track, mirroring git's own behavior — so a file committed with `git add -f` is still analyzed, and no flag is needed to catch it. `--no-ignore` additionally covers *untracked* ignored files, such as generated output. `--baseline` fingerprints are `filename|code|message-with-numbers-normalized|occurrence`, so line-only edits and count changes (`52 lines long` → `53 lines long`) do not re-fail CI, while a *second* identical violation in the same file still does. Version-1 baselines are read transparently; `--update-baseline` writes version 2. A missing baseline file fails closed (exit 1).

### Output formats

Expand Down
2 changes: 2 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ Two places reach outside the process, both only when you ask:
- `--bandit` runs the separate `bandit` executable, if it is installed and on `PATH`, over the files you named. That is a different project with its own dependencies and its own threat model.
- `--staged` runs `git rev-parse` and `git diff --cached` to list staged files.

Directory walks skip `.gitignore` matches, but **only for files git does not track** — matching git's own rule that an ignore pattern has no effect on a committed file. A file added with `git add -f` is therefore still analyzed, and so is a tracked file inside an ignored directory. `--no-ignore` disables ignore handling entirely, for scanning a working tree that is not a git repository or whose ignored output you want covered. Venvs, caches, and `.git` are skipped in every mode.

### In scope

- Anything that makes CodeSnake execute code from a file it is analyzing
Expand Down
4 changes: 2 additions & 2 deletions codesnake-launcher.sh
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@ Options:
--test Run test suite
--banner Show CodeSnake banner

Any other argument (files, directories, --bandit, --staged, --baseline FILE,
--no-color, ...) is passed straight through to CodeSnake.
Any other argument (files, directories, --bandit, --staged, --no-ignore,
--baseline FILE, --no-color, ...) is passed straight through to CodeSnake.

Examples:
$0 mycode.py # Check a file
Expand Down
2 changes: 1 addition & 1 deletion docs/BASH_SCRIPTS_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Activates `codesnake-venv/` (creating and populating it on first run), then `exe
| `--banner` | Print the banner |
| `-e`, `--enhanced` | Deprecated no-op (the "enhanced" checker was merged into the main CLI) |

Anything else — files, directories, `--bandit`, `--staged`, `--baseline FILE`, `--update-baseline FILE`, `--jobs N`, `--no-color` — is passed straight through to `codesnake check`. Arguments are forwarded as an array, so paths with spaces or shell metacharacters are safe. An option that needs a value but has none exits with status 2 and a message.
Anything else — files, directories, `--bandit`, `--staged`, `--no-ignore`, `--baseline FILE`, `--update-baseline FILE`, `--jobs N`, `--no-color` — is passed straight through to `codesnake check`. Arguments are forwarded as an array, so paths with spaces or shell metacharacters are safe. An option that needs a value but has none exits with status 2 and a message.

```bash
./codesnake-launcher.sh mycode.py
Expand Down
23 changes: 19 additions & 4 deletions docs/INTEGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ To have pre-commit manage the install instead, use `language: python` with

## GitHub Actions

`.gitignore` is applied only to files git does not track, matching git's own rule that an ignore pattern has no effect on a committed file. A file added with `git add -f`, or a tracked file inside an ignored directory, is analyzed by a plain `codesnake check src/` — no flag required. This is what stops a pull request from hiding code from the gate by ignoring it and force-adding it.

Two variations are still useful:

```bash
# Also cover UNTRACKED ignored files, e.g. generated code you want linted anyway
codesnake check --no-ignore --severity error --no-color src/

# Exactly what git tracks, and nothing else
codesnake check --no-color --severity error $(git ls-files '*.py')
```

Run the `git ls-files` form from the repository root. If it expands to nothing, CodeSnake exits 2 (`no files to check`); the directory form does not have that empty-tree edge.

### Inline annotations

`--format github` prints workflow commands; GitHub turns them into annotations on the PR diff. This repository's own `.github/workflows/ci.yml` does exactly this.
Expand Down Expand Up @@ -80,7 +94,7 @@ Upload SARIF so findings appear under **Security → Code scanning**. The `|| tr
- uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: codesnake.sarif
- run: codesnake check --severity error --no-color src/
- run: codesnake check --severity error --no-color --no-ignore src/
```

### Only fail on new findings
Expand All @@ -95,7 +109,7 @@ git add .codesnake-baseline.json
Baselines store paths relative to the working directory, so write and read them from the **same** directory — the repository root, to match CI. A baseline recorded at the root suppresses nothing when the check is later run from inside `src/`. (This is why `--baseline` does not belong in the `--staged` hook above without pinning the directory first.)

```yaml
- run: codesnake check --baseline .codesnake-baseline.json --format github --no-color src/
- run: codesnake check --baseline .codesnake-baseline.json --format github --no-color --no-ignore src/
```

A fingerprint is the file path, the rule code, the message with digits normalized away, and an occurrence index. Line numbers and numeric counts therefore do not matter — edits above a finding, or a complexity score drifting from 15 to 16, will not re-fail CI. Anything else does: moving code to another file, or a rename that changes an identifier quoted in the message (`Unused import 'os'`), mints a new fingerprint even though the finding is unchanged. A genuinely *new* violation (including a second identical one in the same file) fails too. Refresh the baseline with `--update-baseline` as you pay down the backlog.
Expand Down Expand Up @@ -159,7 +173,7 @@ lint: ## everything, human-readable
codesnake check src/ test/

lint-errors: ## only what would fail CI
codesnake check --severity error src/
codesnake check --severity error --no-ignore src/

lint-baseline: ## only findings not in the committed baseline
codesnake check --baseline .codesnake-baseline.json src/
Expand Down Expand Up @@ -194,7 +208,8 @@ CodeSnake's differentiators are the taint tracking behind SEC001/SEC003 severiti
| Symptom | Fix |
|---|---|
| Too many findings | Baseline first, then tune thresholds and `check_*` flags; `# noqa: CODE` for the rest |
| Slow on a large tree | `--jobs N` (auto-parallel from 8 files); point at specific directories; generated code under `.gitignore`d directories is already skipped |
| Slow on a large tree | `--jobs N` (auto-parallel from 8 files); point at specific directories; untracked generated code under `.gitignore`d directories is skipped unless you pass `--no-ignore` |
| CI missed a committed file | Tracked files are never hidden by `.gitignore`. If one was missed it is untracked — commit it, or pass `--no-ignore` |
| `No staged Python files.` | Nothing staged, or the files are not `*.py`; a real git failure prints an `Error:` instead |
| Escape codes in CI logs | `--no-color` or `NO_COLOR=1` |
| Warnings don't fail the build | By design; see the exit-code note at the top |
Expand Down
2 changes: 1 addition & 1 deletion docs/PROJECT_STRUCTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ codesnake/

| Module | Contents |
|---|---|
| `checker.py` | `SemanticChecker` (the AST visitor and every rule), `CheckerConfig` / `load_config`, file discovery (`iter_python_files`, `.gitignore` handling, `expand_python_targets`), `check_file`, bandit merge, baselines, `--staged` support, the four report formatters, and `run_check` (the orchestration entry point used by the CLI and by library callers) |
| `checker.py` | `SemanticChecker` (the AST visitor and every rule), `CheckerConfig` / `load_config`, file discovery (`iter_python_files`, `.gitignore` handling, `--no-ignore` / `respect_gitignore`, `expand_python_targets`), `check_file`, bandit merge, baselines, `--staged` support, the four report formatters, and `run_check` (the orchestration entry point used by the CLI and by library callers) |
| `cli.py` | `add_check_arguments()` (the single definition of the `check` flags), `build_parser()`, `normalize_argv()` (so `codesnake FILES` means `codesnake check FILES`), and `main()` |
| `banner.py` | `print_snake_banner()`, `print_version()`; `VERSION` is imported from `_version` |
| `_version.py` | `__version__` — read statically by `pyproject.toml` (`dynamic = ["version"]`) |
Expand Down
123 changes: 107 additions & 16 deletions src/codesnake/checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,34 @@
return None


def git_tracked_files(repo_root: Path) -> Set[Path]:
"""Resolved paths of every file git tracks in ``repo_root``, or an empty set.

git's own rule is that ``.gitignore`` governs *untracked* files only -- a
file that is committed is not affected by an ignore rule matching it. A
scanner that filters tracked files by ``.gitignore`` therefore diverges
from git, and can be made to skip a committed file with ``git add -f``.
Failure here (no git, not a repository, a timeout) yields an empty set,
which restores the previous behavior rather than hiding files.
"""
try:
completed = _subprocess.run(
['git', 'ls-files', '-z', '--full-name'],
capture_output=True,
timeout=30,
cwd=str(repo_root),
)
except (OSError, _subprocess.TimeoutExpired):
return set()
if completed.returncode != 0:
return set()
tracked: Set[Path] = set()
for name in completed.stdout.decode('utf-8', errors='replace').split('\0'):
if name:
tracked.add(repo_root / name)
return tracked


def detect_source_encoding(data: bytes) -> str:
"""PEP 263 encoding cookie, else UTF-8 (with BOM)."""
if data.startswith(b'\xef\xbb\xbf'):
Expand Down Expand Up @@ -404,12 +432,15 @@
return code.upper() in codes


def iter_python_files(root: Path) -> Iterable[Path]:
"""Yield .py files under root, skipping venvs, caches, and .gitignore matches."""
try:
root_resolved = root.resolve()
except OSError:
root_resolved = root
def _load_ignore_state(
root_resolved: Path,
) -> Tuple['_IgnoreStack', Set[Path], Set[Path]]:
"""Ignore rules for a walk, plus the tracked paths exempt from them.

Returns the loaded ``.gitignore`` stack, the set of files git tracks, and
the directories those files live in. Tracked entries are exempt because
git applies ignore rules to untracked files only.
"""
ignore = _IgnoreStack()
# .gitignore files between the repository root and the target apply to
# everything below them, so load them first (top-most first).
Expand All @@ -425,23 +456,68 @@
if gi_path.is_file():
ignore.add_gitignore(gi_path)

if repo_root is None:
return ignore, set(), set()
try:
tracked = git_tracked_files(repo_root.resolve())
except OSError:
return ignore, set(), set()
# An ignored *directory* holding a committed file must still be walked, or
# the file-level exemption is never reached.
tracked_dirs: Set[Path] = set()
for path in tracked:
tracked_dirs.update(path.parents)
return ignore, tracked, tracked_dirs


def iter_python_files(root: Path, *, respect_gitignore: bool = True) -> Iterable[Path]:
"""Yield .py files under root, skipping venvs, caches, and (by default) .gitignore matches.

``respect_gitignore=False`` still skips ``SKIP_DIR_NAMES`` (venvs, caches,
``.git``, ...) so a CI gate does not have to crawl ``site-packages``. It
does not skip a committed file that ``.gitignore`` would hide — pass
``--no-ignore`` for that, or name the file explicitly.
"""
try:
root_resolved = root.resolve()
except OSError:
root_resolved = root
ignore, tracked, tracked_dirs = (
_load_ignore_state(root_resolved) if respect_gitignore
else (_IgnoreStack(), set(), set())
)

for dirpath, dirnames, filenames in os.walk(root_resolved):
current = Path(dirpath)
nested_gi = current / '.gitignore'
if nested_gi.is_file():
ignore.add_gitignore(nested_gi)
views = ignore.view(current)
views: _DirectoryView = []
if respect_gitignore:
nested_gi = current / '.gitignore'
if nested_gi.is_file():
ignore.add_gitignore(nested_gi)
views = ignore.view(current)

dirnames[:] = [
name for name in dirnames
if name not in SKIP_DIR_NAMES
and not name.endswith('.egg-info')
and not _IgnoreStack.ignored(views, name, is_dir=True)
and not (
respect_gitignore
and (current / name) not in tracked_dirs
and _IgnoreStack.ignored(views, name, is_dir=True)
)
]

for name in filenames:
if name.endswith('.py') and not _IgnoreStack.ignored(views, name, is_dir=False):
yield current / name
if not name.endswith('.py'):
continue
candidate = current / name
if (
respect_gitignore
and candidate not in tracked
and _IgnoreStack.ignored(views, name, is_dir=False)
):
continue
yield candidate

Check warning on line 520 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.13)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 15 (max recommended: 10)

Check warning on line 520 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.10)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 15 (max recommended: 10)

Check warning on line 520 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.12)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 15 (max recommended: 10)

Check warning on line 520 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.11)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 15 (max recommended: 10)


@dataclass
Expand Down Expand Up @@ -630,87 +706,87 @@
return CheckerConfig.from_file(str(found))


class SemanticChecker(ast.NodeVisitor):
"""Main semantic checker that analyzes Python AST for issues."""

def __init__(
self,
source_code: str,
filename: str = '<string>',
config: Optional[CheckerConfig] = None,
known_exports: Optional[Dict[str, Set[str]]] = None,
):
self.source_code = source_code
self.filename = filename
self.config = config or CheckerConfig()
self.issues: List[Issue] = []
self.source_lines = source_code.split('\n')

self.aliases: Dict[str, str] = {}
# Cyclomatic complexity per function name (nested scopes not charged).
self.function_complexity: Dict[str, int] = {}
self.scopes: List[_Scope] = []
self._in_type_checking = False
self._with_expr_ids: Set[int] = set()
# id(Name node) -> binding kind for stores that are not plain assignments
# (loop targets, tuple unpacking, bare annotations).
self._store_kinds: Dict[int, str] = {}
# Function bodies are analyzed after the enclosing scope is fully bound,
# so closures may reference names assigned later in that scope.
self._deferred: List[Tuple[ast.AST, List[_Scope], bool]] = []
self.known_exports = known_exports or {}

def add_issue(
self,
severity: str,
category: str,
message: str,
node: ast.AST,
code: str,
):
"""Add an issue at ``node``'s location if its category is enabled."""
self._record_issue(
severity,
category,
message,
getattr(node, 'lineno', 0) or 0,
getattr(node, 'col_offset', 0) or 0,
code,
end_line=getattr(node, 'end_lineno', None) or getattr(node, 'lineno', 0) or 0,
end_col=getattr(node, 'end_col_offset', None) or getattr(node, 'col_offset', 0) or 0,
)

def _record_issue(
self,
severity: str,
category: str,
message: str,
line: int,
col: int,
code: str,
source: str = 'codesnake',
end_line: int = 0,
end_col: int = 0,
suggestion: str = '',
) -> None:
if not self.config.allows_category(category):
return
end_line = end_line or line
col = self._char_col(line, col)
end_col = self._char_col(end_line, end_col) if end_col else col
self.issues.append(Issue(
severity=severity,
category=category,
message=message,
line=line,
col=col,
code=code,
filename=self.filename,
source=source,
end_line=end_line,
end_col=end_col,
suggestion=suggestion or ISSUE_SUGGESTIONS.get(code, ''),
))

Check warning on line 789 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.13)

COMP001 complexity

[COMP001] Function has 11 parameters (max recommended: 7)

Check warning on line 789 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.10)

COMP001 complexity

[COMP001] Function has 11 parameters (max recommended: 7)

Check warning on line 789 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.12)

COMP001 complexity

[COMP001] Function has 11 parameters (max recommended: 7)

Check warning on line 789 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.11)

COMP001 complexity

[COMP001] Function has 11 parameters (max recommended: 7)

def _char_col(self, line: int, col: int) -> int:
"""Convert an AST UTF-8 byte offset into a 0-based character offset."""
Expand Down Expand Up @@ -739,30 +815,30 @@
self._report_unused_imports(scope)
return scope

def _bind(self, name: str, node: ast.AST, kind: str) -> None:
scope = self._current_scope()
if scope is None:
return
if name in scope.global_names:
if len(self.scopes) > 1:
self.scopes[0].bindings.setdefault(name, _Binding(
name,
getattr(node, 'lineno', 0) or 0,
getattr(node, 'col_offset', 0) or 0,
kind,
))
return
if name in scope.nonlocal_names:
return
if name not in scope.bindings:
if kind in _SHADOW_CHECKED_KINDS and scope.kind == 'function':
self._maybe_shadow(name, node)
scope.bindings[name] = _Binding(
name,
getattr(node, 'lineno', 0) or 0,
getattr(node, 'col_offset', 0) or 0,
kind,
)

Check warning on line 841 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.13)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 12 (max recommended: 10)

Check warning on line 841 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.10)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 12 (max recommended: 10)

Check warning on line 841 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.12)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 12 (max recommended: 10)

Check warning on line 841 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.11)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 12 (max recommended: 10)

def _maybe_shadow(self, name: str, node: ast.AST) -> None:
if name.startswith('_') or name in SKIP_UNUSED_NAMES or name in _BUILTIN_NAMES:
Expand Down Expand Up @@ -833,45 +909,45 @@
elif isinstance(target, ast.Starred):
self._mark_tainted_target(target.value)

def _is_tainted_expr(self, node: Optional[ast.AST]) -> bool:
if node is None:
return False
if isinstance(node, ast.Constant):
return False
if isinstance(node, ast.Name):
return self._name_is_tainted(node.id)
if isinstance(node, ast.JoinedStr):
return any(self._is_tainted_expr(value) for value in node.values)
if isinstance(node, ast.FormattedValue):
return self._is_tainted_expr(node.value)
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Mod)):
return self._is_tainted_expr(node.left) or self._is_tainted_expr(node.right)
if isinstance(node, ast.Call):
resolved = self._resolve_name(node.func)
if resolved in TAINT_CALL_NAMES:
return True
if resolved in SANITIZER_NAMES:
return False
if isinstance(node.func, ast.Attribute) and node.func.attr in ('get', 'format'):
if self._is_tainted_expr(node.func.value):
return True
if self._call_args_tainted(node):
return True
return self._call_args_tainted(node)
if isinstance(node, ast.Attribute):
resolved = self._resolve_name(node)
if resolved in TAINT_ATTR_NAMES:
return True
if node.attr in REQUEST_TAINT_ATTRS and self._is_request_like(node.value):
return True
return self._is_tainted_expr(node.value)
if isinstance(node, ast.Subscript):
return self._is_tainted_expr(node.value)
if isinstance(node, ast.Starred):
return self._is_tainted_expr(node.value)
if isinstance(node, (ast.List, ast.Tuple)):
return any(self._is_tainted_expr(elt) for elt in node.elts)
return False

Check warning on line 950 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.13)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 23 (max recommended: 10)

Check warning on line 950 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.10)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 23 (max recommended: 10)

Check warning on line 950 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.12)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 23 (max recommended: 10)

Check warning on line 950 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.11)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 23 (max recommended: 10)

@staticmethod
def _first_arg(node: ast.Call, keyword: Optional[str] = None) -> Optional[ast.AST]:
Expand Down Expand Up @@ -1023,21 +1099,21 @@
if isinstance(node.target, ast.Name) and node.target.id == '__all__':
self._mark_exported(node.value)

def _record_dunder_all_call(self, node: ast.Call) -> None:
"""``__all__.extend([...])`` / ``__all__.append('name')``."""
if not self._at_module_scope():
return
func = node.func
if not isinstance(func, ast.Attribute):
return
if not isinstance(func.value, ast.Name) or func.value.id != '__all__':
return
if func.attr == 'extend' and node.args:
self._mark_exported(node.args[0])
elif func.attr == 'append':
for arg in node.args:
if isinstance(arg, ast.Constant) and isinstance(arg.value, str):
self.scopes[0].used.add(arg.value)

Check warning on line 1116 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.13)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 11 (max recommended: 10)

Check warning on line 1116 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.10)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 11 (max recommended: 10)

Check warning on line 1116 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.12)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 11 (max recommended: 10)

Check warning on line 1116 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.11)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 11 (max recommended: 10)

def _record_constant_assign(self, targets: Sequence[ast.AST], value: ast.AST) -> None:
if not isinstance(value, ast.Constant):
Expand All @@ -1046,26 +1122,26 @@
if isinstance(target, ast.Name):
self._set_const(target.id, value.value)

def _collect_imports(self, tree: ast.AST) -> None:
"""Build a name -> qualified-name map from import statements."""
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
if alias.asname:
self.aliases[alias.asname] = alias.name
else:
root = alias.name.split('.')[0]
self.aliases[root] = root
elif isinstance(node, ast.ImportFrom):
module = node.module or ''
for alias in node.names:
if alias.name == '*':
continue
local = alias.asname or alias.name
if module:
self.aliases[local] = f'{module}.{alias.name}'
else:
self.aliases[local] = alias.name

Check warning on line 1144 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.13)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 11 (max recommended: 10)

Check warning on line 1144 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.10)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 11 (max recommended: 10)

Check warning on line 1144 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.12)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 11 (max recommended: 10)

Check warning on line 1144 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.11)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 11 (max recommended: 10)

def _resolve_name(self, node: Optional[ast.AST]) -> Optional[str]:
"""Return a dotted name for a Call target, using the import map."""
Expand All @@ -1090,27 +1166,27 @@
return True
return False

def _is_stub_body(self, node: ast.AST) -> bool:
body = list(getattr(node, 'body', []))
if (
body
and isinstance(body[0], ast.Expr)
and isinstance(body[0].value, ast.Constant)
and isinstance(body[0].value.value, str)
):
body = body[1:]
if not body:
return True
if len(body) == 1 and isinstance(body[0], ast.Pass):
return True
if (
len(body) == 1
and isinstance(body[0], ast.Expr)
and isinstance(body[0].value, ast.Constant)
and body[0].value.value is ...
):
return True
return False

Check warning on line 1189 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.13)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 12 (max recommended: 10)

Check warning on line 1189 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.10)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 12 (max recommended: 10)

Check warning on line 1189 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.12)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 12 (max recommended: 10)

Check warning on line 1189 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.11)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 12 (max recommended: 10)

def _async_body_has_await(self, node: ast.AST) -> bool:
for child in ast.iter_child_nodes(node):
Expand Down Expand Up @@ -1180,141 +1256,141 @@

# Security Checks

def visit_Call(self, node: ast.Call):
"""Check for security issues in function calls."""
self._record_dunder_all_call(node)
resolved = self._resolve_name(node.func)

if resolved in EVAL_EXEC_NAMES:
called = resolved.rsplit('.', 1)[-1]
arg0 = self._first_arg(node)
if arg0 is not None and self._is_tainted_expr(arg0):
self.add_issue(
'error',
'security',
f"Dangerous use of '{called}()' on untrusted input",
node,
'SEC001',
)
elif arg0 is not None and self._is_literal_expr(arg0):
self.add_issue(
'info',
'security',
f"Use of '{called}()' on a constant - avoid eval/exec",
node,
'SEC001',
)
else:
self.add_issue(
'error',
'security',
f"Dangerous use of '{called}()' - can execute arbitrary code",
node,
'SEC001',
)

if resolved in PICKLE_LOAD_NAMES:
self.add_issue(
'warning',
'security',
f"{resolved}() can execute arbitrary code - use with caution",
node,
'SEC002',
)
elif (
isinstance(node.func, ast.Attribute)
and node.func.attr == 'load'
and isinstance(node.func.value, ast.Call)
and self._resolve_name(node.func.value.func) in UNPICKLER_NAMES
):
unpickler = self._resolve_name(node.func.value.func)
self.add_issue(
'warning',
'security',
f"{unpickler}(...).load() can execute arbitrary code - use with caution",
node,
'SEC002',
)
elif resolved in YAML_LOAD_NAMES:
loader = next((kw.value for kw in node.keywords if kw.arg == 'Loader'), None)
if loader is None and len(node.args) >= 2:
loader = node.args[1]
loader_name = self._resolve_name(loader) if loader is not None else None
if loader is None or loader_name in YAML_UNSAFE_LOADERS:
self.add_issue(
'warning',
'security',
f"{resolved}() without a safe Loader can execute arbitrary code - "
"use yaml.safe_load()",
node,
'SEC002',
)

if resolved in SUBPROCESS_SHELL_NAMES:
cmd = self._first_arg(node, 'args')
tainted_cmd = cmd is not None and self._is_tainted_expr(cmd)
if self._keyword_true(node, 'shell'):
if tainted_cmd:
self.add_issue(
'error',
'security',
"subprocess with shell=True and untrusted input is command injection",
node,
'SEC003',
)
else:
self.add_issue(
'warning',
'security',
"subprocess with shell=True is a security risk - use shell=False",
node,
'SEC003',
)
elif tainted_cmd:
self.add_issue(
'warning',
'security',
"subprocess command built from untrusted input",
node,
'SEC004',
)

if resolved in ALWAYS_SHELL_NAMES:
cmd = self._first_arg(node, 'cmd')
if cmd is not None and self._is_tainted_expr(cmd):
self.add_issue(
'error',
'security',
f"{resolved}() with untrusted input is command injection",
node,
'SEC003',
)
else:
self.add_issue(
'warning',
'security',
f"{resolved}() runs its argument through a shell - "
"use subprocess with shell=False and an argument list",
node,
'SEC003',
)

if resolved in HANDLE_OWNER_NAMES or (
isinstance(node.func, ast.Attribute) and node.func.attr in HANDLE_OWNER_METHODS
):
for arg in node.args:
self._with_expr_ids.add(id(arg))

if resolved in OPEN_NAMES and id(node) not in self._with_expr_ids:
self.add_issue(
'warning',
'bugs',
"open() should be used as a context manager (with open(...) as ...)",
node,
'RES001',
)

self.generic_visit(node)

Check warning on line 1393 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.13)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 31 (max recommended: 10)

Check warning on line 1393 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.10)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 31 (max recommended: 10)

Check warning on line 1393 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.12)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 31 (max recommended: 10)

Check warning on line 1393 in src/codesnake/checker.py

View workflow job for this annotation

GitHub Actions / test (3.11)

COMP002 complexity

[COMP002] Function has cyclomatic complexity of 31 (max recommended: 10)

def _enter_with(self, node: ast.AST) -> None:
for item in node.items: # type: ignore[attr-defined]
Expand Down Expand Up @@ -2139,7 +2215,11 @@
return files, None


def expand_python_targets(paths: Sequence[str]) -> Tuple[List[str], List[Issue]]:
def expand_python_targets(
paths: Sequence[str],
*,
respect_gitignore: bool = True,
) -> Tuple[List[str], List[Issue]]:
"""Expand directories to .py files. Explicit files are kept as given."""
targets: List[str] = []
extras: List[Issue] = []
Expand All @@ -2160,7 +2240,12 @@
for raw in paths:
path = Path(raw)
if path.is_dir():
found = [str(candidate) for candidate in iter_python_files(path)]
found = [
str(candidate)
for candidate in iter_python_files(
path, respect_gitignore=respect_gitignore,
)
]
if not found:
extras.append(_io_issue(raw, f"No Python files found in '{raw}'"))
continue
Expand Down Expand Up @@ -2609,10 +2694,14 @@
baseline_path: Optional[str] = None,
update_baseline: Optional[str] = None,
jobs: Optional[int] = None,
no_ignore: bool = False,
) -> int:
"""Analyze one or more files. Returns 1 if any error-severity issue exists.

``jobs``: worker processes (``None``/``0`` = auto, ``1`` = sequential).
``no_ignore``: walk directories without applying ``.gitignore`` (venvs,
caches, and ``.git`` are still skipped). Explicit file arguments are
never gitignore-filtered.
"""
out = stream if stream is not None else sys.stdout

Expand Down Expand Up @@ -2643,7 +2732,9 @@
if show_banner and output_format == 'text':
print_snake_banner(use_color=use_color)

targets, extra_issues = expand_python_targets(file_list)
targets, extra_issues = expand_python_targets(
file_list, respect_gitignore=not no_ignore,
)

file_reports: List[Tuple[str, List[Issue], Optional[str]]] = []
for extra in extra_issues:
Expand Down
7 changes: 7 additions & 0 deletions src/codesnake/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ def add_check_arguments(parser: argparse.ArgumentParser) -> argparse.ArgumentPar
action='store_true',
help='Check only Python files staged in git',
)
parser.add_argument(
'--no-ignore',
action='store_true',
help='Do not skip .gitignore matches when walking directories '
'(venvs, caches, and .git are still skipped)',
)
parser.add_argument(
'--baseline',
metavar='FILE',
Expand Down Expand Up @@ -141,6 +147,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
baseline_path=args.baseline,
update_baseline=args.update_baseline,
jobs=args.jobs,
no_ignore=args.no_ignore,
)

if args.command == 'config':
Expand Down
Loading