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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "contract-guard",
"displayName": "contract-guard",
"description": "A VS Code extension that finds security issues in code, configs, queries, Dockerfiles, and secrets.",
"version": "1.3.0",
"version": "1.3.1",
"publisher": "BlackplaneSystems",
"license": "Apache-2.0",
"icon": "media/icon.png",
Expand Down Expand Up @@ -164,7 +164,7 @@
},
"scripts": {
"build": "tsc -p ./tsconfig.json",
"package": "node -e \"require('fs').mkdirSync('dist-vsix',{recursive:true})\" && vsce package --out dist-vsix/contractguard-1.3.0.vsix",
"package": "node -e \"require('fs').mkdirSync('dist-vsix',{recursive:true})\" && vsce package --out dist-vsix/contractguard-1.3.1.vsix",
"prepackage": "npm run build"
},
"devDependencies": {
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "contractguard"
version = "1.3.0"
version = "1.3.1"
description = "ContractGuard security analysis core for VS Code and CI workflows."
readme = "README.md"
license = {text = "Apache-2.0"}
Expand Down
2 changes: 1 addition & 1 deletion src/contractguard/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""ContractGuard core package."""

__version__ = "1.3.0"
__version__ = "1.3.1"
9 changes: 8 additions & 1 deletion src/contractguard/analyzers/file_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@
".ruff_cache",
}

_NORMALIZED_SKIP_DIRS = {part.casefold() for part in _SKIP_DIRS}


def should_skip_path(path: Path) -> bool:
return any(part in _SKIP_DIRS for part in path.parts)
parts = path.parts
if path.exists() and path.is_file():
parts = path.parent.parts
elif path.suffix:
parts = path.parent.parts
return any(part.casefold() in _NORMALIZED_SKIP_DIRS for part in parts)
Comment on lines 26 to +32
23 changes: 19 additions & 4 deletions src/contractguard/analyzers/pii_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import ipaddress
import json
import re
import os
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -109,10 +110,17 @@ def load_files(path: str | Path) -> list[tuple[str, str]]:
_skip = {".pyc", ".exe", ".dll", ".png", ".jpg", ".gif", ".zip", ".tar", ".gz"}

if path.is_dir():
for f in sorted(path.rglob("*")):
if f.is_file() and f.suffix.lower() not in _skip and not should_skip_path(f):
for root, dirnames, filenames in os.walk(path):
root_path = Path(root)
dirnames[:] = [
name for name in dirnames if not should_skip_path(root_path / name)
]
Comment on lines +115 to +117
for name in sorted(filenames):
file_path = root_path / name
if file_path.suffix.lower() in _skip or should_skip_path(file_path):
continue
try:
files.append((str(f), f.read_text(encoding="utf-8", errors="replace")))
files.append((str(file_path), file_path.read_text(encoding="utf-8", errors="replace")))
except Exception:
continue
elif path.is_file():
Expand All @@ -130,7 +138,14 @@ def _is_non_personal_ip(value: str) -> bool:
ip_value = ipaddress.ip_address(value)
except ValueError:
return False
return ip_value.is_loopback or ip_value.is_unspecified or ip_value.is_reserved
return (
ip_value.is_loopback
or ip_value.is_unspecified
or ip_value.is_reserved
or ip_value.is_private
or ip_value.is_link_local
or ip_value.is_multicast
)


def analyze(path: str | Path, rules_dir: str | Path) -> list[Finding]:
Expand Down
16 changes: 12 additions & 4 deletions src/contractguard/analyzers/secrets_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import re
import os
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -115,11 +116,18 @@ def load_files(path: str | Path) -> list[tuple[str, str]]:
files: list[tuple[str, str]] = []

if path.is_dir():
for f in sorted(path.rglob("*")):
if f.is_file() and f.suffix.lower() not in _SKIP_EXTENSIONS and not should_skip_path(f):
for root, dirnames, filenames in os.walk(path):
root_path = Path(root)
dirnames[:] = [
name for name in dirnames if not should_skip_path(root_path / name)
]
Comment on lines +121 to +123
for name in sorted(filenames):
file_path = root_path / name
if file_path.suffix.lower() in _SKIP_EXTENSIONS or should_skip_path(file_path):
continue
try:
content = f.read_text(encoding="utf-8", errors="replace")
files.append((str(f), content))
content = file_path.read_text(encoding="utf-8", errors="replace")
files.append((str(file_path), content))
except Exception:
continue
elif path.is_file():
Expand Down
16 changes: 14 additions & 2 deletions tests/test_pii_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
from pathlib import Path
import tempfile

import pytest

from contractguard.analyzers.pii_analyzer import analyze, extract_facts
from contractguard.engine import Severity

Expand Down Expand Up @@ -50,6 +48,12 @@ def test_clean_content_no_pii(self):
assert facts["has_ssn"] is False
assert facts["has_credit_card"] is False

def test_suppresses_non_personal_ips(self):
content = "bind 127.0.0.1\nlisten 0.0.0.0\nprivate 10.0.0.1\n"
facts = extract_facts(content)
assert facts["has_ip_address"] is False
assert facts["pii_count"] == 0

def test_redacted_preview(self):
content = '{"ssn": "123-45-6789"}'
facts = extract_facts(content)
Expand Down Expand Up @@ -90,3 +94,11 @@ def test_findings_have_compliance_info(self):
assert any(f.attack_vector and ("GDPR" in f.attack_vector or "identity" in f.attack_vector.lower()) for f in findings)
finally:
path.unlink(missing_ok=True)

def test_skips_vendor_directories(self, tmp_path):
skipped_dir = tmp_path / "node_modules"
skipped_dir.mkdir()
(skipped_dir / "pii.txt").write_text("ssn: 123-45-6789\n")
(tmp_path / "safe.txt").write_text("No personal info here.\n")
findings = analyze(tmp_path, RULES_DIR)
assert all("node_modules" not in f.location for f in findings)
Comment on lines +101 to +104
10 changes: 8 additions & 2 deletions tests/test_secrets_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
from pathlib import Path
import tempfile

import pytest

from contractguard.analyzers.secrets_analyzer import analyze, extract_facts
from contractguard.engine import Severity

Expand Down Expand Up @@ -118,3 +116,11 @@ def test_findings_have_cwe(self):
assert any(f.cwe for f in findings)
finally:
path.unlink(missing_ok=True)

def test_skips_vendor_directories(self, tmp_path):
skipped_dir = tmp_path / "node_modules"
skipped_dir.mkdir()
(skipped_dir / "secret.env").write_text("DB_PASSWORD=admin123\n")
(tmp_path / "safe.txt").write_text("Nothing here\n")
findings = analyze(tmp_path, RULES_DIR)
Comment on lines +123 to +125
assert all("node_modules" not in f.location for f in findings)
Loading