Skip to content

refactor(security): harden api_key_leak_scanner (redaction, streaming, CI exit codes) - #1

Merged
krishsharma-code merged 3 commits into
mainfrom
audit/review-api-key-leak-scanner
Oct 23, 2026
Merged

krishsharma-code merged 3 commits into
mainfrom
audit/review-api-key-leak-scanner

Conversation

@krishsharma-code

Copy link
Copy Markdown
Owner

Summary

Hardens python_scripts/api_key_leak_scanner.py and adds the first pytest suite for the Python scripts. The scanner previously re-leaked the very secrets it detected; it now redacts matches, streams files instead of reading them whole, validates its target and returns a real exit code so it can gate CI.

Linked Issue

N/A - raised directly by the recurring code-quality audit (no tracking issue opened).

Type of Change

  • Bug fix
  • Documentation update
  • Refactor (no behaviour change)
  • Chore / tooling / CI

What Changed

  • Never echo secret material. The old code printed match.group(0)[:30] - the first 30 characters of the matched secret - into stdout, which is routinely captured in CI logs and pasted into reports. Output now shows a redacted context snippet (***REDACTED***) that preserves the surrounding line but never the credential.
  • Bounded memory. Files are read line by line instead of f.read()-ing the whole file; binary files (NUL-byte sniff) and files over 5 MiB are skipped.
  • Validate the target. A missing or non-directory path now prints an error and returns exit code 2 instead of silently reporting "no leaks".
  • Real exit-code contract (0 clean / 1 findings / 2 error) so the scanner can be used as a CI gate.
  • Standards alignment. Replaced raw sys.argv handling with argparse + --help; added module and function docstrings and comments; centralised patterns and skip lists as named constants.
  • Tests. New tests/ folder with a 17-case pytest suite covering detection, redaction, skip rules and exit codes.

New or Modified Scripts

Script Language Change
python_scripts/api_key_leak_scanner.py Python Hardening refactor + docs
tests/conftest.py Python New test harness (adds python_scripts/ to sys.path)
tests/test_api_key_leak_scanner.py Python New test suite (17 tests)

Safety Review

  • This script only performs actions the user explicitly requests
  • Any destructive operation requires an explicit flag and supports --dry-run (n/a - read-only scan)
  • Any state-changing action is printed before it is carried out (n/a)
  • No credentials, API keys or tokens are committed (the example keys in the tests are well-known, non-functional sample values)
  • No hardcoded personal or machine-specific paths

Testing Checklist

  • python3 -m py_compile python_scripts/*.py passes
  • flake8 python_scripts --select=E9,F63,F7,F82,F821,F823 --max-line-length=120 passes
  • for f in bash_scripts/*.sh; do bash -n "$f"; done passes (no shell files changed)
  • shellcheck bash_scripts/*.sh reports no errors (no shell files changed)
  • I ran the new or changed script end to end (python3 -m pytest - 17 passed)
  • I tested the --help output
  • I tested the failure path (missing directory returns exit code 2)
  • No new console warnings or unhandled errors

Documentation

  • I added the script to the index in README.md (index entry already exists; its description update is noted below)
  • The script has a module docstring / header comment explaining what it does
  • Non-obvious logic is commented

Code Quality Checklist

  • My code follows the standards in CONTRIBUTING.md
  • Imports are at the top of the file, not inside functions
  • I have performed a self-review of my own code
  • I have removed debugging code and unused imports

Additional Notes

  • CI runs py_compile and flake8 only; it does not execute pytest. The new suite runs locally with python3 -m pytest. Wiring it into ci.yml is proposed as a follow-up so the tests do not become dead code.
  • The README.md index line for this script still carries the original one-liner; the description update was split out to keep this PR's diff focused.

@krishsharma-code krishsharma-code left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review: api_key_leak_scanner hardening

Reviewed: python_scripts/api_key_leak_scanner.py at head c521a53 (plus the new tests/ suite).

Scope. The script end to end - path/input handling, the regex pattern set, file reading, output and the CLI contract - checked against the security boundary (does the tool leak what it finds?), memory use, failure handling, and docstring/comment clarity.

What was found

  1. Security - output re-leaked the secret. print(f" Match: {match.group(0)[:30]}...") echoed the first 30 characters of the matched credential to stdout. Scanner output is routinely captured in CI logs and pasted into reports, so the tool re-exposed the very secret it exists to protect.
  2. Memory - whole-file reads. content = f.read() loaded every file fully into memory before matching, with no size bound.
  3. Failure handling - no target validation, no exit code. A non-existent path walked to nothing and printed "no leaks" (a false negative), and the process always exited 0, so it could never gate CI.
  4. Standards - argument handling and docs. Raw sys.argv instead of argparse/--help (CONTRIBUTING.md), and only a one-line docstring.

What changed and why

  • Matched values are redacted through mask_line/_secret_span, so the report keeps surrounding context but never the credential. Covered by test_scan_file_redacts_the_secret and test_mask_line_removes_only_the_secret.
  • Files are read line by line; binaries are skipped via a NUL-byte sniff and files over 5 MiB are skipped, bounding memory.
  • The target is validated (exit 2) and the scanner now returns 0/1/2, making it a usable CI gate. Covered by the exit-code tests.
  • argparse + --help, module/function docstrings and named constants replace the ad-hoc handling.

Behaviour is preserved except for the three genuine defects above (leaked secret, silent false negative, unusable exit code).

Self-review note: GitHub blocks self-approval on one's own PR, so this is submitted as a COMMENT review rather than an approval.

the credential itself is never part of the returned string.
"""
start, end = _secret_span(match)
redacted = f"{line[:start]}{REDACTION_MARKER}{line[end:]}"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Core fix. The previous code printed match.group(0)[:30] - the first 30 characters of the credential - which re-leaked the secret into stdout/CI logs. Replacing only the captured secret span with ***REDACTED*** keeps the line context a reviewer needs while never emitting the value. _secret_span deliberately walks groups in reverse so the generic pattern's secret group (group 2) is redacted rather than the keyword group.

return findings

size = path.stat().st_size
if size > MAX_FILE_BYTES:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Bound on memory: anything above MAX_FILE_BYTES (5 MiB) is skipped with a visible note. This prevents a stray log/archive from being slurped into memory and is easy to tune in one place.

print(f"[!] Skipping large file ({size} bytes): {path}")
return findings

if _looks_binary(path):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Content-based binary detection, independent of extension: a NUL byte in the first KiB means it is not text worth scanning. This catches mislabelled binaries that the old extension-only filter would have read with errors='ignore'.

return findings

with path.open("r", encoding="utf-8", errors="ignore") as handle:
for line_no, line in enumerate(handle, start=1):

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Streaming read replaces content = f.read(). Matching is line-oriented, which is correct here because every signature is single-line; memory stays flat regardless of file size.

print("=== API Key Leak Scanner ===")
print(f"[*] Scanning directory: {target}")

if not target.is_dir():

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Fixes a false negative: previously a bad path produced an empty walk and printed "No hardcoded API keys or secrets detected", which reads as a clean result. Now a non-directory target is reported and surfaced as exit code 2.


findings = scan_directory(target)
report(findings)
return 1 if findings else 0

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Exit-code contract (0 clean / 1 findings / 2 error). This is what makes the scanner usable as a CI gate - the old script always returned 0, so a build could never fail on a detected secret.

@krishsharma-code
krishsharma-code merged commit 2430046 into main Oct 23, 2026
2 checks passed
@krishsharma-code
krishsharma-code deleted the audit/review-api-key-leak-scanner branch October 23, 2026 06:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant