Skip to content

Commit f7002eb

Browse files
authored
fix(scanner): scan gitignored .env files (audit C1, critical) (#47)
Discovery used 'git ls-files --cached --others --exclude-standard', which drops gitignored+untracked files. In a real repo .env is gitignored, so the flagship value-bearing credential detection (hardcoded key in .env) silently never ran — the report showed a clean bill over a live key. The committed fixture .env (tracked) masked this in tests. Fix: union git discovery with a targeted walk for ALWAYS_SCAN_GLOBS (.env, *.env, .env.*, *.envrc) so credential files are scanned even when gitignored. Fixture scan unchanged (32 findings, exact); added a regression test with a real-world gitignored-.env repo layout (would have caught this).
1 parent 012062e commit f7002eb

2 files changed

Lines changed: 54 additions & 4 deletions

File tree

dsgai_scanner_tool/cli/dsgai_scan.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@
4040
}
4141
SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "env",
4242
"dist", "build", ".mypy_cache", ".pytest_cache", ".tox", ".idea"}
43+
# Credential-bearing files that MUST be scanned even when gitignored — a secret
44+
# scanner's whole job is to catch keys in exactly these, and they are almost
45+
# always gitignored (so `git ls-files` would drop them). Matched by basename.
46+
ALWAYS_SCAN_GLOBS = ["*.env", "*.env.*", ".env", ".env.*", "*.envrc"]
4347

4448
# requires_nearby resolution: status when the requirement is violated (required
4549
# rule absent) vs satisfied (present). Read the require list / window from the
@@ -109,16 +113,25 @@ def read_version():
109113
# File discovery
110114
# --------------------------------------------------------------------------- #
111115
def discover_files(target, excludes):
112-
"""Return files under target as (abs_path, rel_path) honoring .gitignore.
116+
"""Return files under target as (abs_path, rel_path).
113117
114-
Uses `git ls-files` (tracked + untracked-not-ignored) when target is inside
115-
a repo — this respects .gitignore yet still includes tracked files such as a
116-
committed fixture .env. Falls back to a filtered os.walk otherwise.
118+
Uses `git ls-files` (tracked + untracked-not-ignored) when target is inside a
119+
repo, so general noise/build output respects .gitignore. BUT credential
120+
files matching ALWAYS_SCAN_GLOBS (e.g. `.env`) are ALWAYS included even when
121+
gitignored — a secret scanner must scan exactly those. Falls back to a
122+
filtered os.walk (which already sees everything) outside a repo.
117123
"""
118124
target = os.path.abspath(target)
119125
files = _git_files(target)
120126
if files is None:
121127
files = _walk_files(target)
128+
else:
129+
# Union in gitignored credential files git would otherwise drop.
130+
seen = set(files)
131+
for ap in _sensitive_walk_files(target):
132+
if ap not in seen:
133+
seen.add(ap)
134+
files.append(ap)
122135
out = []
123136
for ap in files:
124137
rel = os.path.relpath(ap, target).replace(os.sep, "/")
@@ -129,6 +142,18 @@ def discover_files(target, excludes):
129142
return out
130143

131144

145+
def _sensitive_walk_files(target):
146+
"""Files under target whose basename matches ALWAYS_SCAN_GLOBS (skips noise
147+
dirs). Used to re-include gitignored credential files like .env."""
148+
hits = []
149+
for dp, dirs, fns in os.walk(target):
150+
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
151+
for fn in fns:
152+
if any(fnmatch.fnmatch(fn, g) for g in ALWAYS_SCAN_GLOBS):
153+
hits.append(os.path.join(dp, fn))
154+
return hits
155+
156+
132157
def _git_files(target):
133158
try:
134159
top = subprocess.run(["git", "-C", target, "rev-parse", "--show-toplevel"],

dsgai_scanner_tool/tests/test_runner.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,31 @@ def test_atlas_map_valid():
314314
assert all(ctrl_re.match(c) for c in t["controls"])
315315

316316

317+
@requires_rg
318+
def test_gitignored_env_is_still_scanned(tmp_path):
319+
"""Real-world layout: a `.env` is gitignored + untracked. A secret scanner
320+
MUST still scan it — regression test for the discovery bug where git
321+
ls-files silently dropped it (the committed fixture .env masked this)."""
322+
repo = tmp_path / "realrepo"
323+
repo.mkdir()
324+
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
325+
(repo / ".gitignore").write_text(".env\n", encoding="utf-8")
326+
(repo / ".env").write_text(
327+
"OPENAI_API_KEY=sk-proj-FAKE00000000000000000000000000\n", encoding="utf-8")
328+
(repo / "app.py").write_text("x = 1\n", encoding="utf-8")
329+
env = dict(os.environ)
330+
subprocess.run(["git", "add", ".gitignore", "app.py"], cwd=repo, check=True)
331+
subprocess.run(["git", "-c", "user.email=a@b.c", "-c", "user.name=t",
332+
"commit", "-qm", "init"], cwd=repo, check=True)
333+
out = tmp_path / "s.json"
334+
subprocess.run([sys.executable, CLI, "scan", str(repo), "--no-cve",
335+
"--json-out", str(out), "--format", "none"], env=env)
336+
cp = json.loads(out.read_text(encoding="utf-8"))
337+
env_findings = [f for f in cp["findings"] if ".env" in f["path"]]
338+
assert env_findings, "gitignored .env credential was not scanned"
339+
assert cp["controls"]["DSGAI02"] == "FAIL"
340+
341+
317342
def test_rules_json_in_sync():
318343
from_yaml = yaml.safe_load(open(RULES_YAML, encoding="utf-8"))
319344
rebuilt = json.dumps(from_yaml, indent=2, sort_keys=True, ensure_ascii=False) + "\n"

0 commit comments

Comments
 (0)