diff --git a/.controlgate.yml b/.controlgate.yml index 41f904b..342983c 100644 --- a/.controlgate.yml +++ b/.controlgate.yml @@ -4,14 +4,24 @@ gov: false # If true, evaluates against FedRAMP baselines i catalog: baseline/nist80053r5_full_catalog_enriched.json gates: - secrets: { enabled: true, action: block } - crypto: { enabled: true, action: block } - iam: { enabled: true, action: warn } - sbom: { enabled: true, action: warn } - iac: { enabled: true, action: block } - input: { enabled: true, action: block } - audit: { enabled: true, action: warn } - change: { enabled: true, action: warn } + secrets: { enabled: true, action: block } + crypto: { enabled: true, action: block } + iam: { enabled: true, action: warn } + sbom: { enabled: true, action: warn } + iac: { enabled: true, action: block } + input_validation: { enabled: true, action: block } + audit: { enabled: true, action: warn } + change_control: { enabled: true, action: warn } + deps: { enabled: true, action: warn } + api: { enabled: true, action: warn } + privacy: { enabled: true, action: warn } + resilience: { enabled: true, action: warn } + incident: { enabled: true, action: warn } + observability: { enabled: true, action: warn } + memsafe: { enabled: true, action: warn } + license: { enabled: true, action: warn } + aiml: { enabled: true, action: warn } + container: { enabled: true, action: warn } thresholds: block_on: [CRITICAL, HIGH] # Block if finding severity matches diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c692e0..547de95 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,3 +23,13 @@ repos: - id: mypy additional_dependencies: [ types-PyYAML, pytest ] args: [ --config-file, pyproject.toml ] + + - repo: local + hooks: + - id: bump-version + name: Auto-bump version if unchanged from main + entry: python hooks/bump_version.py + language: python + always_run: true + pass_filenames: false + stages: [pre-commit] diff --git a/CHANGELOG.md b/CHANGELOG.md index 9135178..073deaa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **10 New Security Gates**: Expanded scanning capabilities to a total of 18 gates, including API, Privacy, Resilience, Incident, Observability, MemSafe, License, AI/ML, Container, and Dependency vulnerabilities. - **FedRAMP Support**: ControlGate can now evaluate code changes against FedRAMP baselines (LI-SaaS, Low, Moderate, High) via the `--gov` CLI flag or `gov: true` in `.controlgate.yml`. This integrates directly with the enriched `fedramp_membership` catalog data. ## [0.1.3] - 2026-02-20 diff --git a/README.md b/README.md index 8c65e29..d9b3449 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,12 @@ ControlGate is an AI-powered agent skill that scans your code changes against th # Install pip install controlgate +# Bootstrap project (creates .controlgate.yml, pre-commit hook, CI workflows) +controlgate init + +# Full repo audit (first-time scan) +controlgate scan --mode full --format markdown + # Scan staged changes (NIST baseline) controlgate scan --mode pre-commit --format markdown @@ -33,12 +39,12 @@ git commit / Pull Request ↓ ControlGate intercepts the diff ↓ -8 Security Gates scan against 370 non-negotiable NIST controls +18 Security Gates scan against 370 non-negotiable NIST controls ↓ Verdict: BLOCK 🚫 / WARN ⚠️ / PASS ✅ ``` -## The Eight Security Gates +## The 18 Security Gates | # | Gate | NIST Families | What It Catches | |---|------|---------------|-----------------| @@ -47,9 +53,19 @@ Verdict: BLOCK 🚫 / WARN ⚠️ / PASS ✅ | 3 | 🛡️ IAM | AC-3, AC-5, AC-6 | Wildcard IAM, missing auth, overprivileged roles | | 4 | 📦 Supply Chain | SR-3, SR-11, SA-10 | Unpinned deps, missing lockfiles, build tampering | | 5 | 🏗️ IaC | CM-2, CM-6, SC-7 | Public buckets, `0.0.0.0/0` rules, root containers | -| 6 | ✅ Input | SI-10, SI-11 | SQL injection, `eval()`, exposed stack traces | +| 6 | ✅ Input Validation | SI-10, SI-11 | SQL injection, `eval()`, exposed stack traces | | 7 | 📋 Audit | AU-2, AU-3, AU-12 | Missing security logging, PII in logs | -| 8 | 🔄 Change | CM-3, CM-4, CM-5 | Unauthorized config changes, missing CODEOWNERS | +| 8 | 🔄 Change Control | CM-3, CM-4, CM-5 | Unauthorized config changes, missing CODEOWNERS | +| 9 | 🧩 Dependencies | SA-12, RA-5, SI-2 | Vulnerable packages, missing lockfiles | +| 10 | 🌐 API Security | SC-8, AC-3, SI-10 | Unauthenticated endpoints, missing rate limiting | +| 11 | 🔐 Privacy | AR-2, DM-1, IP-1 | PII exposure, missing data classification | +| 12 | 🔁 Resilience | CP-2, CP-10, SC-5 | Missing retry logic, no circuit breakers | +| 13 | 🚨 Incident Response | IR-2, IR-4, IR-6 | Missing error handlers, no alerting integration | +| 14 | 👁️ Observability | AU-6, SI-4, CA-7 | Missing health checks, no structured logging | +| 15 | 🧠 Memory Safety | SI-16, SA-11, SA-15 | Buffer overflows, unsafe memory operations | +| 16 | ⚖️ License Compliance | SA-4, SR-3 | GPL contamination, unlicensed dependencies | +| 17 | 🤖 AI/ML Security | SA-11, SI-7, AC-3 | Untrusted model sources, prompt injection risk | +| 18 | 🐳 Container Security | CM-6, AC-6, SC-7 | Root containers, privileged mode, `latest` tags | ## Installation @@ -87,17 +103,26 @@ Create a `.controlgate.yml` in your project root: ```yaml baseline: moderate # low | moderate | high | privacy | li-saas gov: false # set to true to evaluate against FedRAMP baselines -catalog: baseline/nist80053r5_full_catalog_enriched.json gates: - secrets: { enabled: true, action: block } - crypto: { enabled: true, action: block } - iam: { enabled: true, action: warn } - sbom: { enabled: true, action: warn } - iac: { enabled: true, action: block } - input: { enabled: true, action: block } - audit: { enabled: true, action: warn } - change: { enabled: true, action: warn } + secrets: { enabled: true, action: block } + crypto: { enabled: true, action: block } + iam: { enabled: true, action: warn } + sbom: { enabled: true, action: warn } + iac: { enabled: true, action: block } + input_validation: { enabled: true, action: block } + audit: { enabled: true, action: warn } + change_control: { enabled: true, action: warn } + deps: { enabled: true, action: warn } + api: { enabled: true, action: warn } + privacy: { enabled: true, action: warn } + resilience: { enabled: true, action: warn } + incident: { enabled: true, action: warn } + observability: { enabled: true, action: warn } + memsafe: { enabled: true, action: warn } + license: { enabled: true, action: warn } + aiml: { enabled: true, action: warn } + container: { enabled: true, action: warn } thresholds: block_on: [CRITICAL, HIGH] @@ -106,16 +131,25 @@ thresholds: exclusions: paths: ["tests/**", "docs/**", "*.md"] + +full_scan: + extensions: [.py, .js, .ts, .go, .tf, .yaml, .yml, .json, .sh] + skip_dirs: [.git, node_modules, .venv, dist, build] ``` ## CLI Usage ```bash +# Bootstrap project +controlgate init +controlgate init --baseline high --no-docs + # Scan staged changes (pre-commit mode) controlgate scan --mode pre-commit --format markdown -# Scan explicitly against FedRAMP baselines (pre-commit mode) -controlgate scan --gov --baseline moderate --mode pre-commit --format markdown +# Full repository scan +controlgate scan --mode full --format markdown +controlgate scan --mode full --path ./src --format json # Scan PR diff controlgate scan --mode pr --target-branch main --format json markdown sarif @@ -126,11 +160,12 @@ controlgate scan --gov --baseline high --mode pr --target-branch main --format j # Scan a saved diff file controlgate scan --diff-file path/to/diff --format json -# Scan a saved diff file against FedRAMP baselines -controlgate scan --gov --baseline li-saas --diff-file path/to/diff --format json - # Output reports to directory controlgate scan --output-dir .controlgate/reports --format json markdown sarif + +# Catalog management +controlgate update-catalog +controlgate catalog-info ``` ## Output Formats @@ -168,7 +203,7 @@ The agent prompts and workflows are located in the [`skills/`](skills/) director ## Test Suite To validate the capabilities of ControlGate, we maintain a standalone test suite at [sadayamuthu/controlgate-test-suite](https://github.com/sadayamuthu/controlgate-test-suite). -This suite contains intentionally vulnerable projects spanning multiple languages and frameworks, specifically designed to trigger each of the 8 Security Gates. It is automatically executed in the CI pipeline to ensure zero regression in detection capabilities. +This suite contains intentionally vulnerable projects spanning multiple languages and frameworks, specifically designed to trigger each of the 18 Security Gates. It is automatically executed in the CI pipeline to ensure zero regression in detection capabilities. ## Data Source diff --git a/docs/gates/gate-01-secrets/design.md b/docs/gates/gate-01-secrets/design.md new file mode 100644 index 0000000..647b926 --- /dev/null +++ b/docs/gates/gate-01-secrets/design.md @@ -0,0 +1,58 @@ +# Gate 1 — Secrets & Credential Gate + +**gate_id:** `secrets` +**NIST Controls:** IA-5, IA-6, SC-12, SC-28 +**Priority:** 🔴 High + +--- + +## Purpose + +Prevents hardcoded secrets, API keys, tokens, and private keys from being committed to source control. Credentials committed to git are permanently exposed in history, even after removal, and are a leading cause of cloud account compromise. This gate provides an automated pre-commit tripwire for the most common credential formats used across AWS, GCP, GitHub, OpenAI, Stripe, and database connection strings. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| AWS Access Key ID (AKIA/ASIA format) | Direct cloud account takeover | IA-5 | +| AWS Secret Access Key (40-char base64) | Paired with key ID enables full AWS API access | IA-5 | +| Google API Key (AIza prefix) | Unauthorized GCP service usage and billing fraud | IA-5 | +| Hardcoded credential assignment (`password =`, `token =`, etc.) | Generic pattern catches language-agnostic credential leaks | SC-28 | +| Private key files (RSA, EC, DSA, OpenSSH) | Key compromise enables impersonation and decryption of historical traffic | SC-12 | +| X.509 certificate files | Certificates in repos risk unauthorized service impersonation | SC-12 | +| GitHub Personal Access Tokens (ghp_ prefix) | Repo access, Actions secrets, and org data at risk | IA-5 | +| API secret keys (sk- prefix) | OpenAI/Stripe-style keys enabling fraudulent API usage | IA-5 | +| Bearer tokens in source code | Runtime tokens should not appear in code | IA-6 | +| Database connection strings with embedded credentials | Leaks DB host, port, username, and password | SC-28 | +| High-entropy quoted strings ≥20 chars | Catches randomized secrets not matching known formats | IA-5 | +| Sensitive file types committed (.env, .pem, .key, .p12, .pfx, .jks) | Entire file classes should never appear in source control | SC-28 | + +--- + +## Scope + +- **Scans:** all added lines in every file in the diff +- **File types targeted:** all files; additionally performs file-path checks for sensitive file extensions +- **Special detection:** Shannon entropy analysis — any quoted string ≥20 characters with entropy ≥4.5 bits/char is flagged even if it doesn't match a known pattern. Evidence is truncated to 120 characters. + +--- + +## Known Limitations + +- Does not scan deleted or unmodified lines +- Entropy detection produces false positives on long base64-encoded non-secret values (e.g., public keys, checksums) +- Known secret formats are a subset of real-world credential types; novel formats require new patterns +- Will not detect secrets stored in binary files + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| IA-5 | Authenticator Management | Detects hardcoded passwords, tokens, and API keys that should be managed through a secrets manager | +| IA-6 | Authentication Feedback | Detects bearer tokens embedded in source code where they may be logged or exposed | +| SC-12 | Cryptographic Key Establishment and Management | Detects private keys and certificates committed to repositories | +| SC-28 | Protection of Information at Rest | Detects credentials and sensitive file types that expose data-at-rest protection keys | diff --git a/docs/gates/gate-01-secrets/implementation.md b/docs/gates/gate-01-secrets/implementation.md new file mode 100644 index 0000000..3f9dc43 --- /dev/null +++ b/docs/gates/gate-01-secrets/implementation.md @@ -0,0 +1,73 @@ +# Gate 1 — Secrets & Credential Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/secrets_gate.py` +**Test file:** `tests/test_gates/test_secrets_gate.py` +**Class:** `SecretsGate` +**gate_id:** `secrets` +**mapped_control_ids:** `["IA-5", "IA-6", "SC-12", "SC-28"]` + +--- + +## Scan Method + +`scan()` iterates every `diff_file` and calls two sub-methods: +1. `_check_sensitive_file()` — matches the file path against `_SENSITIVE_FILE_PATTERNS`; fires one SC-28 finding per file if the path matches (breaks after first match per file) +2. `_check_line()` — for each added line, runs all `_PATTERNS` regex matches, then runs Shannon entropy analysis on any quoted string ≥20 chars + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `(?:AKIA\|ASIA)[0-9A-Z]{16}` | AWS Access Key ID detected | IA-5 | Use IAM roles or AWS Secrets Manager instead of hardcoded keys | +| 2 | `(?:"\|')(?:[A-Za-z0-9/+=]{40})(?:"\|')` | Possible AWS Secret Access Key detected | IA-5 | Use IAM roles or AWS Secrets Manager instead of hardcoded keys | +| 3 | `AIza[0-9A-Za-z\-_]{35}` | Google API Key detected | IA-5 | Use GCP Secret Manager or environment variables | +| 4 | `(?i)(?:password\|passwd\|pwd\|secret\|token\|api[_-]?key\|auth[_-]?token\|access[_-]?token)\s*[:=]\s*["'][^"']{4,}["']` | Hardcoded credential detected | SC-28 | Move to environment variable or secrets manager (AWS SSM, GCP Secret Manager, Azure Key Vault) | +| 5 | `(?i)(?:password\|passwd\|pwd\|secret\|token\|api[_-]?key)\s*=\s*(?!None\|null\|""\|''\|os\.environ\|env\(\|getenv)[^\s#]{4,}` | Hardcoded credential in assignment | SC-28 | Move to environment variable or secrets manager | +| 6 | `-----BEGIN (?:RSA \|EC \|DSA \|OPENSSH )?PRIVATE KEY-----` | Private key committed to repository | SC-12 | Never commit private keys. Use a secrets manager or secure key storage | +| 7 | `-----BEGIN CERTIFICATE-----` | Certificate file committed to repository | SC-12 | Manage certificates through a PKI or secrets manager, not source control | +| 8 | `ghp_[0-9a-zA-Z]{36}` | GitHub Personal Access Token detected | IA-5 | Use GitHub Apps or GITHUB_TOKEN instead of personal access tokens | +| 9 | `sk-[0-9a-zA-Z]{20,}` | API secret key detected (OpenAI/Stripe pattern) | IA-5 | Use environment variables or a secrets manager for API keys | +| 10 | `(?i)bearer\s+[a-z0-9\-._~+/]+=*` | Bearer token detected in source code | IA-6 | Tokens should be loaded from environment or config, not hardcoded | +| 11 | `(?i)(?:mongodb\|postgres(?:ql)?\|mysql\|redis\|amqp)://[^\s:]+:[^\s@]+@` | Database connection string with embedded credentials | SC-28 | Use environment variables for connection strings with credentials | + +**Sensitive file path patterns** (checked against `diff_file.path`, fires SC-28): + +| Pattern | Matches | +|---|---| +| `\.env(?:\..+)?$` | `.env`, `.env.prod`, `.env.local`, etc. | +| `(?i)credentials` | Any file with "credentials" in name | +| `(?i)\.pem$` | PEM certificate/key files | +| `(?i)\.key$` | Key files | +| `(?i)\.p12$` | PKCS#12 key stores | +| `(?i)\.pfx$` | Personal Information Exchange files | +| `(?i)\.jks$` | Java KeyStore files | + +--- + +## Special Detection Logic + +**Shannon entropy analysis** — runs after the pattern loop in `_check_line()`: + +1. Scans the line for quoted strings matching `["']([A-Za-z0-9+/=\-_]{20,})["']` +2. For each candidate token of length ≥20: computes Shannon entropy (bits per character) +3. If entropy ≥ 4.5 AND no finding from this gate already exists for that line number, fires an IA-5 finding +4. The duplicate-check prevents double-firing when a pattern already matched the same line + +Constants: `_ENTROPY_THRESHOLD = 4.5`, `_MIN_LENGTH_FOR_ENTROPY = 20` + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_aws_key` | AKIA/ASIA format AWS access key triggers an IA-5 finding with "AWS" in the description | +| `test_detects_hardcoded_password` | `DB_PASSWORD = "super_secret_123"` assignment triggers a finding with "credential" or "password" in the description | +| `test_detects_private_key` | `-----BEGIN RSA PRIVATE KEY-----` header triggers a finding with "key" in the description | +| `test_clean_code_no_findings` | Code that reads credentials from `os.environ` produces zero findings | +| `test_detects_database_uri` | `postgres://user:pass@host/db` connection string triggers a finding with "connection string" or "credential" in the description | +| `test_detects_github_token` | `ghp_…` GitHub Personal Access Token pattern triggers at least one finding | +| `test_findings_have_gate_id` | Every finding from the AWS-key diff carries `gate == "secrets"` | +| `test_findings_have_control_ids` | Every finding from the AWS-key diff uses a control ID within `{IA-5, IA-6, SC-12, SC-28}` | diff --git a/docs/gates/gate-02-crypto/design.md b/docs/gates/gate-02-crypto/design.md new file mode 100644 index 0000000..f609731 --- /dev/null +++ b/docs/gates/gate-02-crypto/design.md @@ -0,0 +1,63 @@ +# Gate 2 — Cryptography & TLS Gate + +**gate_id:** `crypto` +**NIST Controls:** SC-8, SC-13, SC-17, SC-23 +**Priority:** 🔴 High + +--- + +## Purpose + +Gate 2 detects weak cryptographic algorithms, disabled TLS verification, deprecated protocol versions, and insecure session cookie configuration — preventing cryptographic failures that expose data in transit. By scanning all added lines at commit time, it catches dangerous patterns such as MD5/SHA-1 hash functions, DES/RC4/3DES/Blowfish/ECB ciphers, bare `http://` URLs, disabled certificate or hostname verification, deprecated TLS 1.0 and SSL versions, and misconfigured session cookies before they reach production systems. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| MD5 hash algorithm usage | MD5 is cryptographically broken; collisions are trivially producible, making it unsuitable for integrity or authentication | SC-13 | +| SHA-1 hash algorithm usage | SHA-1 is deprecated by NIST (FIPS 180-4); collision attacks have been demonstrated | SC-13 | +| DES cipher usage | DES has a 56-bit key space, broken in under 24 hours with commodity hardware | SC-13 | +| RC4 cipher usage | RC4 has known statistical biases and practical attacks; prohibited in TLS since RFC 7465 | SC-13 | +| 3DES/TripleDES cipher usage | 3DES is deprecated; susceptible to Sweet32 birthday attacks with long sessions | SC-13 | +| Blowfish cipher usage | Blowfish has a 64-bit block size making it vulnerable to Sweet32 attacks; no longer recommended | SC-13 | +| ECB cipher mode usage | ECB mode leaks data patterns through identical ciphertext blocks; deterministic encryption without semantic security | SC-13 | +| Unencrypted HTTP URLs (non-local) | Transmits data in cleartext over the network; susceptible to interception and man-in-the-middle attacks | SC-8 | +| SSL/TLS verification disabled (`ssl_verify=False`) | Disabling certificate verification eliminates protection against MITM attacks | SC-8 | +| TLS certificate verification disabled (`verify=False`) | Same as above; commonly set during development and inadvertently left in production | SC-8 | +| `CERT_NONE` or `CERT_OPTIONAL` in SSL context | Weak or absent certificate validation in Python ssl module | SC-17 | +| TLS hostname checking disabled (`check_hostname=False`) | Allows connections to any host with a valid certificate, defeating SNI-based security | SC-8 | +| Self-signed certificate references | Self-signed certificates in production bypass trusted CA validation | SC-17 | +| Deprecated TLS/SSL versions (TLSv1.0, SSLv2, SSLv3) | Deprecated versions have known vulnerabilities (POODLE, BEAST, DROWN); prohibited by PCI-DSS 3.2+ | SC-8 | +| Insecure session/cookie flags (`Secure=False`, `HttpOnly=False`) | Cookies without Secure can be transmitted over HTTP; cookies without HttpOnly are accessible to JavaScript | SC-23 | +| `SameSite=None` on cookies | Permits cross-site requests without CSRF token validation | SC-23 | + +--- + +## Scope + +- Scans all added lines in every file included in the diff +- Applies to all file types; no extension filter is applied +- Uses three internal pattern groups: `_WEAK_ALGO_PATTERNS` (7 patterns, SC-13), `_TLS_PATTERNS` (7 patterns, SC-8/SC-17), and `_SESSION_PATTERNS` (2 patterns, SC-23) +- All three groups are evaluated for every added line; a single line may produce multiple findings + +--- + +## Known Limitations + +- Does not evaluate key length or key strength — only the algorithm name is matched, so a correctly-sized AES key is not confirmed +- No cross-call analysis to confirm that HTTPS is actually used for all connections; only the literal string `http://` in added lines is flagged +- The bare `http://` URL pattern excludes `localhost`, `127.0.0.1`, `0.0.0.0`, and `example.com` but does not exclude other common development or test hostnames +- Will not detect weak algorithms used through third-party library abstractions that do not mention the algorithm by name + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| SC-8 | Transmission Confidentiality and Integrity | Detects HTTP URLs, disabled TLS verification, hostname check bypasses, and deprecated TLS/SSL versions that undermine transmission security | +| SC-13 | Cryptographic Protection | Detects use of non-FIPS-approved algorithms (MD5, SHA-1, DES, RC4, 3DES, Blowfish) and insecure cipher modes (ECB) | +| SC-17 | Public Key Infrastructure Certificates | Detects `CERT_NONE`/`CERT_OPTIONAL` settings and self-signed certificate references that bypass PKI validation | +| SC-23 | Session Authenticity | Detects insecure cookie/session flags and `SameSite=None` configurations that expose sessions to interception or CSRF | diff --git a/docs/gates/gate-02-crypto/implementation.md b/docs/gates/gate-02-crypto/implementation.md new file mode 100644 index 0000000..9981598 --- /dev/null +++ b/docs/gates/gate-02-crypto/implementation.md @@ -0,0 +1,69 @@ +# Gate 2 — Cryptography & TLS Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/crypto_gate.py` +**Test file:** `tests/test_gates/test_crypto_gate.py` +**Class:** `CryptoGate` +**gate_id:** `crypto` +**mapped_control_ids:** `["SC-8", "SC-13", "SC-17", "SC-23"]` + +--- + +## Scan Method + +`scan()` iterates every `diff_file` in the input list and calls `_check_line()` for each added line (via `diff_file.all_added_lines`). No file-type filter is applied — all files are scanned regardless of extension. + +`_check_line()` runs three separate pattern loops in sequence across the three pattern groups: + +1. `_WEAK_ALGO_PATTERNS` — 3-tuple `(pattern, description, remediation)`; the control ID is hardcoded as `"SC-13"` inside `_check_line()`. +2. `_TLS_PATTERNS` — 4-tuple `(pattern, description, control_id, remediation)`; control ID varies per pattern (SC-8 or SC-17). +3. `_SESSION_PATTERNS` — 4-tuple `(pattern, description, control_id, remediation)`; all patterns map to SC-23. + +A single added line can produce multiple findings if it matches patterns from more than one group. + +--- + +## Patterns + +### Weak Algorithm Patterns (`_WEAK_ALGO_PATTERNS`) — all SC-13 + +Note: these tuples have only 3 fields; `SC-13` is hardcoded in `_check_line()` rather than stored in the tuple. + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `(?i)\b(?:hashlib\.)?md5\b` | Weak hash algorithm MD5 detected | SC-13 | Use SHA-256 or SHA-3 instead of MD5 (FIPS 180-4 compliant) | +| 2 | `(?i)\b(?:hashlib\.)?sha1\b` | Weak hash algorithm SHA-1 detected | SC-13 | Use SHA-256 or SHA-3 instead of SHA-1 (FIPS 180-4 compliant) | +| 3 | `(?i)\bDES\b(?!C|K|IGN)` | Weak cipher DES detected | SC-13 | Use AES-256 instead of DES | +| 4 | `(?i)\bRC4\b` | Weak cipher RC4 detected | SC-13 | Use AES-256 or ChaCha20 instead of RC4 | +| 5 | `(?i)\b3DES\b|triple.?des` | Weak cipher 3DES/TripleDES detected | SC-13 | Use AES-256 instead of 3DES | +| 6 | `(?i)\bBlowfish\b` | Weak cipher Blowfish detected | SC-13 | Use AES-256 or ChaCha20 instead of Blowfish | +| 7 | `(?i)ECB\b` | Insecure cipher mode ECB detected | SC-13 | Use CBC, GCM, or CTR mode instead of ECB | + +### TLS / SSL Patterns (`_TLS_PATTERNS`) + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 8 | `http://(?!localhost\|127\.0\.0\.1\|0\.0\.0\.0\|example\.com)` | Unencrypted HTTP URL in production code | SC-8 | Use HTTPS for all production endpoints to ensure transmission confidentiality | +| 9 | `(?i)ssl[_.]?verify\s*[:=]\s*(?:False\|false\|0\|no\|off)` | SSL/TLS verification disabled | SC-8 | Never disable SSL verification in production. Fix certificate issues instead | +| 10 | `(?i)verify\s*[:=]\s*(?:False\|false\|0)` | TLS certificate verification disabled | SC-8 | Never disable certificate verification in production code | +| 11 | `(?i)CERT_NONE|CERT_OPTIONAL` | Weak or disabled certificate validation | SC-17 | Use CERT_REQUIRED for all TLS connections | +| 12 | `(?i)check_hostname\s*[:=]\s*(?:False\|false\|0)` | TLS hostname checking disabled | SC-8 | Enable hostname checking for TLS connections | +| 13 | `(?i)self[_-]?signed\|selfsigned` | Self-signed certificate reference in production config | SC-17 | Use certificates from a trusted CA in production environments | +| 14 | `(?i)(?:TLSv1(?:\.0)?\|SSLv[23])\b` | Deprecated TLS/SSL version detected | SC-8 | Use TLS 1.2 or higher. TLS 1.0, 1.1, and all SSL versions are deprecated | + +### Session / Cookie Patterns (`_SESSION_PATTERNS`) + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 15 | `(?i)(?:session\|cookie).*(?:secure\s*[:=]\s*(?:False\|false\|0)\|httponly\s*[:=]\s*(?:False\|false\|0))` | Insecure session/cookie configuration | SC-23 | Set Secure=True, HttpOnly=True, and SameSite=Strict on session cookies | +| 16 | `(?i)samesite\s*[:=]\s*(?:["\']?none["\']?)` | SameSite=None on cookies reduces CSRF protection | SC-23 | Use SameSite=Strict or SameSite=Lax unless cross-site access is required | + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_md5` | `hashlib.md5(...)` in an added line triggers a finding with "MD5" in the description | +| `test_detects_ssl_verify_false` | `requests.get(url, verify=False)` triggers at least one finding with "verification" or "ssl" in the description | +| `test_detects_http_url` | A non-local `http://` URL triggers at least one finding with "HTTP" in the description | +| `test_findings_have_crypto_gate_id` | Every finding produced by the MD5 diff carries `gate == "crypto"` | diff --git a/docs/gates/gate-03-iam/design.md b/docs/gates/gate-03-iam/design.md new file mode 100644 index 0000000..9b37119 --- /dev/null +++ b/docs/gates/gate-03-iam/design.md @@ -0,0 +1,47 @@ +# Gate 3 — IAM & Access Control Gate + +**gate_id:** `iam` +**NIST Controls:** AC-3, AC-4, AC-5, AC-6 +**Priority:** 🔴 High + +--- + +## Purpose + +Gate 3 detects overly permissive IAM policies, missing authorization checks, wildcard permissions, and broad CORS configurations in IaC, application code, and API config. By scanning every added line at commit time, it enforces least-privilege and access-control principles before misconfigured policies can reach production environments. + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| Wildcard IAM action (`"Action": "*"`) | Grants the principal permission to perform every AWS action; any compromise becomes a full account takeover | AC-6 | +| Wildcard IAM resource (`"Resource": "*"`) | Applies the policy to every resource in the account rather than scoping to specific ARNs | AC-6 | +| IAM policy allowing all actions (`"Effect": "Allow"` with `"Action": "*"`) | Explicit allow-all in a policy document; combined with a wildcard resource grants unrestricted access | AC-6 | +| Overly permissive managed policy reference (AdministratorAccess, PowerUserAccess, FullAccess) | Broad managed policies violate least privilege and are frequently involved in privilege escalation | AC-6 | +| AdministratorAccess policy ARN attached | Direct attachment of the AWS AdministratorAccess managed policy in code or IaC | AC-5 | +| Wildcard CORS origin (`Access-Control-Allow-Origin: *`) | Allows any external domain to make credentialed cross-origin requests | AC-4 | +| `allow_origins = ["*"]` style CORS configuration | Framework-level CORS wildcard permitting all origins | AC-4 | +| Unauthenticated Flask route handler (`@app.route(...)` without auth decorator) | Route exposed without explicit authentication or authorization middleware | AC-3 | +| Authentication bypass keywords (public, anonymous, no-auth, skip-auth, allow-all) | Explicit markers indicating intentional or accidental auth bypass | AC-3 | +| STS AssumeRole without condition constraints | Assume-role calls without MFA, IP, or time conditions can be abused if credentials are compromised | AC-3 | + +## Scope + +- Scans all added lines in every file included in the diff +- No file-type filter — patterns are evaluated against all file types (`.json`, `.tf`, `.yml`, `.py`, `.js`, etc.) +- Deleted and unmodified lines are not scanned + +## Known Limitations + +- The Flask route-decorator check is heuristic — it only fires when `@app.route()` appears with no other decorators on the same line +- Cannot verify whether a named auth decorator is actually enforced at runtime +- Does not analyze IAM policy documents beyond added lines; nested or multi-statement policies with partial wildcards in unchanged lines may be missed + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| AC-3 | Access Enforcement | Detects unauthenticated route handlers, authentication bypass markers, and unconstrained AssumeRole calls that weaken access enforcement | +| AC-4 | Information Flow Enforcement | Detects wildcard CORS configurations that permit unrestricted cross-origin information flows | +| AC-5 | Separation of Duties | Detects direct attachment of AdministratorAccess, which eliminates role separation by granting a single principal all permissions | +| AC-6 | Least Privilege | Detects wildcard actions, wildcard resources, and overly permissive managed policies that violate the principle of least privilege | diff --git a/docs/gates/gate-03-iam/implementation.md b/docs/gates/gate-03-iam/implementation.md new file mode 100644 index 0000000..2800393 --- /dev/null +++ b/docs/gates/gate-03-iam/implementation.md @@ -0,0 +1,33 @@ +# Gate 3 — IAM & Access Control Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/iam_gate.py` +**Test file:** `tests/test_gates/test_iam_gate.py` +**Class:** `IAMGate` +**gate_id:** `iam` +**mapped_control_ids:** `["AC-3", "AC-4", "AC-5", "AC-6"]` + +## Scan Method + +`scan()` iterates every `diff_file` and calls `_check_line()` for each added line. `_check_line()` runs a single loop over `_IAM_PATTERNS` (10 patterns), each a 4-tuple `(pattern, description, control_id, remediation)`. All patterns are evaluated for every added line; a line can produce multiple findings. There is no file-type filter — all file types are scanned. + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `"Action"\s*:\s*"\*"\|'Action'\s*:\s*'\*'` | Wildcard IAM action detected — grants all permissions | AC-6 | Apply least privilege: specify only the exact actions needed | +| 2 | `"Resource"\s*:\s*"\*"\|'Resource'\s*:\s*'\*'` | Wildcard resource in IAM policy — applies to all resources | AC-6 | Scope resources to specific ARNs instead of using wildcards | +| 3 | `"Effect"\s*:\s*"Allow".*"Action"\s*:\s*"\*"` | IAM policy allows all actions | AC-6 | Follow least privilege principle — enumerate specific actions needed | +| 4 | `(?i)(?:AdministratorAccess|PowerUserAccess|FullAccess)` | Overly permissive managed policy referenced | AC-6 | Use custom policies with minimum required permissions instead of broad managed policies | +| 5 | `(?i)arn:aws:iam::.*:policy/AdministratorAccess` | AdministratorAccess policy attached | AC-5 | Implement separation of duties — avoid admin access in application code | +| 6 | `(?i)(?:access.control.allow.origin|cors.*origin)\s*[:=]\s*["\']?\*["\']?` | Wildcard CORS origin allows any domain | AC-4 | Restrict CORS origins to specific trusted domains | +| 7 | `(?i)allow_origins\s*=\s*\[?\s*["\']?\*["\']?` | CORS configured to allow all origins | AC-4 | Specify allowed origins explicitly instead of using wildcards | +| 8 | `(?i)@app\.route\(.*\)\s*$` | Route handler without explicit authentication decorator | AC-3 | Add authentication/authorization middleware or decorator to this endpoint | +| 9 | `(?i)(?:public|anonymous|no.?auth|skip.?auth|allow.?all)` | Explicit authentication bypass detected | AC-3 | Verify this endpoint should be publicly accessible; document the security decision | +| 10 | `(?i)sts[:\.]assume.?role` | STS AssumeRole without visible condition constraints | AC-3 | Add condition constraints (IP, MFA, time) to assume-role policies | + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_wildcard_action` | A policy document with `"Action": "*"` and `"Resource": "*"` triggers at least one finding with "wildcard" or "Action" in the description | +| `test_detects_cors_wildcard` | `access-control-allow-origin: *` header triggers at least one finding with "CORS" in the description | diff --git a/docs/gates/gate-04-sbom/design.md b/docs/gates/gate-04-sbom/design.md new file mode 100644 index 0000000..38930ac --- /dev/null +++ b/docs/gates/gate-04-sbom/design.md @@ -0,0 +1,52 @@ +# Gate 4 — Supply Chain & SBOM Gate + +**gate_id:** `sbom` +**NIST Controls:** SR-3, SR-11, SA-10, SA-11 +**Priority:** 🟡 Medium + +--- + +## Purpose + +Prevents unreviewed supply chain changes from entering the build by detecting three categories of risk: unpinned dependency versions that allow silent package substitution across install runs, CI/CD pipeline file modifications that create opportunities for unauthorized build-step injection, and dependency manifest changes that are not accompanied by a corresponding lockfile update (manifest-without-lockfile). Together these controls ensure that the resolved software component set remains deterministic, auditable, and reviewed before it reaches downstream environments. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| Dependency manifest modified without corresponding lockfile update | Without a lockfile update the resolved dependency set is non-deterministic; transitive dependencies may silently change between environments and install runs | SR-3 | +| Build/CI pipeline file modified | Pipeline changes can inject malicious build steps, exfiltrate secrets, alter artifact signing, or disable security checks without obvious code review signals | SA-10 | +| Unpinned version specifier in added lines (`>=`, `~=`, `*`, `latest`, `^`) | Unpinned specifiers allow any future version to be resolved at install time; a compromised package registry can silently deliver a malicious version that satisfies the constraint | SR-11 | +| Test coverage threshold lowered or test execution disabled | Weakening coverage thresholds or skipping tests removes verification of functional and security properties, eroding the assurance evidence produced by the test suite | SA-11 | + +--- + +## Scope + +- **Lockfile cross-check:** cross-file analysis — when a manifest file appears in the diff, the gate inspects the full set of modified file basenames across the entire diff to determine whether at least one accepted lockfile was also updated +- **Pipeline file detection:** per-file path check against a filename regex; fires once per matching file regardless of line content +- **Unpinned version patterns:** all added lines in every file in the diff are scanned; checks are not restricted to dependency manifest file types +- **Test coverage weakening patterns:** all added lines in every file in the diff are scanned; checks are not restricted to configuration file types + +--- + +## Known Limitations + +- The lockfile cross-check uses basename matching (`path.split("/")[-1]`), so renaming or moving a lockfile to a non-standard path fools the check — only the filename portion is compared, not the full path +- Does not scan binary lockfiles (e.g., compiled or encrypted lock formats); only text-based lockfile names are recognised +- Cannot detect transitive dependency vulnerabilities — the gate confirms a lockfile was updated alongside the manifest but does not inspect or validate the lockfile content for known-vulnerable versions +- Unpinned version patterns fire on any added line in any file, not exclusively in dependency manifest files, which may produce false positives in documentation, comments, or configuration files that mention version ranges in a non-dependency context +- Pipeline file detection is filename-based; custom CI systems that use non-standard filenames will not be flagged + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| SR-3 | Supply Chain Controls and Processes | Detects dependency manifest changes that are not accompanied by a lockfile update, ensuring the resolved dependency supply chain remains auditable and reproducible across environments | +| SR-11 | Component Authenticity | Detects unpinned version specifiers (`>=`, `~=`, `*`, `latest`, `^`) that allow arbitrary component versions to be resolved at install time, undermining the integrity and verifiability of software components | +| SA-10 | Developer Configuration Management | Detects modifications to build and CI/CD pipeline files, flagging changes that require security review to prevent unauthorized alterations to the build process or artifact pipeline | +| SA-11 | Developer Testing and Evaluation | Detects changes that lower test coverage thresholds or disable test execution, preserving the development-time assurance evidence required to validate functional and security properties | diff --git a/docs/gates/gate-04-sbom/implementation.md b/docs/gates/gate-04-sbom/implementation.md new file mode 100644 index 0000000..16dcc50 --- /dev/null +++ b/docs/gates/gate-04-sbom/implementation.md @@ -0,0 +1,115 @@ +# Gate 4 — Supply Chain & SBOM Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/sbom_gate.py` +**Test file:** `tests/test_gates/test_sbom_gate.py` +**Class:** `SBOMGate` +**gate_id:** `sbom` +**mapped_control_ids:** `["SR-3", "SR-11", "SA-10", "SA-11"]` + +--- + +## Scan Method + +`scan()` operates in three phases that execute for every `diff_file` in the input list: + +**Phase 1 — Cross-file lockfile check (SR-3)** +Before iterating files, `scan()` builds a set of all file basenames present in the diff: + +```python +basenames = {df.path.split("/")[-1] for df in diff_files} +``` + +For each `diff_file` whose basename is a key in `_MANIFEST_LOCKFILE_MAP`, the gate checks whether any of the expected lockfile basenames appear in `basenames`. If none match, one SR-3 finding is emitted at line 1 of the manifest file. This check fires once per manifest file per diff, not per added line. + +**Phase 2 — Pipeline file detection (SA-10)** +For each `diff_file`, the gate tests `diff_file.path` against the `_PIPELINE_FILES` regex. Any match fires one SA-10 finding at line 1, regardless of the file's line content. The check is path-based only. + +**Phase 3 — Added-line pattern scan (SR-11 and SA-11)** +For each added line (`line_no`, `line`) in `diff_file.all_added_lines`, the gate runs two independent pattern loops: + +- All five `_UNPINNED_PATTERNS` are tested against the line; each match fires one SR-11 finding. +- Both `_TEST_COVERAGE_PATTERNS` are tested against the line; each match fires one SA-11 finding. + +Phases 2 and 3 run on every file in the diff regardless of filename. A single file can produce findings from both phases simultaneously (e.g., a GitHub Actions workflow that also contains a version specifier). + +--- + +## Patterns + +### Unpinned Version Patterns (`_UNPINNED_PATTERNS`) — control SR-11 + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `>=` | Unpinned version specifier >= | SR-11 | Pin dependencies to exact versions for reproducible builds | +| 2 | `~=` | Loose version specifier ~= | SR-11 | Pin dependencies to exact versions for reproducible builds | +| 3 | `:\s*["\']?\*["\']?` | Wildcard version specifier * | SR-11 | Pin dependencies to exact versions for reproducible builds | +| 4 | `:\s*["\']?latest["\']?` | Version set to 'latest' | SR-11 | Pin dependencies to exact versions for reproducible builds | +| 5 | `:\s*["\']?\^` | Caret version range (allows minor/patch changes) | SR-11 | Pin dependencies to exact versions for reproducible builds | + +### Test Coverage Weakening Patterns (`_TEST_COVERAGE_PATTERNS`) — control SA-11 + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 6 | `(?i)(?:coverage\|cov).*(?:fail.?under\|threshold\|minimum)\s*[:=]\s*\d+` | Test coverage threshold modification detected | SA-11 | Maintain or increase test coverage thresholds | +| 7 | `(?i)skip.?test\|no.?test\|test.*disabled` | Test execution may be skipped or disabled | SA-11 | Maintain or increase test coverage thresholds | + +--- + +## Special Detection Logic + +### Manifest / Lockfile Cross-Check + +The gate collects the set of all file basenames present in the diff at the start of `scan()`: + +```python +basenames = {df.path.split("/")[-1] for df in diff_files} +``` + +For each `diff_file` whose basename appears as a key in `_MANIFEST_LOCKFILE_MAP`, the gate evaluates: + +```python +has_lockfile_update = any(lock_name in basenames for lock_name in expected_locks) +``` + +Matching is basename-only. If the manifest file is `services/backend/package.json`, only the string `"package.json"` is looked up in `_MANIFEST_LOCKFILE_MAP`; similarly, only the basenames of potential lockfiles are checked against `basenames`. A lockfile at `services/backend/package-lock.json` satisfies the check because its basename `"package-lock.json"` will be present in `basenames`. + +The manifest-to-lockfile mapping used by `_MANIFEST_LOCKFILE_MAP` is: + +| Manifest | Accepted lockfiles | +|---|---| +| `package.json` | `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml` | +| `requirements.txt` | `requirements.lock`, `Pipfile.lock`, `poetry.lock` | +| `Pipfile` | `Pipfile.lock` | +| `pyproject.toml` | `poetry.lock`, `uv.lock`, `pdm.lock` | +| `go.mod` | `go.sum` | +| `Cargo.toml` | `Cargo.lock` | +| `Gemfile` | `Gemfile.lock` | +| `composer.json` | `composer.lock` | + +If no matching lockfile basename is present in the diff, one SR-3 finding is emitted for the manifest file at line 1. The finding's `evidence` field lists all accepted lockfile names so the developer knows which files to update. + +### Pipeline File Detection + +The gate tests each file's path against the module-level `_PIPELINE_FILES` compiled regex: + +``` +(?i)(?:\.github/workflows/.*\.ya?ml|Jenkinsfile|\.gitlab-ci\.ya?ml| +azure-pipelines\.ya?ml|\.circleci/config\.yml|Dockerfile|docker-compose.*\.ya?ml| +Makefile|\.travis\.ya?ml|cloudbuild\.ya?ml) +``` + +The regex is case-insensitive (`(?i)`) and matches common CI/CD and build system filenames. Any file whose path matches fires one SA-10 finding at line 1. The finding description is fixed: `"Build/CI pipeline file modified — requires security review"`. + +--- + +## Test Coverage + +The `SBOMGate` is tested within `tests/test_coverage_gaps.py` under the `TestSBOMGate` class. + +| Test | What It Verifies | +|---|---| +| `test_detects_unpinned_deps` | A `requirements.txt` diff containing `flask>=2.0` and `boto3~=1.26` produces at least one finding with `control_id == "SR-11"` | +| `test_detects_manifest_without_lockfile` | A diff that modifies `requirements.txt` without updating any accepted lockfile produces at least one finding with `control_id == "SR-3"` | +| `test_detects_pipeline_change` | A diff that modifies `.github/workflows/ci.yml` produces at least one finding with `control_id == "SA-10"` | +| `test_detects_coverage_weakening` | A diff adding `cov_fail_under = 50` to `setup.cfg` produces at least one finding with `control_id == "SA-11"` | +| `test_detects_skip_tests` | A diff adding `skip_tests = True` to `config.py` produces at least one finding with `control_id == "SA-11"` | diff --git a/docs/gates/gate-05-iac/design.md b/docs/gates/gate-05-iac/design.md new file mode 100644 index 0000000..786f43d --- /dev/null +++ b/docs/gates/gate-05-iac/design.md @@ -0,0 +1,79 @@ +# Gate 5 — Infrastructure-as-Code Gate + +**gate_id:** `iac` +**NIST Controls:** CM-2, CM-6, CM-7, SC-7 +**Priority:** High + +--- + +## Purpose + +Prevents insecure infrastructure configurations from being deployed by detecting common misconfigurations in Terraform, CloudFormation, Kubernetes YAML, and Dockerfiles at commit time. Publicly accessible network rules, containers running as root, disabled encryption, and missing resource limits are among the most frequent causes of cloud security incidents. Catching these patterns before they reach the deployment pipeline is substantially cheaper and safer than remediating them post-deployment. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| **Network exposure:** Unrestricted IPv4 ingress rule (`0.0.0.0/0`) | Opens the resource to the entire internet, enabling reconnaissance and direct attack | SC-7 | +| **Network exposure:** Unrestricted IPv6 ingress rule (`::/0`) | Same as above for IPv6 traffic; often forgotten when IPv4 is correctly restricted | SC-7 | +| **Public storage:** Public storage ACL (`public-read`, `public-read-write`, `authenticated-read`) | Exposes object storage buckets to anonymous or broadly authenticated reads/writes | SC-7 | +| **Public storage:** S3 public access block disabled (`block_public_acls = false`) | Overrides the account-level public access guardrail, allowing bucket-level policies to grant public access | SC-7 | +| **Container security:** Container running as root or privileged (`USER root`, `runAsUser: 0`, `privileged: true`) | Root or privileged containers can break out of container isolation and access the host kernel | CM-6 | +| **Container security:** Empty or null Kubernetes security context (`securityContext: {}`) | No securityContext means all defaults apply — typically root user, no capability drops, writable root filesystem | CM-6 | +| **Container security:** Privilege escalation allowed (`allowPrivilegeEscalation: true`) | Allows processes inside the container to gain additional privileges via setuid or kernel exploits | CM-6 | +| **Container security:** Container using host network (`hostNetwork: true`) | Bypasses pod-level network isolation; container sees all host network interfaces | CM-7 | +| **Container security:** Container sharing host PID or IPC namespace (`hostPID`/`hostIPC: true`) | Allows process inspection and signal delivery across container boundaries | CM-7 | +| **Resource limits:** No resource limits defined (`resources: {}`) | Absence of CPU/memory limits enables denial-of-service through resource exhaustion | CM-6 | +| **Exposed ports:** Sensitive service port directly exposed (SSH 22, RDP 3389, databases 5432/3306/6379/27017/9200) | Direct exposure of administrative and data-tier ports to network traffic | CM-7 | +| **Insecure defaults:** Encryption explicitly disabled | Disabling at-rest or in-transit encryption leaves data unprotected | CM-6 | +| **Insecure defaults:** Logging disabled | Absence of infrastructure logging prevents detection of and response to incidents | CM-6 | +| **Insecure defaults:** Versioning disabled on storage resource | Without versioning, accidental deletion or ransomware encryption is unrecoverable | CM-2 | + +--- + +## Scope + +**IMPORTANT:** This gate only scans IaC files. All other files are skipped entirely before any pattern evaluation is performed. + +**File extensions included** (matched via `path.lower().endswith(ext)`): + +| Extension | Covers | +|---|---| +| `.tf` | Terraform resource files | +| `.tfvars` | Terraform variable files | +| `.hcl` | HCL configuration (Packer, Consul, Vault, etc.) | +| `.yaml` | Kubernetes manifests, CloudFormation, Docker Compose, CI configs | +| `.yml` | Same as `.yaml` | +| `.json` | CloudFormation, ARM templates, CDK output | +| `Dockerfile` | Docker image build files (suffix match) | + +**Path segments included** (matched via substring in `path.lower()`): + +`terraform/`, `infra/`, `infrastructure/`, `deploy/`, `k8s/`, `kubernetes/`, `helm/`, `.github/`, `cloudformation/`, `cdk/` + +**Filename patterns included** (checked separately): + +Any path containing `Dockerfile` or `docker-compose`. + +--- + +## Known Limitations + +- Only IaC files are scanned; non-IaC files (e.g., `.py`, `.js`, `.go`) are skipped entirely and will produce no findings even if they contain matching text. +- Only added lines are scanned; deleted or context lines are ignored. +- No cross-file analysis is performed; each file is evaluated independently with no knowledge of other files in the diff or the broader infrastructure state. +- The `resources: {}` and `securityContext: {}` patterns require the YAML to appear on a single line; multi-line null or empty definitions are not detected. +- Does not perform semantic analysis of Terraform plan output; only pattern-matches source text. + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| CM-2 | Baseline Configuration | Detects disabled versioning on storage resources, which is a required baseline configuration for data protection and recovery | +| CM-6 | Configuration Settings | Detects insecure container configurations (root user, privilege escalation, empty security contexts, no resource limits, disabled encryption and logging) that violate secure configuration baselines | +| CM-7 | Least Functionality | Detects unnecessary host namespace sharing, exposed sensitive service ports, and host network usage that violate the principle of least functionality | +| SC-7 | Boundary Protection | Detects unrestricted network ingress rules and public storage ACLs that eliminate boundary protection controls | diff --git a/docs/gates/gate-05-iac/implementation.md b/docs/gates/gate-05-iac/implementation.md new file mode 100644 index 0000000..504fd62 --- /dev/null +++ b/docs/gates/gate-05-iac/implementation.md @@ -0,0 +1,74 @@ +# Gate 5 — Infrastructure-as-Code Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/iac_gate.py` +**Test file:** `tests/test_gates/test_iac_gate.py` +**Class:** `IaCGate` +**gate_id:** `iac` +**mapped_control_ids:** `["CM-2", "CM-6", "CM-7", "SC-7"]` + +--- + +## Scan Method + +`scan()` iterates every `diff_file`. Before pattern evaluation, it calls `_is_iac_file(diff_file.path)`. If the file does not qualify as an IaC file, it is skipped entirely and the loop advances to the next file. For qualifying files, `_check_line()` is called for each added line via `diff_file.all_added_lines`. `_check_line()` runs a single loop over `_IAC_PATTERNS` (14 patterns), each a 4-tuple `(pattern, description, control_id, remediation)`. A `Finding` is appended for every pattern that matches. + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `0\.0\.0\.0/0` | Unrestricted ingress rule (0.0.0.0/0) — publicly accessible | SC-7 | Restrict ingress to specific IP ranges or CIDR blocks | +| 2 | `::/0` | Unrestricted IPv6 ingress rule (::/0) — publicly accessible | SC-7 | Restrict IPv6 ingress to specific CIDR blocks | +| 3 | `(?i)(?:acl\|access)\s*[:=]\s*["\']?(?:public-read\|public-read-write\|authenticated-read)["\']?` | Public access configured on storage resource | SC-7 | Set storage ACL to private and use pre-signed URLs for controlled access | +| 4 | `(?i)block_public_acls\s*[:=]\s*(?:false\|0)` | S3 public access block disabled | SC-7 | Enable all S3 public access blocks: block_public_acls, block_public_policy, ignore_public_acls, restrict_public_buckets | +| 5 | `(?i)(?:USER\s+root\|user:\s*["\']?root["\']?\|runAsUser:\s*0\|privileged:\s*true)` | Container configured to run as root or privileged | CM-6 | Run containers as non-root user. Add 'USER nonroot' in Dockerfile or set runAsNonRoot: true | +| 6 | `(?i)securityContext:\s*\{\s*\}\|securityContext:\s*null` | Empty or null security context in Kubernetes | CM-6 | Define securityContext with runAsNonRoot, readOnlyRootFilesystem, and drop ALL capabilities | +| 7 | `(?i)allowPrivilegeEscalation:\s*true` | Privilege escalation allowed in container | CM-6 | Set allowPrivilegeEscalation: false | +| 8 | `(?i)hostNetwork:\s*true` | Container using host network | CM-7 | Avoid hostNetwork unless absolutely necessary; use network policies instead | +| 9 | `(?i)hostPID:\s*true\|hostIPC:\s*true` | Container sharing host PID or IPC namespace | CM-7 | Disable hostPID and hostIPC unless required for specific system containers | +| 10 | `(?i)resources:\s*\{\s*\}\|resources:\s*null` | No resource limits defined for container | CM-6 | Define CPU and memory resource limits to prevent resource exhaustion attacks | +| 11 | `(?i)(?:containerPort\|hostPort\|port):\s*(?:22\|3389\|5432\|3306\|6379\|27017\|9200)\b` | Sensitive service port directly exposed | CM-7 | Avoid exposing sensitive service ports directly; use network policies and internal load balancers | +| 12 | `(?i)(?:encryption\|encrypted\|encrypt)\s*[:=]\s*(?:false\|0\|off\|none\|disabled)` | Encryption explicitly disabled in infrastructure configuration | CM-6 | Enable encryption at rest and in transit for all data stores and communication channels | +| 13 | `(?i)logging\s*[:=]\s*(?:false\|disabled\|off\|0)` | Logging disabled in infrastructure configuration | CM-6 | Enable logging for all infrastructure components for audit and incident response | +| 14 | `(?i)versioning\s*[:=]\s*(?:false\|disabled\|off\|0)` | Versioning disabled on storage resource | CM-2 | Enable versioning for data protection and recovery capability | + +--- + +## Special Detection Logic + +### `_is_iac_file()` File-Type Guard + +Before any pattern evaluation, `scan()` calls `_is_iac_file(path)` which returns `True` if any of the following conditions are met: + +**By file extension** (checked via `path.lower().endswith(ext)`): + +| Extension | Covers | +|---|---| +| `.tf` | Terraform resource files | +| `.tfvars` | Terraform variable files | +| `.hcl` | HCL configuration (Packer, Consul, Vault, etc.) | +| `.yaml` | Kubernetes manifests, CloudFormation, Docker Compose, CI configs | +| `.yml` | Same as `.yaml` | +| `.json` | CloudFormation, ARM templates, CDK output | +| `Dockerfile` | Docker image build files (suffix match) | + +**By path segment** (checked via substring match in `path.lower()`): + +`terraform/`, `infra/`, `infrastructure/`, `deploy/`, `k8s/`, `kubernetes/`, `helm/`, `.github/`, `cloudformation/`, `cdk/` + +**By filename pattern** (checked separately): + +`"Dockerfile" in path` or `"docker-compose" in path.lower()` + +Files that match none of these criteria are skipped; no findings are produced for them regardless of line content. + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_public_ingress` | `cidr_blocks = ["0.0.0.0/0"]` in a `.tf` file triggers at least one finding with `"0.0.0.0/0"` in the description | +| `test_detects_root_container` | `USER root` in a `Dockerfile` triggers at least one finding with `"root"` in the description | +| `test_skips_non_iac_files` | `cidr = "0.0.0.0/0"` in `app.py` (not an IaC file) produces zero findings | diff --git a/docs/gates/gate-06-input-validation/design.md b/docs/gates/gate-06-input-validation/design.md new file mode 100644 index 0000000..c2f224b --- /dev/null +++ b/docs/gates/gate-06-input-validation/design.md @@ -0,0 +1,66 @@ +# Gate 6 — Input Validation & Error Handling Gate + +**gate_id:** `input_validation` +**NIST Controls:** SI-7, SI-10, SI-11, SI-16 +**Priority:** 🔴 High + +--- + +## Purpose + +Gate 6 detects the introduction of code patterns that expose applications to injection attacks, unsafe code execution, insecure deserialization, and information disclosure through improper error handling. It covers SQL injection risks arising from dynamic query construction, use of dangerous execution primitives such as `eval`, `exec`, `os.system`, `os.popen`, and `subprocess` with `shell=True`, shell injection via unsafe C string functions, unsafe deserialization through `pickle` and `yaml.load`, improper error handling via bare `except` clauses or exposed stack traces, enabled debug mode that leaks internal state, and file download calls that omit checksum or hash integrity verification. All checks are applied at the point of code introduction by scanning every added line in the diff. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| **SQL Injection** | | | +| SQL query built with f-string | f-strings embed user input directly into the query string, enabling SQL injection | SI-10 | +| SQL query built with `.format()` | `.format()` interpolates user data into the query, same risk as f-strings | SI-10 | +| SQL query built with `%` interpolation | Classic Python `%`-style string formatting in SQL produces injection-vulnerable queries | SI-10 | +| SQL query built by concatenating user input | Direct string concatenation of `request`/`user`/`params` variables into SQL | SI-10 | +| **Dangerous Functions** | | | +| Use of `eval()` | Executes arbitrary Python from a string; code injection if the argument is user-controlled | SI-10 | +| Use of `exec()` | Executes arbitrary code; same risk as `eval()` | SI-10 | +| `subprocess` with `shell=True` | Passes the command through the shell interpreter, enabling command injection via metacharacters | SI-10 | +| Use of `os.system()` | Invokes a shell command; subject to command injection if any part is user-controlled | SI-10 | +| Use of `os.popen()` | Same shell-execution risk as `os.system()` with the addition of captured output | SI-10 | +| **Deserialization** | | | +| `pickle.loads()` / `pickle.load()` | Deserializes arbitrary Python objects; crafted payloads execute arbitrary code during deserialization | SI-10 | +| `yaml.load()` / `yaml.unsafe_load()` | Unsafe YAML deserialization can trigger arbitrary Python constructors | SI-10 | +| **Error Handling** | | | +| Bare `except: pass` | Silently swallows all exceptions including security-relevant failures; makes breaches invisible | SI-11 | +| Stack trace exposure (`traceback.print`, `print.*traceback`, `traceback.format`) | Exposes internal file paths, function names, and logic to users; aids attacker reconnaissance | SI-11 | +| Debug mode enabled (`DEBUG=True`) | Debug mode enables detailed error pages and disables security hardening in many frameworks | SI-11 | +| **Integrity** | | | +| File download without integrity verification | Downloads without hash verification can be silently replaced with malicious content | SI-7 | +| **Buffer Safety** | | | +| Unsafe C string functions (`strcpy`, `strcat`, `sprintf`, `gets`) | Classic buffer overflow vulnerabilities exploitable for arbitrary code execution in C/C++ code | SI-16 | + +--- + +## Scope + +- Scans all added lines in every file present in the diff +- No file-type filter is applied — all file extensions are included + +--- + +## Known Limitations + +- Detection is pattern-based only; no data flow analysis is performed, so whether a flagged call actually processes untrusted input cannot be determined +- The gate cannot determine whether the argument passed to `eval()` or `exec()` is user-controlled; any call to these functions is flagged regardless of its actual argument +- The bare `except` check is syntactic only and matches `except: pass` or `except: ...` on the same line; a bare `except` block where `pass` appears on the following line is not detected + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| SI-7 | Software, Firmware, and Information Integrity | Detects file downloads that omit integrity verification (checksum/hash validation), flagging the risk of tampered content being consumed by the application | +| SI-10 | Information Input Validation | Detects SQL injection via dynamic query construction, unsafe code execution primitives (`eval`/`exec`/`os.system`/`os.popen`), command injection via `shell=True`, and unsafe deserialization (`pickle`/`yaml.load`) | +| SI-11 | Error Handling | Detects bare `except` clauses that suppress errors, exposed stack traces, and enabled debug mode — all of which constitute improper error handling that can aid attackers or hide security failures | +| SI-16 | Memory Protection | Detects unsafe C string functions (`strcpy`, `strcat`, `sprintf`, `gets`) that are well-known sources of buffer overflow vulnerabilities | diff --git a/docs/gates/gate-06-input-validation/implementation.md b/docs/gates/gate-06-input-validation/implementation.md new file mode 100644 index 0000000..727dfa3 --- /dev/null +++ b/docs/gates/gate-06-input-validation/implementation.md @@ -0,0 +1,46 @@ +# Gate 6 — Input Validation & Error Handling Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/input_gate.py` +**Test file:** `tests/test_gates/test_input_gate.py` +**Class:** `InputGate` +**gate_id:** `input_validation` +**mapped_control_ids:** `["SI-7", "SI-10", "SI-11", "SI-16"]` + +--- + +## Scan Method + +`scan()` iterates every `DiffFile` and calls `_check_line()` for each added line (`diff_file.all_added_lines`). `_check_line()` runs a single loop over `_INPUT_PATTERNS` — a module-level list of 4-tuples `(pattern, description, control_id, remediation)`. No file-type filter is applied; all file extensions are checked. All patterns are evaluated for every added line, so a single line can produce multiple findings. + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `(?i)(?:execute\|cursor\.execute\|query)\s*\(\s*f["']` | SQL query built with f-string — SQL injection risk | SI-10 | Use parameterized queries (`cursor.execute('SELECT ... WHERE id = %s', (id,))`) | +| 2 | `(?i)(?:execute\|cursor\.execute\|query)\s*\(.*\.format\(` | SQL query built with .format() — SQL injection risk | SI-10 | Use parameterized queries instead of string formatting for SQL | +| 3 | `(?i)(?:execute\|cursor\.execute\|query)\s*\(.*%\s` | SQL query built with % string interpolation — SQL injection risk | SI-10 | Use parameterized queries instead of % formatting for SQL | +| 4 | `(?i)(?:execute\|cursor\.execute\|query)\s*\(.*\+\s*(?:request\|req\|params\|user\|input)` | SQL query built with concatenation of user input | SI-10 | Use parameterized queries. Never concatenate user input into SQL | +| 5 | `(?i)\beval\s*\(` | Use of eval() — code injection risk | SI-10 | Avoid eval(). Use ast.literal_eval() for data parsing or json.loads() for JSON | +| 6 | `(?i)\bexec\s*\(` | Use of exec() — code injection risk | SI-10 | Avoid exec(). Refactor to use safe alternatives | +| 7 | `(?i)subprocess\.(?:call\|run\|Popen)\s*\(.*shell\s*=\s*True` | Shell command execution with shell=True — command injection risk | SI-10 | Use shell=False and pass command as a list: `subprocess.run(['cmd', 'arg'])` | +| 8 | `(?i)os\.system\s*\(` | Use of os.system() — command injection risk | SI-10 | Use subprocess.run() with shell=False instead of os.system() | +| 9 | `(?i)os\.popen\s*\(` | Use of os.popen() — command injection risk | SI-10 | Use subprocess.run() with shell=False instead of os.popen() | +| 10 | `(?i)\bpickle\.loads?\b` | Unsafe deserialization with pickle — arbitrary code execution risk | SI-10 | Use JSON or other safe serialization formats. Never unpickle untrusted data | +| 11 | `(?i)\byaml\.(?:load\|unsafe_load)\s*\(` | Unsafe YAML loading — code execution risk | SI-10 | Use yaml.safe_load() instead of yaml.load() or yaml.unsafe_load() | +| 12 | `(?i)except\s*:\s*(?:pass\|\.\.\.|\s*$)` | Bare except clause silently swallows all errors | SI-11 | Catch specific exceptions and log them. Avoid bare except: pass | +| 13 | `(?i)(?:traceback\.print\|print.*traceback\|traceback\.format)` | Stack trace may be exposed to users | SI-11 | Log stack traces server-side only. Never expose traceback details to end users | +| 14 | `(?i)(?:DEBUG\|debug)\s*[:=]\s*(?:True\|true\|1\|on)` | Debug mode enabled — may expose internal details | SI-11 | Disable debug mode in production configurations | +| 15 | `(?i)(?:urllib\|requests\|wget\|curl).*(?:download\|get)\b(?!.*(?:verify\|checksum\|hash\|sha\|md5sum))` | File download without integrity verification | SI-7 | Verify downloaded files with checksums (SHA-256) before use | +| 16 | `(?i)\b(?:strcpy\|strcat\|sprintf\|gets)\s*\(` | Unsafe C string function — buffer overflow risk | SI-16 | Use safe alternatives: strncpy, strncat, snprintf, fgets | + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_sql_injection` | `cursor.execute(f"SELECT * FROM users WHERE name = '{name}'")`triggers at least one finding with `"SQL"` in the description (pattern 1) | +| `test_detects_eval` | `result = eval(data)` triggers at least one finding with `"eval"` in the description (pattern 5) | +| `test_detects_bare_except` | `except: pass` triggers at least one finding with `"except"` in the description (pattern 12) | diff --git a/docs/gates/gate-07-audit/design.md b/docs/gates/gate-07-audit/design.md new file mode 100644 index 0000000..b6b70e7 --- /dev/null +++ b/docs/gates/gate-07-audit/design.md @@ -0,0 +1,56 @@ +# Gate 7 — Audit & Logging Gate + +**gate_id:** `audit` +**NIST Controls:** AU-2, AU-3, AU-12 +**Priority:** 🔴 High + +--- + +## Purpose + +Ensures that security-relevant events are logged and that the audit trail is not inadvertently degraded by code changes. Inadequate logging is consistently cited in incident post-mortems as the primary reason attackers can operate undetected for extended periods. This gate detects three categories of logging risk: removal of existing log statements (which may reduce audit coverage), authentication and authorization functions that lack accompanying log output (leaving security decisions unrecorded), and log statements that include personally identifiable information or sensitive credentials (which creates a secondary data exposure). + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| Removed logging statement | Deleting a log call may eliminate the only record of an event from the audit trail | AU-12 | +| Security-critical function added without logging (`login`, `logout`, `authenticate`, `authorize`, `verify`, `check_permission`, `validate_token`, `reset_password`, `change_password`, `create_user`, `delete_user`, `grant_role`, `revoke_role`, `signup`, `signin`) | Authentication and access-control events that are not logged cannot be used for incident detection or forensic reconstruction | AU-2 | +| SSN or SIN logged | Social security numbers in log output create a PII data exposure incident | AU-3 | +| Password, secret, token, or API key logged | Credential material in log files enables secondary credential compromise | AU-3 | +| Credit card number or CVV logged | Payment card data in logs creates a PCI-DSS compliance violation | AU-3 | +| Date of birth logged | Date of birth is PII; its presence in logs may violate GDPR, HIPAA, or other privacy regulations | AU-3 | + +--- + +## Scope + +This gate scans **both added lines and removed lines**, making it one of only two gates in ControlGate that reads `hunk.removed_lines`. Three distinct scanning mechanisms are applied per diff file: + +- **Removed-line scan** (`_check_removed_logging`): iterates `hunk.removed_lines` across all hunks to detect deleted log statements; uses the `_LOGGING_STATEMENT` pattern. +- **Auth-function heuristic** (`_check_auth_logging`): iterates added lines grouped per hunk; joins all added lines in a hunk into a single string and checks for an auth/security function name without an accompanying log call in the same hunk; uses both `_AUTH_FUNCTION_PATTERNS` and `_LOGGING_STATEMENT`. +- **PII-in-log scan** (`_check_pii_in_logs`): iterates `diff_file.all_added_lines` and runs each added line against the four `_PII_LOG_PATTERNS`. + +No file-extension filter is applied; all files in the diff are scanned. + +--- + +## Known Limitations + +- The auth-function-without-logging check is a **heuristic operating per hunk**: if a function definition and its log statement appear in different hunks of the same diff, the heuristic will produce a false positive. +- The gate **cannot detect logging done in a called function**, decorator, or middleware outside the function body; it only checks whether the same hunk that introduces the function also contains a log call. +- **PII detection is keyword-based and line-scoped**: the regex requires the logging call and the PII keyword to appear on the same line. Structured logging calls that build a dict argument spread across multiple lines will not be detected. +- Removed-logging detection cannot distinguish intentional removal (superseded by a better framework) from accidental removal; all removed log statements are flagged. +- `print()` is treated as a logging statement for both removed-line detection and the auth-function heuristic, which may produce false positives on diagnostic print statements. + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| AU-2 | Event Logging | Detects authentication and authorization functions added without accompanying log statements, ensuring security-relevant events are captured | +| AU-3 | Content of Audit Records | Detects PII and credential data being written to logs, which pollutes audit records with data that should not be present and creates secondary exposure | +| AU-12 | Audit Record Generation | Detects removal of logging statements that may reduce the completeness of the audit record trail | diff --git a/docs/gates/gate-07-audit/implementation.md b/docs/gates/gate-07-audit/implementation.md new file mode 100644 index 0000000..690ad05 --- /dev/null +++ b/docs/gates/gate-07-audit/implementation.md @@ -0,0 +1,75 @@ +# Gate 7 — Audit & Logging Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/audit_gate.py` +**Test file:** `tests/test_gates/test_audit_gate.py` +**Class:** `AuditGate` +**gate_id:** `audit` +**mapped_control_ids:** `["AU-2", "AU-3", "AU-12"]` + +--- + +## Scan Method + +`scan()` performs three distinct sub-checks per `diff_file`, calling dedicated methods for each: + +1. **`_check_removed_logging(diff_file)`** — scans `hunk.removed_lines` for deleted log statements. For each hunk in `diff_file.hunks`, iterates `hunk.removed_lines` (a list of `(line_no, line)` tuples representing deleted lines). Any removed line matching `_LOGGING_STATEMENT` emits one **AU-12** finding at the original line number. + +2. **`_check_auth_logging(diff_file)`** — heuristic that detects auth/security functions added without any logging in the same hunk. For each hunk, joins all added lines into a single string and checks for `_AUTH_FUNCTION_PATTERNS`; if found, checks that `_LOGGING_STATEMENT` also appears in the same joined text. If logging is absent, emits one **AU-2** finding at the line of the auth function and stops after the first match per hunk. + +3. **`_check_pii_in_logs(diff_file.path, line_no, line)`** — called once per added line via `diff_file.all_added_lines`. Runs the added line against all four `_PII_LOG_PATTERNS`; each matching pattern emits one **AU-3** finding. + +This is one of only two gates in ControlGate that reads `hunk.removed_lines`. + +--- + +## Patterns + +### PII in Log Patterns (`_PII_LOG_PATTERNS`) — control AU-3 + +| # | Regex | Description | Remediation | +|---|---|---|---| +| 1 | `(?i)(?:log(?:ger)?\|print\|console)\s*[\.(].*(?:ssn\|social.?security\|sin\b)` | Possible SSN/SIN logged — PII in logs | Redact or mask sensitive data before logging. Log only non-PII identifiers | +| 2 | `(?i)(?:log(?:ger)?\|print\|console)\s*[\.(].*(?:password\|passwd\|pwd\|secret\|token\|api.?key)` | Sensitive credential may be logged — secrets in logs | Redact or mask sensitive data before logging. Log only non-PII identifiers | +| 3 | `(?i)(?:log(?:ger)?\|print\|console)\s*[\.(].*(?:credit.?card\|card.?number\|cvv\|ccn)` | Possible credit card data logged — PII in logs | Redact or mask sensitive data before logging. Log only non-PII identifiers | +| 4 | `(?i)(?:log(?:ger)?\|print\|console)\s*[\.(].*(?:date.?of.?birth\|dob\|birth.?date)` | Possible date of birth logged — PII in logs | Redact or mask sensitive data before logging. Log only non-PII identifiers | + +--- + +## Special Detection Logic + +### (a) Removed-Line Logging Detection (`_check_removed_logging`) + +Iterates `diff_file.hunks` and for each hunk iterates `hunk.removed_lines`. For each removed line that matches `_LOGGING_STATEMENT`, one AU-12 finding is emitted at the original (pre-patch) line number from the diff context. + +The `_LOGGING_STATEMENT` pattern matches: + +- `log.info(`, `log.warn(`, `log.error(`, `log.debug(`, `log.critical(`, `log.warning(` (and `logger.*` variants) +- `logging.info(`, `logging.warn(`, `logging.error(`, `logging.debug(`, `logging.critical(`, `logging.warning(` +- `console.log(`, `console.warn(`, `console.error(` +- `print(` + +### (b) Auth-Function-Without-Logging Heuristic (`_check_auth_logging`) + +For each hunk in `diff_file.hunks`: + +1. Joins all added lines into a single string: `added_text = " ".join(line for _, line in hunk.added_lines)` +2. Checks whether `_AUTH_FUNCTION_PATTERNS` matches anywhere in `added_text` +3. If a match is found, checks whether `_LOGGING_STATEMENT` also appears in `added_text` +4. If the hunk contains an auth function but no log statement, iterates `hunk.added_lines` to find the specific line matching `_AUTH_FUNCTION_PATTERNS`, emits one AU-2 finding at that line number, then breaks (one finding per hunk maximum) + +The auth function name keywords matched by `_AUTH_FUNCTION_PATTERNS` are: `login`, `logout`, `authenticate`, `authorize`, `verify`, `check_permission`, `validate_token`, `reset_password`, `change_password`, `create_user`, `delete_user`, `grant_role`, `revoke_role`, `signup`, `signin`. + +The heuristic is intentionally per-hunk. If the auth function definition and its log statement appear in different hunks of the same diff, the heuristic will fire a false positive. + +### (c) `_LOGGING_STATEMENT` Dual Usage + +The `_LOGGING_STATEMENT` compiled pattern is used in **two** distinct checks: + +- In `_check_removed_logging`: applied to each **removed** line to detect deleted audit calls. +- In `_check_auth_logging`: applied to the joined **added** text of each hunk to determine whether the auth function has accompanying logging. + +--- + +## Test Coverage + +No test file currently exists for `AuditGate`. The file `tests/test_gates/test_audit_gate.py` has not been created; this gate has no automated test coverage. diff --git a/docs/gates/gate-08-change-control/design.md b/docs/gates/gate-08-change-control/design.md new file mode 100644 index 0000000..ea59086 --- /dev/null +++ b/docs/gates/gate-08-change-control/design.md @@ -0,0 +1,50 @@ +# Gate 8 — Change Control Gate + +**gate_id:** `change_control` +**NIST Controls:** CM-3, CM-4, CM-5 +**Priority:** 🟡 Medium + +--- + +## Purpose + +Enforces change management discipline by flagging modifications to security-critical files, deployment configurations, and CODEOWNERS — ensuring these changes go through appropriate review. CI/CD pipelines, Dockerfiles, Terraform configurations, IAM/RBAC policies, CODEOWNERS files, and branch protection settings carry disproportionate security risk relative to ordinary application code changes, and this gate creates mandatory visibility by emitting findings that require a reviewer to acknowledge the security implications before a commit is accepted. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| Security-critical file modified (workflows, CODEOWNERS, Dockerfile, docker-compose, .env, Terraform, deploy/, k8s/, CI configs, nginx/apache/htaccess, supervisord, security/auth source files, IAM/RBAC/ACL policy files) | These files directly control the security posture of the system; unreviewed changes can introduce persistent backdoors or privilege escalation paths | CM-3 | +| Deployment configuration modified (Helm values, Terraform tfvars, Ansible playbooks, Pulumi configs) | Deployment config changes affect the runtime environment; incorrect changes can expose services or disable security controls | CM-4 | +| CODEOWNERS file modified | CODEOWNERS controls who must approve PRs for specific code paths; removal of a security-team entry can allow unapproved changes to sensitive files | CM-5 | +| Branch protection configuration change (`branch_protection`, `protected_branch` in added lines) | Branch protection settings prevent force pushes and require PR reviews; weakening them removes a key access control | CM-5 | + +--- + +## Scope + +- **Scans:** file paths (for security-critical file and deployment config detection, and CODEOWNERS check) and added lines (for branch protection content pattern) +- **File types targeted:** all file types; no extension filter is applied +- **Special detection:** most findings are generated from the **file path** rather than line content; the gate checks the path of each changed file against two large regular expressions (`_SECURITY_CRITICAL_FILES` and `_DEPLOY_CONFIG_FILES`); the CODEOWNERS check is also path-based (a case-insensitive string match on `diff_file.path`); only the branch protection pattern inspects added line text + +--- + +## Known Limitations + +- File-path matching is regex-based and may miss novel paths that follow unusual naming conventions not covered by the two compiled patterns +- Cannot verify whether a formal change ticket actually exists for the modification; the gate emits a finding to prompt review but cannot confirm that the change management process has been followed +- The branch protection check is line-content only and is a heuristic; it fires on any added line containing the words "branch protection" or "protected branch" regardless of context, including comments and documentation +- The gate emits a finding for every matching file modification without assessing whether the change is benign; all security-critical file changes produce findings regardless of the nature of the modification +- The CODEOWNERS check fires on any path containing `CODEOWNERS` (case-insensitive); changes that add restrictions are treated identically to changes that remove them + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| CM-3 | Configuration Change Control | Detects modifications to security-critical files that should follow a formal change control process including security review and documentation | +| CM-4 | Impact Analysis | Detects deployment and infrastructure configuration changes that require analysis of their security impact before being applied to production | +| CM-5 | Access Restrictions for Change | Detects CODEOWNERS modifications and branch protection changes that could weaken the access restrictions controlling who can approve and merge code changes | diff --git a/docs/gates/gate-08-change-control/implementation.md b/docs/gates/gate-08-change-control/implementation.md new file mode 100644 index 0000000..0ca1630 --- /dev/null +++ b/docs/gates/gate-08-change-control/implementation.md @@ -0,0 +1,73 @@ +# Gate 8 — Change Control Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/change_gate.py` +**Test file:** `tests/test_gates/test_change_gate.py` +**Class:** `ChangeGate` +**gate_id:** `change_control` +**mapped_control_ids:** `["CM-3", "CM-4", "CM-5"]` + +--- + +## Scan Method + +`scan()` iterates every `diff_file` and performs four checks, most of which are path-based rather than line-content-based: + +1. **Security-critical file check** — if `diff_file.path` matches `_SECURITY_CRITICAL_FILES` (case-insensitive), fires one CM-3 finding at line 1 with description "Security-critical file modified — requires additional review". +2. **Deployment config check** — if `diff_file.path` matches `_DEPLOY_CONFIG_FILES` (case-insensitive), fires one CM-4 finding at line 1 with description "Deployment configuration modified — impact analysis required". +3. **CODEOWNERS check** — if `"CODEOWNERS"` appears in `diff_file.path.upper()`, fires one CM-5 finding at line 1 with description "CODEOWNERS file modified — may change access restrictions for code review". +4. **Line-content check** — for each added line (via `diff_file.all_added_lines`), calls `_check_line()` which applies the inline branch protection regex; fires CM-5 findings. + +Checks 1–3 are path-based and emit at most one finding each per file. Check 4 is the only content-based check and can emit multiple findings per file. + +--- + +## Patterns + +There is no `_PATTERNS` list in this gate. Instead, two module-level compiled regexes handle path-based detection: + +| Pattern Name | What It Matches | Control | +|---|---|---| +| `_SECURITY_CRITICAL_FILES` | File paths matching CI/CD workflow dirs (`.github/workflows`, `.gitlab-ci`, `.travis`, `.circleci`, `Jenkinsfile`, `azure-pipelines`, `cloudbuild`), container files (`Dockerfile`, `docker-compose*.yml`), environment files (`.env`, `.env.*`), infrastructure-as-code (`terraform/*.tf`, `infra/*.tf`), deployment dirs (`deploy/`, `deployment/`, `k8s/`, `kubernetes/`), web server configs (`nginx.conf`, `apache*.conf`, `.htaccess`, `supervisord.conf`), `Makefile`, security/auth source files (`security*.py\|js\|ts\|rb\|go\|java`, `auth*.py\|js\|ts\|rb\|go\|java`), and IAM/RBAC/ACL/policy files (`iam*.json\|yaml\|py`, `rbac*.json\|yaml\|py`, `acl*.json\|yaml\|py`, `policy*.json\|yaml\|py`) | CM-3 | +| `_DEPLOY_CONFIG_FILES` | File paths matching Helm values files (`helm/.*values.*\.ya?ml`), Helm charts dirs (`charts/`), Terraform variable files (`terraform/.*\.tfvars`), Ansible dirs (`ansible/`), and Pulumi dirs (`pulumi/`) | CM-4 | + +--- + +## Special Detection Logic + +### (a) `_SECURITY_CRITICAL_FILES` matches + +The regex is a single large alternation compiled with `re.VERBOSE` and the `(?i)` flag for case-insensitivity. It is applied via `.search()` against the full file path string, so it fires on partial path matches (e.g., `services/api/Dockerfile` matches `Dockerfile`). Key file types covered include: + +- GitHub Actions workflows and CODEOWNERS (`.github/workflows/`, `.github/CODEOWNERS`) +- Container build files (`Dockerfile`, `docker-compose*.yml`) +- Environment variable files (`.env`, `.env.production`, `.env.local`, etc.) +- Terraform and infra IaC files (`terraform/*.tf`, `infra/*.tf`) +- Deployment directories (`deploy/`, `deployment/`, `k8s/`, `kubernetes/`) +- CI/CD pipeline configs (`.gitlab-ci`, `.travis`, `.circleci`, `Jenkinsfile`, `azure-pipelines`, `cloudbuild`) +- Web server and process manager configs (`nginx.conf`, `apache*.conf`, `.htaccess`, `supervisord.conf`, `Makefile`) +- Security/auth source files (any `security*.{py,js,ts,rb,go,java}` or `auth*.{py,js,ts,rb,go,java}`) +- IAM/RBAC/ACL/policy definition files (`iam*.{json,yaml,py}`, `rbac*.{json,yaml,py}`, `acl*.{json,yaml,py}`, `policy*.{json,yaml,py}`) + +### (b) `_DEPLOY_CONFIG_FILES` matches + +A second compiled regex applied via `.search()` against `diff_file.path` (case-insensitive). Covers Helm values files, Helm chart directories, Terraform `.tfvars` variable files, Ansible playbook directories, and Pulumi project directories. This check is independent of `_SECURITY_CRITICAL_FILES`; a file can match both regexes and produce both a CM-3 and a CM-4 finding. + +### (c) CODEOWNERS path check + +A hard-coded string check — `"CODEOWNERS" in diff_file.path.upper()` — is evaluated after the regex checks. This is not a regex; it is a substring match. It fires on any file whose path contains `CODEOWNERS` in any case (e.g., `CODEOWNERS`, `.github/CODEOWNERS`, `docs/codeowners`). Because `_SECURITY_CRITICAL_FILES` also matches `CODEOWNERS` paths, a CODEOWNERS file will produce both a CM-3 finding (from the regex) and a CM-5 finding (from this check) — this duplication is by design. + +### (d) Branch protection inline regex + +Inside `_check_line()`, a one-off `re.search()` is applied to each added line: + +``` +(?i)(?:branch.?protection|protected.?branch) +``` + +This pattern matches any added line containing "branch protection", "branch-protection", "branchprotection", "protected branch", "protected-branch", or "protectedbranch" (the `.?` allows an optional separator character). It fires a CM-5 finding at the exact line number of the matching added line. This is the only content-based check in the gate. + +--- + +## Test Coverage + +No test file exists for `ChangeGate` at `tests/test_gates/test_change_gate.py`. This gate has no automated test coverage. diff --git a/docs/gates/gate-09-deps/design.md b/docs/gates/gate-09-deps/design.md new file mode 100644 index 0000000..24c71eb --- /dev/null +++ b/docs/gates/gate-09-deps/design.md @@ -0,0 +1,52 @@ +# Gate 9 — Dependency Vulnerability Gate + +**gate_id:** `deps` +**NIST Controls:** RA-5, SI-2, SA-12 +**Priority:** 🟡 Medium + +--- + +## Purpose + +Gate 9 addresses the dependency supply chain as an attack surface by detecting hygiene violations that allow vulnerable or tampered packages to enter the build. Unpinned dependencies can resolve to a newer, malicious, or vulnerable release at install time; integrity verification flags like `--no-verify` and `--ignore-scripts` disable the checksum and hook mechanisms that package managers use to validate downloaded content; and insecure HTTP registry URLs expose package download traffic to man-in-the-middle substitution. Together these weaknesses undermine the assumption that the code built is the code reviewed, creating a gap between the declared dependency graph and the software that actually executes in production. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| Package manager invoked with `--no-verify` (`pip`, `pip3`, `npm`, `yarn`, `gem`) | Disabling checksum verification allows a tampered or malicious package to be installed silently | SA-12 | +| `npm install --ignore-scripts` | Bypassing postinstall hooks removes a class of integrity checks; auditing is required as a substitute | SA-12 | +| Insecure HTTP URL for a package registry (`pypi`, `npmjs`, `rubygems`, `packagist`, `pkg.go.dev`, `registry.*`) | HTTP exposes package downloads to man-in-the-middle substitution; an attacker on the network can silently replace a package | SI-2 | +| `pip install` with a range version specifier (`>=`, `<=`, `~=`, `!=`, `>`, `<`) | Range specifiers allow the resolver to select a newer release that may introduce a vulnerability not present in the tested version | RA-5 | +| `pip install` of a package without any pinned version (`==`) | Without an exact version pin the resolver may silently upgrade to a vulnerable release | RA-5 | + +--- + +## Scope + +- **Scans:** added lines in git diffs +- **File types targeted:** all files +- **Special detection:** none + +--- + +## Known Limitations + +- Does not scan deleted/removed lines +- Does not perform cross-file analysis +- Does not inspect `requirements.txt`, `package.json`, `Gemfile`, or other manifest files for unpinned versions — only inline `pip install` command invocations in diff lines are checked +- Range-specifier and unpinned-version detection is limited to `pip`/`pip3`; equivalent patterns for `npm`, `yarn`, `gem`, or `go get` are not covered +- The `--no-verify` pattern requires the flag and the package manager name to appear on the same line; multi-line shell commands may evade detection +- `git commit --no-verify` and other non-package-manager uses of `--no-verify` are intentionally excluded by the pattern, but the exclusion is name-based and may not cover every non-package-manager tool that uses the flag + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| RA-5 | Vulnerability Monitoring and Scanning | Detects `pip install` commands that use range or unpinned version specifiers, which prevent reproducible builds and allow vulnerable package versions to be silently introduced | +| SI-2 | Flaw Remediation | Detects insecure HTTP URLs used to reach package registries, where a known vulnerability in the transport layer could allow a patched package to be substituted with a vulnerable one | +| SA-12 | Supply Chain Protection | Detects use of `--no-verify` and `--ignore-scripts` flags that bypass the integrity and hook mechanisms package managers provide to validate the provenance and safety of downloaded packages | diff --git a/docs/gates/gate-09-deps/implementation.md b/docs/gates/gate-09-deps/implementation.md new file mode 100644 index 0000000..f88119a --- /dev/null +++ b/docs/gates/gate-09-deps/implementation.md @@ -0,0 +1,41 @@ +# Gate 9 — Dependency Vulnerability Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/deps_gate.py` +**Test file:** `tests/test_gates/test_deps_gate.py` +**Class:** `DepsGate` +**gate_id:** `deps` +**mapped_control_ids:** `["RA-5", "SI-2", "SA-12"]` + +--- + +## Scan Method + +`scan()` iterates every `DiffFile` and, for each added line (`diff_file.all_added_lines`), runs a single loop over `_PATTERNS` — a module-level list of 5-tuples `(pattern, description, control_id, remediation)`. Every pattern is evaluated against every added line using `pattern.search(line)`; a matching line emits one finding via `self._make_finding()`. No file-type filter is applied; all file extensions are checked. A single line can produce multiple findings if it matches more than one pattern. + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `(?:pip\|pip3\|npm\|yarn\|gem)\b.*--no-verify\|--no-verify.*(?:pip\|pip3\|npm\|yarn\|gem)\b` | Package integrity verification bypassed with --no-verify | SA-12 | Remove --no-verify to ensure package checksums are validated | +| 2 | `--ignore-scripts` | npm --ignore-scripts bypasses postinstall security hooks | SA-12 | Audit dependencies manually before using --ignore-scripts; prefer a scanned internal registry | +| 3 | `http://[^\s]*(?:pypi\|npmjs\|rubygems\|packagist\|pkg\.go\.dev\|registry\.)` | Insecure HTTP URL used for package registry — man-in-the-middle risk | SI-2 | Use HTTPS for all package registry URLs | +| 4 | `pip3?\s+install\s+(?!-r\s)[A-Za-z0-9][^\n]*(?:>=\|<=\|~=\|!=\|(?])>(?!=)\|(?])<(?!=))` | pip install with range version specifier — use == for reproducible installs | RA-5 | Pin to exact versions (pip install package==1.2.3) for reproducibility | +| 5 | `pip\s+install\s+(?!-r\s)(?:[A-Za-z0-9][A-Za-z0-9_.-]*)(?:\s+(?![^\s]*==)[A-Za-z0-9][A-Za-z0-9_.-]*)*\s*$` | pip install without pinned version — dependency may resolve to a vulnerable release | RA-5 | Pin all dependencies to exact versions (pip install package==1.2.3) or use a lockfile | + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_no_verify` | `pip install --no-verify requests` produces at least one finding whose description contains "no-verify" or "integrity" (pattern 1) | +| `test_detects_ignore_scripts` | `npm install --ignore-scripts` produces at least one finding (pattern 2) | +| `test_detects_http_registry` | `registry=http://registry.npmjs.org/` produces at least one finding whose description contains "http" (pattern 3) | +| `test_detects_unpinned_pip_install` | `pip install requests flask` (no version pins) produces at least one finding (pattern 5) | +| `test_pinned_install_no_findings` | `pip install requests==2.31.0 flask==3.0.0` (exact pins) produces zero findings | +| `test_git_no_verify_not_flagged` | `git commit --no-verify -m "release"` produces zero findings, confirming the pattern is scoped to package managers only | +| `test_detects_range_specifier` | `pip install requests>=2.0.0` produces at least one finding (pattern 4) | +| `test_findings_have_gate_id` | Every finding produced by the `--no-verify` diff has `gate == "deps"` | +| `test_findings_have_valid_control_ids` | Every finding produced by the `--no-verify` diff has a `control_id` in `{"RA-5", "SI-2", "SA-12"}` | diff --git a/docs/gates/gate-10-api/design.md b/docs/gates/gate-10-api/design.md new file mode 100644 index 0000000..83ab6ad --- /dev/null +++ b/docs/gates/gate-10-api/design.md @@ -0,0 +1,52 @@ +# Gate 10 — API Security Gate + +**gate_id:** `api` +**NIST Controls:** SC-8, AC-3, SC-5, SI-10 +**Priority:** High + +--- + +## Purpose + +Guards against insecure API configuration patterns that are commonly exploited in web application attacks. TLS verification bypasses expose services to man-in-the-middle interception; overly permissive CORS policies allow attacker-controlled origins to make credentialed cross-site requests; API credentials placed in URL query parameters are captured in server access logs, browser history, and proxy caches; and GraphQL introspection left enabled in production hands attackers a complete map of the API surface. By flagging these patterns at diff time, the gate ensures that insecure API configurations are caught before they reach a deployed environment. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| `verify=False` in HTTP client calls | Disabling TLS certificate verification removes the only protection against man-in-the-middle attacks on encrypted channels | SC-8 | +| `CORS_ORIGIN_ALLOW_ALL = True` or `allow_all_origins = True` | Framework-level wildcard CORS setting permits any domain to make credentialed cross-origin requests, undermining same-origin isolation | AC-3 | +| `Access-Control-Allow-Origin: *` header | Wildcard origin in the CORS response header allows any website to read API responses | AC-3 | +| `Access-Control-Allow-Credentials: true` with any origin | Combining credentialed CORS with a wildcard or permissive origin creates a CSRF/CORS bypass risk by forwarding session cookies to attacker-controlled pages | AC-3 | +| API key or token in URL query parameter (`?api_key=`, `?token=`, `?access_token=`, `?secret=`) | Credentials embedded in URLs appear in server access logs, browser history, CDN logs, and referrer headers — where they are visible to non-security personnel | SC-8 | +| `GRAPHQL_INTROSPECTION = True` or `graphiql = True` | GraphQL introspection and the GraphiQL IDE expose the full type system and query structure of the API, giving attackers a complete reconnaissance tool in production | AC-3 | + +--- + +## Scope + +- **Scans:** added lines in git diffs +- **File types targeted:** all files + +--- + +## Known Limitations + +- Does not scan deleted or removed lines +- Does not perform cross-file analysis +- The `Access-Control-Allow-Credentials` pattern fires on any added line containing the header value `true` regardless of whether a wildcard `Access-Control-Allow-Origin` header is set in the same response; the combination check is heuristic, not semantic +- The query-parameter pattern is regex-based and fires on URL strings in any file type, including documentation and test fixtures that may use example URLs with dummy credentials +- Does not detect TLS verification issues expressed through framework-level settings other than the `verify=False` keyword argument (e.g., environment variables that suppress verification) + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| SC-8 | Transmission Confidentiality and Integrity | Detects `verify=False` which disables TLS certificate validation and exposes transmissions to interception, and detects API credentials placed in URL query parameters where they can leak through log channels | +| AC-3 | Access Enforcement | Detects wildcard and overly permissive CORS configurations that allow unauthorized origins to access protected API resources, and detects GraphQL introspection that bypasses access controls by revealing the full API schema | +| SC-5 | Denial of Service Protection | Declared in `mapped_control_ids`; no patterns currently emitted — rate-limiting and DoS detection are deferred (see Known Debt in the implementation reference) | +| SI-10 | Information Input Validation | Declared in `mapped_control_ids`; no patterns currently emitted — API input schema validation checks are deferred (see Known Debt in the implementation reference) | diff --git a/docs/gates/gate-10-api/implementation.md b/docs/gates/gate-10-api/implementation.md new file mode 100644 index 0000000..043aa0d --- /dev/null +++ b/docs/gates/gate-10-api/implementation.md @@ -0,0 +1,48 @@ +# Gate 10 — API Security Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/api_gate.py` +**Test file:** `tests/test_gates/test_api_gate.py` +**Class:** `APIGate` +**gate_id:** `api` +**mapped_control_ids:** `["SC-8", "AC-3", "SC-5", "SI-10"]` + +--- + +## Scan Method + +`scan()` iterates every `diff_file` in the provided list and then iterates every added line via `diff_file.all_added_lines`, which yields `(line_no, line)` tuples. For each added line the method runs all six entries in the module-level `_PATTERNS` list in order. Each entry is a four-tuple of `(compiled_regex, description, control_id, remediation)`. When a pattern's `.search()` call matches the line, `_make_finding()` is called with the corresponding control ID, file path, line number, description, the first 120 characters of the stripped line as evidence, and the remediation string. All findings are collected into a flat list and returned. There is no early-exit per line; a single added line can produce multiple findings if it matches more than one pattern. + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `verify\s*=\s*False` | TLS certificate verification disabled — subject to MITM attacks | SC-8 | Remove verify=False and use a proper CA bundle; never disable TLS verification in production | +| 2 | `(?i)(?:CORS_ORIGIN_ALLOW_ALL\|allow_all_origins)\s*=\s*True` | CORS wildcard origin configured — allows any domain to make credentialed requests | AC-3 | Restrict CORS to an explicit allowlist of trusted origins | +| 3 | `Access-Control-Allow-Origin.*?[=:]\s*["']?\s*\*` | Access-Control-Allow-Origin: * permits requests from any origin | AC-3 | Restrict Access-Control-Allow-Origin to specific trusted origins | +| 4 | `Access-Control-Allow-Credentials.*?[=:]\s*["']?\s*true` (case-insensitive) | Access-Control-Allow-Credentials: true with wildcard origin creates CSRF/CORS bypass risk | AC-3 | Never combine Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: * | +| 5 | `[?&](?:api[_-]?key\|token\|access[_-]?token\|secret)[=]` | API key or token passed in URL query parameter — logged in server access logs | SC-8 | Pass API credentials in Authorization header, not in URL query parameters | +| 6 | `(?i)GRAPHQL_INTROSPECTION\s*=\s*True\|graphiql\s*=\s*True` | GraphQL introspection or GraphiQL enabled — exposes full schema to attackers | AC-3 | Disable introspection and GraphiQL in non-development environments | + +--- + +## Known Debt / Deferred Patterns + +- SC-5 (Denial of Service Protection): declared in `mapped_control_ids` but no patterns currently emit SC-5; rate-limiting and DoS detection deferred +- SI-10 (Information Input Validation): declared in `mapped_control_ids` but no patterns currently emit SI-10; API input schema validation deferred + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_verify_false` | A line containing `verify=False` triggers a finding with "TLS" or "MITM" in the description and control ID SC-8 | +| `test_detects_cors_allow_all` | A line containing `CORS_ORIGIN_ALLOW_ALL = True` or `allow_all_origins = True` triggers a finding with "CORS" or "wildcard" in the description and control ID AC-3 | +| `test_detects_api_key_in_query` | A URL containing a query parameter named `api_key`, `token`, `access_token`, or `secret` triggers a finding with "query parameter" in the description and control ID SC-8 | +| `test_detects_credentialed_cors` | A line containing `Access-Control-Allow-Credentials: true` triggers a finding referencing the CSRF/CORS bypass risk and control ID AC-3 | +| `test_clean_code_no_findings` | Code that uses proper TLS settings, header-based auth, and restricted CORS origins produces zero findings | +| `test_findings_have_gate_id` | Every finding produced by the gate carries `gate == "api"` | +| `test_findings_have_valid_control_ids` | Every finding produced by the gate uses a control ID drawn from `{"SC-8", "AC-3"}` | +| `test_detects_graphql_introspection` | A line containing `GRAPHQL_INTROSPECTION = True` or `graphiql = True` triggers a finding with "introspection" or "GraphiQL" in the description and control ID AC-3 | diff --git a/docs/gates/gate-11-privacy/design.md b/docs/gates/gate-11-privacy/design.md new file mode 100644 index 0000000..91394ce --- /dev/null +++ b/docs/gates/gate-11-privacy/design.md @@ -0,0 +1,49 @@ +# Gate 11 — Data Privacy Gate + +**gate_id:** `privacy` +**NIST Controls:** PT-2, PT-3, SC-28 +**Priority:** 🔴 High + +--- + +## Purpose + +Guards against PII handling violations introduced in code changes. Logging PII fields exposes personal data in log aggregation systems and violates the principle of data minimization; serializing all model fields by default risks leaking sensitive attributes through API responses; data fields with no expiry or TTL create unbounded retention that violates retention policies; and PII stored in plaintext database columns is unprotected at rest if the storage layer is compromised. By flagging these patterns at diff time, the gate ensures that privacy-degrading code is caught before it reaches a deployed environment. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| PII field name (SSN, date of birth, credit card, CVV, passport, driver's license) appearing in a logging or print statement | Writing PII to logs exposes personal data in log storage, log aggregators, and observability tools where access controls are often weaker than production datastores | PT-3 | +| `serialize_all_fields = True` in a serializer class | Opt-in-to-everything serialization exposes every model field — including PII and internal fields — through API responses without an explicit allowlist review | PT-2 | +| `expires_at = None`, `ttl: 0`, or `ttl: null` in a model or data class | A null or zero retention marker means data is stored indefinitely with no automated expiry, violating explicit retention policy requirements | SC-28 | +| PII field name in a plaintext `CharField`, `TextField`, `StringField`, or `Column(String)` database column definition | Defining PII columns without encryption annotations means the data is stored in plaintext at rest, leaving it exposed if the database is accessed directly | SC-28 | + +--- + +## Scope + +- **Scans:** added lines in git diffs +- **File types targeted:** all files + +--- + +## Known Limitations + +- Does not scan deleted or removed lines +- Does not perform cross-file analysis +- PII detection is keyword-based and line-scoped; structured logging calls or field definitions that span multiple lines will not be detected +- The plaintext-column pattern matches on field name keywords in the column definition; it does not detect PII stored under innocuous column names, nor does it inspect ORM `Meta` classes or migration files for encryption markers +- The `serialize_all_fields` pattern fires on any added line containing the literal, including documentation and test fixtures + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| PT-2 | Authority to Process Personally Identifiable Information | Detects `serialize_all_fields = True` which bypasses field-level access control in serializers and may cause unauthorized disclosure of PII through API responses | +| PT-3 | Personally Identifiable Information Processing Purposes | Detects PII field names in logging and print statements, preventing personal data from being written to log systems outside the purpose for which it was collected | +| SC-28 | Protection of Information at Rest | Detects both missing data retention markers (null `expires_at` / zero TTL) and PII stored in plaintext database columns without encryption, ensuring data at rest is protected and bounded in lifetime | diff --git a/docs/gates/gate-11-privacy/implementation.md b/docs/gates/gate-11-privacy/implementation.md new file mode 100644 index 0000000..27bc08f --- /dev/null +++ b/docs/gates/gate-11-privacy/implementation.md @@ -0,0 +1,39 @@ +# Gate 11 — Data Privacy Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/privacy_gate.py` +**Test file:** `tests/test_gates/test_privacy_gate.py` +**Class:** `PrivacyGate` +**gate_id:** `privacy` +**mapped_control_ids:** `["PT-2", "PT-3", "SC-28"]` + +--- + +## Scan Method + +`scan()` iterates every `diff_file` in the provided list and, for each file, iterates every `(line_no, line)` pair from `diff_file.all_added_lines`. Each added line is tested against all four entries in `_PATTERNS`; every match produces one finding via `_make_finding()`. There are no sub-methods, no removed-line checks, and no file-extension filters. + +Patterns 1 and 4 share the `_PII_FIELDS` helper constant, which is a non-capturing alternation of PII keyword fragments. This constant is concatenated into the base regex string at module load time before `re.compile()` is called, so the runtime pattern contains the fully expanded alternation. + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `(?i)(?:logging\.\|logger\.\|print\().*(?:ssn\|social.?security\|date.?of.?birth\|dob\|credit.?card\|card.?number\|cvv\|passport\|drivers.?license)` | PII field name detected in logging/print statement | PT-3 | Remove PII from logs; use opaque identifiers (user_id) instead of PII field values | +| 2 | `(?i)serialize_all_fields\s*=\s*True` | `serialize_all_fields=True` exposes all model fields — may leak PII or sensitive data | PT-2 | Use an explicit fields allowlist in serializers; never serialize all fields by default | +| 3 | `(?i)expires_at\s*=\s*None\|ttl\s*[:=]\s*0\|ttl\s*[:=]\s*null` | Data retention field set to null/0 — no expiry policy enforced | SC-28 | Set an explicit `expires_at` or TTL for all data with retention requirements | +| 4 | `(?i)(?:CharField\|TextField\|StringField\|Column\(String)\s*\(.*?(?:ssn\|social.?security\|date.?of.?birth\|dob\|credit.?card\|card.?number\|cvv\|passport\|drivers.?license)` | PII field stored in plaintext database column without encryption marker | SC-28 | Encrypt PII at rest using field-level encryption or a dedicated vault | + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_pii_in_log` | A diff adding `logging.debug("User SSN: %s, DOB: %s", user.ssn, user.date_of_birth)` produces at least one finding whose description contains "pii" or "log" | +| `test_detects_serialize_all_fields` | A diff adding `serialize_all_fields = True` to a serializer class produces at least one finding | +| `test_detects_no_expiry` | A diff adding `expires_at = None` to a model class produces at least one finding | +| `test_clean_code_no_findings` | A diff adding a log statement that uses only `user_id` (no PII keywords) produces zero findings | +| `test_findings_have_gate_id` | Every finding produced from the PII-in-log diff carries `gate == "privacy"` | +| `test_findings_have_valid_control_ids` | Every finding produced from the PII-in-log diff uses a control ID within `{"PT-2", "PT-3", "SC-28"}` | diff --git a/docs/gates/gate-12-resilience/design.md b/docs/gates/gate-12-resilience/design.md new file mode 100644 index 0000000..4e88c7c --- /dev/null +++ b/docs/gates/gate-12-resilience/design.md @@ -0,0 +1,50 @@ +# Gate 12 — Resilience & Backup Gate + +**gate_id:** `resilience` +**NIST Controls:** CP-9, CP-10, SI-13 +**Priority:** 🟡 Medium + +--- + +## Purpose + +Detects infrastructure and application configuration changes that undermine recoverability. When critical settings such as deletion protection, automated backups, final snapshots, and retry logic are disabled or zeroed out, the system loses its ability to recover from accidental deletion, data loss, or transient failure. This gate flags these misconfigurations at commit time — before they can reach a production environment — providing an automated guardrail aligned with NIST contingency planning and fault-tolerance controls. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| `deletion_protection = false` on a database resource | Allows a database to be accidentally or maliciously deleted with no recovery path | CP-9 | +| `backup = false` on a database resource | Disables automated backups, leaving no restore point if data is corrupted or lost | CP-9 | +| `skip_final_snapshot = true` on a database resource | Prevents a final snapshot from being taken before deletion, making recovery impossible | CP-9 | +| `max_retries = 0` on an external service call or client | Eliminates retry behaviour so any transient failure becomes an unrecoverable error | SI-13 | +| `backup_retention_period = 0` on a database resource | Explicitly zeroes the backup window, functionally disabling all automated backups | CP-9 | + +--- + +## Scope + +- **Scans:** added lines in git diffs +- **File types targeted:** all files (typically IaC and config files) + +--- + +## Known Limitations + +- Does not scan deleted or removed lines +- Does not perform cross-file analysis +- Does not verify that `backup_retention_period` is set to a meaningful non-zero value beyond confirming it is not explicitly 0 +- Does not detect missing backup configuration (only detects explicit disabling) +- Does not verify recovery objectives (RTO/RPO) or that restore procedures have been tested + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| CP-9 | Information System Backup | Detects explicit disabling of deletion protection, automated backups, final snapshots, and backup retention periods on database resources | +| CP-10 | Information System Recovery and Reconstitution | Declared in scope; no patterns currently implemented — see implementation.md for known debt | +| SI-13 | Predictable Failure Prevention | Detects `max_retries = 0`, which removes retry logic and makes external service calls fail immediately on transient errors | diff --git a/docs/gates/gate-12-resilience/implementation.md b/docs/gates/gate-12-resilience/implementation.md new file mode 100644 index 0000000..91b2ec7 --- /dev/null +++ b/docs/gates/gate-12-resilience/implementation.md @@ -0,0 +1,44 @@ +# Gate 12 — Resilience & Backup Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/resilience_gate.py` +**Test file:** `tests/test_gates/test_resilience_gate.py` +**Class:** `ResilienceGate` +**gate_id:** `resilience` +**mapped_control_ids:** `["CP-9", "CP-10", "SI-13"]` + +--- + +## Scan Method + +`scan()` iterates over every `DiffFile` in the provided list and, for each added line (via `diff_file.all_added_lines`), runs the line through all entries in `_PATTERNS`. When a compiled regex matches, `_make_finding()` is called with the associated `control_id`, `description`, and `remediation` strings. Evidence is taken from the matched line stripped of leading/trailing whitespace and truncated to 120 characters. All findings are collected into a flat list and returned. + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `(?i)deletion.?protection\s*[:=]\s*false` | deletion_protection disabled — database can be accidentally or maliciously deleted | CP-9 | Set deletion_protection = true on all production databases | +| 2 | `(?i)backup\s*[:=]\s*false` | Automated backups disabled for database resource | CP-9 | Enable automated backups; set backup_retention_period to at least 7 days | +| 3 | `(?i)skip.?final.?snapshot\s*[:=]\s*true` | skip_final_snapshot = true — no snapshot taken before database deletion | CP-9 | Set skip_final_snapshot = false and specify a final_snapshot_identifier | +| 4 | `(?i)max.?retries\s*[:=]\s*0` | max_retries set to 0 — no retry on transient failures | SI-13 | Set max_retries to at least 3 with exponential backoff for external service calls | +| 5 | `(?i)backup.?retention.?period\s*[:=]\s*0` | backup_retention_period = 0 disables automated database backups | CP-9 | Set backup_retention_period to at least 7 days for production databases | + +--- + +## Known Debt / Deferred Patterns + +- **CP-10 (System Recovery and Reconstitution):** declared in `mapped_control_ids` but no patterns currently emit CP-10 findings. Detection for recovery testing verification, RTO/RPO assertion checks, and restore rehearsal indicators has been deferred. Until patterns are added, CP-10 coverage is nominal only. + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_deletion_protection_false` | A diff adding `deletion_protection = false` produces at least one finding with "deletion_protection" or "backup" in the description | +| `test_detects_skip_final_snapshot` | A diff adding `skip_final_snapshot = true` produces at least one finding | +| `test_detects_max_retries_zero` | A diff adding `MAX_RETRIES = 0` produces at least one finding | +| `test_clean_config_no_findings` | A diff with `deletion_protection = true` and `skip_final_snapshot = false` produces zero findings | +| `test_findings_have_gate_id` | Every finding from the deletion-protection diff carries `gate == "resilience"` | +| `test_findings_have_valid_control_ids` | Every finding from the deletion-protection diff uses a control ID within `{"CP-9", "CP-10", "SI-13"}` | diff --git a/docs/gates/gate-13-incident/design.md b/docs/gates/gate-13-incident/design.md new file mode 100644 index 0000000..d45d5f4 --- /dev/null +++ b/docs/gates/gate-13-incident/design.md @@ -0,0 +1,50 @@ +# Gate 13 — Incident Response Gate + +**gate_id:** `incident` +**NIST Controls:** IR-4, IR-6, AU-6 +**Priority:** High + +--- + +## Purpose + +Guards against code changes that silently degrade or remove an organisation's ability to detect, respond to, and report security incidents. Silent exception swallowing prevents errors from ever reaching monitoring systems; exposed stack traces hand attackers detailed implementation intelligence; and disabled alerting configurations mean that when something does go wrong, nobody is notified. By flagging these patterns at diff time, the gate ensures that incident detection capability is not quietly eroded during routine development. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| Bare `except:` clause in Python | A bare except catches every possible exception, including keyboard interrupts and system exits, and when paired with `pass` it discards the error entirely — no log, no alert, no visibility into what went wrong | IR-4 | +| Empty catch block in JS/TS/Java (`catch(...) {}`) | An empty catch block silently swallows exceptions in JavaScript, TypeScript, and Java, leaving the application in an unknown state with no record of the failure | IR-4 | +| `traceback.print_exc()` or `traceback.format_exc()` in a response path | Returning a Python stack trace to the client leaks internal module paths, dependency versions, and logic structure — intelligence an attacker can use to craft targeted exploits | IR-4 | +| `notify: false` or `notifications_enabled: false/= false` in configuration | Disabling notifications in a monitoring or alerting configuration silences the entire alert pipeline, ensuring that incidents are not reported to on-call responders | IR-6 | + +--- + +## Scope + +- **Scans:** added lines in git diffs +- **File types targeted:** all files + +--- + +## Known Limitations + +- Does not scan deleted or removed lines +- Does not perform cross-file analysis +- The bare-except pattern is Python-specific and relies on the line being exactly `except:` (with optional surrounding whitespace); multi-statement bare-except clauses on a single line are not matched +- The empty-catch pattern requires the opening brace and closing brace to appear on the same line (`catch(e) {}`); multi-line empty catch blocks are not detected +- The traceback pattern matches any added line calling `traceback.print_exc()` or `traceback.format_exc()` regardless of whether the result is returned to a client or logged server-side; legitimate server-side logging calls will produce false positives +- The notification-disabled pattern is case-insensitive but regex-based; equivalent settings expressed through environment variables, feature flags, or programmatic calls are not detected + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| IR-4 | Incident Handling | Detects bare except clauses and empty catch blocks that prevent exceptions from being observed or logged, and detects stack trace exposure that leaks implementation detail to attackers during an incident | +| IR-6 | Incident Reporting | Detects monitoring or alerting configuration that explicitly disables notifications, ensuring that incidents are not silently dropped before reaching on-call responders | +| AU-6 | Audit Review, Analysis, and Reporting | Declared in `mapped_control_ids`; no patterns currently emitted — audit log gap detection and missing review automation deferred (see Known Debt in the implementation reference) | diff --git a/docs/gates/gate-13-incident/implementation.md b/docs/gates/gate-13-incident/implementation.md new file mode 100644 index 0000000..1d21fbb --- /dev/null +++ b/docs/gates/gate-13-incident/implementation.md @@ -0,0 +1,44 @@ +# Gate 13 — Incident Response Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/incident_gate.py` +**Test file:** `tests/test_gates/test_incident_gate.py` +**Class:** `IncidentGate` +**gate_id:** `incident` +**mapped_control_ids:** `["IR-4", "IR-6", "AU-6"]` + +--- + +## Scan Method + +`scan()` iterates every `diff_file` in the provided list and then iterates every added line via `diff_file.all_added_lines`, which yields `(line_no, line)` tuples. For each added line the method runs all four entries in the module-level `_PATTERNS` list in order. Each entry is a four-tuple of `(compiled_regex, description, control_id, remediation)`. When a pattern's `.search()` call matches the line, `_make_finding()` is called with the corresponding control ID, file path, line number, description, the first 120 characters of the stripped line as evidence, and the remediation string. All findings are collected into a flat list and returned. There is no early-exit per line; a single added line can produce multiple findings if it matches more than one pattern. + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `^\s*except\s*:\s*$` | Bare except clause — silently swallows all exceptions, preventing incident detection | IR-4 | Catch specific exceptions and log them; never use bare except: pass | +| 2 | `catch\s*\([^)]*\)\s*\{\s*\}` | Empty catch block — exception swallowed silently in JS/TS/Java | IR-4 | Log or rethrow exceptions; never leave catch blocks empty | +| 3 | `traceback\.print_exc\(\)\|traceback\.format_exc\(\)` | Stack trace exposed in response — leaks implementation details to attackers | IR-4 | Log the traceback server-side only; return a generic error message to clients | +| 4 | `(?i)notify\s*:\s*false\|notifications.?enabled\s*[:=]\s*false` | Alerting/notification disabled in monitoring configuration | IR-6 | Enable notifications for all critical alerts; silence specific alerts rather than disabling all | + +--- + +## Known Debt / Deferred Patterns + +- AU-6 (Audit Review, Analysis, and Reporting): declared in `mapped_control_ids` but no patterns emit AU-6; audit log gap detection and missing review automation deferred + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_bare_except_pass` | A diff adding a bare `except:` clause triggers at least one finding whose description contains "exception" or "silent" | +| `test_detects_empty_catch_js` | A diff adding an empty `catch(e) {}` block in a JavaScript file triggers at least one finding | +| `test_detects_traceback_exposure` | A diff adding a `traceback.print_exc()` call triggers at least one finding | +| `test_detects_notify_false` | A diff adding `notify: false` in a YAML alerting configuration triggers at least one finding | +| `test_logged_exception_no_findings` | A diff adding a properly handled exception using a named exception type, a logger call, and a re-raise produces zero findings | +| `test_findings_have_gate_id` | Every finding produced by the gate carries `gate == "incident"` | +| `test_findings_have_valid_control_ids` | Every finding produced by the gate uses a control ID drawn from `{"IR-4", "IR-6", "AU-6"}` | diff --git a/docs/gates/gate-14-observability/design.md b/docs/gates/gate-14-observability/design.md new file mode 100644 index 0000000..5b303db --- /dev/null +++ b/docs/gates/gate-14-observability/design.md @@ -0,0 +1,51 @@ +# Gate 14 — Observability Gate + +**gate_id:** `observability` +**NIST Controls:** SI-4, AU-12 +**Priority:** High + +--- + +## Purpose + +Guards against code changes that silently disable or omit the monitoring, logging, and health-check infrastructure that organisations rely on to detect system failures and security events in production. Disabling enhanced monitoring on a database instance removes the continuous performance and anomaly signals that feed into security dashboards; setting a container logging driver to `none` means all process output — including error messages and security events — is discarded the moment it is written; and deploying a Kubernetes workload without a liveness probe means the platform cannot detect or automatically recover from a hung or crashed container. By flagging these patterns at diff time, the gate ensures that observability capability is never quietly eroded during routine infrastructure or configuration changes. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| `enable_monitoring = false` or `monitoring = false` in infrastructure configuration | Explicitly disabling monitoring removes continuous visibility into resource health and anomalous behaviour, leaving security and operations teams blind to events that would otherwise trigger alerts | SI-4 | +| `monitoring_interval = 0` in infrastructure configuration | Setting the monitoring interval to zero disables enhanced monitoring on database and compute resources, eliminating the high-frequency metrics used for anomaly detection and capacity planning | SI-4 | +| Container logging driver set to `none` | A `none` logging driver silently discards all container stdout and stderr output; any audit-relevant events, errors, or security signals written by the process are permanently lost | AU-12 | +| `--log-driver=none` CLI flag | Passing `--log-driver=none` at container start time overrides any default logging configuration and discards all container output, bypassing audit record generation requirements | AU-12 | +| Kubernetes workload file with `containers:` but no `livenessProbe` | Without a liveness probe the Kubernetes control plane cannot determine whether a container is healthy; a hung or deadlocked process will continue to receive traffic and will not be automatically restarted, hiding failures from operators | SI-4 | + +--- + +## Scope + +- **Scans:** added lines in git diffs (pattern scan) + full file content for Kubernetes workload files +- **File types targeted:** all files (pattern scan); Kubernetes deployment/statefulset/daemonset YAML files (liveness probe check) +- **Special detection:** Kubernetes liveness probe absence check + +--- + +## Known Limitations + +- Does not scan deleted or removed lines +- Does not perform cross-file analysis +- The `--log-driver=none` pattern matches the exact CLI flag string; equivalent Docker API options or Compose `logging.driver` set through environment variable interpolation are detected only by the separate `driver: none` pattern +- The Kubernetes liveness probe check matches on the literal string `livenessProbe` anywhere in the file; a probe defined in a separate ConfigMap or applied via a mutating admission webhook will not be found and the check will produce a false positive +- The Kubernetes check applies to any file whose path matches `deployment`, `statefulset`, or `daemonset` (case-insensitive) followed by `.yaml` or `.yml`; files that do not follow this naming convention will not be checked even if they define workload resources +- The `monitoring_interval = 0` pattern is case-insensitive and separator-agnostic but does not detect equivalent zero-value expressions such as `monitoring_interval = 0.0` or values supplied through variable references + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| SI-4 | Information System Monitoring | Detects infrastructure configuration that disables enhanced monitoring or sets the monitoring interval to zero, and detects Kubernetes workloads that omit a liveness probe — all of which degrade the organisation's ability to continuously monitor system components for anomalies and failures | +| AU-12 | Audit Record Generation | Detects container logging configuration that routes all output to the `none` driver, whether expressed as a YAML key or a CLI flag, ensuring that containers generate and retain audit records for all security-relevant process output | diff --git a/docs/gates/gate-14-observability/implementation.md b/docs/gates/gate-14-observability/implementation.md new file mode 100644 index 0000000..59de7d8 --- /dev/null +++ b/docs/gates/gate-14-observability/implementation.md @@ -0,0 +1,79 @@ +# Gate 14 — Observability Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/observability_gate.py` +**Test file:** `tests/test_gates/test_observability_gate.py` +**Class:** `ObservabilityGate` +**gate_id:** `observability` +**mapped_control_ids:** `["SI-4", "AU-12"]` + +--- + +## Scan Method + +`scan()` runs two distinct detection passes over the provided list of `DiffFile` objects and collects all findings into a single flat list that is returned at the end. + +**Pass 1 — Standard pattern loop:** For every `diff_file`, the method iterates `diff_file.all_added_lines`, which yields `(line_no, line)` tuples for each added line in the diff. For every added line, each of the four entries in the module-level `_PATTERNS` list is evaluated in order. Each entry is a four-tuple of `(compiled_regex, description, control_id, remediation)`. When a pattern's `.search()` call matches the line, `_make_finding()` is called with the corresponding control ID, file path, line number, description, the first 120 characters of the stripped line as evidence, and the remediation string. There is no early-exit per line; a single added line can produce multiple findings if it matches more than one pattern. + +**Pass 2 — Kubernetes liveness probe absence check:** After the pattern loop, the gate tests whether `diff_file.path` matches the module-level `_K8S_FILE_PATTERN` regex (see Special Detection Logic below). If it does, the gate reads `diff_file.full_content` — the complete current file content, not just added lines — and checks two conditions: (a) the string `"containers:"` is present in the full content, and (b) the `_LIVENESS_PROBE_PATTERN` regex does not match anywhere in the full content. If both conditions are true, one SI-4 finding is emitted pointing to line 1 of the file. This check is file-level rather than line-level. + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `(?i)enable.?monitoring\s*[:=]\s*false\|monitoring\s*[:=]\s*false` | Monitoring disabled in infrastructure configuration | SI-4 | Enable monitoring and set `monitoring_interval` > 0 for all production resources | +| 2 | `(?i)monitoring.?interval\s*[:=]\s*0` | `monitoring_interval = 0` disables enhanced monitoring | SI-4 | Set `monitoring_interval` to 60 or higher for production database instances | +| 3 | `(?i)(?:log.?driver\s*[:=]\s*["\']?none\|driver:\s*none)` | Container logging driver set to `none` — all output is discarded | AU-12 | Use a persistent logging driver (`json-file`, `awslogs`, `fluentd`) for all containers | +| 4 | `--log-driver=none` | Container logging disabled via CLI flag | AU-12 | Remove `--log-driver=none`; all container output must be captured for audit | + +--- + +## Special Detection Logic + +### Kubernetes Liveness Probe Absence Check + +This check runs as a second pass inside the same `diff_file` loop, after the standard pattern scan, and operates at file level rather than line level. + +**File matching — `_K8S_FILE_PATTERN`:** + +```python +_K8S_FILE_PATTERN = re.compile(r"""(?i)(?:deployment|statefulset|daemonset).*\.ya?ml$""") +``` + +The gate first tests `diff_file.path` against `_K8S_FILE_PATTERN`. The pattern matches any file path that contains the word `deployment`, `statefulset`, or `daemonset` (case-insensitive) and ends with `.yaml` or `.yml`. Files that do not match this pattern skip the liveness probe check entirely. + +**Content inspection — `_LIVENESS_PROBE_PATTERN`:** + +```python +_LIVENESS_PROBE_PATTERN = re.compile(r"""livenessProbe""") +``` + +For matching files, the gate reads `diff_file.full_content` (the complete file content, not just the diff hunks) and evaluates two conditions: + +1. The literal string `"containers:"` is present anywhere in the full content. +2. `_LIVENESS_PROBE_PATTERN.search(full_content)` returns no match — that is, the string `livenessProbe` does not appear anywhere in the file. + +**Finding emitted when both conditions are true:** + +- `control_id`: `"SI-4"` +- `file`: `diff_file.path` +- `line`: `1` (file-level finding; no specific line number is available) +- `description`: `"Kubernetes workload added without a livenessProbe — failure will not be detected"` +- `evidence`: `"No livenessProbe found in "` +- `remediation`: `"Add a livenessProbe (httpGet, tcpSocket, or exec) to all container specs"` + +Because the check examines `full_content` rather than added lines, it fires even if the `containers:` block was present before the diff — the gate treats any diff touching a workload file without a probe as a violation. + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_monitoring_false` | A diff adding `monitoring_interval = 0` and `enable_monitoring = false` in a Terraform file produces at least one finding whose description contains "monitor" | +| `test_detects_log_driver_none` | A diff adding `driver: none` under a Docker Compose `logging` key produces at least one finding | +| `test_detects_k8s_missing_liveness_probe` | A diff adding a Kubernetes Deployment YAML with a `containers:` spec but no `livenessProbe` key produces at least one finding | +| `test_k8s_with_liveness_probe_no_findings` | A diff adding a Kubernetes Deployment YAML that includes a `livenessProbe` with an `httpGet` check produces zero findings | +| `test_findings_have_gate_id` | Every finding produced by the gate carries `gate == "observability"` | +| `test_findings_have_valid_control_ids` | Every finding produced by the gate uses a control ID drawn from `{"SI-4", "AU-12"}` | diff --git a/docs/gates/gate-15-memsafe/design.md b/docs/gates/gate-15-memsafe/design.md new file mode 100644 index 0000000..424f393 --- /dev/null +++ b/docs/gates/gate-15-memsafe/design.md @@ -0,0 +1,50 @@ +# Gate 15 — Memory Safety Gate + +**gate_id:** `memsafe` +**NIST Controls:** SI-16, CM-7 +**Priority:** 🟡 Medium + +--- + +## Purpose + +Guards against code changes that introduce dynamic code execution, unsafe memory operations, and patterns that historically lead to memory corruption or code injection across multiple languages. Python's `eval()` and `exec()` allow arbitrary code to run at runtime from attacker-controlled strings; Rust's `unsafe {}` blocks opt out of the language's memory guarantees and require explicit justification; C functions like `strcpy` and `memcpy` with untrusted lengths are the canonical source of buffer overflows; and Python's `ctypes` and `cffi` bindings provide direct access to raw memory in ways that bypass Python's own safety model. By scanning added lines at diff time, the gate prevents these patterns from being merged before they can be exploited. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| `eval()` called with a dynamic (non-string-literal) argument | Evaluating a runtime-constructed or user-controlled expression gives an attacker arbitrary Python code execution | SI-16 | +| `exec()` called with any argument | Executes an arbitrary string as Python code at runtime; equivalent to a full remote code execution primitive if the argument is attacker-influenced | SI-16 | +| `unsafe { }` block in Rust source | Opts out of Rust's memory safety guarantees; without a `// SAFETY:` comment the invariants required to make the block correct are undocumented and unreviewed | CM-7 | +| `ctypes` raw memory operations (`address`, `cast`, `memmove`, `memset`) | Directly manipulates process memory from Python, bypassing all of Python's type and bounds checks | SI-16 | +| `ffi.cast`, `ffi.buffer`, `ffi.from_buffer`, or `ffi.memmove` via cffi | Performs raw pointer arithmetic and buffer access through cffi; an unchecked length or miscast pointer can corrupt arbitrary memory | SI-16 | +| `strcpy()` or `strcat()` without bounds checking | Classic C buffer overflow functions — they copy until a null terminator with no length limit, allowing writes beyond the destination buffer | SI-16 | +| `memcpy()` with a potentially untrusted source length (`req`, `input`, `user`, or `argv` in the argument list) | Copying a user-controlled number of bytes into a fixed-size buffer is the textbook buffer overflow; the untrusted length must be validated before the call | SI-16 | + +--- + +## Scope + +- **Scans:** added lines in git diffs +- **File types targeted:** all files (Python, Rust, C/C++, and any language using cffi/ctypes) + +--- + +## Known Limitations + +- Does not scan deleted/removed lines +- Does not perform cross-file analysis +- eval() pattern excludes string-literal arguments (`(?` | +| 2 | `^FROM\s+[^\s@:]+\s*$` (MULTILINE) | Base image has no tag — always pin to a specific digest or version | SI-7 | Add a version tag or SHA256 digest: `FROM python:3.11-slim@sha256:` | +| 3 | `ADD\s+https?://` | Remote ADD fetches content at build time without checksum verification | SI-7 | Use `RUN curl ... \| sha256sum -c` and COPY instead of ADD with remote URLs | + +### Least Privilege (AC-6) + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `USER\s+root` | Container explicitly set to run as root — violates least privilege | AC-6 | Create a dedicated non-root user: `RUN useradd -r app && USER app` | +| 2 | `(?i)privileged:\s*true\|--privileged` | Privileged container grants full host access — enables container escape | AC-6 | Remove `privileged: true`; grant only specific capabilities if needed | +| 3 | `--cap-add\s+ALL` | ALL Linux capabilities granted — equivalent to running as root | AC-6 | Enumerate only the specific capabilities required (e.g. `--cap-add NET_BIND_SERVICE`) | +| 4 | `--cap-add\s+(?:SYS_ADMIN\|SYS_PTRACE\|NET_ADMIN)` | High-risk Linux capability granted — can lead to host privilege escalation | AC-6 | Audit whether this capability is truly needed; prefer dropping all caps and adding selectively | +| 5 | `allowPrivilegeEscalation:\s*true` | `allowPrivilegeEscalation: true` permits setuid/setgid escalation inside the container | AC-6 | Set `allowPrivilegeEscalation: false` in securityContext | +| 6 | `runAsNonRoot:\s*false` | `runAsNonRoot: false` explicitly permits the container to run as root | AC-6 | Set `runAsNonRoot: true` and specify `runAsUser` with a non-zero UID | + +### Network Isolation (SC-7) + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `hostNetwork:\s*true` | `hostNetwork: true` exposes the container on the host network namespace | SC-7 | Use ClusterIP or NodePort Services; avoid sharing the host network namespace | +| 2 | `hostPort:\s*\d+` | `hostPort` bypasses Kubernetes NetworkPolicy — use Service resources instead | SC-7 | Replace `hostPort` with a Kubernetes Service of type NodePort or LoadBalancer | + +### Runtime Hardening (SC-39) + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `readOnlyRootFilesystem:\s*false` | Writable root filesystem — allows attacker to modify container files | SC-39 | Set `readOnlyRootFilesystem: true` and use emptyDir/PVC mounts for writable paths | +| 2 | `hostPID:\s*true` | `hostPID: true` shares the host process namespace — enables container escape vectors | SC-39 | Remove `hostPID: true`; process isolation must be maintained | +| 3 | `hostIPC:\s*true` | `hostIPC: true` shares host IPC namespace — allows cross-container memory access | SC-39 | Remove `hostIPC: true`; IPC namespace isolation must be maintained | +| 4 | `(?i)seccompProfile.*Unconfined\|seccomp.*unconfined` | Seccomp profile set to Unconfined — all syscalls permitted | SC-39 | Use RuntimeDefault seccomp profile or create a custom restricted profile | + +### Audit (AU-12) + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `(?i)log.?driver.*none` | Container logging driver set to `none` — all container output is discarded | AU-12 | Use a persistent logging driver (json-file, awslogs, fluentd, splunk) | +| 2 | `--log-driver=none` | Container logging disabled via CLI flag — cannot audit container activity | AU-12 | Remove `--log-driver=none`; use a centralised logging destination | + +### Resource Limits (CM-6) + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `resources:\s*\{\}` | Empty resources block — no CPU/memory limits set; enables denial-of-service | CM-6 | Set explicit `resources.requests` and `resources.limits` for CPU and memory | +| 2 | `--memory[= ]["']?-1` | Unlimited container memory allocation — no ceiling for memory consumption | CM-6 | Set an explicit `--memory` limit (e.g. `--memory=512m`) | + +--- + +## Known Debt / Deferred Patterns + +- CM-7 (Least Functionality): declared in `mapped_control_ids` but no patterns emit CM-7; container capability allow-list enforcement deferred +- SA-10 (Developer Security Testing and Evaluation): declared but no patterns emit SA-10; image signing and SBOM verification deferred +- SR-3 (Supply Chain Controls): declared but no patterns emit SR-3; base image provenance and supply chain checks deferred + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_user_root` | A diff adding `USER root` triggers at least one finding with control ID AC-6 | +| `test_detects_privileged_mode` | A diff adding `privileged: true` or `--privileged` triggers at least one finding with control ID AC-6 | +| `test_detects_latest_tag` | A diff adding a `FROM` line with the `:latest` tag triggers at least one finding with control ID SI-7 | +| `test_detects_host_network` | A diff adding `hostNetwork: true` triggers at least one finding with control ID SC-7 | +| `test_detects_host_pid` | A diff adding `hostPID: true` triggers at least one finding with control ID SC-39 | +| `test_clean_dockerfile_no_findings` | A diff adding a well-configured Dockerfile (pinned digest, non-root user, no dangerous flags) produces zero findings | +| `test_findings_have_gate_id` | Every finding produced by the gate carries `gate == "container"` | +| `test_findings_have_valid_control_ids` | Every finding produced by the gate uses a control ID drawn from `{"CM-6", "CM-7", "SC-7", "SC-39", "AC-6", "SI-7", "AU-12", "SA-10", "SR-3"}` | diff --git a/docs/plans/2026-02-27-full-scan-and-init-design.md b/docs/plans/2026-02-27-full-scan-and-init-design.md new file mode 100644 index 0000000..f18c194 --- /dev/null +++ b/docs/plans/2026-02-27-full-scan-and-init-design.md @@ -0,0 +1,232 @@ +# Design: Full Scan Mode & Init Command + +**Date:** 2026-02-27 +**Status:** Approved +**Branch:** develop + +--- + +## Overview + +Two new features: + +1. **`controlgate scan --mode full`** — scan the entire project directory instead of just staged changes or a PR diff. +2. **`controlgate init`** — interactive bootstrap command that creates `.controlgate.yml`, `.pre-commit-config.yaml`, CI/CD workflow files, and a `CONTROLGATE.md` usage guide. + +--- + +## Feature 1: Full Scan Mode + +### Motivation + +The existing scan modes (`pre-commit`, `pr`) only surface findings in changed lines. Teams onboarding ControlGate to an existing codebase need a one-shot full-repo audit to establish a baseline before enforcing gate checks on every commit. + +### Approach: Synthetic DiffFile + +Walk the project directory and create `DiffFile` objects where each file's entire content is a single hunk with all lines as `added_lines`. The engine and all 18 gates run unchanged — they already operate on `added_lines` as the "lines to scan". + +``` +for each file in project directory: + diff_file = DiffFile(path=file) + all_lines = file.read_text().splitlines() + diff_file.hunks = [DiffHunk( + start_line=1, + line_count=len(all_lines), + added_lines=list(enumerate(all_lines, start=1)) + )] + +engine.scan(diff_files) # unchanged +``` + +### CLI Change + +`--mode` gains a third choice: `full`. + +```bash +controlgate scan --mode full --format markdown +controlgate scan --mode full --baseline high --output-dir .controlgate/reports +``` + +No `--target-branch` or `--diff-file` needed for full mode. + +### File Discovery: `_get_full_files()` + +New function in `__main__.py`: + +1. Try `git ls-files` first (respects `.gitignore` automatically) — falls back to `Path.rglob("*")` if not in a git repo. +2. Apply `config.excluded_paths` glob patterns (existing mechanism). +3. Filter by a configurable extension allowlist (from `full_scan.extensions` config key). +4. Skip binary files via `_is_binary()` heuristic (null-byte check on first 8 KB). + +### Config Extension: `full_scan` Section + +```yaml +full_scan: + extensions: + - .py + - .js + - .ts + - .go + - .java + - .rb + - .tf + - .yaml + - .yml + - .json + - .sh + - .env + - .toml + - .ini + - .cfg + skip_dirs: + - .git + - node_modules + - .venv + - venv + - __pycache__ + - dist + - build + - .tox + - .mypy_cache + - .ruff_cache + - .pytest_cache +``` + +`ControlGateConfig` gains two new fields: `full_scan_extensions` and `full_scan_skip_dirs`. + +### Output Difference + +Full-mode reports label themselves as `"mode": "full"` in JSON output. The markdown report header notes "Full Repository Scan" vs "Pre-Commit Scan" / "PR Scan". + +--- + +## Feature 2: `controlgate init` + +### Motivation + +New users need a zero-friction path to set up ControlGate. Running one command should produce a complete, working configuration. + +### CLI + +```bash +controlgate init [--path PATH] [--baseline low|moderate|high|privacy|li-saas] [--no-docs] +``` + +- `--path`: target directory (default: current directory) +- `--baseline`: pre-select baseline without prompting +- `--no-docs`: skip CONTROLGATE.md generation + +### Interactive Flow + +``` +$ controlgate init + +🛡️ ControlGate Init +─────────────────────────────────────────── +Baseline? [moderate]: high +Generate GitHub Actions workflow? [y/N]: y +Generate GitLab CI job? [y/N]: n +Generate Bitbucket Pipelines step? [y/N]: n +─────────────────────────────────────────── +Creating .controlgate.yml ... ✅ +Creating .pre-commit-config.yaml ... ✅ +Creating CONTROLGATE.md ... ✅ +Creating .github/workflows/controlgate.yml ... ✅ +─────────────────────────────────────────── +✅ Done! ControlGate is configured for this project. + +Next steps: + 1. Review .controlgate.yml and adjust gate settings + 2. Run: pip install pre-commit && pre-commit install + 3. Run: controlgate scan --mode full (baseline audit) +``` + +When a file already exists: +``` + .controlgate.yml already exists. Overwrite? [y/N]: n + ↳ Skipped. +``` + +### Files Generated + +| File | Description | +|------|-------------| +| `.controlgate.yml` | Full config with all 18 gates, inline comments | +| `.pre-commit-config.yaml` | pre-commit hook for pre-commit and full scan modes | +| `CONTROLGATE.md` | Usage guide: all 18 gates, CLI reference, config reference | +| `.github/workflows/controlgate.yml` | GitHub Actions PR scan + SARIF upload (optional) | +| `.gitlab-ci.yml` | GitLab CI job appended/created (optional) | +| `bitbucket-pipelines.yml` | Bitbucket Pipelines step appended/created (optional) | + +### Template Strategy + +Templates are inline strings inside `src/controlgate/init_command.py`. This keeps the package self-contained with no additional data files to distribute. + +### `.controlgate.yml` Template + +Full config with all 18 gates listed with inline comments explaining each gate and default action. Baseline is interpolated from user input. + +### `.pre-commit-config.yaml` Template + +```yaml +repos: + - repo: local + hooks: + - id: controlgate + name: ControlGate Security Scan + entry: python -m controlgate scan --mode pre-commit --format markdown + language: python + always_run: true +``` + +### GitHub Actions Template + +Extends the existing `hooks/github_action.yml` with an added full-scan option: +- Trigger: `pull_request`, `push` to main +- Steps: checkout, setup-python, install, scan (pr mode), SARIF upload, PR comment, enforce gate + +### GitLab CI Template / Merge Strategy + +- If `.gitlab-ci.yml` exists: read it, append the `controlgate` job block +- If it doesn't exist: create a minimal file with just the controlgate job + +### Bitbucket Pipelines Strategy + +- If `bitbucket-pipelines.yml` exists: append a `controlgate` step to the default pipeline +- If it doesn't exist: create a minimal pipelines file + +### CONTROLGATE.md Content + +1. **Overview** — what ControlGate is +2. **Quick Start** — install, init, first scan +3. **Scan Modes** — pre-commit, pr, full (with examples) +4. **All 18 Security Gates** — table with gate name, NIST controls, what it catches, default action +5. **Configuration Reference** — every key in `.controlgate.yml` explained +6. **CI/CD Integration** — GitHub, GitLab, Bitbucket setup notes +7. **Output Formats** — markdown, json, sarif + +--- + +## Implementation Plan Summary + +### Files to Create +- `src/controlgate/init_command.py` — init command implementation + all templates + +### Files to Modify +- `src/controlgate/__main__.py` — add `full` mode to scan, add `init` subcommand, wire up `init_command` +- `src/controlgate/config.py` — add `full_scan_extensions` and `full_scan_skip_dirs` fields + defaults +- `README.md` — update gates table (8 → 18), update `.controlgate.yml` example, add `--mode full` and `init` to CLI usage +- `hooks/github_action.yml` — minor: add full-scan step (optional, used as template source) + +### Files to Create (Tests) +- `tests/test_full_scan.py` — test `_get_full_files()`, synthetic DiffFile construction, engine integration +- `tests/test_init_command.py` — test file generation, overwrite prompts, template rendering + +--- + +## Non-Goals + +- No AST-level full-file analysis (gates remain regex/pattern-based) +- No `--watch` mode for continuous scanning +- No parallel gate execution (future optimization) +- No auto-fix / remediation in `init` output diff --git a/docs/plans/2026-02-27-full-scan-and-init-implementation.md b/docs/plans/2026-02-27-full-scan-and-init-implementation.md new file mode 100644 index 0000000..267a1eb --- /dev/null +++ b/docs/plans/2026-02-27-full-scan-and-init-implementation.md @@ -0,0 +1,1541 @@ +# Full Scan Mode & Init Command Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add `controlgate scan --mode full` (whole-repo scan) and `controlgate init` (interactive bootstrap) to the CLI. + +**Architecture:** Full scan creates synthetic `DiffFile` objects from real files — all content becomes `added_lines` — so all 18 gates run unchanged. Init is a new `init_command.py` module with inline templates for all generated files; wired into the existing CLI parser. + +**Tech Stack:** Python 3.10+, argparse, pathlib, subprocess (existing); no new dependencies. + +**Design doc:** `docs/plans/2026-02-27-full-scan-and-init-design.md` + +--- + +## Task 1: Extend `ControlGateConfig` with `full_scan` fields + +**Files:** +- Modify: `src/controlgate/config.py` +- Test: `tests/test_config.py` (create new) + +### Step 1: Write the failing test + +```python +# tests/test_config.py +from controlgate.config import ControlGateConfig + +class TestFullScanConfig: + def test_default_full_scan_extensions(self): + config = ControlGateConfig.load() + assert ".py" in config.full_scan_extensions + assert ".tf" in config.full_scan_extensions + + def test_default_full_scan_skip_dirs(self): + config = ControlGateConfig.load() + assert ".git" in config.full_scan_skip_dirs + assert "node_modules" in config.full_scan_skip_dirs + + def test_full_scan_extensions_override(self, tmp_path): + cfg_file = tmp_path / ".controlgate.yml" + cfg_file.write_text("full_scan:\n extensions: [.py, .rb]\n") + config = ControlGateConfig.load(cfg_file) + assert config.full_scan_extensions == [".py", ".rb"] + + def test_full_scan_skip_dirs_override(self, tmp_path): + cfg_file = tmp_path / ".controlgate.yml" + cfg_file.write_text("full_scan:\n skip_dirs: [vendor]\n") + config = ControlGateConfig.load(cfg_file) + assert config.full_scan_skip_dirs == ["vendor"] +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_config.py -v +``` +Expected: `AttributeError: 'ControlGateConfig' object has no attribute 'full_scan_extensions'` + +### Step 3: Add fields to `_DEFAULT_CONFIG` dict in `config.py` + +In `_DEFAULT_CONFIG`, add after the `"reporting"` key: +```python +"full_scan": { + "extensions": [ + ".py", ".js", ".ts", ".jsx", ".tsx", + ".go", ".java", ".rb", ".rs", ".c", ".cpp", ".h", + ".tf", ".yaml", ".yml", ".json", ".toml", ".ini", ".cfg", + ".sh", ".bash", ".zsh", ".env", ".xml", ".sql", + ], + "skip_dirs": [ + ".git", "node_modules", ".venv", "venv", "env", + "__pycache__", "dist", "build", ".tox", + ".mypy_cache", ".ruff_cache", ".pytest_cache", + ".eggs", "*.egg-info", + ], +}, +``` + +### Step 4: Add fields to `ControlGateConfig` dataclass + +After `output_dir: str = ".controlgate/reports"` add: +```python +full_scan_extensions: list[str] = field( + default_factory=lambda: [ + ".py", ".js", ".ts", ".jsx", ".tsx", + ".go", ".java", ".rb", ".rs", ".c", ".cpp", ".h", + ".tf", ".yaml", ".yml", ".json", ".toml", ".ini", ".cfg", + ".sh", ".bash", ".zsh", ".env", ".xml", ".sql", + ] +) +full_scan_skip_dirs: list[str] = field( + default_factory=lambda: [ + ".git", "node_modules", ".venv", "venv", "env", + "__pycache__", "dist", "build", ".tox", + ".mypy_cache", ".ruff_cache", ".pytest_cache", + ] +) +``` + +### Step 5: Parse the new section in `_from_raw()` + +At the end of `_from_raw()`, before `return cfg`, add: +```python +# Full scan +full_scan = raw.get("full_scan", {}) +cfg.full_scan_extensions = full_scan.get("extensions", cfg.full_scan_extensions) +cfg.full_scan_skip_dirs = full_scan.get("skip_dirs", cfg.full_scan_skip_dirs) +``` + +### Step 6: Run tests to verify they pass + +```bash +pytest tests/test_config.py -v +``` +Expected: 4 tests PASS + +### Step 7: Commit + +```bash +git add src/controlgate/config.py tests/test_config.py +git commit -m "feat: add full_scan config fields (extensions, skip_dirs)" +``` + +--- + +## Task 2: Add `_get_full_files()` to `__main__.py` + +**Files:** +- Modify: `src/controlgate/__main__.py` +- Test: `tests/test_full_scan.py` (create new) + +### Step 1: Write the failing test + +```python +# tests/test_full_scan.py +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from controlgate.__main__ import _get_full_files +from controlgate.config import ControlGateConfig +from controlgate.models import DiffFile + + +class TestGetFullFiles: + def test_returns_diff_files(self, tmp_path): + (tmp_path / "app.py").write_text('x = 1\ny = 2\n') + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert any(f.path.endswith("app.py") for f in files) + + def test_all_lines_are_added(self, tmp_path): + (tmp_path / "app.py").write_text('line1\nline2\nline3\n') + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + py_file = next(f for f in files if f.path.endswith("app.py")) + assert len(py_file.all_added_lines) == 3 + assert py_file.all_added_lines[0] == (1, "line1") + assert py_file.all_added_lines[2] == (3, "line3") + + def test_skips_excluded_paths(self, tmp_path): + (tmp_path / "app.py").write_text("x = 1") + docs = tmp_path / "docs" + docs.mkdir() + (docs / "readme.md").write_text("# Docs") + config = ControlGateConfig.load() + # docs/** is in default exclusions + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + paths = [f.path for f in files] + assert not any("docs" in p for p in paths) + + def test_skips_skip_dirs(self, tmp_path): + (tmp_path / "app.py").write_text("x = 1") + nm = tmp_path / "node_modules" + nm.mkdir() + (nm / "lib.js").write_text("module.exports = {}") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert not any("node_modules" in f.path for f in files) + + def test_skips_unlisted_extensions(self, tmp_path): + (tmp_path / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert not any(f.path.endswith(".png") for f in files) + + def test_skips_binary_files(self, tmp_path): + (tmp_path / "app.py").write_bytes(b"normal\x00binary\x00content") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert not any(f.path.endswith("app.py") for f in files) + + def test_uses_git_ls_files_when_available(self, tmp_path): + (tmp_path / "tracked.py").write_text("x = 1") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run") as mock_run: + mock_run.return_value.stdout = "tracked.py\n" + mock_run.return_value.returncode = 0 + files = _get_full_files(tmp_path, config) + assert any(f.path.endswith("tracked.py") for f in files) + + def test_skips_empty_files(self, tmp_path): + (tmp_path / "empty.py").write_text("") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert not any(f.path.endswith("empty.py") for f in files) +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_full_scan.py -v +``` +Expected: `ImportError: cannot import name '_get_full_files'` + +### Step 3: Implement `_get_full_files()` in `__main__.py` + +Add this import at the top of `__main__.py` (after existing imports): +```python +import os +``` + +Add this function after `_get_diff()`: + +```python +def _get_full_files(root: Path, config: "ControlGateConfig") -> list["DiffFile"]: + """Enumerate all project files for full-repo scan mode. + + Tries ``git ls-files`` first (respects .gitignore). Falls back to + directory walk when not in a git repo. + + Args: + root: Project root directory to scan. + config: ControlGate config (for extension/skip_dir filters). + + Returns: + List of DiffFile objects with all content as added_lines. + """ + from controlgate.models import DiffFile, DiffHunk + + root = root.resolve() + candidate_paths: list[Path] = [] + + # 1. Try git ls-files (honors .gitignore automatically) + try: + result = subprocess.run( + ["git", "ls-files"], + capture_output=True, + text=True, + check=True, + cwd=root, + ) + candidate_paths = [root / p for p in result.stdout.strip().splitlines() if p.strip()] + except (subprocess.CalledProcessError, FileNotFoundError): + # Fall back: walk directory + candidate_paths = [p for p in root.rglob("*") if p.is_file()] + + diff_files: list[DiffFile] = [] + + for abs_path in candidate_paths: + try: + rel_path = str(abs_path.relative_to(root)) + except ValueError: + rel_path = str(abs_path) + + # Skip paths excluded by config glob patterns + if config.is_path_excluded(rel_path): + continue + + # Skip any path component that is in skip_dirs + path_parts = Path(rel_path).parts + if any(part in config.full_scan_skip_dirs for part in path_parts): + continue + + # Filter by extension allowlist (empty list = allow all) + if config.full_scan_extensions and abs_path.suffix not in config.full_scan_extensions: + continue + + # Skip binary files (null-byte heuristic) + try: + sample = abs_path.read_bytes()[:8192] + if b"\x00" in sample: + continue + except (OSError, PermissionError): + continue + + # Read text content + try: + content = abs_path.read_text(encoding="utf-8", errors="ignore") + except (OSError, PermissionError): + continue + + lines = content.splitlines() + if not lines: + continue + + hunk = DiffHunk( + start_line=1, + line_count=len(lines), + added_lines=list(enumerate(lines, start=1)), + ) + df = DiffFile(path=rel_path) + df.hunks = [hunk] + diff_files.append(df) + + return diff_files +``` + +### Step 4: Run tests to verify they pass + +```bash +pytest tests/test_full_scan.py -v +``` +Expected: 8 tests PASS + +### Step 5: Commit + +```bash +git add src/controlgate/__main__.py tests/test_full_scan.py +git commit -m "feat: add _get_full_files() for full-repo scan mode" +``` + +--- + +## Task 3: Wire `full` mode into `scan_command()` and parser + +**Files:** +- Modify: `src/controlgate/__main__.py` +- Test: `tests/test_full_scan.py` (extend), `tests/test_cli.py` (extend) + +### Step 1: Write failing tests + +Add to `tests/test_full_scan.py`: + +```python +class TestFullScanMode: + def test_parser_accepts_full_mode(self): + from controlgate.__main__ import build_parser + parser = build_parser() + args = parser.parse_args(["scan", "--mode", "full"]) + assert args.mode == "full" + + def test_full_scan_finds_secrets(self, tmp_path): + """Full scan detects hardcoded secrets in a real file.""" + from controlgate.__main__ import build_parser, scan_command + secret_file = tmp_path / "config.py" + secret_file.write_text('DB_PASSWORD = "super_secret_123"\n') + parser = build_parser() + args = parser.parse_args(["scan", "--mode", "full", "--format", "json"]) + args.path = str(tmp_path) + exit_code = scan_command(args) + assert exit_code == 1 # BLOCK due to secrets + + def test_full_scan_clean_directory(self, tmp_path): + """Full scan passes for directory with no security issues.""" + from controlgate.__main__ import build_parser, scan_command + clean_file = tmp_path / "utils.py" + clean_file.write_text("import os\nenv = os.environ.get('KEY')\n") + parser = build_parser() + args = parser.parse_args(["scan", "--mode", "full", "--format", "json"]) + args.path = str(tmp_path) + exit_code = scan_command(args) + assert exit_code == 0 # PASS + + def test_full_scan_empty_directory(self, tmp_path): + """Full scan on empty directory returns 0.""" + from controlgate.__main__ import build_parser, scan_command + parser = build_parser() + args = parser.parse_args(["scan", "--mode", "full", "--format", "json"]) + args.path = str(tmp_path) + exit_code = scan_command(args) + assert exit_code == 0 +``` + +### Step 2: Run tests to verify they fail + +```bash +pytest tests/test_full_scan.py::TestFullScanMode -v +``` +Expected: FAIL — `argument --mode: invalid choice: 'full'` for parser test + +### Step 3: Update `build_parser()` — add `full` to `--mode` choices and `--path` arg + +In `build_parser()`, change: +```python +# OLD +choices=["pre-commit", "pr"], +``` +to: +```python +# NEW +choices=["pre-commit", "pr", "full"], +``` + +Add `--path` arg after `--output-dir`: +```python +scan_parser.add_argument( + "--path", + type=str, + default=None, + help="Root directory for full scan mode (default: current directory)", +) +``` + +### Step 4: Update `scan_command()` — handle `full` mode + +In `scan_command()`, replace the block that gets the diff: + +```python +# OLD +if args.diff_file: + diff_text = Path(args.diff_file).read_text(encoding="utf-8") +else: + diff_text = _get_diff(args.mode, args.target_branch) + +if not diff_text.strip(): + print("ℹ️ No changes to scan.", file=sys.stderr) + return 0 + +diff_files = parse_diff(diff_text) +print( + f"📄 Scanning {len(diff_files)} changed file(s)...", + file=sys.stderr, +) +``` + +with: + +```python +if args.mode == "full": + scan_root = Path(getattr(args, "path", None) or ".").resolve() + diff_files = _get_full_files(scan_root, config) + if not diff_files: + print("ℹ️ No files found to scan.", file=sys.stderr) + return 0 + print( + f"📄 Full scan: {len(diff_files)} file(s) in {scan_root}...", + file=sys.stderr, + ) +elif args.diff_file: + diff_text = Path(args.diff_file).read_text(encoding="utf-8") + if not diff_text.strip(): + print("ℹ️ No changes to scan.", file=sys.stderr) + return 0 + diff_files = parse_diff(diff_text) + print(f"📄 Scanning {len(diff_files)} changed file(s)...", file=sys.stderr) +else: + diff_text = _get_diff(args.mode, args.target_branch) + if not diff_text.strip(): + print("ℹ️ No changes to scan.", file=sys.stderr) + return 0 + diff_files = parse_diff(diff_text) + print(f"📄 Scanning {len(diff_files)} changed file(s)...", file=sys.stderr) +``` + +### Step 5: Run tests to verify they pass + +```bash +pytest tests/test_full_scan.py -v +``` +Expected: all PASS (the `test_full_scan_finds_secrets` test may be slow — acceptable) + +### Step 6: Run full test suite to confirm no regressions + +```bash +pytest tests/test_cli.py tests/test_full_scan.py -v +``` +Expected: all PASS + +### Step 7: Commit + +```bash +git add src/controlgate/__main__.py tests/test_full_scan.py +git commit -m "feat: add controlgate scan --mode full with --path option" +``` + +--- + +## Task 4: Create `src/controlgate/init_command.py` with all templates + +**Files:** +- Create: `src/controlgate/init_command.py` +- Test: `tests/test_init_command.py` (create new) + +### Step 1: Write failing tests + +```python +# tests/test_init_command.py +import tempfile +from pathlib import Path +from unittest.mock import patch + +import pytest + +from controlgate.init_command import ( + _build_controlgate_yml, + _build_precommit_config, + _build_controlgate_md, + _build_github_workflow, + _build_gitlab_ci, + _build_bitbucket_pipelines, +) + + +class TestTemplates: + def test_controlgate_yml_contains_all_18_gates(self): + content = _build_controlgate_yml("moderate") + for gate in ["secrets", "crypto", "iam", "sbom", "iac", "input_validation", + "audit", "change_control", "deps", "api", "privacy", "resilience", + "incident", "observability", "memsafe", "license", "aiml", "container"]: + assert gate in content, f"Gate '{gate}' missing from template" + + def test_controlgate_yml_has_chosen_baseline(self): + content = _build_controlgate_yml("high") + assert "baseline: high" in content + + def test_precommit_config_has_controlgate_hook(self): + content = _build_precommit_config() + assert "controlgate" in content + assert "pre-commit" in content + + def test_controlgate_md_has_all_modes(self): + content = _build_controlgate_md() + assert "--mode pre-commit" in content + assert "--mode pr" in content + assert "--mode full" in content + + def test_controlgate_md_has_18_gates_table(self): + content = _build_controlgate_md() + for gate_name in ["Secrets", "Crypto", "IAM", "Supply Chain", "IaC", + "Input Validation", "Audit", "Change Control", + "Dependencies", "API Security", "Privacy", "Resilience", + "Incident Response", "Observability", "Memory Safety", + "License Compliance", "AI/ML Security", "Container Security"]: + assert gate_name in content, f"Gate '{gate_name}' missing from CONTROLGATE.md" + + def test_github_workflow_has_pr_trigger(self): + content = _build_github_workflow() + assert "pull_request" in content + assert "controlgate scan" in content + + def test_gitlab_ci_has_controlgate_job(self): + content = _build_gitlab_ci() + assert "controlgate" in content + assert "controlgate scan" in content + + def test_bitbucket_pipelines_has_controlgate_step(self): + content = _build_bitbucket_pipelines() + assert "controlgate" in content + assert "controlgate scan" in content + + +class TestInitCommand: + def test_creates_controlgate_yml(self, tmp_path): + from controlgate.init_command import init_command + import argparse + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert (tmp_path / ".controlgate.yml").exists() + + def test_creates_precommit_config(self, tmp_path): + from controlgate.init_command import init_command + import argparse + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert (tmp_path / ".pre-commit-config.yaml").exists() + + def test_creates_controlgate_md_by_default(self, tmp_path): + from controlgate.init_command import init_command + import argparse + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert (tmp_path / "CONTROLGATE.md").exists() + + def test_no_docs_skips_controlgate_md(self, tmp_path): + from controlgate.init_command import init_command + import argparse + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=True) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert not (tmp_path / "CONTROLGATE.md").exists() + + def test_creates_github_workflow_when_confirmed(self, tmp_path): + from controlgate.init_command import init_command + import argparse + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "y", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert (tmp_path / ".github" / "workflows" / "controlgate.yml").exists() + + def test_skips_github_workflow_when_denied(self, tmp_path): + from controlgate.init_command import init_command + import argparse + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert not (tmp_path / ".github" / "workflows" / "controlgate.yml").exists() + + def test_overwrite_prompt_on_existing_file(self, tmp_path): + from controlgate.init_command import init_command + import argparse + (tmp_path / ".controlgate.yml").write_text("existing: true\n") + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + # First input = baseline, second = overwrite .controlgate.yml (n), rest = CI prompts + inputs = iter(["moderate", "n", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + # Original content preserved + assert "existing: true" in (tmp_path / ".controlgate.yml").read_text() + + def test_returns_zero_on_success(self, tmp_path): + from controlgate.init_command import init_command + import argparse + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + result = init_command(args) + assert result == 0 +``` + +### Step 2: Run tests to verify they fail + +```bash +pytest tests/test_init_command.py -v +``` +Expected: `ModuleNotFoundError: No module named 'controlgate.init_command'` + +### Step 3: Create `src/controlgate/init_command.py` + +```python +"""ControlGate init command — bootstrap project configuration files.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Template builders +# --------------------------------------------------------------------------- + +def _build_controlgate_yml(baseline: str) -> str: + return f"""\ +# .controlgate.yml — ControlGate Configuration +# Reference: https://github.com/sadayamuthu/controlgate +# +# Baseline: low | moderate | high | privacy | li-saas +baseline: {baseline} + +# Set to true to evaluate against FedRAMP baselines instead of NIST +gov: false + +# NIST control catalog path (auto-managed; change only if self-hosting) +# catalog: baseline/nist80053r5_full_catalog_enriched.json + +# --------------------------------------------------------------------------- +# Security Gates — 18 gates mapped to NIST SP 800-53 Rev. 5 controls +# action: block | warn | disabled +# --------------------------------------------------------------------------- +gates: + # Gate 1 — Secrets & Credentials (IA-5, SC-12, SC-28) + secrets: {{ enabled: true, action: block }} + + # Gate 2 — Cryptography (SC-8, SC-13, SC-17) + crypto: {{ enabled: true, action: block }} + + # Gate 3 — IAM & Access Control (AC-3, AC-5, AC-6) + iam: {{ enabled: true, action: warn }} + + # Gate 4 — Supply Chain / SBOM (SR-3, SR-11, SA-10) + sbom: {{ enabled: true, action: warn }} + + # Gate 5 — Infrastructure as Code (CM-2, CM-6, SC-7) + iac: {{ enabled: true, action: block }} + + # Gate 6 — Input Validation (SI-10, SI-11) + input_validation: {{ enabled: true, action: block }} + + # Gate 7 — Audit Logging (AU-2, AU-3, AU-12) + audit: {{ enabled: true, action: warn }} + + # Gate 8 — Change Control (CM-3, CM-4, CM-5) + change_control: {{ enabled: true, action: warn }} + + # Gate 9 — Dependency Management (SA-12, RA-5, SI-2) + deps: {{ enabled: true, action: warn }} + + # Gate 10 — API Security (SC-8, AC-3, SI-10) + api: {{ enabled: true, action: warn }} + + # Gate 11 — Privacy (AR-2, DM-1, IP-1) + privacy: {{ enabled: true, action: warn }} + + # Gate 12 — Resilience & Reliability (CP-2, CP-10, SC-5) + resilience: {{ enabled: true, action: warn }} + + # Gate 13 — Incident Response (IR-2, IR-4, IR-6) + incident: {{ enabled: true, action: warn }} + + # Gate 14 — Observability (AU-6, SI-4, CA-7) + observability: {{ enabled: true, action: warn }} + + # Gate 15 — Memory Safety (SI-16, SA-11, SA-15) + memsafe: {{ enabled: true, action: warn }} + + # Gate 16 — License Compliance (SA-4, SR-3) + license: {{ enabled: true, action: warn }} + + # Gate 17 — AI/ML Security (SA-11, SI-7, AC-3) + aiml: {{ enabled: true, action: warn }} + + # Gate 18 — Container Security (CM-6, AC-6, SC-7) + container: {{ enabled: true, action: warn }} + +# --------------------------------------------------------------------------- +# Severity thresholds +# --------------------------------------------------------------------------- +thresholds: + block_on: [CRITICAL, HIGH] # Exit code 1, blocks commit/merge + warn_on: [MEDIUM] # Reported but non-blocking + ignore: [LOW] # Suppressed + +# --------------------------------------------------------------------------- +# Exclusions +# --------------------------------------------------------------------------- +exclusions: + paths: + - "tests/**" + - "docs/**" + - "*.md" + controls: [] # e.g. [AC-13, AC-15] + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- +reporting: + format: [json, markdown] + sarif: false + output_dir: .controlgate/reports + +# --------------------------------------------------------------------------- +# Full scan mode (controlgate scan --mode full) +# --------------------------------------------------------------------------- +full_scan: + extensions: + - .py + - .js + - .ts + - .jsx + - .tsx + - .go + - .java + - .rb + - .rs + - .tf + - .yaml + - .yml + - .json + - .toml + - .sh + - .env + - .sql + skip_dirs: + - .git + - node_modules + - .venv + - venv + - __pycache__ + - dist + - build + - .tox +""" + + +def _build_precommit_config() -> str: + return """\ +# .pre-commit-config.yaml — ControlGate pre-commit hook +# Install: pip install pre-commit && pre-commit install +repos: + - repo: local + hooks: + - id: controlgate + name: ControlGate Security Scan + # For FedRAMP baselines, add: --gov --baseline moderate + entry: python -m controlgate scan --mode pre-commit --format markdown + language: python + always_run: true + pass_filenames: false +""" + + +def _build_controlgate_md() -> str: + return """\ +# ControlGate — Security Compliance Guide + +ControlGate enforces **NIST SP 800-53 Rev. 5** (and **FedRAMP**) security controls +on every commit and merge through automated gate scanning. + +--- + +## Quick Start + +```bash +# Install +pip install controlgate + +# Bootstrap this project (creates all config files) +controlgate init + +# Baseline audit — scan the entire repo +controlgate scan --mode full --format markdown + +# Pre-commit scan (staged changes only) +controlgate scan --mode pre-commit --format markdown + +# PR scan (branch diff vs main) +controlgate scan --mode pr --target-branch main --format json markdown sarif +``` + +--- + +## Scan Modes + +| Mode | Command | What It Scans | +|------|---------|---------------| +| `pre-commit` | `controlgate scan --mode pre-commit` | Staged changes (`git diff --cached`) | +| `pr` | `controlgate scan --mode pr --target-branch main` | Branch diff vs target branch | +| `full` | `controlgate scan --mode full [--path ./src]` | Entire project directory | + +### Full Scan Mode + +Use `--mode full` to audit the whole repository — ideal for: +- Onboarding: establish a security baseline before enforcing pre-commit hooks +- CI scheduled scans: weekly full-repo compliance reports +- New team members: run once to understand the codebase's compliance posture + +```bash +# Scan current directory +controlgate scan --mode full --format markdown + +# Scan a specific subdirectory +controlgate scan --mode full --path ./src --format json + +# FedRAMP High full scan with reports +controlgate scan --mode full --gov --baseline high --output-dir .controlgate/reports +``` + +--- + +## The 18 Security Gates + +| # | Gate | NIST Families | What It Catches | Default Action | +|---|------|---------------|-----------------|----------------| +| 1 | 🔑 Secrets | IA-5, SC-12, SC-28 | Hardcoded creds, API keys, private keys | BLOCK | +| 2 | 🔒 Crypto | SC-8, SC-13, SC-17 | Weak algorithms, missing TLS, `ssl_verify=False` | BLOCK | +| 3 | 🛡️ IAM | AC-3, AC-5, AC-6 | Wildcard IAM, missing auth, overprivileged roles | WARN | +| 4 | 📦 Supply Chain | SR-3, SR-11, SA-10 | Unpinned deps, missing lockfiles, build tampering | WARN | +| 5 | 🏗️ IaC | CM-2, CM-6, SC-7 | Public buckets, `0.0.0.0/0` rules, root containers | BLOCK | +| 6 | ✅ Input Validation | SI-10, SI-11 | SQL injection, `eval()`, exposed stack traces | BLOCK | +| 7 | 📋 Audit | AU-2, AU-3, AU-12 | Missing security logging, PII in logs | WARN | +| 8 | 🔄 Change Control | CM-3, CM-4, CM-5 | Unauthorized config changes, missing CODEOWNERS | WARN | +| 9 | 🧩 Dependencies | SA-12, RA-5, SI-2 | Vulnerable packages, missing lockfiles | WARN | +| 10 | 🌐 API Security | SC-8, AC-3, SI-10 | Unauthenticated endpoints, missing rate limits | WARN | +| 11 | 🔐 Privacy | AR-2, DM-1, IP-1 | PII exposure, missing data classification | WARN | +| 12 | 🔁 Resilience | CP-2, CP-10, SC-5 | Missing retry logic, no circuit breakers | WARN | +| 13 | 🚨 Incident Response | IR-2, IR-4, IR-6 | Missing error handlers, no alerting hooks | WARN | +| 14 | 👁️ Observability | AU-6, SI-4, CA-7 | Missing health checks, no structured logging | WARN | +| 15 | 🧠 Memory Safety | SI-16, SA-11, SA-15 | Buffer overflows, unsafe memory ops | WARN | +| 16 | ⚖️ License Compliance | SA-4, SR-3 | GPL contamination, unlicensed dependencies | WARN | +| 17 | 🤖 AI/ML Security | SA-11, SI-7, AC-3 | Untrusted model sources, prompt injection risk | WARN | +| 18 | 🐳 Container Security | CM-6, AC-6, SC-7 | Root containers, privileged mode, latest tags | WARN | + +--- + +## Configuration Reference (`.controlgate.yml`) + +```yaml +baseline: moderate # low | moderate | high | privacy | li-saas +gov: false # true = evaluate against FedRAMP baselines + +gates: + secrets: { enabled: true, action: block } # block | warn | disabled + # ... (all 18 gates) + +thresholds: + block_on: [CRITICAL, HIGH] # Exit 1 — blocks commit/merge + warn_on: [MEDIUM] # Reported, non-blocking + ignore: [LOW] # Suppressed from output + +exclusions: + paths: ["tests/**", "docs/**", "*.md"] + controls: [] # Suppress specific NIST control IDs + +reporting: + format: [json, markdown] # Output formats + sarif: false # Enable SARIF (for GitHub Code Scanning) + output_dir: .controlgate/reports + +full_scan: + extensions: [.py, .js, .ts, .tf, .yaml, .yml, .json, .sh] + skip_dirs: [.git, node_modules, .venv, dist, build] +``` + +--- + +## Output Formats + +| Format | Flag | Use Case | +|--------|------|----------| +| Markdown | `--format markdown` | PR comments, terminal output | +| JSON | `--format json` | Dashboards, programmatic consumption | +| SARIF | `--format sarif` | GitHub Code Scanning integration | + +--- + +## CI/CD Integration + +### GitHub Actions + +```yaml +# .github/workflows/controlgate.yml +- name: Run ControlGate scan + run: | + controlgate scan \\ + --mode pr \\ + --target-branch ${{ github.base_ref || 'main' }} \\ + --format json markdown sarif \\ + --output-dir .controlgate/reports +``` + +### GitLab CI + +```yaml +controlgate: + image: python:3.12-slim + script: + - pip install controlgate + - controlgate scan --mode pr --target-branch $CI_MERGE_REQUEST_TARGET_BRANCH_NAME --format json markdown + artifacts: + paths: [.controlgate/reports/] +``` + +### Bitbucket Pipelines + +```yaml +- step: + name: ControlGate Security Scan + image: python:3.12-slim + script: + - pip install controlgate + - controlgate scan --mode pr --target-branch main --format json markdown + artifacts: + - .controlgate/reports/** +``` + +--- + +## All CLI Options + +```bash +controlgate init [--path PATH] [--baseline LEVEL] [--no-docs] +controlgate scan [--mode pre-commit|pr|full] [--path PATH] + [--target-branch BRANCH] [--diff-file FILE] + [--format json|markdown|sarif] [--output-dir DIR] + [--baseline LEVEL] [--gov] [--config FILE] +controlgate update-catalog +controlgate catalog-info +``` +""" + + +def _build_github_workflow() -> str: + return """\ +name: ControlGate Security Scan + +on: + pull_request: + branches: [main, develop] + push: + branches: [main] + +permissions: + contents: read + pull-requests: write + security-events: write + +jobs: + controlgate: + name: 🛡️ ControlGate Compliance Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for diff + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ControlGate + run: pip install controlgate + + - name: Run ControlGate scan + id: scan + run: | + controlgate scan \\ + --mode pr \\ + --target-branch ${{ github.base_ref || 'main' }} \\ + --format json markdown sarif \\ + --output-dir .controlgate/reports + continue-on-error: true + + - name: Upload SARIF to GitHub Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: .controlgate/reports/verdict.sarif + continue-on-error: true + + - name: Comment on PR + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const reportPath = '.controlgate/reports/verdict.md'; + if (fs.existsSync(reportPath)) { + const body = fs.readFileSync(reportPath, 'utf8'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } + + - name: Enforce gate + if: steps.scan.outcome == 'failure' + run: | + echo "🚫 ControlGate blocked this change due to security findings." + echo "Review the scan results above and fix the issues before merging." + exit 1 +""" + + +def _build_gitlab_ci() -> str: + return """\ +# GitLab CI — ControlGate Security Scan +# Add this job to your existing .gitlab-ci.yml or use as a starting point + +stages: + - security + +controlgate: + stage: security + image: python:3.12-slim + before_script: + - pip install controlgate + script: + - > + controlgate scan + --mode pr + --target-branch ${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-main} + --format json markdown sarif + --output-dir .controlgate/reports + artifacts: + paths: + - .controlgate/reports/ + when: always + expire_in: 30 days + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH +""" + + +def _build_bitbucket_pipelines() -> str: + return """\ +# Bitbucket Pipelines — ControlGate Security Scan +# Add the controlgate step to your existing bitbucket-pipelines.yml +# or use this as a starting point. + +image: python:3.12-slim + +pipelines: + pull-requests: + '**': + - step: + name: 🛡️ ControlGate Security Scan + script: + - pip install controlgate + - > + controlgate scan + --mode pr + --target-branch main + --format json markdown + --output-dir .controlgate/reports + artifacts: + - .controlgate/reports/** + branches: + main: + - step: + name: 🛡️ ControlGate Full Scan + script: + - pip install controlgate + - > + controlgate scan + --mode full + --format json markdown + --output-dir .controlgate/reports + artifacts: + - .controlgate/reports/** +""" + + +# --------------------------------------------------------------------------- +# Interactive helpers +# --------------------------------------------------------------------------- + +def _prompt(question: str, default: str = "") -> str: + """Prompt with an optional default value.""" + if default: + answer = input(f" {question} [{default}]: ").strip() + return answer if answer else default + return input(f" {question}: ").strip() + + +def _prompt_yn(question: str, default: bool = False) -> bool: + """Yes/no prompt.""" + default_str = "Y/n" if default else "y/N" + answer = input(f" {question} [{default_str}]: ").strip().lower() + if not answer: + return default + return answer.startswith("y") + + +def _write_file(path: Path, content: str) -> bool: + """Write file, prompting if it already exists. Returns True if written.""" + if path.exists(): + if not _prompt_yn(f"{path} already exists. Overwrite?", default=False): + print(f" ↳ Skipped: {path}") + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + print(f" Creating {path} ... ✅") + return True + + +# --------------------------------------------------------------------------- +# Main command +# --------------------------------------------------------------------------- + +def init_command(args) -> int: + """Execute the init command — interactive project bootstrap.""" + root = Path(getattr(args, "path", None) or ".").resolve() + no_docs = getattr(args, "no_docs", False) + + print() + print("🛡️ ControlGate Init") + print("─" * 50) + + # Baseline + default_baseline = getattr(args, "baseline", None) or "moderate" + baseline = _prompt( + "Baseline? (low/moderate/high/privacy/li-saas)", default=default_baseline + ) + if baseline not in ("low", "moderate", "high", "privacy", "li-saas"): + print(f" ⚠️ Unknown baseline '{baseline}', defaulting to 'moderate'") + baseline = "moderate" + + # CI/CD choices + gen_github = _prompt_yn("Generate GitHub Actions workflow?", default=False) + gen_gitlab = _prompt_yn("Generate GitLab CI job?", default=False) + gen_bitbucket = _prompt_yn("Generate Bitbucket Pipelines step?", default=False) + + print("─" * 50) + + # Write files + _write_file(root / ".controlgate.yml", _build_controlgate_yml(baseline)) + _write_file(root / ".pre-commit-config.yaml", _build_precommit_config()) + + if not no_docs: + _write_file(root / "CONTROLGATE.md", _build_controlgate_md()) + + if gen_github: + _write_file( + root / ".github" / "workflows" / "controlgate.yml", + _build_github_workflow(), + ) + + if gen_gitlab: + _write_file(root / ".gitlab-ci.yml", _build_gitlab_ci()) + + if gen_bitbucket: + _write_file(root / "bitbucket-pipelines.yml", _build_bitbucket_pipelines()) + + print("─" * 50) + print("✅ Done! ControlGate is configured for this project.") + print() + print(" Next steps:") + print(" 1. Review .controlgate.yml and adjust gate settings") + print(" 2. pip install pre-commit && pre-commit install") + print(" 3. controlgate scan --mode full (baseline audit)") + print() + return 0 +``` + +### Step 4: Run tests to verify they pass + +```bash +pytest tests/test_init_command.py -v +``` +Expected: all PASS + +### Step 5: Run full suite to catch regressions + +```bash +pytest --tb=short -q +``` +Expected: all PASS + +### Step 6: Commit + +```bash +git add src/controlgate/init_command.py tests/test_init_command.py +git commit -m "feat: add init_command with templates for all generated files" +``` + +--- + +## Task 5: Wire `init` subcommand into parser and `main()` + +**Files:** +- Modify: `src/controlgate/__main__.py` +- Test: `tests/test_cli.py` (extend), `tests/test_init_command.py` (extend) + +### Step 1: Write failing tests + +Add to `tests/test_cli.py`: + +```python +class TestInitCommand: + def test_parser_has_init_command(self): + parser = build_parser() + args = parser.parse_args(["init"]) + assert args.command == "init" + + def test_init_defaults(self): + parser = build_parser() + args = parser.parse_args(["init"]) + assert args.baseline is None + assert args.no_docs is False + + def test_init_with_baseline(self): + parser = build_parser() + args = parser.parse_args(["init", "--baseline", "high"]) + assert args.baseline == "high" + + def test_init_with_no_docs(self): + parser = build_parser() + args = parser.parse_args(["init", "--no-docs"]) + assert args.no_docs is True + + def test_main_init_command(self, tmp_path): + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + with patch("sys.argv", ["controlgate", "init", "--path", str(tmp_path)]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 0 +``` + +### Step 2: Run tests to verify they fail + +```bash +pytest tests/test_cli.py::TestInitCommand -v +``` +Expected: `argument command: invalid choice: 'init'` + +### Step 3: Add `init` subcommand to `build_parser()` + +After the `catalog-info` subparser block, add: + +```python +# init subcommand +init_parser = subparsers.add_parser( + "init", + help="Bootstrap ControlGate config files for this project", +) +init_parser.add_argument( + "--path", + type=str, + default=None, + help="Target directory to initialize (default: current directory)", +) +init_parser.add_argument( + "--baseline", + type=str, + choices=["low", "moderate", "high", "privacy", "li-saas"], + default=None, + help="Pre-select the NIST/FedRAMP baseline level", +) +init_parser.add_argument( + "--no-docs", + action="store_true", + default=False, + help="Skip generating CONTROLGATE.md", +) +``` + +### Step 4: Wire `init_command` in `main()` + +Add import at top of `__main__.py`: +```python +from controlgate.init_command import init_command +``` + +In `main()`, add after the `catalog-info` branch: +```python +elif args.command == "init": + sys.exit(init_command(args)) +``` + +### Step 5: Run tests to verify they pass + +```bash +pytest tests/test_cli.py::TestInitCommand -v +``` +Expected: all PASS + +### Step 6: Run full suite + +```bash +pytest --tb=short -q +``` +Expected: all PASS + +### Step 7: Commit + +```bash +git add src/controlgate/__main__.py tests/test_cli.py +git commit -m "feat: wire controlgate init subcommand into CLI" +``` + +--- + +## Task 6: Update `README.md` and `hooks/github_action.yml` + +**Files:** +- Modify: `README.md` +- Modify: `hooks/github_action.yml` + +No tests needed (docs only). + +### Step 1: Update `README.md` + +**a. Quick Start section** — add `--mode full` and `init` examples: + +```markdown +## Quick Start + +```bash +# Install +pip install controlgate + +# Bootstrap project (creates .controlgate.yml, pre-commit hook, CI workflows) +controlgate init + +# Full repo audit (first-time scan) +controlgate scan --mode full --format markdown + +# Scan staged changes (NIST baseline) +controlgate scan --mode pre-commit --format markdown + +# Scan staged changes (FedRAMP baseline) +controlgate scan --gov --baseline moderate --mode pre-commit --format markdown + +# Scan PR diff against main +controlgate scan --mode pr --target-branch main --format json markdown +``` +``` + +**b. How It Works section** — update "8 Security Gates" to "18 Security Gates": + +```markdown +8 Security Gates scan against 370 non-negotiable NIST controls +``` +→ +```markdown +18 Security Gates scan against 370 non-negotiable NIST controls +``` + +**c. Replace "The Eight Security Gates" section** with a full 18-gate table: + +```markdown +## The 18 Security Gates + +| # | Gate | NIST Families | What It Catches | +|---|------|---------------|-----------------| +| 1 | 🔑 Secrets | IA-5, SC-12, SC-28 | Hardcoded creds, API keys, private keys | +| 2 | 🔒 Crypto | SC-8, SC-13, SC-17 | Weak algorithms, missing TLS, `ssl_verify=False` | +| 3 | 🛡️ IAM | AC-3, AC-5, AC-6 | Wildcard IAM, missing auth, overprivileged roles | +| 4 | 📦 Supply Chain | SR-3, SR-11, SA-10 | Unpinned deps, missing lockfiles, build tampering | +| 5 | 🏗️ IaC | CM-2, CM-6, SC-7 | Public buckets, `0.0.0.0/0` rules, root containers | +| 6 | ✅ Input Validation | SI-10, SI-11 | SQL injection, `eval()`, exposed stack traces | +| 7 | 📋 Audit | AU-2, AU-3, AU-12 | Missing security logging, PII in logs | +| 8 | 🔄 Change Control | CM-3, CM-4, CM-5 | Unauthorized config changes, missing CODEOWNERS | +| 9 | 🧩 Dependencies | SA-12, RA-5, SI-2 | Vulnerable packages, missing lockfiles | +| 10 | 🌐 API Security | SC-8, AC-3, SI-10 | Unauthenticated endpoints, missing rate limiting | +| 11 | 🔐 Privacy | AR-2, DM-1, IP-1 | PII exposure, missing data classification | +| 12 | 🔁 Resilience | CP-2, CP-10, SC-5 | Missing retry logic, no circuit breakers | +| 13 | 🚨 Incident Response | IR-2, IR-4, IR-6 | Missing error handlers, no alerting integration | +| 14 | 👁️ Observability | AU-6, SI-4, CA-7 | Missing health checks, no structured logging | +| 15 | 🧠 Memory Safety | SI-16, SA-11, SA-15 | Buffer overflows, unsafe memory operations | +| 16 | ⚖️ License Compliance | SA-4, SR-3 | GPL contamination, unlicensed dependencies | +| 17 | 🤖 AI/ML Security | SA-11, SI-7, AC-3 | Untrusted model sources, prompt injection risk | +| 18 | 🐳 Container Security | CM-6, AC-6, SC-7 | Root containers, privileged mode, `latest` tags | +``` + +**d. Update Configuration section** — replace the 8-gate `.controlgate.yml` example with the full 18-gate version (use output of `_build_controlgate_yml("moderate")` from `init_command.py`). + +**e. Update CLI Usage section** — add `init` command and `--mode full`: + +```markdown +## CLI Usage + +```bash +# Bootstrap project +controlgate init +controlgate init --baseline high --no-docs + +# Scan staged changes (pre-commit mode) +controlgate scan --mode pre-commit --format markdown + +# Full repository scan +controlgate scan --mode full --format markdown +controlgate scan --mode full --path ./src --format json + +# Scan PR diff +controlgate scan --mode pr --target-branch main --format json markdown sarif + +# Scan PR diff explicitly against FedRAMP baselines +controlgate scan --gov --baseline high --mode pr --target-branch main --format json markdown sarif + +# Scan a saved diff file +controlgate scan --diff-file path/to/diff --format json + +# Output reports to directory +controlgate scan --output-dir .controlgate/reports --format json markdown sarif + +# Catalog management +controlgate update-catalog +controlgate catalog-info +``` +``` + +**f. Update Test Suite section** — bump "8 Security Gates" → "18 Security Gates". + +### Step 2: Update `hooks/github_action.yml` + +No functional changes needed — the existing file is already the source of truth and matches the template in `init_command.py`. + +### Step 3: Verify README renders correctly + +```bash +# Quick sanity check — count gate rows in README +grep -c "| [0-9]" README.md +``` +Expected: `18` + +### Step 4: Commit + +```bash +git add README.md hooks/github_action.yml +git commit -m "docs: update README for 18 gates, full scan mode, and init command" +``` + +--- + +## Task 7: Final validation + +### Step 1: Run full test suite with coverage + +```bash +pytest --tb=short -q +``` +Expected: all PASS, no regressions + +### Step 2: Smoke test the CLI + +```bash +# Verify --mode full is accepted +python -m controlgate scan --mode full --help + +# Verify init command is available +python -m controlgate init --help + +# Verify build_parser has all commands +python -m controlgate --help +``` +Expected: `full` appears in mode choices; `init` appears in command list + +### Step 3: Smoke test init in a temp directory + +```bash +cd /tmp && mkdir cg-test && cd cg-test +echo "moderate +n +n +n" | python -m controlgate init +ls -la +``` +Expected: `.controlgate.yml`, `.pre-commit-config.yaml`, `CONTROLGATE.md` all created + +### Step 4: Commit and summarise + +```bash +git add -p # stage any remaining changes +git commit -m "chore: final cleanup and validation" +``` diff --git a/docs/plans/2026-02-27-gate-gap-fixes.md b/docs/plans/2026-02-27-gate-gap-fixes.md new file mode 100644 index 0000000..ad27642 --- /dev/null +++ b/docs/plans/2026-02-27-gate-gap-fixes.md @@ -0,0 +1,302 @@ +# Gate Gap Fixes Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Fix 3 concrete gaps between the approved gate design and the current implementation: a control-ID mismatch in APIGate, a missing `cffi.` pattern in MemSafeGate, and a missing SC-28 plaintext weights pattern in AIMLGate. + +**Architecture:** Pure additive changes — no new files, no new gate classes, no changes to engine, base, or reporters. Each fix touches at most 3 files: the gate module, its test file, and `catalog.py`. + +**Tech Stack:** Python 3.10+, pytest, re (stdlib). Run tests with `.venv/bin/pytest tests/ -v`. + +--- + +## Context + +Reference files before starting: +- `docs/plans/2026-02-27-gates-gap-analysis.md` — full gap analysis +- `docs/plans/2026-02-27-new-gates-design.md` — original approved design + +--- + +## Task 1: Fix APIGate — Add SC-5 and SI-10 to control map + +**Files:** +- Modify: `src/controlgate/catalog.py` (line ~21, the `"api"` entry) +- Modify: `src/controlgate/gates/api_gate.py` (line ~61, `mapped_control_ids`) +- Modify: `tests/test_gates/test_api_gate.py` (update `valid_ids` in control ID test) + +**Background:** The design specifies `["SC-8", "AC-3", "SC-5", "SI-10"]` for the API gate. The current implementation only has `["SC-8", "AC-3"]`. SC-5 (Denial of Service Protection) and SI-10 (Information Input Validation) are genuinely covered by the existing CORS-wildcard and GraphQL introspection patterns. + +### Step 1: Update the failing test to assert SC-5/SI-10 are valid + +Open `tests/test_gates/test_api_gate.py` and find `test_findings_have_valid_control_ids`. Change: + +```python +valid_ids = {"SC-8", "AC-3", "SC-5", "SI-10"} +``` + +It already says this — confirm it does. If it only says `{"SC-8", "AC-3"}`, update it. + +### Step 2: Run tests to confirm current state + +```bash +.venv/bin/pytest tests/test_gates/test_api_gate.py -v +``` + +Expected: All pass (the test already uses `{"SC-8", "AC-3", "SC-5", "SI-10"}` as valid_ids — this passes because it's a superset check; findings only use SC-8/AC-3 which are in the set). + +### Step 3: Update `catalog.py` GATE_CONTROL_MAP + +In `src/controlgate/catalog.py`, change: + +```python +"api": ["SC-8", "AC-3"], +``` + +to: + +```python +"api": ["SC-8", "AC-3", "SC-5", "SI-10"], +``` + +### Step 4: Update `APIGate.mapped_control_ids` + +In `src/controlgate/gates/api_gate.py`, change: + +```python +mapped_control_ids = ["SC-8", "AC-3"] +``` + +to: + +```python +mapped_control_ids = ["SC-8", "AC-3", "SC-5", "SI-10"] +``` + +### Step 5: Run full test suite to confirm nothing breaks + +```bash +.venv/bin/pytest tests/ -v --tb=short +``` + +Expected: All 223 tests pass. + +### Step 6: Commit + +```bash +git add src/controlgate/catalog.py src/controlgate/gates/api_gate.py +PATH="$PWD/.venv/bin:$PATH" git commit -m "fix: add SC-5 and SI-10 to APIGate control map (align with design)" +``` + +--- + +## Task 2: Add `cffi.` memory patterns to MemSafeGate + +**Files:** +- Modify: `src/controlgate/gates/memsafe_gate.py` (add 1 pattern to `_PATTERNS`) +- Modify: `tests/test_gates/test_memsafe_gate.py` (add 1 new test) + +**Background:** The design specifies `"ctypes. / cffi. direct memory function calls"` as a SI-16 pattern. Current implementation covers `ctypes.*` only. `cffi` is a widely used C FFI library for Python whose `cast`, `buffer`, and `from_buffer` functions bypass memory safety. + +### Step 1: Write the failing test + +Add to `tests/test_gates/test_memsafe_gate.py`, inside `TestMemSafeGate`: + +```python +_CFFI_DIFF = """\ +diff --git a/bridge.py b/bridge.py +--- /dev/null ++++ b/bridge.py +@@ -0,0 +1,4 @@ ++import cffi ++ffi = cffi.FFI() ++buf = ffi.buffer(ptr, size) ++data = ffi.cast("uint8_t *", ptr) +""" + +def test_detects_cffi_memory_ops(self, gate): + diff_files = parse_diff(_CFFI_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("cffi" in f.description.lower() for f in findings) +``` + +### Step 2: Run test to verify it fails + +```bash +.venv/bin/pytest tests/test_gates/test_memsafe_gate.py::TestMemSafeGate::test_detects_cffi_memory_ops -v +``` + +Expected: FAIL — `assert 0 > 0` (no findings, pattern not implemented yet). + +### Step 3: Add the cffi pattern to `memsafe_gate.py` + +In `src/controlgate/gates/memsafe_gate.py`, add to `_PATTERNS` after the ctypes entry: + +```python +( + re.compile(r"""ffi\.(?:cast|buffer|from_buffer|memmove)\s*\("""), + "cffi raw memory operation — bypasses Python memory safety", + "SI-16", + "Audit cffi usage; ensure pointer arithmetic is bounds-checked and never uses untrusted lengths", +), +``` + +### Step 4: Run the new test to verify it passes + +```bash +.venv/bin/pytest tests/test_gates/test_memsafe_gate.py -v +``` + +Expected: All 8 tests pass (7 existing + 1 new). + +### Step 5: Run full suite + +```bash +.venv/bin/pytest tests/ -v --tb=short +``` + +Expected: All 224 tests pass. + +### Step 6: Commit + +```bash +git add src/controlgate/gates/memsafe_gate.py tests/test_gates/test_memsafe_gate.py +PATH="$PWD/.venv/bin:$PATH" git commit -m "feat: add cffi memory operation detection to MemSafeGate (SI-16)" +``` + +--- + +## Task 3: Add plaintext model weights pattern to AIMLGate + +**Files:** +- Modify: `src/controlgate/gates/aiml_gate.py` (add 1 pattern to `_PATTERNS`) +- Modify: `tests/test_gates/test_aiml_gate.py` (add 1 new test) + +**Background:** The design specifies `"Model weights in plaintext config fields"` → SC-28. This catches hardcoded model weight paths or checkpoint references in config files/code that indicate weights are stored in plaintext (no vault or encryption reference). Pattern: detect `model_path`, `weights_path`, `checkpoint_path`, `model_weights` assigned to a bare string literal. + +### Step 1: Write the failing test + +Add to `tests/test_gates/test_aiml_gate.py`, inside `TestAIMLGate`: + +```python +_PLAINTEXT_WEIGHTS_DIFF = """\ +diff --git a/config.py b/config.py +--- /dev/null ++++ b/config.py +@@ -0,0 +1,3 @@ ++MODEL_WEIGHTS = "/data/models/prod_weights.bin" ++checkpoint_path = "s3://bucket/model.pt" ++weights_path = "/mnt/nfs/weights.ckpt" +""" + +def test_detects_plaintext_model_weights(self, gate): + diff_files = parse_diff(_PLAINTEXT_WEIGHTS_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("plaintext" in f.description.lower() or "weight" in f.description.lower() for f in findings) + assert all(f.control_id == "SC-28" for f in findings) +``` + +### Step 2: Run test to verify it fails + +```bash +.venv/bin/pytest tests/test_gates/test_aiml_gate.py::TestAIMLGate::test_detects_plaintext_model_weights -v +``` + +Expected: FAIL — `assert 0 > 0`. + +### Step 3: Add the pattern to `aiml_gate.py` + +In `src/controlgate/gates/aiml_gate.py`, add to `_PATTERNS`: + +```python +( + re.compile( + r"""(?i)(?:model_path|weights_path|checkpoint_path|model_weights)\s*=\s*["\'][^"\']+["\']""" + ), + "Model weights path stored in plaintext config — weights location exposed without encryption", + "SC-28", + "Store model paths in a secrets manager or encrypted config; avoid hardcoding weight locations", +), +``` + +### Step 4: Run the new test to verify it passes + +```bash +.venv/bin/pytest tests/test_gates/test_aiml_gate.py -v +``` + +Expected: All 8 tests pass (7 existing + 1 new). + +### Step 5: Run full suite + +```bash +.venv/bin/pytest tests/ -v --tb=short +``` + +Expected: All 225 tests pass. + +### Step 6: Commit + +```bash +git add src/controlgate/gates/aiml_gate.py tests/test_gates/test_aiml_gate.py +PATH="$PWD/.venv/bin:$PATH" git commit -m "feat: add plaintext model weights detection to AIMLGate (SC-28)" +``` + +--- + +## Task 4: Final verification + +### Step 1: Run full test suite + +```bash +.venv/bin/pytest tests/ -v --tb=short +``` + +Expected: All 225 tests pass across 18 gates. + +### Step 2: Verify gate catalog completeness + +```bash +.venv/bin/python -c " +from controlgate.catalog import GATE_CONTROL_MAP +from controlgate.gates import ALL_GATES +print(f'{len(ALL_GATES)} gates, {len(GATE_CONTROL_MAP)} catalog entries') +for g in ALL_GATES: + ids = GATE_CONTROL_MAP.get(g.gate_id, []) + cls_ids = g.mapped_control_ids + match = set(ids) == set(cls_ids) + status = '✓' if match else '✗ MISMATCH' + print(f' {g.gate_id:16} catalog={ids} class={cls_ids} {status}') +" +``` + +Expected: All 18 gates show `✓` — catalog and class `mapped_control_ids` fully agree. + +### Step 3: Commit checklist summary + +No code changes — just a note that verification passed. Optionally update the gap analysis doc status line: + +In `docs/plans/2026-02-27-gates-gap-analysis.md`, change the header status from: +``` +**Status:** Approved — Approach A (fix clear bugs, document known debt) +``` +to: +``` +**Status:** Complete — Approach A fixes applied 2026-02-27 +``` + +```bash +git add docs/plans/2026-02-27-gates-gap-analysis.md +PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: mark gap analysis complete — all Approach A fixes applied" +``` + +--- + +## Checklist + +- [ ] Task 1: APIGate — SC-5/SI-10 added to catalog and class +- [ ] Task 2: MemSafeGate — `cffi.` pattern added, 1 new test +- [ ] Task 3: AIMLGate — SC-28 plaintext weights pattern, 1 new test +- [ ] Task 4: Full suite passes (225 tests), catalog/class alignment verified diff --git a/docs/plans/2026-02-27-gates-9-18-implementation.md b/docs/plans/2026-02-27-gates-9-18-implementation.md new file mode 100644 index 0000000..5aafcfe --- /dev/null +++ b/docs/plans/2026-02-27-gates-9-18-implementation.md @@ -0,0 +1,2467 @@ +# Gates 9–18 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add 10 new security gates (deps, api, privacy, resilience, incident, observability, memsafe, license, aiml, container) to ControlGate, expanding from 8 to 18 gates. + +**Architecture:** Each gate is a standalone file following the identical `BaseGate` + `_PATTERNS` contract used by all existing gates. Each gate is wired into `catalog.py` (`GATE_CONTROL_MAP`) and `gates/__init__.py` (`ALL_GATES`) immediately when created. No changes to `engine.py`, `base.py`, `models.py`, or reporters. + +**Tech Stack:** Python 3.10+, pytest, re (stdlib). No new dependencies. + +--- + +## Context: How Gates Work + +Read these files before starting: +- `src/controlgate/gates/base.py` — abstract `BaseGate` class with `_make_finding()` helper +- `src/controlgate/gates/secrets_gate.py` — canonical example gate (pattern + scan loop) +- `tests/test_gates/test_secrets_gate.py` — canonical test pattern +- `src/controlgate/catalog.py` — `GATE_CONTROL_MAP` that must be updated per gate +- `src/controlgate/gates/__init__.py` — `ALL_GATES` list that must be updated per gate + +The `_make_finding(control_id, file, line, description, evidence, remediation)` helper looks up severity and `non_negotiable` from the NIST catalog. If the control_id isn't in the catalog it falls back to `"MEDIUM"` severity — this is acceptable. + +**Run tests with:** `pytest tests/ -v` (requires virtualenv with `pip install -e ".[dev]"`) + +--- + +## Task 1: Dependency Vulnerability Gate (`deps`) + +**Files:** +- Create: `src/controlgate/gates/deps_gate.py` +- Create: `tests/test_gates/test_deps_gate.py` +- Modify: `src/controlgate/catalog.py` (add to `GATE_CONTROL_MAP`) +- Modify: `src/controlgate/gates/__init__.py` (add to `ALL_GATES`) + +### Step 1: Write the failing test + +Create `tests/test_gates/test_deps_gate.py`: + +```python +"""Tests for the Dependency Vulnerability Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.deps_gate import DepsGate + + +@pytest.fixture +def gate(catalog): + return DepsGate(catalog) + + +_NO_VERIFY_DIFF = """\ +diff --git a/Makefile b/Makefile +--- a/Makefile ++++ b/Makefile +@@ -1,3 +1,4 @@ + install: ++\tpip install --no-verify requests +""" + +_IGNORE_SCRIPTS_DIFF = """\ +diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -1,3 +1,4 @@ ++ "install": "npm install --ignore-scripts" +""" + +_HTTP_REGISTRY_DIFF = """\ +diff --git a/.npmrc b/.npmrc +--- /dev/null ++++ b/.npmrc +@@ -0,0 +1,1 @@ ++registry=http://registry.npmjs.org/ +""" + +_UNPINNED_PIP_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,3 @@ ++FROM python:3.11 ++RUN pip install requests flask ++CMD ["python", "app.py"] +""" + +_CLEAN_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,3 @@ ++FROM python:3.11 ++RUN pip install requests==2.31.0 flask==3.0.0 ++CMD ["python", "app.py"] +""" + + +class TestDepsGate: + def test_detects_no_verify(self, gate): + diff_files = parse_diff(_NO_VERIFY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("no-verify" in f.description.lower() or "integrity" in f.description.lower() for f in findings) + + def test_detects_ignore_scripts(self, gate): + diff_files = parse_diff(_IGNORE_SCRIPTS_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_http_registry(self, gate): + diff_files = parse_diff(_HTTP_REGISTRY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("http" in f.description.lower() for f in findings) + + def test_detects_unpinned_pip_install(self, gate): + diff_files = parse_diff(_UNPINNED_PIP_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_pinned_install_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_NO_VERIFY_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "deps" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_NO_VERIFY_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"RA-5", "SI-2", "SA-12"} + for f in findings: + assert f.control_id in valid_ids +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_gates/test_deps_gate.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'controlgate.gates.deps_gate'` + +### Step 3: Implement `deps_gate.py` + +Create `src/controlgate/gates/deps_gate.py`: + +```python +"""Gate 9 — Dependency Vulnerability Gate. + +Detects dependency hygiene violations that indicate vulnerability risk: +bypassed integrity checks, unpinned runtime installs, and insecure registry URLs. + +NIST Controls: RA-5, SI-2, SA-12 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""--no-verify"""), + "Package integrity verification bypassed with --no-verify", + "SA-12", + "Remove --no-verify to ensure package checksums are validated", + ), + ( + re.compile(r"""--ignore-scripts"""), + "npm --ignore-scripts bypasses postinstall security hooks", + "SA-12", + "Audit dependencies manually before using --ignore-scripts; prefer a scanned internal registry", + ), + ( + re.compile(r"""http://[^\s]*(?:pypi|npmjs|rubygems|packagist|pkg\.go\.dev|registry\.)"""), + "Insecure HTTP URL used for package registry — man-in-the-middle risk", + "SI-2", + "Use HTTPS for all package registry URLs", + ), + ( + re.compile(r"""pip\s+install\s+(?!-r\s)(?:[A-Za-z0-9][A-Za-z0-9_.-]*)(?:\s+[A-Za-z0-9][A-Za-z0-9_.-]*)*\s*$"""), + "pip install without pinned version — dependency may resolve to a vulnerable release", + "RA-5", + "Pin all dependencies to exact versions (pip install package==1.2.3) or use a lockfile", + ), +] + + +class DepsGate(BaseGate): + """Gate 9: Detect dependency vulnerability hygiene violations.""" + + name = "Dependency Vulnerability Gate" + gate_id = "deps" + mapped_control_ids = ["RA-5", "SI-2", "SA-12"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings +``` + +### Step 4: Wire gate into catalog and __init__ + +In `src/controlgate/catalog.py`, add to `GATE_CONTROL_MAP`: +```python + "deps": ["RA-5", "SI-2", "SA-12"], +``` + +In `src/controlgate/gates/__init__.py`, add import and list entry: +```python +from controlgate.gates.deps_gate import DepsGate +# add DepsGate to ALL_GATES list and __all__ +``` + +### Step 5: Run tests to verify they pass + +```bash +pytest tests/test_gates/test_deps_gate.py -v +``` + +Expected: All tests PASS (note: `test_pinned_install_no_findings` may need pattern tuning if false-positives appear — adjust the pip pattern to require `==` absence check). + +### Step 6: Commit + +```bash +git add src/controlgate/gates/deps_gate.py tests/test_gates/test_deps_gate.py \ + src/controlgate/catalog.py src/controlgate/gates/__init__.py +git commit -m "feat: add Gate 9 — Dependency Vulnerability gate (deps, RA-5/SI-2/SA-12)" +``` + +--- + +## Task 2: API Security Gate (`api`) + +**Files:** +- Create: `src/controlgate/gates/api_gate.py` +- Create: `tests/test_gates/test_api_gate.py` +- Modify: `src/controlgate/catalog.py` +- Modify: `src/controlgate/gates/__init__.py` + +### Step 1: Write the failing test + +Create `tests/test_gates/test_api_gate.py`: + +```python +"""Tests for the API Security Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.api_gate import APIGate + + +@pytest.fixture +def gate(catalog): + return APIGate(catalog) + + +_VERIFY_FALSE_DIFF = """\ +diff --git a/client.py b/client.py +--- a/client.py ++++ b/client.py +@@ -1,3 +1,4 @@ + import requests ++response = requests.get("https://api.example.com", verify=False) +""" + +_CORS_ALL_DIFF = """\ +diff --git a/settings.py b/settings.py +--- /dev/null ++++ b/settings.py +@@ -0,0 +1,2 @@ ++CORS_ORIGIN_ALLOW_ALL = True ++ALLOWED_HOSTS = ["*"] +""" + +_API_KEY_QUERY_DIFF = """\ +diff --git a/api.py b/api.py +--- a/api.py ++++ b/api.py +@@ -1,3 +1,4 @@ ++url = f"https://api.example.com/data?api_key={key}&format=json" +""" + +_CREDENTIALED_CORS_DIFF = """\ +diff --git a/headers.py b/headers.py +--- /dev/null ++++ b/headers.py +@@ -0,0 +1,2 @@ ++response.headers["Access-Control-Allow-Origin"] = "*" ++response.headers["Access-Control-Allow-Credentials"] = "true" +""" + +_GRAPHQL_INTROSPECTION_DIFF = """\ +diff --git a/schema.py b/schema.py +--- /dev/null ++++ b/schema.py +@@ -0,0 +1,2 @@ ++app.add_url_rule("/graphql", view_func=GraphQLView.as_view("graphql", schema=schema, graphiql=True)) ++GRAPHQL_INTROSPECTION = True +""" + +_CLEAN_DIFF = """\ +diff --git a/client.py b/client.py +--- /dev/null ++++ b/client.py +@@ -0,0 +1,3 @@ ++import requests ++response = requests.get("https://api.example.com") ++assert response.status_code == 200 +""" + + +class TestAPIGate: + def test_detects_verify_false(self, gate): + diff_files = parse_diff(_VERIFY_FALSE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("verify" in f.description.lower() or "tls" in f.description.lower() for f in findings) + + def test_detects_cors_allow_all(self, gate): + diff_files = parse_diff(_CORS_ALL_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_api_key_in_query(self, gate): + diff_files = parse_diff(_API_KEY_QUERY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_credentialed_cors(self, gate): + diff_files = parse_diff(_CREDENTIALED_CORS_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_clean_code_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_VERIFY_FALSE_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "api" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_VERIFY_FALSE_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"SC-8", "AC-3", "SC-5", "SI-10"} + for f in findings: + assert f.control_id in valid_ids +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_gates/test_api_gate.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'controlgate.gates.api_gate'` + +### Step 3: Implement `api_gate.py` + +Create `src/controlgate/gates/api_gate.py`: + +```python +"""Gate 10 — API Security Gate. + +Detects insecure API patterns: TLS verification disabled, wildcard CORS, +API credentials in query params, and GraphQL introspection in production. + +NIST Controls: SC-8, AC-3, SC-5, SI-10 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""verify\s*=\s*False"""), + "TLS certificate verification disabled — subject to MITM attacks", + "SC-8", + "Remove verify=False and use a proper CA bundle; never disable TLS verification in production", + ), + ( + re.compile(r"""(?i)(?:CORS_ORIGIN_ALLOW_ALL|allow_all_origins)\s*=\s*True"""), + "CORS wildcard origin configured — allows any domain to make credentialed requests", + "AC-3", + "Restrict CORS to an explicit allowlist of trusted origins", + ), + ( + re.compile(r"""Access-Control-Allow-Origin["\s]*[:=]["\s]*\*"""), + "Access-Control-Allow-Origin: * permits requests from any origin", + "AC-3", + "Restrict Access-Control-Allow-Origin to specific trusted origins", + ), + ( + re.compile(r"""Access-Control-Allow-Credentials["\s]*[:=]["\s]*["\']?true""", re.IGNORECASE), + "Access-Control-Allow-Credentials: true with wildcard origin creates CSRF/CORS bypass risk", + "AC-3", + "Never combine Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: *", + ), + ( + re.compile(r"""[?&](?:api[_-]?key|token|access[_-]?token|secret)[=]"""), + "API key or token passed in URL query parameter — logged in server access logs", + "SC-8", + "Pass API credentials in Authorization header, not in URL query parameters", + ), + ( + re.compile(r"""(?i)GRAPHQL_INTROSPECTION\s*=\s*True|graphiql\s*=\s*True"""), + "GraphQL introspection or GraphiQL enabled — exposes full schema to attackers", + "AC-3", + "Disable introspection and GraphiQL in non-development environments", + ), +] + + +class APIGate(BaseGate): + """Gate 10: Detect insecure API patterns.""" + + name = "API Security Gate" + gate_id = "api" + mapped_control_ids = ["SC-8", "AC-3", "SC-5", "SI-10"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings +``` + +### Step 4: Wire into catalog and __init__ + +In `src/controlgate/catalog.py` add to `GATE_CONTROL_MAP`: +```python + "api": ["SC-8", "AC-3", "SC-5", "SI-10"], +``` + +In `src/controlgate/gates/__init__.py` add `APIGate` import, to `ALL_GATES`, and `__all__`. + +### Step 5: Run tests + +```bash +pytest tests/test_gates/test_api_gate.py -v +``` + +Expected: All PASS. + +### Step 6: Commit + +```bash +git add src/controlgate/gates/api_gate.py tests/test_gates/test_api_gate.py \ + src/controlgate/catalog.py src/controlgate/gates/__init__.py +git commit -m "feat: add Gate 10 — API Security gate (api, SC-8/AC-3/SC-5)" +``` + +--- + +## Task 3: Data Privacy Gate (`privacy`) + +**Files:** +- Create: `src/controlgate/gates/privacy_gate.py` +- Create: `tests/test_gates/test_privacy_gate.py` +- Modify: `src/controlgate/catalog.py` +- Modify: `src/controlgate/gates/__init__.py` + +### Step 1: Write the failing test + +Create `tests/test_gates/test_privacy_gate.py`: + +```python +"""Tests for the Data Privacy Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.privacy_gate import PrivacyGate + + +@pytest.fixture +def gate(catalog): + return PrivacyGate(catalog) + + +_PII_IN_LOG_DIFF = """\ +diff --git a/views.py b/views.py +--- a/views.py ++++ b/views.py +@@ -1,4 +1,5 @@ + def register(request): ++ logging.debug("User SSN: %s, DOB: %s", user.ssn, user.date_of_birth) + save(user) +""" + +_SERIALIZE_ALL_DIFF = """\ +diff --git a/serializers.py b/serializers.py +--- /dev/null ++++ b/serializers.py +@@ -0,0 +1,3 @@ ++class UserSerializer(ModelSerializer): ++ serialize_all_fields = True ++ model = User +""" + +_NO_EXPIRY_DIFF = """\ +diff --git a/models.py b/models.py +--- /dev/null ++++ b/models.py +@@ -0,0 +1,3 @@ ++class Session(Model): ++ token = CharField() ++ expires_at = None +""" + +_CLEAN_DIFF = """\ +diff --git a/views.py b/views.py +--- /dev/null ++++ b/views.py +@@ -0,0 +1,3 @@ ++def register(request): ++ logging.info("User registered: user_id=%s", user.id) ++ save(user) +""" + + +class TestPrivacyGate: + def test_detects_pii_in_log(self, gate): + diff_files = parse_diff(_PII_IN_LOG_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("pii" in f.description.lower() or "log" in f.description.lower() for f in findings) + + def test_detects_serialize_all_fields(self, gate): + diff_files = parse_diff(_SERIALIZE_ALL_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_no_expiry(self, gate): + diff_files = parse_diff(_NO_EXPIRY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_clean_code_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_PII_IN_LOG_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "privacy" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_PII_IN_LOG_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"PT-2", "PT-3", "SC-28"} + for f in findings: + assert f.control_id in valid_ids +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_gates/test_privacy_gate.py -v +``` + +### Step 3: Implement `privacy_gate.py` + +Create `src/controlgate/gates/privacy_gate.py`: + +```python +"""Gate 11 — Data Privacy Gate. + +Detects PII handling violations: PII in logs, data over-exposure in serializers, +and missing data retention policies. + +NIST Controls: PT-2, PT-3, SC-28 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +# PII field name keywords +_PII_FIELDS = r"""(?:ssn|social.?security|date.?of.?birth|dob|credit.?card|card.?number|cvv|passport|drivers.?license)""" + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile( + r"""(?i)(?:logging\.|logger\.|print\().*""" + _PII_FIELDS, + ), + "PII field name detected in logging/print statement", + "PT-3", + "Remove PII from logs; use opaque identifiers (user_id) instead of PII field values", + ), + ( + re.compile(r"""(?i)serialize_all_fields\s*=\s*True"""), + "serialize_all_fields=True exposes all model fields — may leak PII or sensitive data", + "PT-2", + "Use an explicit fields allowlist in serializers; never serialize all fields by default", + ), + ( + re.compile(r"""(?i)expires_at\s*=\s*None|ttl\s*[:=]\s*0|ttl\s*[:=]\s*null"""), + "Data retention field set to null/0 — no expiry policy enforced", + "SC-28", + "Set an explicit expires_at or TTL for all data with retention requirements", + ), + ( + re.compile( + r"""(?i)(?:CharField|TextField|StringField|Column\(String)\s*\(.*?""" + _PII_FIELDS, + ), + "PII field stored in plaintext database column without encryption marker", + "SC-28", + "Encrypt PII at rest using field-level encryption or a dedicated vault", + ), +] + + +class PrivacyGate(BaseGate): + """Gate 11: Detect data privacy and PII handling violations.""" + + name = "Data Privacy Gate" + gate_id = "privacy" + mapped_control_ids = ["PT-2", "PT-3", "SC-28"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings +``` + +### Step 4: Wire into catalog and __init__ + +Add `"privacy": ["PT-2", "PT-3", "SC-28"]` to `GATE_CONTROL_MAP`. +Add `PrivacyGate` to imports, `ALL_GATES`, and `__all__`. + +### Step 5: Run and commit + +```bash +pytest tests/test_gates/test_privacy_gate.py -v +git add src/controlgate/gates/privacy_gate.py tests/test_gates/test_privacy_gate.py \ + src/controlgate/catalog.py src/controlgate/gates/__init__.py +git commit -m "feat: add Gate 11 — Data Privacy gate (privacy, PT-2/PT-3/SC-28)" +``` + +--- + +## Task 4: Resilience & Backup Gate (`resilience`) + +**Files:** +- Create: `src/controlgate/gates/resilience_gate.py` +- Create: `tests/test_gates/test_resilience_gate.py` +- Modify: `src/controlgate/catalog.py` +- Modify: `src/controlgate/gates/__init__.py` + +### Step 1: Write the failing test + +Create `tests/test_gates/test_resilience_gate.py`: + +```python +"""Tests for the Resilience & Backup Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.resilience_gate import ResilienceGate + + +@pytest.fixture +def gate(catalog): + return ResilienceGate(catalog) + + +_DELETION_PROTECTION_DIFF = """\ +diff --git a/main.tf b/main.tf +--- /dev/null ++++ b/main.tf +@@ -0,0 +1,5 @@ ++resource "aws_db_instance" "main" { ++ identifier = "prod-db" ++ deletion_protection = false ++ instance_class = "db.t3.micro" ++} +""" + +_SKIP_SNAPSHOT_DIFF = """\ +diff --git a/database.tf b/database.tf +--- /dev/null ++++ b/database.tf +@@ -0,0 +1,4 @@ ++resource "aws_db_instance" "prod" { ++ skip_final_snapshot = true ++ deletion_protection = false ++} +""" + +_MAX_RETRIES_ZERO_DIFF = """\ +diff --git a/config.py b/config.py +--- /dev/null ++++ b/config.py +@@ -0,0 +1,2 @@ ++MAX_RETRIES = 0 ++RETRY_DELAY = 1 +""" + +_CLEAN_DIFF = """\ +diff --git a/main.tf b/main.tf +--- /dev/null ++++ b/main.tf +@@ -0,0 +1,5 @@ ++resource "aws_db_instance" "main" { ++ identifier = "prod-db" ++ deletion_protection = true ++ skip_final_snapshot = false ++} +""" + + +class TestResilienceGate: + def test_detects_deletion_protection_false(self, gate): + diff_files = parse_diff(_DELETION_PROTECTION_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("deletion_protection" in f.description.lower() or "backup" in f.description.lower() for f in findings) + + def test_detects_skip_final_snapshot(self, gate): + diff_files = parse_diff(_SKIP_SNAPSHOT_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_max_retries_zero(self, gate): + diff_files = parse_diff(_MAX_RETRIES_ZERO_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_clean_config_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_DELETION_PROTECTION_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "resilience" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_DELETION_PROTECTION_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"CP-9", "CP-10", "SI-13"} + for f in findings: + assert f.control_id in valid_ids +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_gates/test_resilience_gate.py -v +``` + +### Step 3: Implement `resilience_gate.py` + +Create `src/controlgate/gates/resilience_gate.py`: + +```python +"""Gate 12 — Resilience & Backup Gate. + +Detects code patterns that disable recoverability: deletion protection off, +no final snapshots, zero retries, and missing connection timeouts. + +NIST Controls: CP-9, CP-10, SI-13 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""(?i)deletion.?protection\s*[:=]\s*false"""), + "deletion_protection disabled — database can be accidentally or maliciously deleted", + "CP-9", + "Set deletion_protection = true on all production databases", + ), + ( + re.compile(r"""(?i)backup\s*[:=]\s*false"""), + "Automated backups disabled for database resource", + "CP-9", + "Enable automated backups; set backup_retention_period to at least 7 days", + ), + ( + re.compile(r"""(?i)skip.?final.?snapshot\s*[:=]\s*true"""), + "skip_final_snapshot = true — no snapshot taken before database deletion", + "CP-9", + "Set skip_final_snapshot = false and specify a final_snapshot_identifier", + ), + ( + re.compile(r"""(?i)max.?retries\s*[:=]\s*0"""), + "max_retries set to 0 — no retry on transient failures", + "SI-13", + "Set max_retries to at least 3 with exponential backoff for external service calls", + ), + ( + re.compile(r"""(?i)backup.?retention.?period\s*[:=]\s*0"""), + "backup_retention_period = 0 disables automated database backups", + "CP-9", + "Set backup_retention_period to at least 7 days for production databases", + ), +] + + +class ResilienceGate(BaseGate): + """Gate 12: Detect resilience and backup configuration violations.""" + + name = "Resilience & Backup Gate" + gate_id = "resilience" + mapped_control_ids = ["CP-9", "CP-10", "SI-13"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings +``` + +### Step 4: Wire and commit + +Add `"resilience": ["CP-9", "CP-10", "SI-13"]` to `GATE_CONTROL_MAP`. +Add `ResilienceGate` to `ALL_GATES` and `__all__`. + +```bash +pytest tests/test_gates/test_resilience_gate.py -v +git add src/controlgate/gates/resilience_gate.py tests/test_gates/test_resilience_gate.py \ + src/controlgate/catalog.py src/controlgate/gates/__init__.py +git commit -m "feat: add Gate 12 — Resilience & Backup gate (resilience, CP-9/CP-10/SI-13)" +``` + +--- + +## Task 5: Incident Response Gate (`incident`) + +**Files:** +- Create: `src/controlgate/gates/incident_gate.py` +- Create: `tests/test_gates/test_incident_gate.py` +- Modify: `src/controlgate/catalog.py` +- Modify: `src/controlgate/gates/__init__.py` + +### Step 1: Write the failing test + +Create `tests/test_gates/test_incident_gate.py`: + +```python +"""Tests for the Incident Response Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.incident_gate import IncidentGate + + +@pytest.fixture +def gate(catalog): + return IncidentGate(catalog) + + +_SILENT_EXCEPT_DIFF = """\ +diff --git a/worker.py b/worker.py +--- a/worker.py ++++ b/worker.py +@@ -1,4 +1,6 @@ + def process(): ++ try: ++ do_work() ++ except: ++ pass +""" + +_EMPTY_CATCH_JS_DIFF = """\ +diff --git a/handler.js b/handler.js +--- /dev/null ++++ b/handler.js +@@ -0,0 +1,5 @@ ++async function handle() { ++ try { ++ await process(); ++ } catch(e) {} ++} +""" + +_TRACEBACK_DIFF = """\ +diff --git a/app.py b/app.py +--- /dev/null ++++ b/app.py +@@ -0,0 +1,4 @@ ++@app.errorhandler(500) ++def server_error(e): ++ traceback.print_exc() ++ return str(e), 500 +""" + +_NOTIFY_FALSE_DIFF = """\ +diff --git a/alerting.yaml b/alerting.yaml +--- /dev/null ++++ b/alerting.yaml +@@ -0,0 +1,3 @@ ++alerts: ++ notify: false ++ threshold: critical +""" + +_CLEAN_DIFF = """\ +diff --git a/worker.py b/worker.py +--- /dev/null ++++ b/worker.py +@@ -0,0 +1,6 @@ ++def process(): ++ try: ++ do_work() ++ except ValueError as e: ++ logger.error("Processing failed: %s", e) ++ raise +""" + + +class TestIncidentGate: + def test_detects_bare_except_pass(self, gate): + diff_files = parse_diff(_SILENT_EXCEPT_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("exception" in f.description.lower() or "silent" in f.description.lower() for f in findings) + + def test_detects_empty_catch_js(self, gate): + diff_files = parse_diff(_EMPTY_CATCH_JS_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_traceback_exposure(self, gate): + diff_files = parse_diff(_TRACEBACK_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_notify_false(self, gate): + diff_files = parse_diff(_NOTIFY_FALSE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_logged_exception_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_SILENT_EXCEPT_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "incident" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_SILENT_EXCEPT_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"IR-4", "IR-6", "AU-6"} + for f in findings: + assert f.control_id in valid_ids +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_gates/test_incident_gate.py -v +``` + +### Step 3: Implement `incident_gate.py` + +Create `src/controlgate/gates/incident_gate.py`: + +```python +"""Gate 13 — Incident Response Gate. + +Ensures code changes don't remove alerting, monitoring, or incident-handling +capability: silent exception swallowing, stack trace exposure, disabled notifications. + +NIST Controls: IR-4, IR-6, AU-6 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""^\s*except\s*:\s*$""", re.MULTILINE), + "Bare except clause silently swallows all exceptions — prevents incident detection", + "IR-4", + "Catch specific exceptions and log them; never use bare except: pass", + ), + ( + re.compile(r"""^\s*except.*:\s*\n\s*pass\s*$""", re.MULTILINE), + "Exception handler with only 'pass' — incident will be silently ignored", + "IR-4", + "Log the exception before passing; use logger.exception() to capture stack trace", + ), + ( + re.compile(r"""catch\s*\([^)]*\)\s*\{\s*\}"""), + "Empty catch block — exception swallowed silently in JS/TS/Java", + "IR-4", + "Log or rethrow exceptions; never leave catch blocks empty", + ), + ( + re.compile(r"""traceback\.print_exc\(\)|traceback\.format_exc\(\)"""), + "Stack trace exposed in response — leaks implementation details to attackers", + "IR-4", + "Log the traceback server-side only; return a generic error message to clients", + ), + ( + re.compile(r"""(?i)notify\s*[:=]\s*false|notifications.?enabled\s*[:=]\s*false"""), + "Alerting/notification disabled in monitoring configuration", + "IR-6", + "Enable notifications for all critical alerts; silence specific alerts rather than disabling all", + ), +] + + +class IncidentGate(BaseGate): + """Gate 13: Detect incident response capability removal.""" + + name = "Incident Response Gate" + gate_id = "incident" + mapped_control_ids = ["IR-4", "IR-6", "AU-6"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + # For multi-line patterns, scan the full added content + full_content = diff_file.full_content + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.flags & re.MULTILINE: + if pattern.search(full_content): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=1, + description=description, + evidence=full_content[:120], + remediation=remediation, + ) + ) + else: + for line_no, line in diff_file.all_added_lines: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings +``` + +### Step 4: Wire and commit + +Add `"incident": ["IR-4", "IR-6", "AU-6"]` to `GATE_CONTROL_MAP`. +Add `IncidentGate` to `ALL_GATES` and `__all__`. + +```bash +pytest tests/test_gates/test_incident_gate.py -v +git add src/controlgate/gates/incident_gate.py tests/test_gates/test_incident_gate.py \ + src/controlgate/catalog.py src/controlgate/gates/__init__.py +git commit -m "feat: add Gate 13 — Incident Response gate (incident, IR-4/IR-6/AU-6)" +``` + +--- + +## Task 6: Observability Gate (`observability`) + +**Files:** +- Create: `src/controlgate/gates/observability_gate.py` +- Create: `tests/test_gates/test_observability_gate.py` +- Modify: `src/controlgate/catalog.py` +- Modify: `src/controlgate/gates/__init__.py` + +### Step 1: Write the failing test + +Create `tests/test_gates/test_observability_gate.py`: + +```python +"""Tests for the Observability Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.observability_gate import ObservabilityGate + + +@pytest.fixture +def gate(catalog): + return ObservabilityGate(catalog) + + +_MONITORING_FALSE_DIFF = """\ +diff --git a/main.tf b/main.tf +--- /dev/null ++++ b/main.tf +@@ -0,0 +1,4 @@ ++resource "aws_db_instance" "prod" { ++ monitoring_interval = 0 ++ enable_monitoring = false ++} +""" + +_LOG_DRIVER_NONE_DIFF = """\ +diff --git a/docker-compose.yml b/docker-compose.yml +--- /dev/null ++++ b/docker-compose.yml +@@ -0,0 +1,5 @@ ++services: ++ app: ++ image: myapp:1.0 ++ logging: ++ driver: none +""" + +_K8S_NO_PROBE_DIFF = """\ +diff --git a/deployment.yaml b/deployment.yaml +--- /dev/null ++++ b/deployment.yaml +@@ -0,0 +1,8 @@ ++apiVersion: apps/v1 ++kind: Deployment ++spec: ++ template: ++ spec: ++ containers: ++ - name: app ++ image: myapp:1.0 +""" + +_CLEAN_DIFF = """\ +diff --git a/deployment.yaml b/deployment.yaml +--- /dev/null ++++ b/deployment.yaml +@@ -0,0 +1,10 @@ ++apiVersion: apps/v1 ++kind: Deployment ++spec: ++ template: ++ spec: ++ containers: ++ - name: app ++ image: myapp:1.0 ++ livenessProbe: ++ httpGet: {path: /health, port: 8080} +""" + + +class TestObservabilityGate: + def test_detects_monitoring_false(self, gate): + diff_files = parse_diff(_MONITORING_FALSE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("monitor" in f.description.lower() for f in findings) + + def test_detects_log_driver_none(self, gate): + diff_files = parse_diff(_LOG_DRIVER_NONE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_k8s_missing_liveness_probe(self, gate): + diff_files = parse_diff(_K8S_NO_PROBE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_k8s_with_liveness_probe_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_MONITORING_FALSE_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "observability" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_MONITORING_FALSE_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"SI-4", "AU-12"} + for f in findings: + assert f.control_id in valid_ids +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_gates/test_observability_gate.py -v +``` + +### Step 3: Implement `observability_gate.py` + +Create `src/controlgate/gates/observability_gate.py`: + +```python +"""Gate 14 — Observability Gate. + +Detects removal of metrics, health probes, and monitoring configuration — +distinct from the Audit gate which focuses on log content. + +NIST Controls: SI-4, AU-12 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""(?i)enable.?monitoring\s*[:=]\s*false|monitoring\s*[:=]\s*false"""), + "Monitoring disabled in infrastructure configuration", + "SI-4", + "Enable monitoring and set monitoring_interval > 0 for all production resources", + ), + ( + re.compile(r"""(?i)monitoring.?interval\s*[:=]\s*0"""), + "monitoring_interval = 0 disables enhanced monitoring", + "SI-4", + "Set monitoring_interval to 60 or higher for production database instances", + ), + ( + re.compile(r"""(?i)logging:\s*\n\s*driver:\s*none|log.?driver\s*[:=]\s*["\']?none"""), + "Container logging driver set to 'none' — all output is discarded", + "AU-12", + "Use a persistent logging driver (json-file, awslogs, fluentd) for all containers", + ), + ( + re.compile(r"""--log-driver=none"""), + "Container logging disabled via CLI flag", + "AU-12", + "Remove --log-driver=none; all container output must be captured for audit", + ), +] + +# Kubernetes deployment file pattern — needs separate handling +_K8S_FILE_PATTERN = re.compile(r"""(?i)(?:deployment|statefulset|daemonset).*\.ya?ml$""") +_LIVENESS_PROBE_PATTERN = re.compile(r"""livenessProbe""") + + +class ObservabilityGate(BaseGate): + """Gate 14: Detect removal of monitoring, health probes, and observability.""" + + name = "Observability Gate" + gate_id = "observability" + mapped_control_ids = ["SI-4", "AU-12"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + # Line-level pattern scan + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + + # Kubernetes workload: flag if new containers added without liveness probe + if diff_file.is_new and _K8S_FILE_PATTERN.search(diff_file.path): + full_content = diff_file.full_content + if "containers:" in full_content and not _LIVENESS_PROBE_PATTERN.search(full_content): + findings.append( + self._make_finding( + control_id="SI-4", + file=diff_file.path, + line=1, + description="Kubernetes workload added without a livenessProbe — failure will not be detected", + evidence=f"No livenessProbe found in {diff_file.path}", + remediation="Add a livenessProbe (httpGet, tcpSocket, or exec) to all container specs", + ) + ) + + return findings +``` + +### Step 4: Wire and commit + +Add `"observability": ["SI-4", "AU-12"]` to `GATE_CONTROL_MAP`. +Add `ObservabilityGate` to `ALL_GATES` and `__all__`. + +```bash +pytest tests/test_gates/test_observability_gate.py -v +git add src/controlgate/gates/observability_gate.py tests/test_gates/test_observability_gate.py \ + src/controlgate/catalog.py src/controlgate/gates/__init__.py +git commit -m "feat: add Gate 14 — Observability gate (observability, SI-4/AU-12)" +``` + +--- + +## Task 7: Memory Safety Gate (`memsafe`) + +**Files:** +- Create: `src/controlgate/gates/memsafe_gate.py` +- Create: `tests/test_gates/test_memsafe_gate.py` +- Modify: `src/controlgate/catalog.py` +- Modify: `src/controlgate/gates/__init__.py` + +### Step 1: Write the failing test + +Create `tests/test_gates/test_memsafe_gate.py`: + +```python +"""Tests for the Memory Safety Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.memsafe_gate import MemSafeGate + + +@pytest.fixture +def gate(catalog): + return MemSafeGate(catalog) + + +_EVAL_DYNAMIC_DIFF = """\ +diff --git a/app.py b/app.py +--- /dev/null ++++ b/app.py +@@ -0,0 +1,3 @@ ++def process(user_input): ++ result = eval(user_input) ++ return result +""" + +_EXEC_DYNAMIC_DIFF = """\ +diff --git a/template.py b/template.py +--- /dev/null ++++ b/template.py +@@ -0,0 +1,2 @@ ++code = f"x = {request.form['value']}" ++exec(code) +""" + +_UNSAFE_RUST_DIFF = """\ +diff --git a/src/lib.rs b/src/lib.rs +--- /dev/null ++++ b/src/lib.rs +@@ -0,0 +1,5 @@ ++pub fn read_ptr(ptr: *const u8) -> u8 { ++ unsafe { ++ *ptr ++ } ++} +""" + +_STRCPY_DIFF = """\ +diff --git a/handler.c b/handler.c +--- /dev/null ++++ b/handler.c +@@ -0,0 +1,4 @@ ++void copy_name(char *dest, char *src) { ++ strcpy(dest, src); ++} +""" + +_CLEAN_DIFF = """\ +diff --git a/app.py b/app.py +--- /dev/null ++++ b/app.py +@@ -0,0 +1,3 @@ ++import ast ++def process(user_input): ++ return ast.literal_eval(user_input) +""" + + +class TestMemSafeGate: + def test_detects_eval_dynamic(self, gate): + diff_files = parse_diff(_EVAL_DYNAMIC_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("eval" in f.description.lower() for f in findings) + + def test_detects_exec_dynamic(self, gate): + diff_files = parse_diff(_EXEC_DYNAMIC_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_unsafe_rust(self, gate): + diff_files = parse_diff(_UNSAFE_RUST_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_strcpy(self, gate): + diff_files = parse_diff(_STRCPY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_ast_literal_eval_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_EVAL_DYNAMIC_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "memsafe" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_EVAL_DYNAMIC_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"SI-16", "CM-7"} + for f in findings: + assert f.control_id in valid_ids +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_gates/test_memsafe_gate.py -v +``` + +### Step 3: Implement `memsafe_gate.py` + +Create `src/controlgate/gates/memsafe_gate.py`: + +```python +"""Gate 15 — Memory Safety Gate. + +Detects dynamic code execution, unsafe memory operations, and patterns +that historically lead to memory corruption and code injection. + +NIST Controls: SI-16, CM-7 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""(? list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings +``` + +### Step 4: Wire and commit + +Add `"memsafe": ["SI-16", "CM-7"]` to `GATE_CONTROL_MAP`. +Add `MemSafeGate` to `ALL_GATES` and `__all__`. + +```bash +pytest tests/test_gates/test_memsafe_gate.py -v +git add src/controlgate/gates/memsafe_gate.py tests/test_gates/test_memsafe_gate.py \ + src/controlgate/catalog.py src/controlgate/gates/__init__.py +git commit -m "feat: add Gate 15 — Memory Safety gate (memsafe, SI-16/CM-7)" +``` + +--- + +## Task 8: License Compliance Gate (`license`) + +**Files:** +- Create: `src/controlgate/gates/license_gate.py` +- Create: `tests/test_gates/test_license_gate.py` +- Modify: `src/controlgate/catalog.py` +- Modify: `src/controlgate/gates/__init__.py` + +### Step 1: Write the failing test + +Create `tests/test_gates/test_license_gate.py`: + +```python +"""Tests for the License Compliance Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.license_gate import LicenseGate + + +@pytest.fixture +def gate(catalog): + return LicenseGate(catalog) + + +_GPL_PIP_DIFF = """\ +diff --git a/requirements.txt b/requirements.txt +--- a/requirements.txt ++++ b/requirements.txt +@@ -1,2 +1,3 @@ + requests==2.31.0 ++gpl-licensed-lib==1.0.0 # GPL-3.0 +""" + +_AGPL_PACKAGE_JSON_DIFF = """\ +diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -1,5 +1,8 @@ + { + "dependencies": { ++ "some-agpl-package": "^1.0.0" + } + } +""" + +_SPDX_GPL_DIFF = """\ +diff --git a/src/vendor/lib.py b/src/vendor/lib.py +--- /dev/null ++++ b/src/vendor/lib.py +@@ -0,0 +1,2 @@ ++# SPDX-License-Identifier: GPL-3.0-only ++def helper(): pass +""" + +_MIT_CLEAN_DIFF = """\ +diff --git a/requirements.txt b/requirements.txt +--- a/requirements.txt ++++ b/requirements.txt +@@ -1,2 +1,3 @@ + requests==2.31.0 ++flask==3.0.0 # MIT +""" + + +class TestLicenseGate: + def test_detects_gpl_in_requirements(self, gate): + diff_files = parse_diff(_GPL_PIP_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("gpl" in f.description.lower() or "license" in f.description.lower() for f in findings) + + def test_detects_agpl_in_package_json(self, gate): + diff_files = parse_diff(_AGPL_PACKAGE_JSON_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_spdx_gpl_in_source(self, gate): + diff_files = parse_diff(_SPDX_GPL_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_mit_license_no_findings(self, gate): + diff_files = parse_diff(_MIT_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_GPL_PIP_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "license" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_GPL_PIP_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"SA-4", "SR-3"} + for f in findings: + assert f.control_id in valid_ids +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_gates/test_license_gate.py -v +``` + +### Step 3: Implement `license_gate.py` + +Create `src/controlgate/gates/license_gate.py`: + +```python +"""Gate 16 — License Compliance Gate. + +Prevents copyleft-licensed dependencies from entering proprietary codebases. +Detects GPL, AGPL, SSPL, and LGPL licenses in dependency manifests and source files. + +NIST Controls: SA-4, SR-3 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +# Copyleft license keywords in comments or SPDX identifiers +_COPYLEFT_PATTERN = re.compile( + r"""(?i)\b(?:GPL|AGPL|SSPL|LGPL|GNU\s+(?:General|Affero|Lesser))\b""" +) + +# Package manifest files to scan +_MANIFEST_FILES = re.compile( + r"""(?i)(?:requirements.*\.txt|package\.json|go\.mod|Cargo\.toml|Gemfile|composer\.json|setup\.cfg|pyproject\.toml)$""" +) + +# SPDX copyleft identifiers +_SPDX_COPYLEFT = re.compile( + r"""SPDX-License-Identifier:\s*(?:GPL|AGPL|SSPL|LGPL|EUPL|OSL|CDDL)""" +) + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + _COPYLEFT_PATTERN, + "Copyleft license (GPL/AGPL/SSPL/LGPL) detected in dependency manifest", + "SA-4", + "Review license compatibility; copyleft licenses may require open-sourcing your codebase", + ), + ( + _SPDX_COPYLEFT, + "SPDX copyleft license identifier in source file", + "SR-3", + "Audit this file's license; copyleft source may contaminate your proprietary codebase", + ), +] + + +class LicenseGate(BaseGate): + """Gate 16: Detect copyleft license compliance violations.""" + + name = "License Compliance Gate" + gate_id = "license" + mapped_control_ids = ["SA-4", "SR-3"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + is_manifest = bool(_MANIFEST_FILES.search(diff_file.path)) + for line_no, line in diff_file.all_added_lines: + # Only flag copyleft keywords in manifest files to reduce false positives + if is_manifest and _COPYLEFT_PATTERN.search(line): + findings.append( + self._make_finding( + control_id="SA-4", + file=diff_file.path, + line=line_no, + description="Copyleft license (GPL/AGPL/SSPL/LGPL) detected in dependency manifest", + evidence=line.strip()[:120], + remediation="Review license compatibility; copyleft licenses may require open-sourcing your codebase", + ) + ) + # SPDX identifiers in any source file + if _SPDX_COPYLEFT.search(line): + findings.append( + self._make_finding( + control_id="SR-3", + file=diff_file.path, + line=line_no, + description="SPDX copyleft license identifier in source file", + evidence=line.strip()[:120], + remediation="Audit this file's license; copyleft source may contaminate your proprietary codebase", + ) + ) + return findings +``` + +### Step 4: Wire and commit + +Add `"license": ["SA-4", "SR-3"]` to `GATE_CONTROL_MAP`. +Add `LicenseGate` to `ALL_GATES` and `__all__`. + +```bash +pytest tests/test_gates/test_license_gate.py -v +git add src/controlgate/gates/license_gate.py tests/test_gates/test_license_gate.py \ + src/controlgate/catalog.py src/controlgate/gates/__init__.py +git commit -m "feat: add Gate 16 — License Compliance gate (license, SA-4/SR-3)" +``` + +--- + +## Task 9: AI/ML Security Gate (`aiml`) + +**Files:** +- Create: `src/controlgate/gates/aiml_gate.py` +- Create: `tests/test_gates/test_aiml_gate.py` +- Modify: `src/controlgate/catalog.py` +- Modify: `src/controlgate/gates/__init__.py` + +### Step 1: Write the failing test + +Create `tests/test_gates/test_aiml_gate.py`: + +```python +"""Tests for the AI/ML Security Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.aiml_gate import AIMLGate + + +@pytest.fixture +def gate(catalog): + return AIMLGate(catalog) + + +_TRUST_REMOTE_CODE_DIFF = """\ +diff --git a/model.py b/model.py +--- /dev/null ++++ b/model.py +@@ -0,0 +1,3 @@ ++from transformers import AutoModelForCausalLM ++model = AutoModelForCausalLM.from_pretrained("some/model", trust_remote_code=True) +""" + +_PICKLE_LOAD_DIFF = """\ +diff --git a/inference.py b/inference.py +--- /dev/null ++++ b/inference.py +@@ -0,0 +1,4 @@ ++import pickle ++with open("model.pkl", "rb") as f: ++ model = pickle.load(f) +""" + +_HTTP_MODEL_DIFF = """\ +diff --git a/download.py b/download.py +--- /dev/null ++++ b/download.py +@@ -0,0 +1,3 @@ ++import urllib.request ++urllib.request.urlretrieve("http://models.example.com/weights.bin", "weights.bin") +""" + +_PROMPT_INJECTION_DIFF = """\ +diff --git a/llm.py b/llm.py +--- /dev/null ++++ b/llm.py +@@ -0,0 +1,4 @@ ++def query_llm(user_input): ++ prompt = f"Answer this: {user_input}" ++ return llm.complete(prompt) +""" + +_CLEAN_DIFF = """\ +diff --git a/model.py b/model.py +--- /dev/null ++++ b/model.py +@@ -0,0 +1,4 @@ ++import torch ++model = torch.load("model.pt", map_location="cpu") ++model.eval() +""" + + +class TestAIMLGate: + def test_detects_trust_remote_code(self, gate): + diff_files = parse_diff(_TRUST_REMOTE_CODE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("trust_remote_code" in f.description.lower() or "remote" in f.description.lower() for f in findings) + + def test_detects_pickle_load(self, gate): + diff_files = parse_diff(_PICKLE_LOAD_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_http_model_download(self, gate): + diff_files = parse_diff(_HTTP_MODEL_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_prompt_injection_pattern(self, gate): + diff_files = parse_diff(_PROMPT_INJECTION_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_clean_model_load_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_TRUST_REMOTE_CODE_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "aiml" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_TRUST_REMOTE_CODE_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"SI-10", "SC-28", "SR-3"} + for f in findings: + assert f.control_id in valid_ids +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_gates/test_aiml_gate.py -v +``` + +### Step 3: Implement `aiml_gate.py` + +Create `src/controlgate/gates/aiml_gate.py`: + +```python +"""Gate 17 — AI/ML Security Gate. + +Detects security risks specific to AI/ML codebases: prompt injection vectors, +unsafe model loading, remote code execution via trust_remote_code, and +insecure model transfer channels. + +NIST Controls: SI-10, SC-28, SR-3 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""trust_remote_code\s*=\s*True"""), + "trust_remote_code=True executes arbitrary code from a remote model repository", + "SR-3", + "Never use trust_remote_code=True; audit the model source and load from a vetted internal registry", + ), + ( + re.compile(r"""pickle\.load\s*\(|pickle\.loads\s*\("""), + "pickle.load() deserializes arbitrary Python objects — code execution on load", + "SI-10", + "Use safetensors or ONNX format instead of pickle; never load pickle files from untrusted sources", + ), + ( + re.compile(r"""joblib\.load\s*\("""), + "joblib.load() uses pickle internally — arbitrary code execution risk", + "SI-10", + "Verify the source and checksum of joblib files before loading; prefer safetensors", + ), + ( + re.compile(r"""http://[^\s]*(?:model|weight|checkpoint|\.bin|\.pt|\.pkl|\.onnx)"""), + "Model or weights downloaded over unencrypted HTTP", + "SR-3", + "Use HTTPS for all model downloads and verify checksums (SHA256) after download", + ), + ( + re.compile(r"""f["\'].*\{.*(?:user|request|input|query|prompt).*\}.*["\']"""), + "User input interpolated directly into LLM prompt — prompt injection risk", + "SI-10", + "Sanitize and validate user input before including in prompts; use structured message formats", + ), +] + + +class AIMLGate(BaseGate): + """Gate 17: Detect AI/ML-specific security vulnerabilities.""" + + name = "AI/ML Security Gate" + gate_id = "aiml" + mapped_control_ids = ["SI-10", "SC-28", "SR-3"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings +``` + +### Step 4: Wire and commit + +Add `"aiml": ["SI-10", "SC-28", "SR-3"]` to `GATE_CONTROL_MAP`. +Add `AIMLGate` to `ALL_GATES` and `__all__`. + +```bash +pytest tests/test_gates/test_aiml_gate.py -v +git add src/controlgate/gates/aiml_gate.py tests/test_gates/test_aiml_gate.py \ + src/controlgate/catalog.py src/controlgate/gates/__init__.py +git commit -m "feat: add Gate 17 — AI/ML Security gate (aiml, SI-10/SC-28/SR-3)" +``` + +--- + +## Task 10: Container Security Gate (`container`) + +**Files:** +- Create: `src/controlgate/gates/container_gate.py` +- Create: `tests/test_gates/test_container_gate.py` +- Modify: `src/controlgate/catalog.py` +- Modify: `src/controlgate/gates/__init__.py` + +### Step 1: Write the failing test + +Create `tests/test_gates/test_container_gate.py`: + +```python +"""Tests for the Container Security Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.container_gate import ContainerGate + + +@pytest.fixture +def gate(catalog): + return ContainerGate(catalog) + + +_ROOT_USER_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,3 @@ ++FROM python:3.11 ++USER root ++CMD ["python", "app.py"] +""" + +_PRIVILEGED_DIFF = """\ +diff --git a/docker-compose.yml b/docker-compose.yml +--- /dev/null ++++ b/docker-compose.yml +@@ -0,0 +1,5 @@ ++services: ++ app: ++ image: myapp:1.0 ++ privileged: true +""" + +_LATEST_TAG_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,2 @@ ++FROM nginx:latest ++EXPOSE 80 +""" + +_HOST_NETWORK_DIFF = """\ +diff --git a/deployment.yaml b/deployment.yaml +--- /dev/null ++++ b/deployment.yaml +@@ -0,0 +1,5 @@ ++spec: ++ template: ++ spec: ++ hostNetwork: true ++ containers: [] +""" + +_HOST_PID_DIFF = """\ +diff --git a/deployment.yaml b/deployment.yaml +--- /dev/null ++++ b/deployment.yaml +@@ -0,0 +1,4 @@ ++spec: ++ template: ++ spec: ++ hostPID: true +""" + +_LOG_DRIVER_NONE_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,2 @@ ++FROM python:3.11 +""" + +_CLEAN_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,5 @@ ++FROM python:3.11-slim@sha256:abc123 ++RUN groupadd -r app && useradd -r -g app app ++COPY . /app ++USER app ++CMD ["python", "app.py"] +""" + + +class TestContainerGate: + def test_detects_user_root(self, gate): + diff_files = parse_diff(_ROOT_USER_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("root" in f.description.lower() for f in findings) + + def test_detects_privileged_mode(self, gate): + diff_files = parse_diff(_PRIVILEGED_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_latest_tag(self, gate): + diff_files = parse_diff(_LATEST_TAG_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("latest" in f.description.lower() or "unpinned" in f.description.lower() for f in findings) + + def test_detects_host_network(self, gate): + diff_files = parse_diff(_HOST_NETWORK_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_host_pid(self, gate): + diff_files = parse_diff(_HOST_PID_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_clean_dockerfile_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_ROOT_USER_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "container" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_ROOT_USER_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"CM-6", "CM-7", "SC-7", "SC-39", "AC-6", "SI-7", "AU-12", "SA-10", "SR-3"} + for f in findings: + assert f.control_id in valid_ids +``` + +### Step 2: Run test to verify it fails + +```bash +pytest tests/test_gates/test_container_gate.py -v +``` + +### Step 3: Implement `container_gate.py` + +Create `src/controlgate/gates/container_gate.py`: + +```python +"""Gate 18 — Container Security Gate. + +Detects container and Kubernetes misconfigurations across five security domains: +image integrity, least privilege, network isolation, runtime hardening, and audit. + +NIST Controls: CM-6, CM-7, SC-7, SC-39, AC-6, SI-7, AU-12, SA-10, SR-3 + +Note: Each pattern uses a single primary control ID (most directly relevant). +Secondary controls are noted in remediation text. +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +# ── IMAGE INTEGRITY (primary: SI-7) ────────────────────────────────────────── +_IMAGE_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""FROM\s+\S+:latest"""), + "Unpinned :latest tag — image may change between builds, breaking reproducibility", + "SI-7", + "Pin to a specific version tag or use a digest: FROM python@sha256:", + ), + ( + re.compile(r"""^FROM\s+[^\s@:]+\s*$""", re.MULTILINE), + "Base image has no tag — always pin to a specific digest or version", + "SI-7", + "Add a version tag or SHA256 digest: FROM python:3.11-slim@sha256:", + ), + ( + re.compile(r"""ADD\s+https?://"""), + "Remote ADD fetches content at build time without checksum verification", + "SI-7", + "Use RUN curl ... | sha256sum -c and COPY instead of ADD with remote URLs", + ), +] + +# ── LEAST PRIVILEGE (primary: AC-6) ────────────────────────────────────────── +_PRIVILEGE_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""USER\s+root"""), + "Container explicitly set to run as root — violates least privilege", + "AC-6", + "Create a dedicated non-root user: RUN useradd -r app && USER app", + ), + ( + re.compile(r"""(?i)privileged:\s*true|--privileged"""), + "Privileged container grants full host access — enables container escape", + "AC-6", + "Remove privileged: true; grant only specific capabilities if needed", + ), + ( + re.compile(r"""--cap-add\s+ALL"""), + "ALL Linux capabilities granted — equivalent to running as root", + "AC-6", + "Enumerate only the specific capabilities required (e.g. --cap-add NET_BIND_SERVICE)", + ), + ( + re.compile(r"""--cap-add\s+(?:SYS_ADMIN|SYS_PTRACE|NET_ADMIN)"""), + "High-risk Linux capability granted — can lead to host privilege escalation", + "AC-6", + "Audit whether this capability is truly needed; prefer dropping all caps and adding selectively", + ), + ( + re.compile(r"""allowPrivilegeEscalation:\s*true"""), + "allowPrivilegeEscalation: true permits setuid/setgid escalation inside the container", + "AC-6", + "Set allowPrivilegeEscalation: false in securityContext", + ), + ( + re.compile(r"""runAsNonRoot:\s*false"""), + "runAsNonRoot: false explicitly permits the container to run as root", + "AC-6", + "Set runAsNonRoot: true and specify runAsUser with a non-zero UID", + ), +] + +# ── NETWORK ISOLATION (primary: SC-7) ──────────────────────────────────────── +_NETWORK_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""hostNetwork:\s*true"""), + "hostNetwork: true exposes the container on the host network namespace", + "SC-7", + "Use ClusterIP or NodePort Services; avoid sharing the host network namespace", + ), + ( + re.compile(r"""hostPort:\s*\d+"""), + "hostPort bypasses Kubernetes NetworkPolicy — use Service resources instead", + "SC-7", + "Replace hostPort with a Kubernetes Service of type NodePort or LoadBalancer", + ), +] + +# ── RUNTIME HARDENING (primary: SC-39) ─────────────────────────────────────── +_RUNTIME_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""readOnlyRootFilesystem:\s*false"""), + "Writable root filesystem — allows attacker to modify container files", + "SC-39", + "Set readOnlyRootFilesystem: true and use emptyDir/PVC mounts for writable paths", + ), + ( + re.compile(r"""hostPID:\s*true"""), + "hostPID: true shares the host process namespace — enables container escape vectors", + "SC-39", + "Remove hostPID: true; process isolation must be maintained", + ), + ( + re.compile(r"""hostIPC:\s*true"""), + "hostIPC: true shares host IPC namespace — allows cross-container memory access", + "SC-39", + "Remove hostIPC: true; IPC namespace isolation must be maintained", + ), + ( + re.compile(r"""seccompProfile.*Unconfined|seccomp.*unconfined""", re.IGNORECASE), + "Seccomp profile set to Unconfined — all syscalls permitted", + "SC-39", + "Use RuntimeDefault seccomp profile or create a custom restricted profile", + ), +] + +# ── AUDIT (primary: AU-12) ──────────────────────────────────────────────────── +_AUDIT_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""log.?driver.*none|logging:\s*\n\s*driver:\s*none""", re.IGNORECASE), + "Container logging driver set to 'none' — all container output is discarded", + "AU-12", + "Use a persistent logging driver (json-file, awslogs, fluentd, splunk)", + ), + ( + re.compile(r"""--log-driver=none"""), + "Container logging disabled via CLI flag — cannot audit container activity", + "AU-12", + "Remove --log-driver=none; use a centralised logging destination", + ), +] + +# ── RESOURCE LIMITS (primary: CM-6) ────────────────────────────────────────── +_RESOURCE_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""resources:\s*\{\}"""), + "Empty resources block — no CPU/memory limits set; enables denial-of-service", + "CM-6", + "Set explicit resources.requests and resources.limits for CPU and memory", + ), + ( + re.compile(r"""--memory[= ]["\']?-1"""), + "Unlimited container memory allocation — no ceiling for memory consumption", + "CM-6", + "Set an explicit --memory limit (e.g. --memory=512m)", + ), +] + +_ALL_PATTERN_GROUPS = [ + _IMAGE_PATTERNS, + _PRIVILEGE_PATTERNS, + _NETWORK_PATTERNS, + _RUNTIME_PATTERNS, + _AUDIT_PATTERNS, + _RESOURCE_PATTERNS, +] + + +class ContainerGate(BaseGate): + """Gate 18: Detect container and Kubernetes security misconfigurations.""" + + name = "Container Security Gate" + gate_id = "container" + mapped_control_ids = ["CM-6", "CM-7", "SC-7", "SC-39", "AC-6", "SI-7", "AU-12", "SA-10", "SR-3"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern_group in _ALL_PATTERN_GROUPS: + for pattern, description, control_id, remediation in pattern_group: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings +``` + +### Step 4: Wire and commit + +Add to `GATE_CONTROL_MAP`: +```python + "container": ["CM-6", "CM-7", "SC-7", "SC-39", "AC-6", "SI-7", "AU-12", "SA-10", "SR-3"], +``` + +Add `ContainerGate` to `ALL_GATES` and `__all__`. + +```bash +pytest tests/test_gates/test_container_gate.py -v +git add src/controlgate/gates/container_gate.py tests/test_gates/test_container_gate.py \ + src/controlgate/catalog.py src/controlgate/gates/__init__.py +git commit -m "feat: add Gate 18 — Container Security gate (container, CM-7/SC-7/SC-39/AC-6/SI-7)" +``` + +--- + +## Task 11: Full Test Suite Verification + +### Step 1: Run the complete test suite + +```bash +pytest tests/ -v --tb=short +``` + +Expected: All tests pass, including the existing 8 gate tests and 10 new gate tests. + +### Step 2: Verify gate count + +```bash +python -c "from controlgate.gates import ALL_GATES; print(f'{len(ALL_GATES)} gates loaded'); [print(f' {g.gate_id}') for g in ALL_GATES]" +``` + +Expected output: +``` +18 gates loaded + secrets + crypto + iam + sbom + iac + input_validation + audit + change_control + deps + api + privacy + resilience + incident + observability + memsafe + license + aiml + container +``` + +### Step 3: Final commit + +```bash +git commit --allow-empty -m "chore: all 18 gates implemented and passing" +``` + +--- + +## Checklist + +- [ ] Task 1: DepsGate (deps) — RA-5, SI-2, SA-12 +- [ ] Task 2: APIGate (api) — SC-8, AC-3, SC-5, SI-10 +- [ ] Task 3: PrivacyGate (privacy) — PT-2, PT-3, SC-28 +- [ ] Task 4: ResilienceGate (resilience) — CP-9, CP-10, SI-13 +- [ ] Task 5: IncidentGate (incident) — IR-4, IR-6, AU-6 +- [ ] Task 6: ObservabilityGate (observability) — SI-4, AU-12 +- [ ] Task 7: MemSafeGate (memsafe) — SI-16, CM-7 +- [ ] Task 8: LicenseGate (license) — SA-4, SR-3 +- [ ] Task 9: AIMLGate (aiml) — SI-10, SC-28, SR-3 +- [ ] Task 10: ContainerGate (container) — CM-6, CM-7, SC-7, SC-39, AC-6, SI-7, AU-12 +- [ ] Task 11: Full test suite passes diff --git a/docs/plans/2026-02-27-gates-gap-analysis.md b/docs/plans/2026-02-27-gates-gap-analysis.md new file mode 100644 index 0000000..d93d9fe --- /dev/null +++ b/docs/plans/2026-02-27-gates-gap-analysis.md @@ -0,0 +1,92 @@ +# Gates 9–18 Gap Analysis + +**Date:** 2026-02-27 +**Status:** Complete — Approach A fixes applied 2026-02-27 +**Reviewer:** Design review against `docs/plans/2026-02-27-new-gates-design.md` + +--- + +## Summary + +All 10 gates are implemented and 223 tests pass. This document records the delta between the approved design and the current implementation, and the decision on how to resolve each gap. + +--- + +## Bugs to Fix (Approach A) + +### 1. APIGate — Missing SC-5 and SI-10 in control map + +**File:** `src/controlgate/catalog.py` and `src/controlgate/gates/api_gate.py` + +| Location | Design | Actual | +|---|---|---| +| `GATE_CONTROL_MAP["api"]` | `["SC-8", "AC-3", "SC-5", "SI-10"]` | `["SC-8", "AC-3"]` | +| `APIGate.mapped_control_ids` | `["SC-8", "AC-3", "SC-5", "SI-10"]` | `["SC-8", "AC-3"]` | + +**Fix:** Add `"SC-5"` and `"SI-10"` to both. + +--- + +### 2. MemSafeGate — Missing `cffi.` memory patterns + +**File:** `src/controlgate/gates/memsafe_gate.py` + +Design spec: _"`ctypes.` / `cffi.` direct memory function calls`"_ + +Current implementation only covers `ctypes.*` — `cffi` raw buffer/cast calls are not detected. + +**Fix:** Add pattern: +```python +re.compile(r"""cffi\.(?:cast|buffer|from_buffer|memmove)""") +``` +Control: `SI-16` + +--- + +### 3. AIMLGate — Missing SC-28 plaintext model weights pattern + +**File:** `src/controlgate/gates/aiml_gate.py` + +Design spec: _"Model weights in plaintext config fields"_ → `SC-28` + +Current implementation has no pattern for detecting model weights/checkpoint paths stored in plaintext config (e.g. `model_path = "weights.bin"`, `checkpoint = "/models/prod.pt"`). + +**Fix:** Add pattern: +```python +re.compile(r"""(?i)(?:model_path|weights_path|checkpoint_path|model_weights)\s*=\s*["\'][^"\']+["\']""") +``` +Control: `SC-28` + +--- + +## Known Debt (Requires Gate Model Extension) + +These patterns from the design require scanning **removed lines** or performing **cross-file analysis** — neither is supported by the current `BaseGate.scan()` model which only iterates `all_added_lines`. Deferred to a future gate model enhancement. + +| Gate | Design Pattern | Control | Blocker | +|---|---|---|---| +| `deps` (Gate 9) | Manifest changed without lockfile | `SR-3` | Cross-file analysis: check if `requirements.txt`/`package.json` appears in diff but no `.lock` file does | +| `resilience` (Gate 12) | DB connection without `connect_timeout` | `CP-10` | Absence/negative detection: flag if a DB URL appears without a timeout param | +| `observability` (Gate 14) | DLQ resource deleted | `AU-12` | Requires scanning removed lines (`all_removed_lines`) | +| `license` (Gate 16) | License header stripped | `SR-3` | Requires scanning removed lines (`all_removed_lines`) | + +**Future work:** Add `all_removed_lines` property to `DiffFile` and a `scan_removed` hook to `BaseGate` to enable removal-aware gates. + +--- + +## No-Gap Gates + +The following gates fully match the design spec: + +| Gate | Status | +|---|---| +| Gate 9 — DepsGate | ✅ All added-line patterns implemented | +| Gate 10 — APIGate | ⚠️ SC-5/SI-10 missing from map (tracked above) | +| Gate 11 — PrivacyGate | ✅ | +| Gate 12 — ResilienceGate | ✅ All added-line patterns; CP-10 timeout detection deferred | +| Gate 13 — IncidentGate | ✅ | +| Gate 14 — ObservabilityGate | ✅ All added-line patterns; DLQ deletion deferred | +| Gate 15 — MemSafeGate | ⚠️ `cffi.` pattern missing (tracked above) | +| Gate 16 — LicenseGate | ✅ All added-line patterns; removal detection deferred | +| Gate 17 — AIMLGate | ⚠️ SC-28 plaintext weights pattern missing (tracked above) | +| Gate 18 — ContainerGate | ✅ All pattern groups fully implemented | diff --git a/docs/plans/2026-02-27-new-gates-design.md b/docs/plans/2026-02-27-new-gates-design.md new file mode 100644 index 0000000..77a8ea6 --- /dev/null +++ b/docs/plans/2026-02-27-new-gates-design.md @@ -0,0 +1,262 @@ +# ControlGate — New Gates Design (Gates 9–18) + +**Date:** 2026-02-27 +**Status:** Approved +**Approach:** Flat — one file per gate, consistent with existing 8 gates + +--- + +## Summary + +Add 10 new security gates to ControlGate, expanding coverage from 8 to 18 gates and closing major NIST SP 800-53 Rev. 5 gaps in the RA, CP, IR, PT, SI-16, SA-4, SC-39, and ML-specific control families. + +--- + +## Coverage Map — Before and After + +### Existing 8 Gates +| Gate | gate_id | NIST Families | +|---|---|---| +| Secrets | `secrets` | IA, SC | +| Crypto | `crypto` | SC | +| IAM | `iam` | AC | +| Supply Chain | `sbom` | SR, SA | +| IaC | `iac` | CM, SC | +| Input Validation | `input_validation` | SI | +| Audit | `audit` | AU | +| Change Control | `change_control` | CM | + +### New 10 Gates +| Gate | gate_id | NIST Families | Priority | +|---|---|---|---| +| Dependency Vulnerability | `deps` | RA, SI, SA | 🔴 High | +| API Security | `api` | SC, AC, SI | 🔴 High | +| Data Privacy | `privacy` | PT, SC | 🔴 High | +| Resilience & Backup | `resilience` | CP, SI | 🟡 Medium | +| Incident Response | `incident` | IR, AU | 🟡 Medium | +| Observability | `observability` | SI, AU | 🟡 Medium | +| Memory Safety | `memsafe` | SI, CM | 🟢 Later | +| License Compliance | `license` | SA, SR | 🟢 Later | +| AI/ML Security | `aiml` | SI, SC, SR | 🟢 Later | +| Container Security | `container` | CM, SC, AC, SI, AU, SR | 🔴 High | + +--- + +## Architecture + +No changes to `engine.py`, `base.py`, `models.py`, or any reporter. Each gate is a standalone file following the identical contract: + +``` +src/controlgate/gates/ +├── deps_gate.py # Gate 9 — RA-5, SI-2, SA-12 +├── api_gate.py # Gate 10 — SC-8, AC-3, SC-5, SI-10 +├── privacy_gate.py # Gate 11 — PT-2, PT-3, SC-28 +├── resilience_gate.py # Gate 12 — CP-9, CP-10, SI-13 +├── incident_gate.py # Gate 13 — IR-4, IR-6, AU-6 +├── observability_gate.py # Gate 14 — SI-4, AU-12 +├── memsafe_gate.py # Gate 15 — SI-16, CM-7 +├── license_gate.py # Gate 16 — SA-4, SR-3 +├── aiml_gate.py # Gate 17 — SI-10, SC-28, SR-3 +└── container_gate.py # Gate 18 — CM-6, CM-7, SC-7, SC-39, AC-6, SI-7, AU-12, SA-10, SR-3 +``` + +Two supporting changes: +1. `catalog.py` — add 10 new entries to `GATE_CONTROL_MAP` +2. `gates/__init__.py` — add 10 new classes to `ALL_GATES` and `__all__` + +--- + +## Multi-Control-ID Decision + +The ContainerGate spec groups patterns by category with multiple control IDs per pattern. Since `_make_finding()` takes a single `control_id`, each pattern uses its **primary** (most directly relevant) control ID: + +| Pattern group | Primary control ID | +|---|---| +| Image integrity | `SI-7` | +| Least privilege | `AC-6` | +| Network isolation | `SC-7` | +| Runtime hardening | `SC-39` | +| Audit/logging | `AU-12` | +| Resource limits | `CM-6` | + +--- + +## Gate Designs + +### Gate 9 — Dependency Vulnerability (`deps`) +**NIST:** RA-5, SI-2, SA-12 +**Scope:** Static pattern detection only. Live CVE lookup requires a network call outside the gate model and is out of scope. + +| Pattern | Description | Control | Severity | +|---|---|---|---| +| `--no-verify` flag in pip/npm | Bypasses integrity checks | `SA-12` | HIGH | +| `--ignore-scripts` in npm | Skips postinstall security scripts | `SA-12` | MEDIUM | +| `pip install` without pinned version (no `==`) | Unpinned install at runtime | `RA-5` | HIGH | +| `http://` in package registry URLs | Unencrypted package fetch | `SI-2` | HIGH | +| File: `requirements.txt`/`package.json` changed without lockfile | Dependency change without audit trail | `SR-3` borrow | MEDIUM | + +### Gate 10 — API Security (`api`) +**NIST:** SC-8, AC-3, SC-5, SI-10 + +| Pattern | Description | Control | Severity | +|---|---|---|---| +| `verify=False` in requests/urllib | TLS verification disabled | `SC-8` | CRITICAL | +| `CORS_ORIGIN_ALLOW_ALL = True` / `allow_all_origins = True` | Wildcard CORS origin | `AC-3` | HIGH | +| `Access-Control-Allow-Origin: *` + `Credentials: true` | Credentialed wildcard CORS | `AC-3` | CRITICAL | +| `?api_key=` / `?token=` in URL construction | API key in query param | `SC-8` | HIGH | +| `GraphQL introspection` enabled outside dev | Schema exposure | `AC-3` | MEDIUM | + +### Gate 11 — Data Privacy (`privacy`) +**NIST:** PT-2, PT-3, SC-28 +**Note:** MP-6 (media sanitization) has no applicable code patterns and is excluded. + +| Pattern | Description | Control | Severity | +|---|---|---|---| +| `logging.*` / `print()` containing PII field names (ssn, social_security, date_of_birth, credit_card) | PII in logs | `PT-3` | HIGH | +| `serialize_all_fields = True` | Data over-exposure in API | `PT-2` | HIGH | +| `ttl: 0` / `expires_at: null` in data models | Missing retention policy | `SC-28` | MEDIUM | +| PII field names (`ssn`, `dob`, `credit_card`) in unencrypted model fields | PII stored without encryption marker | `SC-28` | CRITICAL | + +### Gate 12 — Resilience & Backup (`resilience`) +**NIST:** CP-9, CP-10, SI-13 + +| Pattern | Description | Control | Severity | +|---|---|---|---| +| `deletion_protection: false` / `backup: false` | Backup protection disabled | `CP-9` | CRITICAL | +| `skip_final_snapshot = true` | No final snapshot on delete | `CP-9` | HIGH | +| `max_retries = 0` / `max_retries: 0` | Retry disabled | `SI-13` | HIGH | +| DB connection with no timeout (`connect_timeout` absent) | No connection timeout | `CP-10` | MEDIUM | + +### Gate 13 — Incident Response (`incident`) +**NIST:** IR-4, IR-6, AU-6 + +| Pattern | Description | Control | Severity | +|---|---|---|---| +| `except:` with no body or just `pass` | Silent exception swallowing | `IR-4` | HIGH | +| `catch (e) {}` / `catch(e) {}` in JS/TS | Silent exception swallowing | `IR-4` | HIGH | +| `traceback.print_exc()` in non-test files | Stack trace exposed | `IR-4` | HIGH | +| `notify: false` / `notifications_enabled: false` | Alerting disabled | `IR-6` | MEDIUM | + +### Gate 14 — Observability (`observability`) +**NIST:** SI-4, AU-12 + +| Pattern | Description | Control | Severity | +|---|---|---|---| +| `livenessProbe` absent from K8s pod spec additions | No liveness probe | `SI-4` | HIGH | +| `monitoring: false` / `enable_monitoring = false` | Monitoring disabled | `SI-4` | HIGH | +| `logging_driver: none` / `--log-driver=none` | Container logging disabled | `AU-12` | HIGH | +| DLQ resource deleted from diff | Dead letter queue removed | `AU-12` | HIGH | + +### Gate 15 — Memory Safety (`memsafe`) +**NIST:** SI-16, CM-7 + +| Pattern | Description | Control | Severity | +|---|---|---|---| +| `eval(` / `exec(` with non-literal argument | Dynamic code execution | `SI-16` | CRITICAL | +| `unsafe {` in Rust (no safety comment on same/prev line) | Unsafe Rust block | `CM-7` | HIGH | +| `ctypes.` / `cffi.` direct memory function calls | Raw memory access | `SI-16` | MEDIUM | +| `strcpy(` / `memcpy(` in C diffs | Unbounded copy functions | `SI-16` | HIGH | + +### Gate 16 — License Compliance (`license`) +**NIST:** SA-4, SR-3 + +| Pattern | Description | Control | Severity | +|---|---|---|---| +| GPL/AGPL/SSPL/LGPL in package manifests | Copyleft license in dep | `SA-4` | HIGH | +| SPDX copyleft identifiers in added source files | Copyleft in source | `SA-4` | HIGH | +| License header removed from diff (detected line removal) | License stripped | `SR-3` | MEDIUM | + +### Gate 17 — AI/ML Security (`aiml`) +**NIST:** SI-10, SC-28, SR-3 + +| Pattern | Description | Control | Severity | +|---|---|---|---| +| `trust_remote_code=True` | Executes arbitrary remote code | `SR-3` | CRITICAL | +| `pickle.load(` / `pickle.loads(` | Arbitrary code exec on load | `SI-10` | CRITICAL | +| Model URL via `http://` (not `https://`) | Unencrypted model fetch | `SR-3` | HIGH | +| LLM prompt built with f-string from user input | Prompt injection vector | `SI-10` | CRITICAL | +| Model weights in plaintext config fields | Model stored unencrypted | `SC-28` | HIGH | + +### Gate 18 — Container Security (`container`) +**NIST:** CM-6, CM-7, SC-7, SC-39, AC-6, SI-7, AU-12, SA-10, SR-3 + +Patterns organized by group (primary control ID per pattern): + +**Image Integrity (→ `SI-7`)** +- `FROM :latest` — unpinned latest tag (HIGH) +- `FROM ` with no tag — untagged base image (HIGH) +- `ADD https?://` — remote ADD without verification (HIGH) + +**Least Privilege (→ `AC-6`)** +- `USER root` — container runs as root (CRITICAL) +- `--privileged` flag — full host access (CRITICAL) +- `--cap-add ALL` / `--cap-add SYS_ADMIN` — dangerous capabilities (CRITICAL/HIGH) +- `allowPrivilegeEscalation: true` — escalation permitted (HIGH) +- `runAsNonRoot: false` — root explicitly allowed (HIGH) + +**Network Isolation (→ `SC-7`)** +- `hostNetwork: true` — host network namespace (HIGH) +- `hostPort: ` — host port binding (MEDIUM) + +**Runtime Hardening (→ `SC-39`)** +- `readOnlyRootFilesystem: false` — writable root FS (HIGH) +- `hostPID: true` — host process namespace (CRITICAL) +- `hostIPC: true` — host IPC namespace (CRITICAL) +- `seccompProfile.*Unconfined` — no seccomp (HIGH) + +**Audit (→ `AU-12`)** +- `log_driver.*none` / `--log-driver=none` — logging disabled (HIGH) + +**Resource Limits (→ `CM-6`)** +- `resources: {}` — no resource limits (MEDIUM) +- `--memory.*-1` — unlimited memory (MEDIUM) + +--- + +## Supporting Changes + +### `catalog.py` — `GATE_CONTROL_MAP` additions +```python +"deps": ["RA-5", "SI-2", "SA-12"], +"api": ["SC-8", "AC-3", "SC-5", "SI-10"], +"privacy": ["PT-2", "PT-3", "SC-28"], +"resilience": ["CP-9", "CP-10", "SI-13"], +"incident": ["IR-4", "IR-6", "AU-6"], +"observability": ["SI-4", "AU-12"], +"memsafe": ["SI-16", "CM-7"], +"license": ["SA-4", "SR-3"], +"aiml": ["SI-10", "SC-28", "SR-3"], +"container": ["CM-6", "CM-7", "SC-7", "SC-39", "AC-6", "SI-7", "AU-12", "SA-10", "SR-3"], +``` + +### `gates/__init__.py` — `ALL_GATES` additions +```python +from controlgate.gates.deps_gate import DepsGate +from controlgate.gates.api_gate import APIGate +from controlgate.gates.privacy_gate import PrivacyGate +from controlgate.gates.resilience_gate import ResilienceGate +from controlgate.gates.incident_gate import IncidentGate +from controlgate.gates.observability_gate import ObservabilityGate +from controlgate.gates.memsafe_gate import MemSafeGate +from controlgate.gates.license_gate import LicenseGate +from controlgate.gates.aiml_gate import AIMLGate +from controlgate.gates.container_gate import ContainerGate +``` + +--- + +## Testing Strategy + +Each gate gets a `tests/test_gates/test__gate.py` file following the existing pattern: +- One positive test per major pattern group (detects the violation) +- One negative test (clean code returns no findings) +- One test asserting `gate_id` on all findings +- One test asserting `control_id` is from the gate's `mapped_control_ids` + +--- + +## Out of Scope + +- Live CVE lookup for `deps` gate (requires network call) +- Enhancement control IDs like `AC-6(1)`, `CM-7(2)` as primary — these are noted in remediation text only, since catalog lookup falls back gracefully if the enhancement ID isn't present +- Changes to engine, reporters, models, or config system diff --git a/docs/plans/2026-02-27-per-gate-docs-design.md b/docs/plans/2026-02-27-per-gate-docs-design.md new file mode 100644 index 0000000..0801b6a --- /dev/null +++ b/docs/plans/2026-02-27-per-gate-docs-design.md @@ -0,0 +1,185 @@ +# Per-Gate Documentation Design + +**Date:** 2026-02-27 +**Status:** Approved +**Scope:** Reverse-engineer all 18 gates into individual design + implementation docs + +--- + +## Goal + +Create a self-contained documentation folder for each of the 18 security gates. Each folder contains two files targeting different audiences: + +- `design.md` — compliance/security reviewers: what the gate detects, why it matters, NIST control mapping, known limitations +- `implementation.md` — developers: source file, class, patterns table, special logic, known debt, test coverage + +This is a pure documentation effort — no code changes. Existing files in `docs/plans/` are left as-is (historical record). + +--- + +## Folder Structure + +``` +docs/gates/ +├── gate-01-secrets/ +│ ├── design.md +│ └── implementation.md +├── gate-02-crypto/ +│ ├── design.md +│ └── implementation.md +├── gate-03-iam/ +├── gate-04-sbom/ +├── gate-05-iac/ +├── gate-06-input-validation/ +├── gate-07-audit/ +├── gate-08-change-control/ +├── gate-09-deps/ +├── gate-10-api/ +├── gate-11-privacy/ +├── gate-12-resilience/ +├── gate-13-incident/ +├── gate-14-observability/ +├── gate-15-memsafe/ +├── gate-16-license/ +├── gate-17-aiml/ +└── gate-18-container/ + ├── design.md + └── implementation.md +``` + +Folder names use zero-padded gate number + `gate_id` for consistent sort order. + +--- + +## Gate Index + +| Folder | Gate Name | Source File | gate_id | NIST Controls | +|---|---|---|---|---| +| `gate-01-secrets` | Secrets & Credential Gate | `secrets_gate.py` | `secrets` | IA-5, IA-6, SC-12, SC-28 | +| `gate-02-crypto` | Cryptography Gate | `crypto_gate.py` | `crypto` | SC-8, SC-13, SC-17, SC-23 | +| `gate-03-iam` | IAM Gate | `iam_gate.py` | `iam` | AC-3, AC-4, AC-5, AC-6 | +| `gate-04-sbom` | Supply Chain / SBOM Gate | `sbom_gate.py` | `sbom` | SR-3, SR-11, SA-10, SA-11 | +| `gate-05-iac` | Infrastructure-as-Code Gate | `iac_gate.py` | `iac` | CM-2, CM-6, CM-7, SC-7 | +| `gate-06-input-validation` | Input Validation Gate | `input_gate.py` | `input_validation` | SI-7, SI-10, SI-11, SI-16 | +| `gate-07-audit` | Audit Logging Gate | `audit_gate.py` | `audit` | AU-2, AU-3, AU-12 | +| `gate-08-change-control` | Change Control Gate | `change_gate.py` | `change_control` | CM-3, CM-4, CM-5 | +| `gate-09-deps` | Dependency Vulnerability Gate | `deps_gate.py` | `deps` | RA-5, SI-2, SA-12 | +| `gate-10-api` | API Security Gate | `api_gate.py` | `api` | SC-8, AC-3, SC-5, SI-10 | +| `gate-11-privacy` | Data Privacy Gate | `privacy_gate.py` | `privacy` | PT-2, PT-3, SC-28 | +| `gate-12-resilience` | Resilience & Backup Gate | `resilience_gate.py` | `resilience` | CP-9, CP-10, SI-13 | +| `gate-13-incident` | Incident Response Gate | `incident_gate.py` | `incident` | IR-4, IR-6, AU-6 | +| `gate-14-observability` | Observability Gate | `observability_gate.py` | `observability` | SI-4, AU-12 | +| `gate-15-memsafe` | Memory Safety Gate | `memsafe_gate.py` | `memsafe` | SI-16, CM-7 | +| `gate-16-license` | License Compliance Gate | `license_gate.py` | `license` | SA-4, SR-3 | +| `gate-17-aiml` | AI/ML Security Gate | `aiml_gate.py` | `aiml` | SI-10, SC-28, SR-3 | +| `gate-18-container` | Container Security Gate | `container_gate.py` | `container` | CM-6, CM-7, SC-7, SC-39, AC-6, SI-7, AU-12, SA-10, SR-3 | + +--- + +## `design.md` Template + +```markdown +# Gate N — + +**gate_id:** `` +**NIST Controls:** +**Priority:** 🔴 High / 🟡 Medium / 🟢 Later + +--- + +## Purpose + +One paragraph: what threat or compliance gap this gate addresses, and why it matters. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| | | | + +--- + +## Scope + +- **Scans:** added lines in git diffs +- **File types targeted:** all / specific +- **Special detection:** entropy analysis / file-type flags / other (if applicable) + +--- + +## Known Limitations + +- Does not scan deleted/removed lines +- Does not perform cross-file analysis +- + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| | | <one sentence> | +``` + +--- + +## `implementation.md` Template + +```markdown +# Gate N — <Gate Name>: Implementation Reference + +**Source file:** `src/controlgate/gates/<gate_id>_gate.py` +**Test file:** `tests/test_gates/test_<gate_id>_gate.py` +**Class:** `<ClassName>` +**gate_id:** `<gate_id>` +**mapped_control_ids:** `["X-1", "X-2"]` + +--- + +## Scan Method + +Description of how `scan()` works — standard pattern loop vs. custom logic. + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `<regex>` | <description> | <ID> | <remediation> | + +--- + +## Special Detection Logic + +(Omit section if gate uses only the standard pattern loop.) + +--- + +## Known Debt / Deferred Patterns + +(Omit section if no deferred patterns.) + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_<name>` | <description> | +``` + +--- + +## Authoring Notes + +- Write `design.md` from the code, not from prior plan docs — the code is the source of truth +- `implementation.md` patterns table is extracted verbatim from `_PATTERNS` in the source file +- For gates with multiple pattern groups (ContainerGate), use sub-sections per group in the patterns table +- SecretsGate has entropy-based detection in addition to patterns — document this in Special Detection Logic +- ObservabilityGate has a K8s liveness probe absence check — document this in Special Detection Logic +- Known debt entries come from `docs/plans/2026-02-27-gates-gap-analysis.md` +- Total deliverable: 36 files (18 × design.md + 18 × implementation.md) diff --git a/docs/plans/2026-02-27-per-gate-docs-implementation.md b/docs/plans/2026-02-27-per-gate-docs-implementation.md new file mode 100644 index 0000000..41216cb --- /dev/null +++ b/docs/plans/2026-02-27-per-gate-docs-implementation.md @@ -0,0 +1,721 @@ +# Per-Gate Documentation Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Reverse-engineer all 18 security gates into individual `design.md` + `implementation.md` files under `docs/gates/gate-NN-<id>/`. + +**Architecture:** Pure documentation — no code changes, no tests. Each task creates one gate folder with two files, then commits. Content is reverse-engineered from the gate source file. Source of truth is the gate `.py` file, not prior plan docs. + +**Tech Stack:** Markdown, Python source reading only. Run no tests. Commit with `git add docs/gates/... && git commit`. + +--- + +## Reference + +Design spec: `docs/plans/2026-02-27-per-gate-docs-design.md` + +Templates are defined there. Quick summary: + +**`design.md`** — for compliance/security reviewers: +- Purpose (1 paragraph) +- What This Gate Detects (table: detection | why it matters | NIST control) +- Scope (what lines/files scanned, special detection) +- Known Limitations +- NIST Control Mapping (table: control ID | title | how addressed) + +**`implementation.md`** — for developers: +- Header (source file, test file, class, gate_id, mapped_control_ids) +- Scan Method (how scan() works) +- Patterns (table: # | regex | description | control | remediation) +- Special Detection Logic (only if gate has logic beyond standard pattern loop) +- Known Debt / Deferred Patterns (only if applicable) +- Test Coverage (table: test name | what it verifies) + +--- + +## Task 1: Directory scaffold + Gate 1 — Secrets + +**Files to create:** +- `docs/gates/gate-01-secrets/design.md` +- `docs/gates/gate-01-secrets/implementation.md` + +**Source:** `src/controlgate/gates/secrets_gate.py` +**Test file:** `tests/test_gates/test_secrets_gate.py` + +### Step 1: Create `docs/gates/gate-01-secrets/design.md` + +```markdown +# Gate 1 — Secrets & Credential Gate + +**gate_id:** `secrets` +**NIST Controls:** IA-5, IA-6, SC-12, SC-28 +**Priority:** 🔴 High + +--- + +## Purpose + +Prevents hardcoded secrets, API keys, tokens, and private keys from being committed to source control. Credentials committed to git are permanently exposed in history, even after removal, and are a leading cause of cloud account compromise. This gate provides an automated pre-commit tripwire for the most common credential formats used across AWS, GCP, GitHub, OpenAI, Stripe, and database connection strings. + +--- + +## What This Gate Detects + +| Detection | Why It Matters | NIST Control | +|---|---|---| +| AWS Access Key ID (AKIA/ASIA format) | Direct cloud account takeover | IA-5 | +| AWS Secret Access Key (40-char base64) | Paired with key ID enables full AWS API access | IA-5 | +| Google API Key (AIza prefix) | Unauthorized GCP service usage and billing fraud | IA-5 | +| Hardcoded credential assignment (`password =`, `token =`, etc.) | Generic pattern catches language-agnostic credential leaks | SC-28 | +| Private key files (RSA, EC, DSA, OpenSSH) | Key compromise enables impersonation and decryption of historical traffic | SC-12 | +| X.509 certificate files | Certificates in repos risk unauthorized service impersonation | SC-12 | +| GitHub Personal Access Tokens (ghp_ prefix) | Repo access, Actions secrets, and org data at risk | IA-5 | +| API secret keys (sk- prefix) | OpenAI/Stripe-style keys enabling fraudulent API usage | IA-5 | +| Bearer tokens in source code | Runtime tokens should not appear in code | IA-6 | +| Database connection strings with embedded credentials | Leaks DB host, port, username, and password | SC-28 | +| High-entropy quoted strings ≥20 chars | Catches randomized secrets not matching known formats | IA-5 | +| Sensitive file types committed (.env, .pem, .key, .p12, .pfx, .jks) | Entire file classes should never appear in source control | SC-28 | + +--- + +## Scope + +- **Scans:** all added lines in every file in the diff +- **File types targeted:** all files; additionally performs file-path checks for sensitive file extensions +- **Special detection:** Shannon entropy analysis — any quoted string ≥20 characters with entropy ≥4.5 bits/char is flagged even if it doesn't match a known pattern. Evidence is truncated to 120 characters. + +--- + +## Known Limitations + +- Does not scan deleted or unmodified lines +- Entropy detection produces false positives on long base64-encoded non-secret values (e.g., public keys, checksums) +- Known secret formats are a subset of real-world credential types; novel formats require new patterns +- Will not detect secrets stored in binary files + +--- + +## NIST Control Mapping + +| Control ID | Title | How This Gate Addresses It | +|---|---|---| +| IA-5 | Authenticator Management | Detects hardcoded passwords, tokens, and API keys that should be managed through a secrets manager | +| IA-6 | Authentication Feedback | Detects bearer tokens embedded in source code where they may be logged or exposed | +| SC-12 | Cryptographic Key Establishment and Management | Detects private keys and certificates committed to repositories | +| SC-28 | Protection of Information at Rest | Detects credentials and sensitive file types that expose data-at-rest protection keys | +``` + +### Step 2: Create `docs/gates/gate-01-secrets/implementation.md` + +```markdown +# Gate 1 — Secrets & Credential Gate: Implementation Reference + +**Source file:** `src/controlgate/gates/secrets_gate.py` +**Test file:** `tests/test_gates/test_secrets_gate.py` +**Class:** `SecretsGate` +**gate_id:** `secrets` +**mapped_control_ids:** `["IA-5", "IA-6", "SC-12", "SC-28"]` + +--- + +## Scan Method + +`scan()` iterates every `diff_file` and calls two sub-methods: +1. `_check_sensitive_file()` — matches the file path against `_SENSITIVE_FILE_PATTERNS`; fires one SC-28 finding per file if the path matches +2. `_check_line()` — for each added line, runs all `_PATTERNS` regex matches, then runs Shannon entropy analysis on any quoted string ≥20 chars + +--- + +## Patterns + +| # | Regex | Description | Control | Remediation | +|---|---|---|---|---| +| 1 | `(?:AKIA\|ASIA)[0-9A-Z]{16}` | AWS Access Key ID detected | IA-5 | Use IAM roles or AWS Secrets Manager | +| 2 | `(?:"\|')(?:[A-Za-z0-9/+=]{40})(?:"\|')` | Possible AWS Secret Access Key detected | IA-5 | Use IAM roles or AWS Secrets Manager | +| 3 | `AIza[0-9A-Za-z\-_]{35}` | Google API Key detected | IA-5 | Use GCP Secret Manager or env vars | +| 4 | `(?i)(?:password\|passwd\|pwd\|secret\|token\|api[_-]?key\|auth[_-]?token\|access[_-]?token)\s*[:=]\s*["'][^"']{4,}["']` | Hardcoded credential detected | SC-28 | Use env var or secrets manager | +| 5 | `(?i)(?:password\|passwd\|pwd\|secret\|token\|api[_-]?key)\s*=\s*(?!None\|null\|""\|''\|os\.environ\|env\(\|getenv)[^\s#]{4,}` | Hardcoded credential in assignment | SC-28 | Use env var or secrets manager | +| 6 | `-----BEGIN (?:RSA \|EC \|DSA \|OPENSSH )?PRIVATE KEY-----` | Private key committed to repository | SC-12 | Use secrets manager; never commit keys | +| 7 | `-----BEGIN CERTIFICATE-----` | Certificate file committed | SC-12 | Manage via PKI or secrets manager | +| 8 | `ghp_[0-9a-zA-Z]{36}` | GitHub Personal Access Token | IA-5 | Use GitHub Apps or GITHUB_TOKEN | +| 9 | `sk-[0-9a-zA-Z]{20,}` | API secret key (OpenAI/Stripe pattern) | IA-5 | Use env vars or secrets manager | +| 10 | `(?i)bearer\s+[a-z0-9\-._~+/]+=*` | Bearer token in source code | IA-6 | Load from environment or config | +| 11 | `(?i)(?:mongodb\|postgres(?:ql)?\|mysql\|redis\|amqp)://[^\s:]+:[^\s@]+@` | DB connection string with embedded credentials | SC-28 | Use env vars for connection strings | + +**Sensitive file path patterns (checked against `diff_file.path`):** +- `\.env(?:\..+)?$` +- `(?i)credentials` +- `(?i)\.pem$` +- `(?i)\.key$` +- `(?i)\.p12$` +- `(?i)\.pfx$` +- `(?i)\.jks$` + +--- + +## Special Detection Logic + +**Shannon entropy analysis** (`_check_line`, after pattern loop): +- Scans every added line for quoted strings matching `["']([A-Za-z0-9+/=\-_]{20,})["']` +- For each candidate string of length ≥20: computes Shannon entropy +- Fires an IA-5 finding if entropy ≥4.5 bits/char AND no finding already exists for that line +- Avoids duplicate findings: checks if a pattern-based finding already fired on the same line number + +--- + +## Test Coverage + +| Test | What It Verifies | +|---|---| +| `test_detects_aws_key` | AKIA/ASIA format AWS key | +| `test_detects_hardcoded_password` | `password = "..."` assignment | +| `test_detects_private_key` | `-----BEGIN RSA PRIVATE KEY-----` | +| `test_clean_code_no_findings` | Safe code produces zero findings | +| `test_detects_sensitive_file` | `.env` file path triggers finding | +| `test_detects_high_entropy_string` | Long random string triggers entropy detection | +| `test_findings_have_gate_id` | All findings carry `gate == "secrets"` | +| `test_findings_have_valid_control_ids` | All findings use IA-5, IA-6, SC-12, or SC-28 | +``` + +### Step 3: Commit + +```bash +git add docs/gates/gate-01-secrets/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 1 — Secrets design and implementation reference" +``` + +--- + +## Task 2: Gate 2 — Crypto + +**Files to create:** +- `docs/gates/gate-02-crypto/design.md` +- `docs/gates/gate-02-crypto/implementation.md` + +**Source:** `src/controlgate/gates/crypto_gate.py` +**Test file:** `tests/test_gates/test_crypto_gate.py` + +**Key facts:** +- Class: `CryptoGate` | gate_id: `crypto` | Controls: SC-8, SC-13, SC-17, SC-23 +- Three pattern groups: `_WEAK_ALGO_PATTERNS` (SC-13), `_TLS_PATTERNS` (SC-8/SC-17), `_SESSION_PATTERNS` (SC-23) +- Weak algorithms: MD5, SHA-1, DES, RC4, 3DES, Blowfish, ECB mode +- TLS patterns: bare http:// URLs (non-localhost), ssl_verify=False, verify=False, CERT_NONE/CERT_OPTIONAL, check_hostname=False, self-signed references, TLSv1.0/SSLv2/SSLv3 +- Session patterns: secure=False on cookies, SameSite=None +- Scan method: standard pattern loop across 3 groups; no special logic + +### Step 1: Write `design.md` covering: +- Purpose: detect weak crypto algorithms, disabled TLS verification, deprecated protocol versions, and insecure session cookie configuration +- Detections table covering all three groups +- Scope: all added lines, all file types +- Limitations: does not detect correct key length (only algorithm name), no cross-call analysis +- NIST mapping: SC-8 (transmission confidentiality), SC-13 (approved cryptography), SC-17 (PKI certificates), SC-23 (session authenticity) + +### Step 2: Write `implementation.md` covering: +- All patterns from the three groups with their control IDs +- Note that `_WEAK_ALGO_PATTERNS` tuples have only 3 fields (pattern, description, remediation) — control ID is hardcoded as SC-13 in `_check_line` + +### Step 3: Commit +```bash +git add docs/gates/gate-02-crypto/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 2 — Crypto design and implementation reference" +``` + +--- + +## Task 3: Gate 3 — IAM + +**Files to create:** +- `docs/gates/gate-03-iam/design.md` +- `docs/gates/gate-03-iam/implementation.md` + +**Source:** `src/controlgate/gates/iam_gate.py` +**Test file:** `tests/test_gates/test_iam_gate.py` + +**Key facts:** +- Class: `IAMGate` | gate_id: `iam` | Controls: AC-3, AC-4, AC-5, AC-6 +- Single `_IAM_PATTERNS` list: 10 patterns +- Detections: wildcard IAM Action (`"*"`), wildcard Resource (`"*"`), Allow+Action=*, AdministratorAccess/PowerUserAccess managed policies, AdministratorAccess ARN (AC-5 separation of duties), wildcard CORS origin (AC-4), allow_origins=[`*`] (AC-4), Flask route without auth decorator (AC-3), explicit auth bypass keywords (AC-3), STS AssumeRole (AC-3) +- Scan method: standard pattern loop; no file-type filtering + +### Step 1: Write `design.md` covering: +- Purpose: detect overly permissive IAM policies, missing authorization, and wildcard access grants in IaC, application code, and API config +- Detections table for all 10 patterns +- Scope: all added lines, all file types +- Limitations: route-decorator check is heuristic (only detects `@app.route` without decorators on the same line); cannot verify whether a named auth decorator is actually enforced +- NIST mapping: AC-3 (access enforcement), AC-4 (info flow), AC-5 (separation of duties), AC-6 (least privilege) + +### Step 2: Write `implementation.md` +- Full patterns table (10 rows) + +### Step 3: Commit +```bash +git add docs/gates/gate-03-iam/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 3 — IAM design and implementation reference" +``` + +--- + +## Task 4: Gate 4 — SBOM + +**Files to create:** +- `docs/gates/gate-04-sbom/design.md` +- `docs/gates/gate-04-sbom/implementation.md` + +**Source:** `src/controlgate/gates/sbom_gate.py` +**Test file:** `tests/test_gates/test_sbom_gate.py` + +**Key facts:** +- Class: `SBOMGate` | gate_id: `sbom` | Controls: SR-3, SR-11, SA-10, SA-11 +- Three detection mechanisms: + 1. **Cross-file check (SR-3):** if a manifest file (package.json, requirements.txt, go.mod, etc.) is modified but no corresponding lockfile appears in the same diff, fires a finding + 2. **Pipeline file flag (SA-10):** if a CI/CD or build file (Dockerfile, .github/workflows/*.yml, Jenkinsfile, etc.) appears in the diff, fires a review-required finding + 3. **Unpinned version patterns (SR-11):** `>=`, `~=`, `:*`, `:latest`, `:\^` on added lines + 4. **Test coverage weakening (SA-11):** coverage threshold modification, skip-test patterns +- Manifest-to-lockfile map: package.json→{package-lock.json,yarn.lock,pnpm-lock.yaml}, requirements.txt→{requirements.lock,Pipfile.lock,poetry.lock}, pyproject.toml→{poetry.lock,uv.lock,pdm.lock}, go.mod→go.sum, Cargo.toml→Cargo.lock, Gemfile→Gemfile.lock, composer.json→composer.lock + +### Step 1: Write `design.md` covering: +- Purpose: prevent unreviewed supply chain changes — unpinned dependencies, pipeline modifications, and manifest-without-lockfile changes +- Detections table covering all four mechanisms +- Scope: cross-file analysis for lockfile check; all added lines for version/test patterns; file-path check for pipeline files +- Limitations: lockfile cross-check uses basename matching, so renaming a lockfile fools it; does not scan binary lockfiles +- NIST mapping: SR-3 (supply chain controls), SR-11 (component authenticity), SA-10 (developer config management), SA-11 (developer testing) + +### Step 2: Write `implementation.md` with special note on the cross-file lockfile logic + +### Step 3: Commit +```bash +git add docs/gates/gate-04-sbom/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 4 — SBOM design and implementation reference" +``` + +--- + +## Task 5: Gate 5 — IaC + +**Files to create:** +- `docs/gates/gate-05-iac/design.md` +- `docs/gates/gate-05-iac/implementation.md` + +**Source:** `src/controlgate/gates/iac_gate.py` +**Test file:** `tests/test_gates/test_iac_gate.py` + +**Key facts:** +- Class: `IaCGate` | gate_id: `iac` | Controls: CM-2, CM-6, CM-7, SC-7 +- **File-type filter**: only scans IaC files — `.tf`, `.tfvars`, `.hcl`, `.yaml`, `.yml`, `.json`, `Dockerfile`, and paths containing `terraform/`, `infra/`, `infrastructure/`, `deploy/`, `k8s/`, `kubernetes/`, `helm/`, `.github/`, `cloudformation/`, `cdk/` +- 14 patterns: 0.0.0.0/0 ingress (SC-7), ::/0 ingress (SC-7), public ACL on storage (SC-7), S3 block_public_acls=false (SC-7), root/privileged container (CM-6), empty securityContext (CM-6), allowPrivilegeEscalation=true (CM-6), hostNetwork=true (CM-7), hostPID/hostIPC=true (CM-7), empty resources block (CM-6), sensitive ports exposed (CM-7), encryption disabled (CM-6), logging disabled (CM-6), versioning disabled (CM-2) + +### Step 1: Write `design.md` — note the file-type filtering in Scope section + +### Step 2: Write `implementation.md` — document `_is_iac_file()` logic in Special Detection Logic section + +### Step 3: Commit +```bash +git add docs/gates/gate-05-iac/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 5 — IaC design and implementation reference" +``` + +--- + +## Task 6: Gate 6 — Input Validation + +**Files to create:** +- `docs/gates/gate-06-input-validation/design.md` +- `docs/gates/gate-06-input-validation/implementation.md` + +**Source:** `src/controlgate/gates/input_gate.py` +**Test file:** `tests/test_gates/test_input_gate.py` + +**Key facts:** +- Class: `InputGate` | gate_id: `input_validation` | Controls: SI-7, SI-10, SI-11, SI-16 +- 15 patterns in a single `_INPUT_PATTERNS` list +- Detection groups: SQL injection via f-string/format()/%/concatenation (SI-10), eval()/exec()/subprocess+shell=True/os.system()/os.popen() (SI-10), pickle.loads/yaml.load unsafe (SI-10), bare except:pass/traceback exposure/debug=True (SI-11), download without integrity verification (SI-7), strcpy/strcat/sprintf/gets C functions (SI-16) +- Standard pattern loop; no file-type filtering + +### Step 1: Write `design.md` + +### Step 2: Write `implementation.md` with 15-row patterns table + +### Step 3: Commit +```bash +git add docs/gates/gate-06-input-validation/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 6 — Input Validation design and implementation reference" +``` + +--- + +## Task 7: Gate 7 — Audit + +**Files to create:** +- `docs/gates/gate-07-audit/design.md` +- `docs/gates/gate-07-audit/implementation.md` + +**Source:** `src/controlgate/gates/audit_gate.py` +**Test file:** `tests/test_gates/test_audit_gate.py` + +**Key facts:** +- Class: `AuditGate` | gate_id: `audit` | Controls: AU-2, AU-3, AU-12 +- **Three detection methods:** + 1. `_check_removed_logging()` — scans `hunk.removed_lines` for logging statements (AU-12); this is one of only two gates that reads REMOVED lines + 2. `_check_auth_logging()` — for each hunk, checks if an auth function name (login, logout, authenticate, etc.) appears in added lines WITHOUT an accompanying log statement in the same hunk (AU-2) + 3. `_check_pii_in_logs()` — pattern scan of added lines for PII keywords inside log/print calls (AU-3) +- PII patterns: SSN/SIN, passwords/tokens, credit cards, date of birth — all in log context + +### Step 1: Write `design.md` — highlight removed-line detection as a special capability (unlike most gates) + +### Step 2: Write `implementation.md` — document all three methods in Special Detection Logic + +### Step 3: Commit +```bash +git add docs/gates/gate-07-audit/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 7 — Audit design and implementation reference" +``` + +--- + +## Task 8: Gate 8 — Change Control + +**Files to create:** +- `docs/gates/gate-08-change-control/design.md` +- `docs/gates/gate-08-change-control/implementation.md` + +**Source:** `src/controlgate/gates/change_gate.py` +**Test file:** `tests/test_gates/test_change_gate.py` + +**Key facts:** +- Class: `ChangeGate` | gate_id: `change_control` | Controls: CM-3, CM-4, CM-5 +- **File-path based detection (not line content):** + - `_SECURITY_CRITICAL_FILES` regex flags .github/workflows, CODEOWNERS, Dockerfile, docker-compose, .env, terraform/*.tf, deploy/, k8s/, CI pipeline files (CM-3) + - `_DEPLOY_CONFIG_FILES` regex flags helm/values*.yml, terraform/*.tfvars, ansible/, pulumi/ (CM-4) + - Hard-coded CODEOWNERS path check (CM-5) +- One line-content pattern: `branch.?protection` keyword in added lines (CM-5) + +### Step 1: Write `design.md` — emphasize that most detections are file-path based, not content-based + +### Step 2: Write `implementation.md` — document the two file-path regexes and the CODEOWNERS path check in Special Detection Logic + +### Step 3: Commit +```bash +git add docs/gates/gate-08-change-control/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 8 — Change Control design and implementation reference" +``` + +--- + +## Task 9: Gate 9 — Deps + +**Files to create:** +- `docs/gates/gate-09-deps/design.md` +- `docs/gates/gate-09-deps/implementation.md` + +**Source:** `src/controlgate/gates/deps_gate.py` +**Test file:** `tests/test_gates/test_deps_gate.py` + +**Key facts:** +- Class: `DepsGate` | gate_id: `deps` | Controls: RA-5, SI-2, SA-12 +- 5 patterns: --no-verify bypass (SA-12), --ignore-scripts (SA-12), http:// package registry URL (SI-2), pip install with range specifier (RA-5), pip install without pinned version (RA-5) +- Standard pattern loop; no file-type filtering + +### Step 1: Write `design.md` + +### Step 2: Write `implementation.md` + +### Step 3: Commit +```bash +git add docs/gates/gate-09-deps/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 9 — Deps design and implementation reference" +``` + +--- + +## Task 10: Gate 10 — API Security + +**Files to create:** +- `docs/gates/gate-10-api/design.md` +- `docs/gates/gate-10-api/implementation.md` + +**Source:** `src/controlgate/gates/api_gate.py` +**Test file:** `tests/test_gates/test_api_gate.py` + +**Key facts:** +- Class: `APIGate` | gate_id: `api` | Controls: SC-8, AC-3, SC-5, SI-10 +- 6 patterns: verify=False (SC-8), CORS_ORIGIN_ALLOW_ALL/allow_all_origins=True (AC-3), Access-Control-Allow-Origin:* header (AC-3), Access-Control-Allow-Credentials:true (AC-3), API key in URL query param (SC-8), GraphQL introspection/GraphiQL enabled (AC-3) +- Standard pattern loop +- Note: SC-5 (DoS protection) and SI-10 (input validation) are in `mapped_control_ids` per design spec but no patterns currently fire those specific control IDs — the existing 6 patterns all fire SC-8 or AC-3 + +### Step 1: Write `design.md` — note SC-5/SI-10 are mapped but not directly triggered by current patterns + +### Step 2: Write `implementation.md` + +### Step 3: Commit +```bash +git add docs/gates/gate-10-api/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 10 — API Security design and implementation reference" +``` + +--- + +## Task 11: Gate 11 — Privacy + +**Files to create:** +- `docs/gates/gate-11-privacy/design.md` +- `docs/gates/gate-11-privacy/implementation.md` + +**Source:** `src/controlgate/gates/privacy_gate.py` +**Test file:** `tests/test_gates/test_privacy_gate.py` + +**Key facts:** +- Class: `PrivacyGate` | gate_id: `privacy` | Controls: PT-2, PT-3, SC-28 +- 4 patterns: PII field name in logging/print (PT-3), serialize_all_fields=True (PT-2), expires_at=None/ttl=0 (SC-28), PII field in plaintext DB column definition (SC-28) +- PII field keywords shared via `_PII_FIELDS` string: ssn, social security, date of birth, dob, credit card, cvv, passport, driver's license +- Standard pattern loop + +### Step 1: Write `design.md` + +### Step 2: Write `implementation.md` + +### Step 3: Commit +```bash +git add docs/gates/gate-11-privacy/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 11 — Privacy design and implementation reference" +``` + +--- + +## Task 12: Gate 12 — Resilience + +**Files to create:** +- `docs/gates/gate-12-resilience/design.md` +- `docs/gates/gate-12-resilience/implementation.md` + +**Source:** `src/controlgate/gates/resilience_gate.py` +**Test file:** `tests/test_gates/test_resilience_gate.py` + +**Key facts:** +- Class: `ResilienceGate` | gate_id: `resilience` | Controls: CP-9, CP-10, SI-13 +- 5 patterns: deletion_protection=false (CP-9), backup=false (CP-9), skip_final_snapshot=true (CP-9), max_retries=0 (SI-13), backup_retention_period=0 (CP-9) +- Standard pattern loop +- Known debt: CP-10 (system recovery) — design spec included `connect_timeout` absence detection but this requires negative/absence detection not supported by the added-lines model + +### Step 1: Write `design.md` + +### Step 2: Write `implementation.md` — include Known Debt section for CP-10 connect_timeout + +### Step 3: Commit +```bash +git add docs/gates/gate-12-resilience/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 12 — Resilience design and implementation reference" +``` + +--- + +## Task 13: Gate 13 — Incident Response + +**Files to create:** +- `docs/gates/gate-13-incident/design.md` +- `docs/gates/gate-13-incident/implementation.md` + +**Source:** `src/controlgate/gates/incident_gate.py` +**Test file:** `tests/test_gates/test_incident_gate.py` + +**Key facts:** +- Class: `IncidentGate` | gate_id: `incident` | Controls: IR-4, IR-6, AU-6 +- 4 patterns: bare `except:` clause (IR-4), empty JS/TS/Java catch block (IR-4), traceback.print_exc()/traceback.format_exc() exposure (IR-4), notify:false/notifications_enabled=false (IR-6) +- AU-6 (audit review/analysis) is in `mapped_control_ids` but no pattern directly fires it; AU-6 coverage is implicit via the incident detection patterns +- Standard pattern loop + +### Step 1: Write `design.md` + +### Step 2: Write `implementation.md` — note AU-6 in Known Limitations (mapped but implicit) + +### Step 3: Commit +```bash +git add docs/gates/gate-13-incident/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 13 — Incident Response design and implementation reference" +``` + +--- + +## Task 14: Gate 14 — Observability + +**Files to create:** +- `docs/gates/gate-14-observability/design.md` +- `docs/gates/gate-14-observability/implementation.md` + +**Source:** `src/controlgate/gates/observability_gate.py` +**Test file:** `tests/test_gates/test_observability_gate.py` + +**Key facts:** +- Class: `ObservabilityGate` | gate_id: `observability` | Controls: SI-4, AU-12 +- 4 line-level patterns: enable_monitoring=false (SI-4), monitoring_interval=0 (SI-4), log driver=none (AU-12), --log-driver=none (AU-12) +- **Special logic:** For files matching `(?i)(?:deployment|statefulset|daemonset).*\.ya?ml$`, scans `diff_file.full_content` (not just added lines) for `livenessProbe`; if `containers:` is present but no `livenessProbe`, fires SI-4 finding +- Known debt: design spec included DLQ resource deletion detection but that requires scanning removed lines + +### Step 1: Write `design.md` — describe the K8s liveness probe check in Scope + +### Step 2: Write `implementation.md` — document the `full_content` probe check in Special Detection Logic; include Known Debt for DLQ deletion pattern + +### Step 3: Commit +```bash +git add docs/gates/gate-14-observability/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 14 — Observability design and implementation reference" +``` + +--- + +## Task 15: Gate 15 — Memory Safety + +**Files to create:** +- `docs/gates/gate-15-memsafe/design.md` +- `docs/gates/gate-15-memsafe/implementation.md` + +**Source:** `src/controlgate/gates/memsafe_gate.py` +**Test file:** `tests/test_gates/test_memsafe_gate.py` + +**Key facts:** +- Class: `MemSafeGate` | gate_id: `memsafe` | Controls: SI-16, CM-7 +- 7 patterns: eval() with dynamic arg (SI-16), exec() (SI-16), unsafe Rust block (CM-7), ctypes raw memory ops (SI-16), cffi ffi.cast/buffer/from_buffer/memmove (SI-16), strcpy/strcat (SI-16), memcpy with user input (SI-16) +- Note on cffi pattern: uses `(?<!\w)` negative lookbehind to avoid false positives on variables like `audio_ffi.cast()` +- Standard pattern loop + +### Step 1: Write `design.md` + +### Step 2: Write `implementation.md` — note the word-boundary guard on the cffi pattern + +### Step 3: Commit +```bash +git add docs/gates/gate-15-memsafe/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 15 — Memory Safety design and implementation reference" +``` + +--- + +## Task 16: Gate 16 — License + +**Files to create:** +- `docs/gates/gate-16-license/design.md` +- `docs/gates/gate-16-license/implementation.md` + +**Source:** `src/controlgate/gates/license_gate.py` +**Test file:** `tests/test_gates/test_license_gate.py` + +**Key facts:** +- Class: `LicenseGate` | gate_id: `license` | Controls: SA-4, SR-3 +- Two pattern objects (not a `_PATTERNS` list — unique structure): + - `_COPYLEFT_PATTERN`: `(?i)\b(?:GPL|AGPL|SSPL|LGPL|GNU\s+(?:General|Affero|Lesser))\b` — only fires for manifest files (SA-4) + - `_SPDX_COPYLEFT`: `SPDX-License-Identifier:\s*(?:GPL|AGPL|SSPL|LGPL|EUPL|OSL|CDDL)` — fires for any source file (SR-3) +- Manifest file detection: `_MANIFEST_FILES` regex matches requirements*.txt, package.json, go.mod, Cargo.toml, Gemfile, composer.json, setup.cfg, pyproject.toml +- Copyleft keyword in non-manifest files is intentionally NOT flagged (to avoid false positives in comments) +- Known debt: design spec included license header removal detection, but requires scanning removed lines + +### Step 1: Write `design.md` — explain the manifest-only restriction for copyleft keywords + +### Step 2: Write `implementation.md` — describe the dual-pattern + manifest-file-check logic in Special Detection Logic; include Known Debt for license header removal + +### Step 3: Commit +```bash +git add docs/gates/gate-16-license/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 16 — License Compliance design and implementation reference" +``` + +--- + +## Task 17: Gate 17 — AI/ML Security + +**Files to create:** +- `docs/gates/gate-17-aiml/design.md` +- `docs/gates/gate-17-aiml/implementation.md` + +**Source:** `src/controlgate/gates/aiml_gate.py` +**Test file:** `tests/test_gates/test_aiml_gate.py` + +**Key facts:** +- Class: `AIMLGate` | gate_id: `aiml` | Controls: SI-10, SC-28, SR-3 +- 6 patterns: trust_remote_code=True (SR-3), pickle.load/pickle.loads (SI-10), joblib.load (SI-10), http:// model/weights URL (SR-3), f-string with user/input/request/query/prompt variables (SI-10), model_path/weights_path/checkpoint_path/model_weights assigned to string literal (SC-28) +- Standard pattern loop +- Known limitation: commented-out config lines fire the SC-28 pattern (accepted trade-off, documented in test file) + +### Step 1: Write `design.md` — include the comment-line limitation in Known Limitations + +### Step 2: Write `implementation.md` + +### Step 3: Commit +```bash +git add docs/gates/gate-17-aiml/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 17 — AI/ML Security design and implementation reference" +``` + +--- + +## Task 18: Gate 18 — Container Security + +**Files to create:** +- `docs/gates/gate-18-container/design.md` +- `docs/gates/gate-18-container/implementation.md` + +**Source:** `src/controlgate/gates/container_gate.py` +**Test file:** `tests/test_gates/test_container_gate.py` + +**Key facts:** +- Class: `ContainerGate` | gate_id: `container` | Controls: CM-6, CM-7, SC-7, SC-39, AC-6, SI-7, AU-12, SA-10, SR-3 +- **Six pattern groups** (each is a separate module-level list): + - `_IMAGE_PATTERNS` (SI-7): FROM :latest, FROM with no tag, ADD https:// + - `_PRIVILEGE_PATTERNS` (AC-6): USER root, privileged:true, --cap-add ALL, --cap-add SYS_ADMIN/SYS_PTRACE/NET_ADMIN, allowPrivilegeEscalation:true, runAsNonRoot:false + - `_NETWORK_PATTERNS` (SC-7): hostNetwork:true, hostPort:N + - `_RUNTIME_PATTERNS` (SC-39): readOnlyRootFilesystem:false, hostPID:true, hostIPC:true, seccomp Unconfined + - `_AUDIT_PATTERNS` (AU-12): log driver none (docker-compose), --log-driver=none + - `_RESOURCE_PATTERNS` (CM-6): resources:{}, --memory=-1 +- scan() iterates all pattern groups in `_ALL_PATTERN_GROUPS`; no file-type filtering + +### Step 1: Write `design.md` — organize detections table by domain (image integrity, privilege, network, runtime, audit, resources) + +### Step 2: Write `implementation.md` — use sub-sections per group in the Patterns table + +### Step 3: Commit +```bash +git add docs/gates/gate-18-container/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: add Gate 18 — Container Security design and implementation reference" +``` + +--- + +## Task 19: Final verification + +### Step 1: Verify all 36 files exist + +```bash +find docs/gates -name "*.md" | sort +``` + +Expected: 36 files — `design.md` and `implementation.md` in each of 18 gate folders. + +### Step 2: Verify all required sections are present in each file + +```bash +for f in docs/gates/*/design.md; do + echo "=== $f ===" + grep "^## " "$f" +done +``` + +Each `design.md` should show: Purpose, What This Gate Detects, Scope, Known Limitations, NIST Control Mapping. + +```bash +for f in docs/gates/*/implementation.md; do + echo "=== $f ===" + grep "^## " "$f" +done +``` + +Each `implementation.md` should show at minimum: Scan Method, Patterns, Test Coverage. + +### Step 3: Commit (only if any final corrections were needed) + +```bash +git add docs/gates/ && PATH="$PWD/.venv/bin:$PATH" git commit -m "docs: final corrections to per-gate documentation" +``` + +--- + +## Checklist + +- [ ] Task 1: Gate 1 — Secrets (design.md + implementation.md) +- [ ] Task 2: Gate 2 — Crypto +- [ ] Task 3: Gate 3 — IAM +- [ ] Task 4: Gate 4 — SBOM +- [ ] Task 5: Gate 5 — IaC +- [ ] Task 6: Gate 6 — Input Validation +- [ ] Task 7: Gate 7 — Audit +- [ ] Task 8: Gate 8 — Change Control +- [ ] Task 9: Gate 9 — Deps +- [ ] Task 10: Gate 10 — API Security +- [ ] Task 11: Gate 11 — Privacy +- [ ] Task 12: Gate 12 — Resilience +- [ ] Task 13: Gate 13 — Incident Response +- [ ] Task 14: Gate 14 — Observability +- [ ] Task 15: Gate 15 — Memory Safety +- [ ] Task 16: Gate 16 — License Compliance +- [ ] Task 17: Gate 17 — AI/ML Security +- [ ] Task 18: Gate 18 — Container Security +- [ ] Task 19: Final verification — 36 files, all required sections present diff --git a/docs/plans/2026-02-28-auto-version-bump-design.md b/docs/plans/2026-02-28-auto-version-bump-design.md new file mode 100644 index 0000000..7d1bcd4 --- /dev/null +++ b/docs/plans/2026-02-28-auto-version-bump-design.md @@ -0,0 +1,73 @@ +# Auto Version Bump Pre-Commit Hook — Design + +**Date:** 2026-02-28 +**Status:** Approved + +## Problem + +The CI pipeline enforces a version bump at PR time, but developers only discover the missing bump when the PR check fails. The fix requires an extra commit just to update the version. Moving version management to commit time eliminates this friction. + +## Goal + +Add a pre-commit hook that automatically bumps the minor version in `pyproject.toml` when the current branch version matches the `main` branch version, stages the change, and includes it in the commit being made. + +## Approach + +Use a local pre-commit hook (`hooks/bump_version.py`) that compares versions using `git show origin/main:pyproject.toml` — the cached remote ref. No network call is needed after the initial fetch, so it works offline and adds negligible latency. + +## Components + +### `hooks/bump_version.py` + +Self-contained Python script (no third-party deps): + +1. Read current version from `pyproject.toml` via regex on `version = "X.Y.Z"` +2. Read main-branch version via `git show origin/main:pyproject.toml` +3. If versions match → bump minor (e.g. `0.1.7 → 0.2.0`, patch resets to 0), write file, `git add pyproject.toml`, print notice +4. If versions differ → exit 0 (already bumped, nothing to do) +5. If `origin/main` unavailable → exit 0 with warning (don't block offline commits) + +### `.pre-commit-config.yaml` addition + +New `local` repo entry: + +```yaml +- repo: local + hooks: + - id: bump-version + name: Auto-bump version if unchanged from main + entry: python hooks/bump_version.py + language: python + always_run: true + pass_filenames: false +``` + +## Bump Rule + +| Before | After | +|--------|-------| +| `0.1.7` | `0.2.0` | +| `0.2.0` | `0.3.0` | +| `1.5.3` | `1.6.0` | + +Minor segment incremented, patch reset to `0`, major unchanged. + +## Error Handling + +| Condition | Behaviour | +|-----------|-----------| +| `origin/main` not reachable | Warn, exit 0 (non-blocking) | +| `pyproject.toml` missing version line | Exit 1 with clear error | +| `git add` fails | Exit 1 with error | +| Versions already differ | Exit 0 silently | + +## Testing + +- Unit tests in `tests/test_bump_version.py` using `tmp_path` and mocked `subprocess` +- Cases: same version (bumps), different version (skips), origin unavailable (warns, passes), malformed version (errors) + +## Out of Scope + +- Major version bumps (manual only) +- Configurable bump type (always minor) +- CHANGELOG updates diff --git a/docs/plans/2026-02-28-auto-version-bump-implementation.md b/docs/plans/2026-02-28-auto-version-bump-implementation.md new file mode 100644 index 0000000..d65f327 --- /dev/null +++ b/docs/plans/2026-02-28-auto-version-bump-implementation.md @@ -0,0 +1,372 @@ +# Auto Version Bump Pre-Commit Hook — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a pre-commit hook that auto-bumps the minor version in `pyproject.toml` when the working branch version matches `origin/main`, staging the change so it lands in the current commit. + +**Architecture:** A standalone Python script (`hooks/bump_version.py`) reads both versions via regex and `git show origin/main:pyproject.toml`, bumps minor+resets patch when they match, then re-stages `pyproject.toml`. A new local hook entry in `.pre-commit-config.yaml` wires it into the existing pre-commit pipeline. + +**Tech Stack:** Python 3.10 stdlib only (`re`, `subprocess`, `pathlib`), pytest, pre-commit framework. + +--- + +### Task 1: Write `hooks/bump_version.py` (TDD) + +**Files:** +- Create: `hooks/bump_version.py` +- Create: `tests/test_bump_version.py` + +--- + +**Step 1: Write the failing tests** + +Create `tests/test_bump_version.py`: + +```python +"""Tests for hooks/bump_version.py""" + +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +# bump_version.py lives in hooks/, not src/ — import it directly +sys.path.insert(0, str(Path(__file__).parent.parent / "hooks")) +import bump_version # noqa: E402 + + +PYPROJECT_SAME = '[project]\nname = "controlgate"\nversion = "0.1.7"\n' +PYPROJECT_MAIN = '[project]\nname = "controlgate"\nversion = "0.1.7"\n' +PYPROJECT_BUMPED = '[project]\nname = "controlgate"\nversion = "0.2.0"\n' +PYPROJECT_HIGHER = '[project]\nname = "controlgate"\nversion = "0.2.0"\n' + + +class TestParseVersion: + def test_parses_version_from_content(self): + assert bump_version.parse_version(PYPROJECT_SAME) == (0, 1, 7) + + def test_raises_on_missing_version(self): + with pytest.raises(SystemExit): + bump_version.parse_version("[project]\nname = 'x'\n") + + +class TestBumpMinor: + def test_bumps_minor_and_resets_patch(self): + assert bump_version.bump_minor((0, 1, 7)) == (0, 2, 0) + + def test_bumps_minor_on_zero_patch(self): + assert bump_version.bump_minor((0, 2, 0)) == (0, 3, 0) + + def test_preserves_major(self): + assert bump_version.bump_minor((1, 5, 3)) == (1, 6, 0) + + +class TestWriteVersion: + def test_replaces_version_in_content(self): + result = bump_version.write_version(PYPROJECT_SAME, (0, 2, 0)) + assert 'version = "0.2.0"' in result + assert 'version = "0.1.7"' not in result + + +class TestGetMainVersion: + def test_returns_tuple_when_origin_available(self): + mock_result = type("R", (), {"returncode": 0, "stdout": PYPROJECT_MAIN})() + with patch("subprocess.run", return_value=mock_result): + assert bump_version.get_main_version() == (0, 1, 7) + + def test_returns_none_when_origin_unavailable(self, capsys): + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, "git")): + result = bump_version.get_main_version() + assert result is None + captured = capsys.readouterr() + assert "warning" in captured.out.lower() + + def test_returns_none_when_git_not_found(self, capsys): + with patch("subprocess.run", side_effect=FileNotFoundError): + result = bump_version.get_main_version() + assert result is None + + +class TestMain: + def test_bumps_and_stages_when_versions_match(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(PYPROJECT_SAME) + mock_result = type("R", (), {"returncode": 0, "stdout": PYPROJECT_MAIN})() + with ( + patch("bump_version.PYPROJECT_PATH", pyproject), + patch("subprocess.run", return_value=mock_result), + ): + exit_code = bump_version.main() + assert exit_code == 0 + assert 'version = "0.2.0"' in pyproject.read_text() + + def test_skips_when_versions_differ(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(PYPROJECT_HIGHER) + mock_result = type("R", (), {"returncode": 0, "stdout": PYPROJECT_MAIN})() + with ( + patch("bump_version.PYPROJECT_PATH", pyproject), + patch("subprocess.run", return_value=mock_result), + ): + exit_code = bump_version.main() + assert exit_code == 0 + assert 'version = "0.2.0"' in pyproject.read_text() # unchanged + + def test_passes_when_origin_unavailable(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(PYPROJECT_SAME) + with ( + patch("bump_version.PYPROJECT_PATH", pyproject), + patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, "git")), + ): + exit_code = bump_version.main() + assert exit_code == 0 + # Version unchanged since we couldn't compare + assert 'version = "0.1.7"' in pyproject.read_text() +``` + +**Step 2: Run tests to confirm they fail** + +```bash +pytest tests/test_bump_version.py -v +``` + +Expected: `ModuleNotFoundError: No module named 'bump_version'` + +--- + +**Step 3: Implement `hooks/bump_version.py`** + +Create `hooks/bump_version.py`: + +```python +#!/usr/bin/env python3 +"""Pre-commit hook: auto-bump minor version if unchanged from origin/main. + +When the version in pyproject.toml matches origin/main, bumps the minor +segment (e.g. 0.1.7 → 0.2.0) and stages pyproject.toml so the bump is +included in the current commit. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +PYPROJECT_PATH = Path(__file__).parent.parent / "pyproject.toml" +_VERSION_RE = re.compile(r'^version\s*=\s*"(\d+)\.(\d+)\.(\d+)"', re.MULTILINE) + + +def parse_version(content: str) -> tuple[int, int, int]: + """Extract (major, minor, patch) from pyproject.toml content.""" + m = _VERSION_RE.search(content) + if not m: + print("bump-version: ERROR: could not find version in pyproject.toml", flush=True) + sys.exit(1) + return int(m.group(1)), int(m.group(2)), int(m.group(3)) + + +def bump_minor(version: tuple[int, int, int]) -> tuple[int, int, int]: + """Increment minor, reset patch to 0.""" + major, minor, _ = version + return major, minor + 1, 0 + + +def write_version(content: str, version: tuple[int, int, int]) -> str: + """Return content with version replaced.""" + new = f'{version[0]}.{version[1]}.{version[2]}' + return _VERSION_RE.sub(f'version = "{new}"', content) + + +def get_main_version() -> tuple[int, int, int] | None: + """Read version from origin/main:pyproject.toml. Returns None if unavailable.""" + try: + result = subprocess.run( + ["git", "show", "origin/main:pyproject.toml"], + capture_output=True, + text=True, + check=True, + ) + return parse_version(result.stdout) + except (subprocess.CalledProcessError, FileNotFoundError): + print( + "bump-version: WARNING: could not read origin/main — skipping version check", + flush=True, + ) + return None + + +def main() -> int: + content = PYPROJECT_PATH.read_text(encoding="utf-8") + current = parse_version(content) + main_ver = get_main_version() + + if main_ver is None: + return 0 # offline or no remote — non-blocking + + if current != main_ver: + return 0 # already bumped + + new_ver = bump_minor(current) + new_content = write_version(content, new_ver) + PYPROJECT_PATH.write_text(new_content, encoding="utf-8") + + subprocess.run(["git", "add", str(PYPROJECT_PATH)], check=True) + + old_str = f"{current[0]}.{current[1]}.{current[2]}" + new_str = f"{new_ver[0]}.{new_ver[1]}.{new_ver[2]}" + print(f"bump-version: auto-bumped {old_str} → {new_str}", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) +``` + +**Step 4: Run tests to confirm they pass** + +```bash +pytest tests/test_bump_version.py -v +``` + +Expected: all tests PASS. + +**Step 5: Confirm 100% coverage still holds** + +```bash +pytest --cov=controlgate --cov-report=term-missing -q +``` + +Expected: `Total coverage: 100%` (bump_version.py is in `hooks/`, not `src/controlgate/`, so it doesn't affect the coverage target). + +**Step 6: Commit** + +```bash +git add hooks/bump_version.py tests/test_bump_version.py +git commit -m "feat: add auto-bump minor version pre-commit hook" +``` + +--- + +### Task 2: Wire hook into `.pre-commit-config.yaml` + +**Files:** +- Modify: `.pre-commit-config.yaml` + +--- + +**Step 1: Add the local hook entry** + +Append to `.pre-commit-config.yaml`: + +```yaml + - repo: local + hooks: + - id: bump-version + name: Auto-bump version if unchanged from main + entry: python hooks/bump_version.py + language: python + always_run: true + pass_filenames: false +``` + +The full file should look like: + +```yaml +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.6.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.4.0 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.10.0 + hooks: + - id: mypy + additional_dependencies: [ types-PyYAML, pytest ] + args: [ --config-file, pyproject.toml ] + + - repo: local + hooks: + - id: bump-version + name: Auto-bump version if unchanged from main + entry: python hooks/bump_version.py + language: python + always_run: true + pass_filenames: false +``` + +**Step 2: Verify the hook is recognised by pre-commit** + +```bash +pre-commit run bump-version --all-files +``` + +Expected output contains: `Auto-bump version if unchanged from main...Passed` (or the bump message if versions matched). + +**Step 3: Commit** + +```bash +git add .pre-commit-config.yaml +git commit -m "chore: register bump-version as a local pre-commit hook" +``` + +--- + +### Task 3: Smoke-test end-to-end + +**Step 1: Simulate a fresh commit with matching versions** + +Reset your local `pyproject.toml` version to match `origin/main` (`0.1.7`), stage a trivial change, and commit: + +```bash +# Check what main has +git show origin/main:pyproject.toml | grep '^version' + +# If current version already differs, temporarily set it to match +# (edit pyproject.toml manually to version = "0.1.7") +git add pyproject.toml +echo "# smoke test" >> README.md +git add README.md +git commit -m "test: smoke test auto-bump hook" +``` + +Expected: pre-commit output includes `bump-version: auto-bumped 0.1.7 → 0.2.0` and the commit contains the bumped `pyproject.toml`. + +**Step 2: Verify the committed version** + +```bash +git show HEAD:pyproject.toml | grep '^version' +``` + +Expected: `version = "0.2.0"` + +**Step 3: Revert smoke-test changes if needed** + +```bash +git revert HEAD --no-edit +``` + +--- + +## Notes + +- `hooks/bump_version.py` uses only stdlib — no extra install needed. +- The hook runs **before** ruff/mypy so a bumped `pyproject.toml` is linted too. + If ordering matters, place the `local` repo block first in `.pre-commit-config.yaml`. +- The coverage config in `pyproject.toml` sources only `controlgate` (`source = ["controlgate"]`), so `hooks/` is outside coverage scope by design. +- If a developer is on a branch where `origin/main` hasn't been fetched recently, run `git fetch origin main` once to refresh the cached ref. diff --git a/hooks/bump_version.py b/hooks/bump_version.py new file mode 100644 index 0000000..1321fac --- /dev/null +++ b/hooks/bump_version.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Pre-commit hook: auto-bump minor version if unchanged from origin/main. + +When the version in pyproject.toml matches origin/main, bumps the minor +segment (e.g. 0.1.7 → 0.2.0) and stages pyproject.toml so the bump is +included in the current commit. +""" + +from __future__ import annotations + +import re +import subprocess +import sys +from pathlib import Path + +PYPROJECT_PATH = Path(__file__).parent.parent / "pyproject.toml" +_VERSION_RE = re.compile(r'^version\s*=\s*"(\d+)\.(\d+)\.(\d+)"', re.MULTILINE) + + +def parse_version(content: str) -> tuple[int, int, int]: + """Extract (major, minor, patch) from pyproject.toml content.""" + m = _VERSION_RE.search(content) + if not m: + print("bump-version: ERROR: could not find version in pyproject.toml", flush=True) + sys.exit(1) + return int(m.group(1)), int(m.group(2)), int(m.group(3)) + + +def bump_minor(version: tuple[int, int, int]) -> tuple[int, int, int]: + """Increment minor, reset patch to 0.""" + major, minor, _ = version + return major, minor + 1, 0 + + +def write_version(content: str, version: tuple[int, int, int]) -> str: + """Return content with version replaced.""" + new = f"{version[0]}.{version[1]}.{version[2]}" + return _VERSION_RE.sub(f'version = "{new}"', content, count=1) + + +def get_main_version() -> tuple[int, int, int] | None: + """Read version from origin/main:pyproject.toml. Returns None if unavailable.""" + try: + result = subprocess.run( + ["git", "show", "origin/main:pyproject.toml"], + capture_output=True, + text=True, + check=True, + ) + return parse_version(result.stdout) + except (subprocess.CalledProcessError, FileNotFoundError): + print( + "bump-version: WARNING: could not read origin/main — skipping version check", + flush=True, + ) + return None + + +def main() -> int: + content = PYPROJECT_PATH.read_text(encoding="utf-8") + current = parse_version(content) + main_ver = get_main_version() + + if main_ver is None: + return 0 # offline or no remote — non-blocking + + if current != main_ver: + return 0 # already bumped + + new_ver = bump_minor(current) + new_content = write_version(content, new_ver) + PYPROJECT_PATH.write_text(new_content, encoding="utf-8") + + try: + subprocess.run(["git", "add", str(PYPROJECT_PATH)], check=True) + except subprocess.CalledProcessError as e: + print(f"bump-version: ERROR: git add failed: {e}", flush=True) + return 1 + + old_str = f"{current[0]}.{current[1]}.{current[2]}" + new_str = f"{new_ver[0]}.{new_ver[1]}.{new_ver[2]}" + print(f"bump-version: auto-bumped {old_str} → {new_str}", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pyproject.toml b/pyproject.toml index 47702ee..81fed95 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "controlgate" -version = "0.1.7" +version = "0.2.0" description = "NIST RMF Cloud Security Hardening — Pre-Commit & Pre-Merge Compliance Gate" readme = "README.md" license = {text = "MIT"} @@ -53,9 +53,11 @@ controlgate = ["data/*.json"] [tool.pytest.ini_options] testpaths = ["tests"] +pythonpath = ["src", "hooks"] [tool.coverage.run] -source = ["controlgate"] +source = ["controlgate", "hooks"] +omit = ["hooks/pre_commit.py"] [tool.coverage.report] fail_under = 100 @@ -82,3 +84,4 @@ warn_return_any = true warn_unused_configs = true disallow_untyped_defs = false check_untyped_defs = true +ignore_missing_imports = true diff --git a/skills/codeex/instructions.md b/skills/codex/instructions.md similarity index 68% rename from skills/codeex/instructions.md rename to skills/codex/instructions.md index 3174056..3f2fce9 100644 --- a/skills/codeex/instructions.md +++ b/skills/codex/instructions.md @@ -1,5 +1,5 @@ <system_prompt> -You are an expert AI software developer functioning as "**CodeEx**". Your primary domain is writing secure, scalable, and robust code. +You are an expert AI software developer functioning as "**Codex**". Your primary domain is writing secure, scalable, and robust code. In the current project environment, you must adhere rigidly to the **ControlGate** security practices. <objective> @@ -22,11 +22,11 @@ To write code that passes ControlGate security validation before the user commit <example> User: "Write a Terraform script to create an S3 bucket." -CodeEx: *Writes the script but accidentally leaves it public.* -CodeEx: *Runs `controlgate scan .`* +Codex: *Writes the script but accidentally leaves it public.* +Codex: *Runs `controlgate scan .`* ControlGate Output: "[FAILED] S3 bucket is public (NIST AC-3)" -CodeEx: *Modifies the Terraform script to set `acl = "private"` and enable Block Public Access.* -CodeEx: *Runs scan again. Passes.* -CodeEx: "Here is your S3 bucket Terraform configuration. I have ensured that the bucket is explicitly set to private and all public access is blocked, adhering to the NIST AC-3 control for Access Enforcement to prevent data leakage." +Codex: *Modifies the Terraform script to set `acl = "private"` and enable Block Public Access.* +Codex: *Runs scan again. Passes.* +Codex: "Here is your S3 bucket Terraform configuration. I have ensured that the bucket is explicitly set to private and all public access is blocked, adhering to the NIST AC-3 control for Access Enforcement to prevent data leakage." </example> </system_prompt> diff --git a/src/controlgate/__main__.py b/src/controlgate/__main__.py index 95f145d..e29ccf8 100644 --- a/src/controlgate/__main__.py +++ b/src/controlgate/__main__.py @@ -1,7 +1,7 @@ """ControlGate CLI entry point. Usage: - controlgate scan [--config PATH] [--format json|markdown|sarif] [--baseline low|moderate|high] + controlgate scan [--config PATH] [--format json|markdown|sarif] [--baseline low|moderate|high|privacy|li-saas] [--gov] controlgate scan --diff-file PATH # scan a saved diff file python -m controlgate scan [options] """ @@ -11,13 +11,15 @@ import argparse import subprocess import sys +from fnmatch import fnmatch from pathlib import Path from controlgate.catalog import CatalogIndex from controlgate.config import ControlGateConfig from controlgate.diff_parser import parse_diff from controlgate.engine import ControlGateEngine -from controlgate.models import Action +from controlgate.init_command import init_command +from controlgate.models import Action, DiffFile, DiffHunk from controlgate.reporters.json_reporter import JSONReporter from controlgate.reporters.markdown_reporter import MarkdownReporter from controlgate.reporters.sarif_reporter import SARIFReporter @@ -49,6 +51,90 @@ def _get_diff(mode: str, target_branch: str = "main") -> str: sys.exit(1) +def _get_full_files(root: Path, config: ControlGateConfig) -> list[DiffFile]: + """Enumerate all project files for full-repo scan mode. + + Tries ``git ls-files`` first (respects .gitignore). Falls back to + directory walk when not in a git repo. + + Args: + root: Project root directory to scan. + config: ControlGate config (for extension/skip_dir filters). + + Returns: + List of DiffFile objects with all content as added_lines. + """ + root = root.resolve() + candidate_paths: list[Path] = [] + + # 1. Try git ls-files (honors .gitignore automatically) + try: + result = subprocess.run( + ["git", "ls-files"], + capture_output=True, + text=True, + check=True, + cwd=root, + ) + candidate_paths = [root / p for p in result.stdout.strip().splitlines() if p.strip()] + except (subprocess.CalledProcessError, FileNotFoundError): + # Fall back: walk directory + candidate_paths = [p for p in root.rglob("*") if p.is_file()] + + diff_files: list[DiffFile] = [] + + for abs_path in candidate_paths: + try: + rel_path = str(abs_path.relative_to(root)) + except ValueError: + rel_path = str(abs_path) + + # Skip paths excluded by config glob patterns + if config.is_path_excluded(rel_path): + continue + + # Skip any path component matching a pattern in skip_dirs + path_parts = Path(rel_path).parts + if any( + any(fnmatch(part, pattern) for pattern in config.full_scan_skip_dirs) + for part in path_parts + ): + continue + + # Filter by extension allowlist (empty list = allow all) + if config.full_scan_extensions and abs_path.suffix not in config.full_scan_extensions: + continue + + # Skip binary files (null-byte heuristic) + try: + sample = abs_path.read_bytes()[:8192] + if b"\x00" in sample: + continue + except (OSError, PermissionError): + continue + + # Read text content + try: + content = abs_path.read_text(encoding="utf-8", errors="ignore") + except (OSError, PermissionError): + continue + + lines = content.splitlines() + if not lines: + continue + + hunk = DiffHunk( + start_line=1, + line_count=len(lines), + added_lines=list(enumerate(lines, start=1)), + ) + df = DiffFile(path=rel_path) + df.hunks = [hunk] + diff_files.append(df) + + return diff_files + + def _resolve_catalog_path(config: ControlGateConfig) -> Path: """Resolve the catalog path, auto-downloading if needed. @@ -113,20 +199,30 @@ def scan_command(args: argparse.Namespace) -> int: print(f"📚 Loaded catalog: {catalog.count} controls", file=sys.stderr) # Get diff - if args.diff_file: + if args.mode == "full": + scan_root = Path(getattr(args, "path", None) or ".").resolve() + diff_files = _get_full_files(scan_root, config) + if not diff_files: + print("ℹ️ No files found to scan.", file=sys.stderr) + return 0 + print( + f"📄 Full scan: {len(diff_files)} file(s) in {scan_root}...", + file=sys.stderr, + ) + elif args.diff_file: diff_text = Path(args.diff_file).read_text(encoding="utf-8") + if not diff_text.strip(): + print("ℹ️ No changes to scan.", file=sys.stderr) + return 0 + diff_files = parse_diff(diff_text) + print(f"📄 Scanning {len(diff_files)} changed file(s)...", file=sys.stderr) else: diff_text = _get_diff(args.mode, args.target_branch) - - if not diff_text.strip(): - print("ℹ️ No changes to scan.", file=sys.stderr) - return 0 - - diff_files = parse_diff(diff_text) - print( - f"📄 Scanning {len(diff_files)} changed file(s)...", - file=sys.stderr, - ) + if not diff_text.strip(): + print("ℹ️ No changes to scan.", file=sys.stderr) + return 0 + diff_files = parse_diff(diff_text) + print(f"📄 Scanning {len(diff_files)} changed file(s)...", file=sys.stderr) # Run engine engine = ControlGateEngine(config, catalog) @@ -211,9 +307,9 @@ def build_parser() -> argparse.ArgumentParser: scan_parser.add_argument( "--mode", type=str, - choices=["pre-commit", "pr"], + choices=["pre-commit", "pr", "full"], default="pre-commit", - help="Scan mode: pre-commit (staged) or pr (branch diff)", + help="Scan mode: pre-commit (staged), pr (branch diff), or full (entire repo)", ) scan_parser.add_argument( "--target-branch", @@ -233,6 +329,12 @@ def build_parser() -> argparse.ArgumentParser: default=None, help="Directory to write report files to", ) + scan_parser.add_argument( + "--path", + type=str, + default=None, + help="Root directory for full scan mode (default: current directory)", + ) # update-catalog subcommand subparsers.add_parser( @@ -246,6 +348,31 @@ def build_parser() -> argparse.ArgumentParser: help="Show information about the current catalog", ) + # init subcommand + init_parser = subparsers.add_parser( + "init", + help="Bootstrap ControlGate config files for this project", + ) + init_parser.add_argument( + "--path", + type=str, + default=None, + help="Target directory to initialize (default: current directory)", + ) + init_parser.add_argument( + "--baseline", + type=str, + choices=["low", "moderate", "high", "privacy", "li-saas"], + default=None, + help="Pre-select the NIST/FedRAMP baseline level", + ) + init_parser.add_argument( + "--no-docs", + action="store_true", + default=False, + help="Skip generating CONTROLGATE.md", + ) + return parser @@ -295,6 +422,8 @@ def main() -> None: sys.exit(update_catalog_command()) elif args.command == "catalog-info": sys.exit(catalog_info_command()) + elif args.command == "init": + sys.exit(init_command(args)) else: parser.print_help() sys.exit(0) diff --git a/src/controlgate/catalog.py b/src/controlgate/catalog.py index 3d40d47..08da32a 100644 --- a/src/controlgate/catalog.py +++ b/src/controlgate/catalog.py @@ -17,6 +17,16 @@ "input_validation": ["SI-7", "SI-10", "SI-11", "SI-16"], "audit": ["AU-2", "AU-3", "AU-12"], "change_control": ["CM-3", "CM-4", "CM-5"], + "deps": ["RA-5", "SI-2", "SA-12"], + "api": ["SC-8", "AC-3", "SC-5", "SI-10"], + "privacy": ["PT-2", "PT-3", "SC-28"], + "resilience": ["CP-9", "CP-10", "SI-13"], + "incident": ["IR-4", "IR-6", "AU-6"], + "observability": ["SI-4", "AU-12"], + "memsafe": ["SI-16", "CM-7"], + "license": ["SA-4", "SR-3"], + "aiml": ["SI-10", "SC-28", "SR-3"], + "container": ["CM-6", "CM-7", "SC-7", "SC-39", "AC-6", "SI-7", "AU-12", "SA-10", "SR-3"], } diff --git a/src/controlgate/config.py b/src/controlgate/config.py index e77f5ac..1c116c3 100644 --- a/src/controlgate/config.py +++ b/src/controlgate/config.py @@ -18,9 +18,19 @@ "iam": {"enabled": True, "action": "warn"}, "sbom": {"enabled": True, "action": "warn"}, "iac": {"enabled": True, "action": "block"}, - "input": {"enabled": True, "action": "block"}, + "input_validation": {"enabled": True, "action": "block"}, "audit": {"enabled": True, "action": "warn"}, - "change": {"enabled": True, "action": "warn"}, + "change_control": {"enabled": True, "action": "warn"}, + "deps": {"enabled": True, "action": "warn"}, + "api": {"enabled": True, "action": "warn"}, + "privacy": {"enabled": True, "action": "warn"}, + "resilience": {"enabled": True, "action": "warn"}, + "incident": {"enabled": True, "action": "warn"}, + "observability": {"enabled": True, "action": "warn"}, + "memsafe": {"enabled": True, "action": "warn"}, + "license": {"enabled": True, "action": "warn"}, + "aiml": {"enabled": True, "action": "warn"}, + "container": {"enabled": True, "action": "warn"}, }, "thresholds": { "block_on": ["CRITICAL", "HIGH"], @@ -36,6 +46,51 @@ "sarif": False, "output_dir": ".controlgate/reports", }, + "full_scan": { + "extensions": [ + ".py", + ".js", + ".ts", + ".jsx", + ".tsx", + ".go", + ".java", + ".rb", + ".rs", + ".c", + ".cpp", + ".h", + ".tf", + ".yaml", + ".yml", + ".json", + ".toml", + ".ini", + ".cfg", + ".sh", + ".bash", + ".zsh", + ".env", + ".xml", + ".sql", + ], + "skip_dirs": [ + ".git", + "node_modules", + ".venv", + "venv", + "env", + "__pycache__", + "dist", + "build", + ".tox", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + ".eggs", + "*.egg-info", + ], + }, } @@ -63,6 +118,53 @@ class ControlGateConfig: report_formats: list[str] = field(default_factory=lambda: ["json", "markdown"]) sarif_enabled: bool = False output_dir: str = ".controlgate/reports" + full_scan_extensions: list[str] = field( + default_factory=lambda: [ + ".py", + ".js", + ".ts", + ".jsx", + ".tsx", + ".go", + ".java", + ".rb", + ".rs", + ".c", + ".cpp", + ".h", + ".tf", + ".yaml", + ".yml", + ".json", + ".toml", + ".ini", + ".cfg", + ".sh", + ".bash", + ".zsh", + ".env", + ".xml", + ".sql", + ] + ) + full_scan_skip_dirs: list[str] = field( + default_factory=lambda: [ + ".git", + "node_modules", + ".venv", + "venv", + "env", + "__pycache__", + "dist", + "build", + ".tox", + ".mypy_cache", + ".ruff_cache", + ".pytest_cache", + ".eggs", + "*.egg-info", + ] + ) @classmethod def load(cls, config_path: str | Path | None = None) -> ControlGateConfig: @@ -124,6 +226,11 @@ def _from_raw(cls, raw: dict[str, Any]) -> ControlGateConfig: cfg.sarif_enabled = reporting.get("sarif", cfg.sarif_enabled) cfg.output_dir = reporting.get("output_dir", cfg.output_dir) + # Full scan + full_scan = raw.get("full_scan", {}) + cfg.full_scan_extensions = full_scan.get("extensions", cfg.full_scan_extensions) + cfg.full_scan_skip_dirs = full_scan.get("skip_dirs", cfg.full_scan_skip_dirs) + return cfg def is_gate_enabled(self, gate_name: str) -> bool: diff --git a/src/controlgate/gates/__init__.py b/src/controlgate/gates/__init__.py index 0938646..37668f0 100644 --- a/src/controlgate/gates/__init__.py +++ b/src/controlgate/gates/__init__.py @@ -1,11 +1,21 @@ """Security gate scanners for ControlGate.""" +from controlgate.gates.aiml_gate import AIMLGate +from controlgate.gates.api_gate import APIGate from controlgate.gates.audit_gate import AuditGate from controlgate.gates.change_gate import ChangeGate +from controlgate.gates.container_gate import ContainerGate from controlgate.gates.crypto_gate import CryptoGate +from controlgate.gates.deps_gate import DepsGate from controlgate.gates.iac_gate import IaCGate from controlgate.gates.iam_gate import IAMGate +from controlgate.gates.incident_gate import IncidentGate from controlgate.gates.input_gate import InputGate +from controlgate.gates.license_gate import LicenseGate +from controlgate.gates.memsafe_gate import MemSafeGate +from controlgate.gates.observability_gate import ObservabilityGate +from controlgate.gates.privacy_gate import PrivacyGate +from controlgate.gates.resilience_gate import ResilienceGate from controlgate.gates.sbom_gate import SBOMGate from controlgate.gates.secrets_gate import SecretsGate @@ -18,6 +28,16 @@ InputGate, AuditGate, ChangeGate, + DepsGate, + APIGate, + PrivacyGate, + ResilienceGate, + IncidentGate, + ObservabilityGate, + MemSafeGate, + LicenseGate, + AIMLGate, + ContainerGate, ] __all__ = [ @@ -29,5 +49,15 @@ "InputGate", "AuditGate", "ChangeGate", + "DepsGate", + "APIGate", + "PrivacyGate", + "ResilienceGate", + "IncidentGate", + "ObservabilityGate", + "MemSafeGate", + "LicenseGate", + "AIMLGate", + "ContainerGate", "ALL_GATES", ] diff --git a/src/controlgate/gates/aiml_gate.py b/src/controlgate/gates/aiml_gate.py new file mode 100644 index 0000000..f62d1a0 --- /dev/null +++ b/src/controlgate/gates/aiml_gate.py @@ -0,0 +1,82 @@ +"""Gate 17 — AI/ML Security Gate. + +Detects security risks specific to AI/ML codebases: prompt injection vectors, +unsafe model loading, remote code execution via trust_remote_code, and +insecure model transfer channels. + +NIST Controls: SI-10, SC-28, SR-3 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""trust_remote_code\s*=\s*True"""), + "trust_remote_code=True executes arbitrary code from a remote model repository", + "SR-3", + "Never use trust_remote_code=True; audit the model source and load from a vetted internal registry", + ), + ( + re.compile(r"""pickle\.load\s*\(|pickle\.loads\s*\("""), + "pickle.load() deserializes arbitrary Python objects — code execution on load", + "SI-10", + "Use safetensors or ONNX format instead of pickle; never load pickle files from untrusted sources", + ), + ( + re.compile(r"""joblib\.load\s*\("""), + "joblib.load() uses pickle internally — arbitrary code execution risk", + "SI-10", + "Verify the source and checksum of joblib files before loading; prefer safetensors", + ), + ( + re.compile(r"""http://[^\s]*(?:model|weight|checkpoint|\.bin|\.pt|\.pkl|\.onnx)"""), + "Model or weights downloaded over unencrypted HTTP", + "SR-3", + "Use HTTPS for all model downloads and verify checksums (SHA256) after download", + ), + ( + re.compile(r"""f["\'].*\{.*(?:user|request|input|query|prompt).*\}.*["\']"""), + "User input interpolated directly into LLM prompt — prompt injection risk", + "SI-10", + "Sanitize and validate user input before including in prompts; use structured message formats", + ), + ( + re.compile( + r"""(?i)(?:model_path|weights_path|checkpoint_path|model_weights)\s*=\s*["'][^"']+["']""" + ), + "Model weights path stored in plaintext config — weights location exposed without encryption", + "SC-28", + "Store model paths in a secrets manager or encrypted config; avoid hardcoding weight locations", + ), +] + + +class AIMLGate(BaseGate): + """Gate 17: Detect AI/ML-specific security vulnerabilities.""" + + name = "AI/ML Security Gate" + gate_id = "aiml" + mapped_control_ids = ["SI-10", "SC-28", "SR-3"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings diff --git a/src/controlgate/gates/api_gate.py b/src/controlgate/gates/api_gate.py new file mode 100644 index 0000000..f3abe48 --- /dev/null +++ b/src/controlgate/gates/api_gate.py @@ -0,0 +1,79 @@ +"""Gate 10 — API Security Gate. + +Detects insecure API patterns: TLS verification disabled, wildcard CORS, +API credentials in query params, and GraphQL introspection in production. + +NIST Controls: SC-8, AC-3, SC-5, SI-10 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""verify\s*=\s*False"""), + "TLS certificate verification disabled — subject to MITM attacks", + "SC-8", + "Remove verify=False and use a proper CA bundle; never disable TLS verification in production", + ), + ( + re.compile(r"""(?i)(?:CORS_ORIGIN_ALLOW_ALL|allow_all_origins)\s*=\s*True"""), + "CORS wildcard origin configured — allows any domain to make credentialed requests", + "AC-3", + "Restrict CORS to an explicit allowlist of trusted origins", + ), + ( + re.compile(r"""Access-Control-Allow-Origin.*?[=:]\s*["\']?\s*\*"""), + "Access-Control-Allow-Origin: * permits requests from any origin", + "AC-3", + "Restrict Access-Control-Allow-Origin to specific trusted origins", + ), + ( + re.compile(r"""Access-Control-Allow-Credentials.*?[=:]\s*["\']?\s*true""", re.IGNORECASE), + "Access-Control-Allow-Credentials: true with wildcard origin creates CSRF/CORS bypass risk", + "AC-3", + "Never combine Access-Control-Allow-Credentials: true with Access-Control-Allow-Origin: *", + ), + ( + re.compile(r"""[?&](?:api[_-]?key|token|access[_-]?token|secret)[=]"""), + "API key or token passed in URL query parameter — logged in server access logs", + "SC-8", + "Pass API credentials in Authorization header, not in URL query parameters", + ), + ( + re.compile(r"""(?i)GRAPHQL_INTROSPECTION\s*=\s*True|graphiql\s*=\s*True"""), + "GraphQL introspection or GraphiQL enabled — exposes full schema to attackers", + "AC-3", + "Disable introspection and GraphiQL in non-development environments", + ), +] + + +class APIGate(BaseGate): + """Gate 10: Detect insecure API patterns.""" + + name = "API Security Gate" + gate_id = "api" + mapped_control_ids = ["SC-8", "AC-3", "SC-5", "SI-10"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings diff --git a/src/controlgate/gates/container_gate.py b/src/controlgate/gates/container_gate.py new file mode 100644 index 0000000..31e03df --- /dev/null +++ b/src/controlgate/gates/container_gate.py @@ -0,0 +1,191 @@ +"""Gate 18 — Container Security Gate. + +Detects container and Kubernetes misconfigurations across five security domains: +image integrity, least privilege, network isolation, runtime hardening, and audit. + +NIST Controls: CM-6, CM-7, SC-7, SC-39, AC-6, SI-7, AU-12, SA-10, SR-3 + +Note: Each pattern uses a single primary control ID (most directly relevant). +Secondary controls are noted in remediation text. +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +# ── IMAGE INTEGRITY (primary: SI-7) ────────────────────────────────────────── +_IMAGE_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""FROM\s+\S+:latest"""), + "Unpinned :latest tag — image may change between builds, breaking reproducibility", + "SI-7", + "Pin to a specific version tag or use a digest: FROM python@sha256:<hash>", + ), + ( + re.compile(r"""^FROM\s+[^\s@:]+\s*$""", re.MULTILINE), + "Base image has no tag — always pin to a specific digest or version", + "SI-7", + "Add a version tag or SHA256 digest: FROM python:3.11-slim@sha256:<hash>", + ), + ( + re.compile(r"""ADD\s+https?://"""), + "Remote ADD fetches content at build time without checksum verification", + "SI-7", + "Use RUN curl ... | sha256sum -c and COPY instead of ADD with remote URLs", + ), +] + +# ── LEAST PRIVILEGE (primary: AC-6) ────────────────────────────────────────── +_PRIVILEGE_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""USER\s+root"""), + "Container explicitly set to run as root — violates least privilege", + "AC-6", + "Create a dedicated non-root user: RUN useradd -r app && USER app", + ), + ( + re.compile(r"""(?i)privileged:\s*true|--privileged"""), + "Privileged container grants full host access — enables container escape", + "AC-6", + "Remove privileged: true; grant only specific capabilities if needed", + ), + ( + re.compile(r"""--cap-add\s+ALL"""), + "ALL Linux capabilities granted — equivalent to running as root", + "AC-6", + "Enumerate only the specific capabilities required (e.g. --cap-add NET_BIND_SERVICE)", + ), + ( + re.compile(r"""--cap-add\s+(?:SYS_ADMIN|SYS_PTRACE|NET_ADMIN)"""), + "High-risk Linux capability granted — can lead to host privilege escalation", + "AC-6", + "Audit whether this capability is truly needed; prefer dropping all caps and adding selectively", + ), + ( + re.compile(r"""allowPrivilegeEscalation:\s*true"""), + "allowPrivilegeEscalation: true permits setuid/setgid escalation inside the container", + "AC-6", + "Set allowPrivilegeEscalation: false in securityContext", + ), + ( + re.compile(r"""runAsNonRoot:\s*false"""), + "runAsNonRoot: false explicitly permits the container to run as root", + "AC-6", + "Set runAsNonRoot: true and specify runAsUser with a non-zero UID", + ), +] + +# ── NETWORK ISOLATION (primary: SC-7) ──────────────────────────────────────── +_NETWORK_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""hostNetwork:\s*true"""), + "hostNetwork: true exposes the container on the host network namespace", + "SC-7", + "Use ClusterIP or NodePort Services; avoid sharing the host network namespace", + ), + ( + re.compile(r"""hostPort:\s*\d+"""), + "hostPort bypasses Kubernetes NetworkPolicy — use Service resources instead", + "SC-7", + "Replace hostPort with a Kubernetes Service of type NodePort or LoadBalancer", + ), +] + +# ── RUNTIME HARDENING (primary: SC-39) ─────────────────────────────────────── +_RUNTIME_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""readOnlyRootFilesystem:\s*false"""), + "Writable root filesystem — allows attacker to modify container files", + "SC-39", + "Set readOnlyRootFilesystem: true and use emptyDir/PVC mounts for writable paths", + ), + ( + re.compile(r"""hostPID:\s*true"""), + "hostPID: true shares the host process namespace — enables container escape vectors", + "SC-39", + "Remove hostPID: true; process isolation must be maintained", + ), + ( + re.compile(r"""hostIPC:\s*true"""), + "hostIPC: true shares host IPC namespace — allows cross-container memory access", + "SC-39", + "Remove hostIPC: true; IPC namespace isolation must be maintained", + ), + ( + re.compile(r"""(?i)seccompProfile.*Unconfined|seccomp.*unconfined"""), + "Seccomp profile set to Unconfined — all syscalls permitted", + "SC-39", + "Use RuntimeDefault seccomp profile or create a custom restricted profile", + ), +] + +# ── AUDIT (primary: AU-12) ──────────────────────────────────────────────────── +_AUDIT_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""(?i)log.?driver.*none"""), + "Container logging driver set to 'none' — all container output is discarded", + "AU-12", + "Use a persistent logging driver (json-file, awslogs, fluentd, splunk)", + ), + ( + re.compile(r"""--log-driver=none"""), + "Container logging disabled via CLI flag — cannot audit container activity", + "AU-12", + "Remove --log-driver=none; use a centralised logging destination", + ), +] + +# ── RESOURCE LIMITS (primary: CM-6) ────────────────────────────────────────── +_RESOURCE_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""resources:\s*\{\}"""), + "Empty resources block — no CPU/memory limits set; enables denial-of-service", + "CM-6", + "Set explicit resources.requests and resources.limits for CPU and memory", + ), + ( + re.compile(r"""--memory[= ]["\']?-1"""), + "Unlimited container memory allocation — no ceiling for memory consumption", + "CM-6", + "Set an explicit --memory limit (e.g. --memory=512m)", + ), +] + +_ALL_PATTERN_GROUPS = [ + _IMAGE_PATTERNS, + _PRIVILEGE_PATTERNS, + _NETWORK_PATTERNS, + _RUNTIME_PATTERNS, + _AUDIT_PATTERNS, + _RESOURCE_PATTERNS, +] + + +class ContainerGate(BaseGate): + """Gate 18: Detect container and Kubernetes security misconfigurations.""" + + name = "Container Security Gate" + gate_id = "container" + mapped_control_ids = ["CM-6", "CM-7", "SC-7", "SC-39", "AC-6", "SI-7", "AU-12", "SA-10", "SR-3"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern_group in _ALL_PATTERN_GROUPS: + for pattern, description, control_id, remediation in pattern_group: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings diff --git a/src/controlgate/gates/deps_gate.py b/src/controlgate/gates/deps_gate.py new file mode 100644 index 0000000..a884024 --- /dev/null +++ b/src/controlgate/gates/deps_gate.py @@ -0,0 +1,79 @@ +"""Gate 9 — Dependency Vulnerability Gate. + +Detects dependency hygiene violations that indicate vulnerability risk: +bypassed integrity checks, unpinned runtime installs, and insecure registry URLs. + +NIST Controls: RA-5, SI-2, SA-12 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile( + r"""(?:pip|pip3|npm|yarn|gem)\b.*--no-verify|--no-verify.*(?:pip|pip3|npm|yarn|gem)\b""" + ), + "Package integrity verification bypassed with --no-verify", + "SA-12", + "Remove --no-verify to ensure package checksums are validated", + ), + ( + re.compile(r"""--ignore-scripts"""), + "npm --ignore-scripts bypasses postinstall security hooks", + "SA-12", + "Audit dependencies manually before using --ignore-scripts; prefer a scanned internal registry", + ), + ( + re.compile(r"""http://[^\s]*(?:pypi|npmjs|rubygems|packagist|pkg\.go\.dev|registry\.)"""), + "Insecure HTTP URL used for package registry — man-in-the-middle risk", + "SI-2", + "Use HTTPS for all package registry URLs", + ), + ( + re.compile( + r"""pip3?\s+install\s+(?!-r\s)[A-Za-z0-9][^\n]*(?:>=|<=|~=|!=|(?<![=!<>])>(?!=)|(?<![=!<>])<(?!=))""" + ), + "pip install with range version specifier — use == for reproducible installs", + "RA-5", + "Pin to exact versions (pip install package==1.2.3) for reproducibility", + ), + ( + re.compile( + r"""pip\s+install\s+(?!-r\s)(?:[A-Za-z0-9][A-Za-z0-9_.-]*)(?:\s+(?![^\s]*==)[A-Za-z0-9][A-Za-z0-9_.-]*)*\s*$""" + ), + "pip install without pinned version — dependency may resolve to a vulnerable release", + "RA-5", + "Pin all dependencies to exact versions (pip install package==1.2.3) or use a lockfile", + ), +] + + +class DepsGate(BaseGate): + """Gate 9: Detect dependency vulnerability hygiene violations.""" + + name = "Dependency Vulnerability Gate" + gate_id = "deps" + mapped_control_ids = ["RA-5", "SI-2", "SA-12"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings diff --git a/src/controlgate/gates/incident_gate.py b/src/controlgate/gates/incident_gate.py new file mode 100644 index 0000000..8ee7fcd --- /dev/null +++ b/src/controlgate/gates/incident_gate.py @@ -0,0 +1,67 @@ +"""Gate 13 — Incident Response Gate. + +Ensures code changes don't remove alerting, monitoring, or incident-handling +capability: silent exception swallowing, stack trace exposure, disabled notifications. + +NIST Controls: IR-4, IR-6, AU-6 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""^\s*except\s*:\s*$"""), + "Bare except clause — silently swallows all exceptions, preventing incident detection", + "IR-4", + "Catch specific exceptions and log them; never use bare except: pass", + ), + ( + re.compile(r"""catch\s*\([^)]*\)\s*\{\s*\}"""), + "Empty catch block — exception swallowed silently in JS/TS/Java", + "IR-4", + "Log or rethrow exceptions; never leave catch blocks empty", + ), + ( + re.compile(r"""traceback\.print_exc\(\)|traceback\.format_exc\(\)"""), + "Stack trace exposed in response — leaks implementation details to attackers", + "IR-4", + "Log the traceback server-side only; return a generic error message to clients", + ), + ( + re.compile(r"""(?i)notify\s*:\s*false|notifications.?enabled\s*[:=]\s*false"""), + "Alerting/notification disabled in monitoring configuration", + "IR-6", + "Enable notifications for all critical alerts; silence specific alerts rather than disabling all", + ), +] + + +class IncidentGate(BaseGate): + """Gate 13: Detect incident response capability removal.""" + + name = "Incident Response Gate" + gate_id = "incident" + mapped_control_ids = ["IR-4", "IR-6", "AU-6"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings diff --git a/src/controlgate/gates/license_gate.py b/src/controlgate/gates/license_gate.py new file mode 100644 index 0000000..35e50de --- /dev/null +++ b/src/controlgate/gates/license_gate.py @@ -0,0 +1,66 @@ +"""Gate 16 — License Compliance Gate. + +Prevents copyleft-licensed dependencies from entering proprietary codebases. +Detects GPL, AGPL, SSPL, and LGPL licenses in dependency manifests and source files. + +NIST Controls: SA-4, SR-3 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +# Copyleft license keywords in comments or SPDX identifiers +_COPYLEFT_PATTERN = re.compile( + r"""(?i)\b(?:GPL|AGPL|SSPL|LGPL|GNU\s+(?:General|Affero|Lesser))\b""" +) + +# Package manifest files to scan +_MANIFEST_FILES = re.compile( + r"""(?i)(?:requirements.*\.txt|package\.json|go\.mod|Cargo\.toml|Gemfile|composer\.json|setup\.cfg|pyproject\.toml)$""" +) + +# SPDX copyleft identifiers +_SPDX_COPYLEFT = re.compile(r"""SPDX-License-Identifier:\s*(?:GPL|AGPL|SSPL|LGPL|EUPL|OSL|CDDL)""") + + +class LicenseGate(BaseGate): + """Gate 16: Detect copyleft license compliance violations.""" + + name = "License Compliance Gate" + gate_id = "license" + mapped_control_ids = ["SA-4", "SR-3"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + is_manifest = bool(_MANIFEST_FILES.search(diff_file.path)) + for line_no, line in diff_file.all_added_lines: + # Only flag copyleft keywords in manifest files to reduce false positives + if is_manifest and _COPYLEFT_PATTERN.search(line): + findings.append( + self._make_finding( + control_id="SA-4", + file=diff_file.path, + line=line_no, + description="Copyleft license (GPL/AGPL/SSPL/LGPL) detected in dependency manifest", + evidence=line.strip()[:120], + remediation="Review license compatibility; copyleft licenses may require open-sourcing your codebase", + ) + ) + # SPDX identifiers in any source file + if _SPDX_COPYLEFT.search(line): + findings.append( + self._make_finding( + control_id="SR-3", + file=diff_file.path, + line=line_no, + description="SPDX copyleft license identifier in source file", + evidence=line.strip()[:120], + remediation="Audit this file's license; copyleft source may contaminate your proprietary codebase", + ) + ) + return findings diff --git a/src/controlgate/gates/memsafe_gate.py b/src/controlgate/gates/memsafe_gate.py new file mode 100644 index 0000000..cb33e34 --- /dev/null +++ b/src/controlgate/gates/memsafe_gate.py @@ -0,0 +1,85 @@ +"""Gate 15 — Memory Safety Gate. + +Detects dynamic code execution, unsafe memory operations, and patterns +that historically lead to memory corruption and code injection. + +NIST Controls: SI-16, CM-7 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""(?<!\w)eval\s*\((?!\s*["\'])"""), + "eval() called with dynamic argument — arbitrary code execution risk", + "SI-16", + "Use ast.literal_eval() for data; never eval() user-controlled input", + ), + ( + re.compile(r"""(?<!\w)exec\s*\("""), + "exec() detected — executes arbitrary Python code at runtime", + "SI-16", + "Avoid exec(); use explicit function dispatch or importlib for dynamic behavior", + ), + ( + re.compile(r"""unsafe\s*\{"""), + "Unsafe Rust block — must have an explicit safety justification comment", + "CM-7", + "Add a // SAFETY: comment explaining the invariants that make this block safe", + ), + ( + re.compile(r"""ctypes\.\w+.*\baddress\b|ctypes\.cast|ctypes\.memmove|ctypes\.memset"""), + "ctypes raw memory operation — bypasses Python memory safety", + "SI-16", + "Audit ctypes usage; prefer cffi with stricter type checking or avoid direct memory access", + ), + ( + re.compile(r"""(?<!\w)ffi\.(?:cast|buffer|from_buffer|memmove)\s*\("""), + "cffi raw memory operation — bypasses Python memory safety", + "SI-16", + "Audit cffi usage; ensure pointer arithmetic is bounds-checked and never uses untrusted lengths", + ), + ( + re.compile(r"""\bstrcpy\s*\(|\bstrcat\s*\("""), + "strcpy/strcat used without bounds checking — classic buffer overflow vector", + "SI-16", + "Use strlcpy/strlcat or snprintf with explicit size limits", + ), + ( + re.compile(r"""\bmemcpy\s*\(.*(?:req|input|user|argv)"""), + "memcpy with potentially untrusted source length — buffer overflow risk", + "SI-16", + "Validate source buffer size before memcpy; consider memmove for overlapping regions", + ), +] + + +class MemSafeGate(BaseGate): + """Gate 15: Detect memory safety and dynamic code execution violations.""" + + name = "Memory Safety Gate" + gate_id = "memsafe" + mapped_control_ids = ["SI-16", "CM-7"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings diff --git a/src/controlgate/gates/observability_gate.py b/src/controlgate/gates/observability_gate.py new file mode 100644 index 0000000..021cd7f --- /dev/null +++ b/src/controlgate/gates/observability_gate.py @@ -0,0 +1,90 @@ +"""Gate 14 — Observability Gate. + +Detects removal of metrics, health probes, and monitoring configuration — +distinct from the Audit gate which focuses on log content. + +NIST Controls: SI-4, AU-12 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""(?i)enable.?monitoring\s*[:=]\s*false|monitoring\s*[:=]\s*false"""), + "Monitoring disabled in infrastructure configuration", + "SI-4", + "Enable monitoring and set monitoring_interval > 0 for all production resources", + ), + ( + re.compile(r"""(?i)monitoring.?interval\s*[:=]\s*0"""), + "monitoring_interval = 0 disables enhanced monitoring", + "SI-4", + "Set monitoring_interval to 60 or higher for production database instances", + ), + ( + re.compile(r"""(?i)(?:log.?driver\s*[:=]\s*["\']?none|driver:\s*none)"""), + "Container logging driver set to 'none' — all output is discarded", + "AU-12", + "Use a persistent logging driver (json-file, awslogs, fluentd) for all containers", + ), + ( + re.compile(r"""--log-driver=none"""), + "Container logging disabled via CLI flag", + "AU-12", + "Remove --log-driver=none; all container output must be captured for audit", + ), +] + +# Kubernetes deployment file pattern — needs separate handling +_K8S_FILE_PATTERN = re.compile(r"""(?i)(?:deployment|statefulset|daemonset).*\.ya?ml$""") +_LIVENESS_PROBE_PATTERN = re.compile(r"""livenessProbe""") + + +class ObservabilityGate(BaseGate): + """Gate 14: Detect removal of monitoring, health probes, and observability.""" + + name = "Observability Gate" + gate_id = "observability" + mapped_control_ids = ["SI-4", "AU-12"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + # Line-level pattern scan + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + + # Kubernetes workload: flag if containers spec has no liveness probe + if _K8S_FILE_PATTERN.search(diff_file.path): + full_content = diff_file.full_content + if "containers:" in full_content and not _LIVENESS_PROBE_PATTERN.search( + full_content + ): + findings.append( + self._make_finding( + control_id="SI-4", + file=diff_file.path, + line=1, + description="Kubernetes workload added without a livenessProbe — failure will not be detected", + evidence=f"No livenessProbe found in {diff_file.path}", + remediation="Add a livenessProbe (httpGet, tcpSocket, or exec) to all container specs", + ) + ) + + return findings diff --git a/src/controlgate/gates/privacy_gate.py b/src/controlgate/gates/privacy_gate.py new file mode 100644 index 0000000..056e8e5 --- /dev/null +++ b/src/controlgate/gates/privacy_gate.py @@ -0,0 +1,74 @@ +"""Gate 11 — Data Privacy Gate. + +Detects PII handling violations: PII in logs, data over-exposure in serializers, +and missing data retention policies. + +NIST Controls: PT-2, PT-3, SC-28 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +# PII field name keywords +_PII_FIELDS = r"""(?:ssn|social.?security|date.?of.?birth|dob|credit.?card|card.?number|cvv|passport|drivers.?license)""" + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile( + r"""(?i)(?:logging\.|logger\.|print\().*""" + _PII_FIELDS, + ), + "PII field name detected in logging/print statement", + "PT-3", + "Remove PII from logs; use opaque identifiers (user_id) instead of PII field values", + ), + ( + re.compile(r"""(?i)serialize_all_fields\s*=\s*True"""), + "serialize_all_fields=True exposes all model fields — may leak PII or sensitive data", + "PT-2", + "Use an explicit fields allowlist in serializers; never serialize all fields by default", + ), + ( + re.compile(r"""(?i)expires_at\s*=\s*None|ttl\s*[:=]\s*0|ttl\s*[:=]\s*null"""), + "Data retention field set to null/0 — no expiry policy enforced", + "SC-28", + "Set an explicit expires_at or TTL for all data with retention requirements", + ), + ( + re.compile( + r"""(?i)(?:CharField|TextField|StringField|Column\(String)\s*\(.*?""" + _PII_FIELDS, + ), + "PII field stored in plaintext database column without encryption marker", + "SC-28", + "Encrypt PII at rest using field-level encryption or a dedicated vault", + ), +] + + +class PrivacyGate(BaseGate): + """Gate 11: Detect data privacy and PII handling violations.""" + + name = "Data Privacy Gate" + gate_id = "privacy" + mapped_control_ids = ["PT-2", "PT-3", "SC-28"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings diff --git a/src/controlgate/gates/resilience_gate.py b/src/controlgate/gates/resilience_gate.py new file mode 100644 index 0000000..2f3c150 --- /dev/null +++ b/src/controlgate/gates/resilience_gate.py @@ -0,0 +1,73 @@ +"""Gate 12 — Resilience & Backup Gate. + +Detects code patterns that disable recoverability: deletion protection off, +no final snapshots, zero retries, and missing connection timeouts. + +NIST Controls: CP-9, CP-10, SI-13 +""" + +from __future__ import annotations + +import re + +from controlgate.gates.base import BaseGate +from controlgate.models import DiffFile, Finding + +_PATTERNS: list[tuple[re.Pattern, str, str, str]] = [ + ( + re.compile(r"""(?i)deletion.?protection\s*[:=]\s*false"""), + "deletion_protection disabled — database can be accidentally or maliciously deleted", + "CP-9", + "Set deletion_protection = true on all production databases", + ), + ( + re.compile(r"""(?i)backup\s*[:=]\s*false"""), + "Automated backups disabled for database resource", + "CP-9", + "Enable automated backups; set backup_retention_period to at least 7 days", + ), + ( + re.compile(r"""(?i)skip.?final.?snapshot\s*[:=]\s*true"""), + "skip_final_snapshot = true — no snapshot taken before database deletion", + "CP-9", + "Set skip_final_snapshot = false and specify a final_snapshot_identifier", + ), + ( + re.compile(r"""(?i)max.?retries\s*[:=]\s*0"""), + "max_retries set to 0 — no retry on transient failures", + "SI-13", + "Set max_retries to at least 3 with exponential backoff for external service calls", + ), + ( + re.compile(r"""(?i)backup.?retention.?period\s*[:=]\s*0"""), + "backup_retention_period = 0 disables automated database backups", + "CP-9", + "Set backup_retention_period to at least 7 days for production databases", + ), +] + + +class ResilienceGate(BaseGate): + """Gate 12: Detect resilience and backup configuration violations.""" + + name = "Resilience & Backup Gate" + gate_id = "resilience" + mapped_control_ids = ["CP-9", "CP-10", "SI-13"] + + def scan(self, diff_files: list[DiffFile]) -> list[Finding]: + findings: list[Finding] = [] + for diff_file in diff_files: + for line_no, line in diff_file.all_added_lines: + for pattern, description, control_id, remediation in _PATTERNS: + if pattern.search(line): + findings.append( + self._make_finding( + control_id=control_id, + file=diff_file.path, + line=line_no, + description=description, + evidence=line.strip()[:120], + remediation=remediation, + ) + ) + return findings diff --git a/src/controlgate/init_command.py b/src/controlgate/init_command.py new file mode 100644 index 0000000..9bb1240 --- /dev/null +++ b/src/controlgate/init_command.py @@ -0,0 +1,502 @@ +"""ControlGate init command — bootstrap project configuration files.""" + +from __future__ import annotations + +from pathlib import Path + +# --------------------------------------------------------------------------- +# Template builders +# --------------------------------------------------------------------------- + + +def _build_controlgate_yml(baseline: str) -> str: + return f"""\ +# .controlgate.yml — ControlGate Configuration +# Reference: https://github.com/sadayamuthu/controlgate +# +# Baseline: low | moderate | high | privacy | li-saas +baseline: {baseline} + +# Set to true to evaluate against FedRAMP baselines instead of NIST +gov: false + +# NIST control catalog path (auto-managed; change only if self-hosting) +# catalog: baseline/nist80053r5_full_catalog_enriched.json + +# --------------------------------------------------------------------------- +# Security Gates — 18 gates mapped to NIST SP 800-53 Rev. 5 controls +# action: block | warn | disabled +# --------------------------------------------------------------------------- +gates: + # Gate 1 — Secrets & Credentials (IA-5, SC-12, SC-28) + secrets: {{ enabled: true, action: block }} + + # Gate 2 — Cryptography (SC-8, SC-13, SC-17) + crypto: {{ enabled: true, action: block }} + + # Gate 3 — IAM & Access Control (AC-3, AC-5, AC-6) + iam: {{ enabled: true, action: warn }} + + # Gate 4 — Supply Chain / SBOM (SR-3, SR-11, SA-10) + sbom: {{ enabled: true, action: warn }} + + # Gate 5 — Infrastructure as Code (CM-2, CM-6, SC-7) + iac: {{ enabled: true, action: block }} + + # Gate 6 — Input Validation (SI-10, SI-11) + input_validation: {{ enabled: true, action: block }} + + # Gate 7 — Audit Logging (AU-2, AU-3, AU-12) + audit: {{ enabled: true, action: warn }} + + # Gate 8 — Change Control (CM-3, CM-4, CM-5) + change_control: {{ enabled: true, action: warn }} + + # Gate 9 — Dependency Management (SA-12, RA-5, SI-2) + deps: {{ enabled: true, action: warn }} + + # Gate 10 — API Security (SC-8, AC-3, SI-10) + api: {{ enabled: true, action: warn }} + + # Gate 11 — Privacy (AR-2, DM-1, IP-1) + privacy: {{ enabled: true, action: warn }} + + # Gate 12 — Resilience & Reliability (CP-2, CP-10, SC-5) + resilience: {{ enabled: true, action: warn }} + + # Gate 13 — Incident Response (IR-2, IR-4, IR-6) + incident: {{ enabled: true, action: warn }} + + # Gate 14 — Observability (AU-6, SI-4, CA-7) + observability: {{ enabled: true, action: warn }} + + # Gate 15 — Memory Safety (SI-16, SA-11, SA-15) + memsafe: {{ enabled: true, action: warn }} + + # Gate 16 — License Compliance (SA-4, SR-3) + license: {{ enabled: true, action: warn }} + + # Gate 17 — AI/ML Security (SA-11, SI-7, AC-3) + aiml: {{ enabled: true, action: warn }} + + # Gate 18 — Container Security (CM-6, AC-6, SC-7) + container: {{ enabled: true, action: warn }} + +# --------------------------------------------------------------------------- +# Severity thresholds +# --------------------------------------------------------------------------- +thresholds: + block_on: [CRITICAL, HIGH] + warn_on: [MEDIUM] + ignore: [LOW] + +# --------------------------------------------------------------------------- +# Exclusions +# --------------------------------------------------------------------------- +exclusions: + paths: + - "tests/**" + - "docs/**" + - "*.md" + controls: [] + +# --------------------------------------------------------------------------- +# Reporting +# --------------------------------------------------------------------------- +reporting: + format: [json, markdown] + sarif: false + output_dir: .controlgate/reports + +# --------------------------------------------------------------------------- +# Full scan mode +# --------------------------------------------------------------------------- +full_scan: + extensions: + - .py + - .js + - .ts + - .go + - .java + - .rb + - .tf + - .yaml + - .yml + - .json + - .toml + - .sh + - .env + - .sql + skip_dirs: + - .git + - node_modules + - .venv + - venv + - __pycache__ + - dist + - build + - .tox +""" + + +def _build_precommit_config() -> str: + return """\ +# .pre-commit-config.yaml — ControlGate pre-commit hook +# Install: pip install pre-commit && pre-commit install +repos: + - repo: local + hooks: + - id: controlgate + name: ControlGate Security Scan + entry: python -m controlgate scan --mode pre-commit --format markdown + language: python + always_run: true + pass_filenames: false +""" + + +def _build_controlgate_md() -> str: + return """\ +# ControlGate — Security Compliance Guide + +ControlGate enforces **NIST SP 800-53 Rev. 5** (and **FedRAMP**) security controls +on every commit and merge through automated gate scanning. + +--- + +## Quick Start + +```bash +# Install +pip install controlgate + +# Bootstrap this project +controlgate init + +# Baseline audit — scan the entire repo +controlgate scan --mode full --format markdown + +# Pre-commit scan (staged changes only) +controlgate scan --mode pre-commit --format markdown + +# PR scan (branch diff vs main) +controlgate scan --mode pr --target-branch main --format json markdown sarif +``` + +--- + +## Scan Modes + +| Mode | Command | What It Scans | +|------|---------|---------------| +| `pre-commit` | `controlgate scan --mode pre-commit` | Staged changes (`git diff --cached`) | +| `pr` | `controlgate scan --mode pr --target-branch main` | Branch diff vs target branch | +| `full` | `controlgate scan --mode full [--path ./src]` | Entire project directory | + +--- + +## The 18 Security Gates + +| # | Gate | NIST Families | What It Catches | Default Action | +|---|------|---------------|-----------------|----------------| +| 1 | Secrets | IA-5, SC-12, SC-28 | Hardcoded creds, API keys, private keys | BLOCK | +| 2 | Crypto | SC-8, SC-13, SC-17 | Weak algorithms, missing TLS, ssl_verify=False | BLOCK | +| 3 | IAM | AC-3, AC-5, AC-6 | Wildcard IAM, missing auth, overprivileged roles | WARN | +| 4 | Supply Chain | SR-3, SR-11, SA-10 | Unpinned deps, missing lockfiles, build tampering | WARN | +| 5 | IaC | CM-2, CM-6, SC-7 | Public buckets, 0.0.0.0/0 rules, root containers | BLOCK | +| 6 | Input Validation | SI-10, SI-11 | SQL injection, eval(), exposed stack traces | BLOCK | +| 7 | Audit | AU-2, AU-3, AU-12 | Missing security logging, PII in logs | WARN | +| 8 | Change Control | CM-3, CM-4, CM-5 | Unauthorized config changes, missing CODEOWNERS | WARN | +| 9 | Dependencies | SA-12, RA-5, SI-2 | Vulnerable packages, missing lockfiles | WARN | +| 10 | API Security | SC-8, AC-3, SI-10 | Unauthenticated endpoints, missing rate limits | WARN | +| 11 | Privacy | AR-2, DM-1, IP-1 | PII exposure, missing data classification | WARN | +| 12 | Resilience | CP-2, CP-10, SC-5 | Missing retry logic, no circuit breakers | WARN | +| 13 | Incident Response | IR-2, IR-4, IR-6 | Missing error handlers, no alerting hooks | WARN | +| 14 | Observability | AU-6, SI-4, CA-7 | Missing health checks, no structured logging | WARN | +| 15 | Memory Safety | SI-16, SA-11, SA-15 | Buffer overflows, unsafe memory ops | WARN | +| 16 | License Compliance | SA-4, SR-3 | GPL contamination, unlicensed dependencies | WARN | +| 17 | AI/ML Security | SA-11, SI-7, AC-3 | Untrusted model sources, prompt injection risk | WARN | +| 18 | Container Security | CM-6, AC-6, SC-7 | Root containers, privileged mode, latest tags | WARN | + +--- + +## Configuration Reference + +```yaml +baseline: moderate # low | moderate | high | privacy | li-saas +gov: false # true = FedRAMP baselines + +gates: + secrets: { enabled: true, action: block } + # ... (all 18 gates) + +thresholds: + block_on: [CRITICAL, HIGH] + warn_on: [MEDIUM] + ignore: [LOW] + +exclusions: + paths: ["tests/**", "docs/**", "*.md"] + controls: [] + +reporting: + format: [json, markdown] + sarif: false + output_dir: .controlgate/reports + +full_scan: + extensions: [.py, .js, .ts, .tf, .yaml, .yml, .json, .sh] + skip_dirs: [.git, node_modules, .venv, dist, build] +``` + +--- + +## CLI Reference + +```bash +controlgate init [--path PATH] [--baseline LEVEL] [--no-docs] +controlgate scan [--mode pre-commit|pr|full] [--path PATH] + [--target-branch BRANCH] [--diff-file FILE] + [--format json|markdown|sarif] [--output-dir DIR] + [--baseline LEVEL] [--gov] [--config FILE] +controlgate update-catalog +controlgate catalog-info +``` +""" + + +def _build_github_workflow() -> str: + return """\ +name: ControlGate Security Scan + +on: + pull_request: + branches: [main, develop] + push: + branches: [main] + +permissions: + contents: read + pull-requests: write + security-events: write + +jobs: + controlgate: + name: ControlGate Compliance Scan + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ControlGate + run: pip install controlgate + + - name: Run ControlGate scan + id: scan + run: | + controlgate scan \\ + --mode pr \\ + --target-branch ${{ github.base_ref || 'main' }} \\ + --format json markdown sarif \\ + --output-dir .controlgate/reports + continue-on-error: true + + - name: Upload SARIF to GitHub Code Scanning + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: .controlgate/reports/verdict.sarif + continue-on-error: true + + - name: Comment on PR + if: github.event_name == 'pull_request' && always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const reportPath = '.controlgate/reports/verdict.md'; + if (fs.existsSync(reportPath)) { + const body = fs.readFileSync(reportPath, 'utf8'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body, + }); + } + + - name: Enforce gate + if: steps.scan.outcome == 'failure' + run: | + echo "ControlGate blocked this change due to security findings." + exit 1 +""" + + +def _build_gitlab_ci() -> str: + return """\ +stages: + - security + +controlgate: + stage: security + image: python:3.12-slim + before_script: + - pip install controlgate + script: + - > + controlgate scan + --mode pr + --target-branch ${CI_MERGE_REQUEST_TARGET_BRANCH_NAME:-main} + --format json markdown sarif + --output-dir .controlgate/reports + artifacts: + paths: + - .controlgate/reports/ + when: always + expire_in: 30 days + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH +""" + + +def _build_bitbucket_pipelines() -> str: + return """\ +image: python:3.12-slim + +pipelines: + pull-requests: + '**': + - step: + name: ControlGate Security Scan + script: + - pip install controlgate + - > + controlgate scan + --mode pr + --target-branch main + --format json markdown + --output-dir .controlgate/reports + artifacts: + - .controlgate/reports/** + branches: + main: + - step: + name: ControlGate Full Scan + script: + - pip install controlgate + - > + controlgate scan + --mode full + --format json markdown + --output-dir .controlgate/reports + artifacts: + - .controlgate/reports/** +""" + + +# --------------------------------------------------------------------------- +# Interactive helpers +# --------------------------------------------------------------------------- + + +def _prompt(question: str, default: str = "") -> str: + """Prompt with an optional default value.""" + if default: + answer = input(f" {question} [{default}]: ").strip() + return answer if answer else default + return input(f" {question}: ").strip() + + +def _prompt_yn(question: str, default: bool = False) -> bool: + """Yes/no prompt.""" + default_str = "Y/n" if default else "y/N" + answer = input(f" {question} [{default_str}]: ").strip().lower() + if not answer: + return default + return answer.startswith("y") + + +def _write_file(path: Path, content: str) -> bool: + """Write file, prompting if it already exists. Returns True if written.""" + if path.exists() and not _prompt_yn(f"{path} already exists. Overwrite?", default=False): + print(f" Skipped: {path}") + return False + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + print(f" Creating {path} ... done") + return True + + +# --------------------------------------------------------------------------- +# Main command +# --------------------------------------------------------------------------- + + +def init_command(args: object) -> int: + """Execute the init command — interactive project bootstrap. + + Args: + args: Parsed CLI arguments with optional ``path``, ``baseline``, + and ``no_docs`` attributes. + + Returns: + 0 on success. + """ + root = Path(getattr(args, "path", None) or ".").resolve() + no_docs = getattr(args, "no_docs", False) + + print() + print("ControlGate Init") + print("-" * 50) + + # Baseline + default_baseline = getattr(args, "baseline", None) or "moderate" + baseline = _prompt("Baseline? (low/moderate/high/privacy/li-saas)", default=default_baseline) + if baseline not in ("low", "moderate", "high", "privacy", "li-saas"): + print(f" Unknown baseline '{baseline}', using 'moderate'") + baseline = "moderate" + + # CI/CD choices + gen_github = _prompt_yn("Generate GitHub Actions workflow?", default=False) + gen_gitlab = _prompt_yn("Generate GitLab CI job?", default=False) + gen_bitbucket = _prompt_yn("Generate Bitbucket Pipelines step?", default=False) + + print("-" * 50) + + _write_file(root / ".controlgate.yml", _build_controlgate_yml(baseline)) + _write_file(root / ".pre-commit-config.yaml", _build_precommit_config()) + + if not no_docs: + _write_file(root / "CONTROLGATE.md", _build_controlgate_md()) + + if gen_github: + _write_file( + root / ".github" / "workflows" / "controlgate.yml", + _build_github_workflow(), + ) + + if gen_gitlab: + _write_file(root / ".gitlab-ci.yml", _build_gitlab_ci()) + + if gen_bitbucket: + _write_file(root / "bitbucket-pipelines.yml", _build_bitbucket_pipelines()) + + print("-" * 50) + print("Done! ControlGate is configured for this project.") + print() + print(" Next steps:") + print(" 1. Review .controlgate.yml and adjust gate settings") + print(" 2. pip install pre-commit && pre-commit install") + print(" 3. controlgate scan --mode full (baseline audit)") + print() + return 0 diff --git a/tests/test_bump_version.py b/tests/test_bump_version.py new file mode 100644 index 0000000..dd12aaa --- /dev/null +++ b/tests/test_bump_version.py @@ -0,0 +1,112 @@ +"""Tests for hooks/bump_version.py""" + +import subprocess +from unittest.mock import MagicMock, patch + +import bump_version +import pytest + +PYPROJECT_SAME = '[project]\nname = "controlgate"\nversion = "0.1.7"\n' +PYPROJECT_HIGHER = '[project]\nname = "controlgate"\nversion = "0.2.0"\n' + + +class TestParseVersion: + def test_parses_version_from_content(self): + assert bump_version.parse_version(PYPROJECT_SAME) == (0, 1, 7) + + def test_raises_on_missing_version(self): + with pytest.raises(SystemExit): + bump_version.parse_version("[project]\nname = 'x'\n") + + +class TestBumpMinor: + def test_bumps_minor_and_resets_patch(self): + assert bump_version.bump_minor((0, 1, 7)) == (0, 2, 0) + + def test_bumps_minor_on_zero_patch(self): + assert bump_version.bump_minor((0, 2, 0)) == (0, 3, 0) + + def test_preserves_major(self): + assert bump_version.bump_minor((1, 5, 3)) == (1, 6, 0) + + +class TestWriteVersion: + def test_replaces_version_in_content(self): + result = bump_version.write_version(PYPROJECT_SAME, (0, 2, 0)) + assert 'version = "0.2.0"' in result + assert 'version = "0.1.7"' not in result + + +class TestGetMainVersion: + def test_returns_tuple_when_origin_available(self): + mock_result = MagicMock(stdout=PYPROJECT_SAME) + with patch("subprocess.run", return_value=mock_result): + assert bump_version.get_main_version() == (0, 1, 7) + + def test_returns_none_when_origin_unavailable(self, capsys): + with patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, "git")): + result = bump_version.get_main_version() + assert result is None + captured = capsys.readouterr() + assert "warning" in captured.out.lower() + + def test_returns_none_when_git_not_found(self, capsys): + with patch("subprocess.run", side_effect=FileNotFoundError): + result = bump_version.get_main_version() + assert result is None + assert "warning" in capsys.readouterr().out.lower() + + +class TestMain: + def test_bumps_and_stages_when_versions_match(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(PYPROJECT_SAME) + mock_result = MagicMock(stdout=PYPROJECT_SAME) + with ( + patch("bump_version.PYPROJECT_PATH", pyproject), + patch("subprocess.run", return_value=mock_result), + ): + exit_code = bump_version.main() + assert exit_code == 0 + assert 'version = "0.2.0"' in pyproject.read_text() + + def test_skips_when_versions_differ(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(PYPROJECT_HIGHER) + mock_result = MagicMock(stdout=PYPROJECT_SAME) + with ( + patch("bump_version.PYPROJECT_PATH", pyproject), + patch("subprocess.run", return_value=mock_result), + ): + exit_code = bump_version.main() + assert exit_code == 0 + assert pyproject.read_text() == PYPROJECT_HIGHER # file must be byte-for-byte unchanged + + def test_passes_when_origin_unavailable(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(PYPROJECT_SAME) + with ( + patch("bump_version.PYPROJECT_PATH", pyproject), + patch("subprocess.run", side_effect=subprocess.CalledProcessError(128, "git")), + ): + exit_code = bump_version.main() + assert exit_code == 0 + # Version unchanged since we couldn't compare + assert 'version = "0.1.7"' in pyproject.read_text() + + def test_returns_one_when_git_add_fails(self, tmp_path): + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text(PYPROJECT_SAME) + mock_result = type("R", (), {"returncode": 0, "stdout": PYPROJECT_SAME})() + + def side_effects(cmd, **kwargs): + if cmd[0:2] == ["git", "show"]: + return mock_result + raise subprocess.CalledProcessError(1, cmd) + + with ( + patch("bump_version.PYPROJECT_PATH", pyproject), + patch("subprocess.run", side_effect=side_effects), + ): + exit_code = bump_version.main() + assert exit_code == 1 diff --git a/tests/test_cli.py b/tests/test_cli.py index 074faee..dffd2ba 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -302,6 +302,14 @@ def test_scan_uses_git_diff_when_no_file(self): assert exit_code == 0 mock_diff.assert_called_once() + def test_scan_else_branch_with_diff_content(self): + """Covers else branch lines 224-225: no --diff-file, non-full mode, non-empty _get_diff.""" + parser = build_parser() + args = parser.parse_args(["scan", "--mode", "pre-commit", "--format", "json"]) + with patch("controlgate.__main__._get_diff", return_value=_SAMPLE_DIFF): + exit_code = scan_command(args) + assert exit_code == 1 # BLOCK due to secrets + def test_scan_default_formats_from_config(self): """When no --format given, uses config.report_formats.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".diff", delete=False) as f: @@ -372,3 +380,36 @@ def test_main_catalog_info(self): with pytest.raises(SystemExit) as exc_info: main() assert exc_info.value.code == 0 + + +class TestInitCommand: + def test_parser_has_init_command(self): + parser = build_parser() + args = parser.parse_args(["init"]) + assert args.command == "init" + + def test_init_defaults(self): + parser = build_parser() + args = parser.parse_args(["init"]) + assert args.baseline is None + assert args.no_docs is False + + def test_init_with_baseline(self): + parser = build_parser() + args = parser.parse_args(["init", "--baseline", "high"]) + assert args.baseline == "high" + + def test_init_with_no_docs(self): + parser = build_parser() + args = parser.parse_args(["init", "--no-docs"]) + assert args.no_docs is True + + def test_main_init_command(self, tmp_path): + inputs = iter(["moderate", "n", "n", "n"]) + with ( + patch("builtins.input", side_effect=inputs), + patch("sys.argv", ["controlgate", "init", "--path", str(tmp_path)]), + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 0 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..eb5f071 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,28 @@ +"""Tests for ControlGate configuration loading and defaults.""" + +# tests/test_config.py +from controlgate.config import ControlGateConfig + + +class TestFullScanConfig: + def test_default_full_scan_extensions(self): + config = ControlGateConfig.load() + assert ".py" in config.full_scan_extensions + assert ".tf" in config.full_scan_extensions + + def test_default_full_scan_skip_dirs(self): + config = ControlGateConfig.load() + assert ".git" in config.full_scan_skip_dirs + assert "node_modules" in config.full_scan_skip_dirs + + def test_full_scan_extensions_override(self, tmp_path): + cfg_file = tmp_path / ".controlgate.yml" + cfg_file.write_text("full_scan:\n extensions: [.py, .rb]\n") + config = ControlGateConfig.load(cfg_file) + assert config.full_scan_extensions == [".py", ".rb"] + + def test_full_scan_skip_dirs_override(self, tmp_path): + cfg_file = tmp_path / ".controlgate.yml" + cfg_file.write_text("full_scan:\n skip_dirs: [vendor]\n") + config = ControlGateConfig.load(cfg_file) + assert config.full_scan_skip_dirs == ["vendor"] diff --git a/tests/test_full_scan.py b/tests/test_full_scan.py new file mode 100644 index 0000000..9196b40 --- /dev/null +++ b/tests/test_full_scan.py @@ -0,0 +1,192 @@ +"""Tests for full-repo scan mode.""" + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from controlgate.__main__ import _get_full_files +from controlgate.config import ControlGateConfig + + +class TestGetFullFiles: + def test_returns_diff_files(self, tmp_path): + (tmp_path / "app.py").write_text("x = 1\ny = 2\n") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert any(f.path.endswith("app.py") for f in files) + + def test_all_lines_are_added(self, tmp_path): + (tmp_path / "app.py").write_text("line1\nline2\nline3\n") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + py_file = next(f for f in files if f.path.endswith("app.py")) + assert len(py_file.all_added_lines) == 3 + assert py_file.all_added_lines[0] == (1, "line1") + assert py_file.all_added_lines[2] == (3, "line3") + + def test_skips_excluded_paths(self, tmp_path): + (tmp_path / "app.py").write_text("x = 1") + docs = tmp_path / "docs" + docs.mkdir() + (docs / "readme.md").write_text("# Docs") + config = ControlGateConfig.load() + # docs/** is in default exclusions + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + paths = [f.path for f in files] + assert not any("docs" in p for p in paths) + + def test_skips_skip_dirs(self, tmp_path): + (tmp_path / "app.py").write_text("x = 1") + nm = tmp_path / "node_modules" + nm.mkdir() + (nm / "lib.js").write_text("module.exports = {}") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert not any("node_modules" in f.path for f in files) + + def test_skips_unlisted_extensions(self, tmp_path): + (tmp_path / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert not any(f.path.endswith(".png") for f in files) + + def test_skips_binary_files(self, tmp_path): + (tmp_path / "app.py").write_bytes(b"normal\x00binary\x00content") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert not any(f.path.endswith("app.py") for f in files) + + def test_uses_git_ls_files_when_available(self, tmp_path): + (tmp_path / "tracked.py").write_text("x = 1") + (tmp_path / "untracked_secret.py").write_text("password = 'hunter2'") + config = ControlGateConfig.load() + mock_result = MagicMock() + mock_result.stdout = "tracked.py\n" # only tracked.py listed + mock_result.returncode = 0 + with patch("controlgate.__main__.subprocess.run", return_value=mock_result): + files = _get_full_files(tmp_path, config) + assert any(f.path.endswith("tracked.py") for f in files) + assert not any(f.path.endswith("untracked_secret.py") for f in files) + + def test_skips_glob_skip_dirs(self, tmp_path): + (tmp_path / "app.py").write_text("x = 1") + egg_dir = tmp_path / "controlgate.egg-info" + egg_dir.mkdir() + (egg_dir / "PKG-INFO").write_text("[metadata]") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert not any("egg-info" in f.path for f in files) + + def test_skips_empty_files(self, tmp_path): + (tmp_path / "empty.py").write_text("") + config = ControlGateConfig.load() + with patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError): + files = _get_full_files(tmp_path, config) + assert not any(f.path.endswith("empty.py") for f in files) + + def test_handles_path_outside_root(self, tmp_path): + """Covers except ValueError branch (lines 89-90) when abs_path is not under root. + + When git returns an absolute path outside of root, Path.relative_to raises + ValueError and the fallback uses str(abs_path) as rel_path. + """ + outside_file = tmp_path.parent / "_cg_outside_coverage_test.py" + try: + outside_file.write_text("x = 1\n") + config = ControlGateConfig.load() + mock_result = MagicMock() + # Git returns an absolute path — root / absolute resolves to that absolute path, + # which is a sibling of tmp_path and therefore NOT under root, triggering ValueError. + mock_result.stdout = str(outside_file) + "\n" + with patch("controlgate.__main__.subprocess.run", return_value=mock_result): + files = _get_full_files(tmp_path, config) + assert any("_cg_outside_coverage_test.py" in f.path for f in files) + finally: + outside_file.unlink(missing_ok=True) + + def test_skips_file_with_read_bytes_error(self, tmp_path): + """Covers except (OSError, PermissionError) on read_bytes (lines 113-114).""" + (tmp_path / "unreadable.py").write_text("x = 1") + config = ControlGateConfig.load() + + original = Path.read_bytes + + def mock_read_bytes(self): + if self.name == "unreadable.py": + raise PermissionError("Permission denied") + return original(self) + + with ( + patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError), + patch.object(Path, "read_bytes", mock_read_bytes), + ): + files = _get_full_files(tmp_path, config) + assert not any(f.path.endswith("unreadable.py") for f in files) + + def test_skips_file_with_read_text_error(self, tmp_path): + """Covers except (OSError, PermissionError) on read_text (lines 119-120).""" + (tmp_path / "locked.py").write_text("x = 1") + config = ControlGateConfig.load() + + original_text = Path.read_text + + def mock_read_text(self, *args, **kwargs): + if self.name == "locked.py": + raise PermissionError("Permission denied") + return original_text(self, *args, **kwargs) + + with ( + patch("controlgate.__main__.subprocess.run", side_effect=FileNotFoundError), + patch.object(Path, "read_text", mock_read_text), + ): + files = _get_full_files(tmp_path, config) + assert not any(f.path.endswith("locked.py") for f in files) + + +class TestFullScanMode: + def test_parser_accepts_full_mode(self): + from controlgate.__main__ import build_parser + + parser = build_parser() + args = parser.parse_args(["scan", "--mode", "full"]) + assert args.mode == "full" + + def test_full_scan_finds_secrets(self, tmp_path): + """Full scan detects hardcoded secrets in a real file.""" + from controlgate.__main__ import build_parser, scan_command + + secret_file = tmp_path / "config.py" + secret_file.write_text('DB_PASSWORD = "super_secret_123"\n') + parser = build_parser() + args = parser.parse_args(["scan", "--mode", "full", "--format", "json"]) + args.path = str(tmp_path) + exit_code = scan_command(args) + assert exit_code == 1 # BLOCK due to secrets + + def test_full_scan_clean_directory(self, tmp_path): + """Full scan passes for directory with no security issues.""" + from controlgate.__main__ import build_parser, scan_command + + clean_file = tmp_path / "utils.py" + clean_file.write_text("import os\nenv = os.environ.get('KEY')\n") + parser = build_parser() + args = parser.parse_args(["scan", "--mode", "full", "--format", "json"]) + args.path = str(tmp_path) + exit_code = scan_command(args) + assert exit_code == 0 # PASS + + def test_full_scan_empty_directory(self, tmp_path): + """Full scan on empty directory returns 0.""" + from controlgate.__main__ import build_parser, scan_command + + parser = build_parser() + args = parser.parse_args(["scan", "--mode", "full", "--format", "json"]) + args.path = str(tmp_path) + exit_code = scan_command(args) + assert exit_code == 0 diff --git a/tests/test_gates/test_aiml_gate.py b/tests/test_gates/test_aiml_gate.py new file mode 100644 index 0000000..0dbab3d --- /dev/null +++ b/tests/test_gates/test_aiml_gate.py @@ -0,0 +1,149 @@ +"""Tests for the AI/ML Security Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.aiml_gate import AIMLGate + + +@pytest.fixture +def gate(catalog): + return AIMLGate(catalog) + + +_TRUST_REMOTE_CODE_DIFF = """\ +diff --git a/model.py b/model.py +--- /dev/null ++++ b/model.py +@@ -0,0 +1,3 @@ ++from transformers import AutoModelForCausalLM ++model = AutoModelForCausalLM.from_pretrained("some/model", trust_remote_code=True) +""" + +_PICKLE_LOAD_DIFF = """\ +diff --git a/inference.py b/inference.py +--- /dev/null ++++ b/inference.py +@@ -0,0 +1,4 @@ ++import pickle ++with open("model.pkl", "rb") as f: ++ model = pickle.load(f) +""" + +_HTTP_MODEL_DIFF = """\ +diff --git a/download.py b/download.py +--- /dev/null ++++ b/download.py +@@ -0,0 +1,3 @@ ++import urllib.request ++urllib.request.urlretrieve("http://models.example.com/weights.bin", "weights.bin") +""" + +_PROMPT_INJECTION_DIFF = """\ +diff --git a/llm.py b/llm.py +--- /dev/null ++++ b/llm.py +@@ -0,0 +1,4 @@ ++def query_llm(user_input): ++ prompt = f"Answer this: {user_input}" ++ return llm.complete(prompt) +""" + +_PLAINTEXT_WEIGHTS_DIFF = """\ +diff --git a/config.py b/config.py +--- /dev/null ++++ b/config.py +@@ -0,0 +1,3 @@ ++MODEL_WEIGHTS = "/data/models/prod_weights.bin" ++checkpoint_path = "s3://bucket/model.pt" ++weights_path = "/mnt/nfs/weights.ckpt" +""" + +_CLEAN_DIFF = """\ +diff --git a/model.py b/model.py +--- /dev/null ++++ b/model.py +@@ -0,0 +1,4 @@ ++import torch ++model = torch.load("model.pt", map_location="cpu") ++model.eval() +""" + +# NOTE: comment-line trade-off — all_added_lines (models.py) collects every +# added line verbatim; no comment-prefix stripping is applied by the diff +# parser. A commented-out path like `# model_path = "/data/weights.bin"` +# would still fire SC-28. This is an accepted, consistent behaviour across +# all 18 gates (see test_memsafe_gate.py, test_secrets_gate.py — neither +# gates filters comment lines either). Document with a test below so the +# behaviour is intentional and visible. +_SAFE_WEIGHTS_DIFF = """\ +diff --git a/config.py b/config.py +--- /dev/null ++++ b/config.py +@@ -0,0 +1,3 @@ ++model_path = os.environ["MODEL_PATH"] ++checkpoint_path = config.get("checkpoint") ++weights_path = vault.read_secret("weights_path") +""" + + +class TestAIMLGate: + def test_detects_trust_remote_code(self, gate): + diff_files = parse_diff(_TRUST_REMOTE_CODE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any( + "trust_remote_code" in f.description.lower() or "remote" in f.description.lower() + for f in findings + ) + + def test_detects_pickle_load(self, gate): + diff_files = parse_diff(_PICKLE_LOAD_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_http_model_download(self, gate): + diff_files = parse_diff(_HTTP_MODEL_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_prompt_injection_pattern(self, gate): + diff_files = parse_diff(_PROMPT_INJECTION_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_clean_model_load_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_TRUST_REMOTE_CODE_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "aiml" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_TRUST_REMOTE_CODE_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"SI-10", "SC-28", "SR-3"} + for f in findings: + assert f.control_id in valid_ids + + def test_detects_plaintext_model_weights(self, gate): + diff_files = parse_diff(_PLAINTEXT_WEIGHTS_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any( + "plaintext" in f.description.lower() or "weight" in f.description.lower() + for f in findings + ) + assert all(f.control_id == "SC-28" for f in findings) + + def test_no_false_positive_safe_model_path_access(self, gate): + # Safe alternatives: env var lookup, config dict access, and secrets + # manager calls do NOT hardcode a path string, so SC-28 must not fire. + diff_files = parse_diff(_SAFE_WEIGHTS_DIFF) + findings = gate.scan(diff_files) + sc28_findings = [f for f in findings if f.control_id == "SC-28"] + assert len(sc28_findings) == 0 diff --git a/tests/test_gates/test_api_gate.py b/tests/test_gates/test_api_gate.py new file mode 100644 index 0000000..c627745 --- /dev/null +++ b/tests/test_gates/test_api_gate.py @@ -0,0 +1,118 @@ +"""Tests for the API Security Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.api_gate import APIGate + + +@pytest.fixture +def gate(catalog): + return APIGate(catalog) + + +_VERIFY_FALSE_DIFF = """\ +diff --git a/client.py b/client.py +--- a/client.py ++++ b/client.py +@@ -1,3 +1,4 @@ + import requests ++response = requests.get("https://api.example.com", verify=False) +""" + +_CORS_ALL_DIFF = """\ +diff --git a/settings.py b/settings.py +--- /dev/null ++++ b/settings.py +@@ -0,0 +1,2 @@ ++CORS_ORIGIN_ALLOW_ALL = True ++ALLOWED_HOSTS = ["*"] +""" + +_API_KEY_QUERY_DIFF = """\ +diff --git a/api.py b/api.py +--- a/api.py ++++ b/api.py +@@ -1,3 +1,4 @@ ++url = f"https://api.example.com/data?api_key={key}&format=json" +""" + +_CREDENTIALED_CORS_DIFF = """\ +diff --git a/headers.py b/headers.py +--- /dev/null ++++ b/headers.py +@@ -0,0 +1,2 @@ ++response.headers["Access-Control-Allow-Origin"] = "*" ++response.headers["Access-Control-Allow-Credentials"] = "true" +""" + +_GRAPHQL_INTROSPECTION_DIFF = """\ +diff --git a/schema.py b/schema.py +--- /dev/null ++++ b/schema.py +@@ -0,0 +1,2 @@ ++app.add_url_rule("/graphql", view_func=GraphQLView.as_view("graphql", schema=schema, graphiql=True)) ++GRAPHQL_INTROSPECTION = True +""" + +_CLEAN_DIFF = """\ +diff --git a/client.py b/client.py +--- /dev/null ++++ b/client.py +@@ -0,0 +1,3 @@ ++import requests ++response = requests.get("https://api.example.com") ++assert response.status_code == 200 +""" + + +class TestAPIGate: + def test_detects_verify_false(self, gate): + diff_files = parse_diff(_VERIFY_FALSE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any( + "verify" in f.description.lower() or "tls" in f.description.lower() for f in findings + ) + + def test_detects_cors_allow_all(self, gate): + diff_files = parse_diff(_CORS_ALL_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_api_key_in_query(self, gate): + diff_files = parse_diff(_API_KEY_QUERY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_credentialed_cors(self, gate): + diff_files = parse_diff(_CREDENTIALED_CORS_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_clean_code_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_VERIFY_FALSE_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "api" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_VERIFY_FALSE_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"SC-8", "AC-3", "SC-5", "SI-10"} + for f in findings: + assert f.control_id in valid_ids + + def test_detects_graphql_introspection(self, gate): + diff_files = parse_diff(_GRAPHQL_INTROSPECTION_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any( + "graphql" in f.description.lower() or "introspection" in f.description.lower() + for f in findings + ) diff --git a/tests/test_gates/test_container_gate.py b/tests/test_gates/test_container_gate.py new file mode 100644 index 0000000..1c079b7 --- /dev/null +++ b/tests/test_gates/test_container_gate.py @@ -0,0 +1,127 @@ +"""Tests for the Container Security Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.container_gate import ContainerGate + + +@pytest.fixture +def gate(catalog): + return ContainerGate(catalog) + + +_ROOT_USER_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,3 @@ ++FROM python:3.11 ++USER root ++CMD ["python", "app.py"] +""" + +_PRIVILEGED_DIFF = """\ +diff --git a/docker-compose.yml b/docker-compose.yml +--- /dev/null ++++ b/docker-compose.yml +@@ -0,0 +1,5 @@ ++services: ++ app: ++ image: myapp:1.0 ++ privileged: true +""" + +_LATEST_TAG_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,2 @@ ++FROM nginx:latest ++EXPOSE 80 +""" + +_HOST_NETWORK_DIFF = """\ +diff --git a/deployment.yaml b/deployment.yaml +--- /dev/null ++++ b/deployment.yaml +@@ -0,0 +1,5 @@ ++spec: ++ template: ++ spec: ++ hostNetwork: true ++ containers: [] +""" + +_HOST_PID_DIFF = """\ +diff --git a/deployment.yaml b/deployment.yaml +--- /dev/null ++++ b/deployment.yaml +@@ -0,0 +1,4 @@ ++spec: ++ template: ++ spec: ++ hostPID: true +""" + +_CLEAN_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,5 @@ ++FROM python:3.11-slim@sha256:abc123 ++RUN groupadd -r app && useradd -r -g app app ++COPY . /app ++USER app ++CMD ["python", "app.py"] +""" + + +class TestContainerGate: + def test_detects_user_root(self, gate): + diff_files = parse_diff(_ROOT_USER_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("root" in f.description.lower() for f in findings) + + def test_detects_privileged_mode(self, gate): + diff_files = parse_diff(_PRIVILEGED_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_latest_tag(self, gate): + diff_files = parse_diff(_LATEST_TAG_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any( + "latest" in f.description.lower() or "unpinned" in f.description.lower() + for f in findings + ) + + def test_detects_host_network(self, gate): + diff_files = parse_diff(_HOST_NETWORK_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_host_pid(self, gate): + diff_files = parse_diff(_HOST_PID_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_clean_dockerfile_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_ROOT_USER_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "container" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_ROOT_USER_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"CM-6", "CM-7", "SC-7", "SC-39", "AC-6", "SI-7", "AU-12", "SA-10", "SR-3"} + for f in findings: + assert f.control_id in valid_ids diff --git a/tests/test_gates/test_deps_gate.py b/tests/test_gates/test_deps_gate.py new file mode 100644 index 0000000..3b595ab --- /dev/null +++ b/tests/test_gates/test_deps_gate.py @@ -0,0 +1,130 @@ +"""Tests for the Dependency Vulnerability Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.deps_gate import DepsGate + + +@pytest.fixture +def gate(catalog): + return DepsGate(catalog) + + +_NO_VERIFY_DIFF = """\ +diff --git a/Makefile b/Makefile +--- a/Makefile ++++ b/Makefile +@@ -1,3 +1,4 @@ + install: ++\tpip install --no-verify requests +""" + +_IGNORE_SCRIPTS_DIFF = """\ +diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -1,3 +1,4 @@ ++ "install": "npm install --ignore-scripts" +""" + +_HTTP_REGISTRY_DIFF = """\ +diff --git a/.npmrc b/.npmrc +--- /dev/null ++++ b/.npmrc +@@ -0,0 +1,1 @@ ++registry=http://registry.npmjs.org/ +""" + +_UNPINNED_PIP_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,3 @@ ++FROM python:3.11 ++RUN pip install requests flask ++CMD ["python", "app.py"] +""" + +_CLEAN_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,3 @@ ++FROM python:3.11 ++RUN pip install requests==2.31.0 flask==3.0.0 ++CMD ["python", "app.py"] +""" + +_GIT_NO_VERIFY_DIFF = """\ +diff --git a/Makefile b/Makefile +--- a/Makefile ++++ b/Makefile +@@ -1,3 +1,4 @@ + release: ++\tgit commit --no-verify -m "release" +""" + +_RANGE_SPECIFIER_DIFF = """\ +diff --git a/Dockerfile b/Dockerfile +--- /dev/null ++++ b/Dockerfile +@@ -0,0 +1,2 @@ ++FROM python:3.11 ++RUN pip install requests>=2.0.0 +""" + + +class TestDepsGate: + def test_detects_no_verify(self, gate): + diff_files = parse_diff(_NO_VERIFY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any( + "no-verify" in f.description.lower() or "integrity" in f.description.lower() + for f in findings + ) + + def test_detects_ignore_scripts(self, gate): + diff_files = parse_diff(_IGNORE_SCRIPTS_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_http_registry(self, gate): + diff_files = parse_diff(_HTTP_REGISTRY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("http" in f.description.lower() for f in findings) + + def test_detects_unpinned_pip_install(self, gate): + diff_files = parse_diff(_UNPINNED_PIP_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_pinned_install_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_git_no_verify_not_flagged(self, gate): + diff_files = parse_diff(_GIT_NO_VERIFY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_detects_range_specifier(self, gate): + diff_files = parse_diff(_RANGE_SPECIFIER_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_NO_VERIFY_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "deps" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_NO_VERIFY_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"RA-5", "SI-2", "SA-12"} + for f in findings: + assert f.control_id in valid_ids diff --git a/tests/test_gates/test_incident_gate.py b/tests/test_gates/test_incident_gate.py new file mode 100644 index 0000000..fb45627 --- /dev/null +++ b/tests/test_gates/test_incident_gate.py @@ -0,0 +1,114 @@ +"""Tests for the Incident Response Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.incident_gate import IncidentGate + + +@pytest.fixture +def gate(catalog): + return IncidentGate(catalog) + + +_SILENT_EXCEPT_DIFF = """\ +diff --git a/worker.py b/worker.py +--- a/worker.py ++++ b/worker.py +@@ -1,4 +1,6 @@ + def process(): ++ try: ++ do_work() ++ except: ++ pass +""" + +_EMPTY_CATCH_JS_DIFF = """\ +diff --git a/handler.js b/handler.js +--- /dev/null ++++ b/handler.js +@@ -0,0 +1,5 @@ ++async function handle() { ++ try { ++ await process(); ++ } catch(e) {} ++} +""" + +_TRACEBACK_DIFF = """\ +diff --git a/app.py b/app.py +--- /dev/null ++++ b/app.py +@@ -0,0 +1,4 @@ ++@app.errorhandler(500) ++def server_error(e): ++ traceback.print_exc() ++ return str(e), 500 +""" + +_NOTIFY_FALSE_DIFF = """\ +diff --git a/alerting.yaml b/alerting.yaml +--- /dev/null ++++ b/alerting.yaml +@@ -0,0 +1,3 @@ ++alerts: ++ notify: false ++ threshold: critical +""" + +_CLEAN_DIFF = """\ +diff --git a/worker.py b/worker.py +--- /dev/null ++++ b/worker.py +@@ -0,0 +1,6 @@ ++def process(): ++ try: ++ do_work() ++ except ValueError as e: ++ logger.error("Processing failed: %s", e) ++ raise +""" + + +class TestIncidentGate: + def test_detects_bare_except_pass(self, gate): + diff_files = parse_diff(_SILENT_EXCEPT_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any( + "exception" in f.description.lower() or "silent" in f.description.lower() + for f in findings + ) + + def test_detects_empty_catch_js(self, gate): + diff_files = parse_diff(_EMPTY_CATCH_JS_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_traceback_exposure(self, gate): + diff_files = parse_diff(_TRACEBACK_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_notify_false(self, gate): + diff_files = parse_diff(_NOTIFY_FALSE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_logged_exception_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_SILENT_EXCEPT_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "incident" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_SILENT_EXCEPT_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"IR-4", "IR-6", "AU-6"} + for f in findings: + assert f.control_id in valid_ids diff --git a/tests/test_gates/test_license_gate.py b/tests/test_gates/test_license_gate.py new file mode 100644 index 0000000..c7a199a --- /dev/null +++ b/tests/test_gates/test_license_gate.py @@ -0,0 +1,89 @@ +"""Tests for the License Compliance Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.license_gate import LicenseGate + + +@pytest.fixture +def gate(catalog): + return LicenseGate(catalog) + + +_GPL_PIP_DIFF = """\ +diff --git a/requirements.txt b/requirements.txt +--- a/requirements.txt ++++ b/requirements.txt +@@ -1,2 +1,3 @@ + requests==2.31.0 ++gpl-licensed-lib==1.0.0 # GPL-3.0 +""" + +_AGPL_PACKAGE_JSON_DIFF = """\ +diff --git a/package.json b/package.json +--- a/package.json ++++ b/package.json +@@ -1,5 +1,8 @@ + { + "dependencies": { ++ "some-agpl-package": "^1.0.0" + } + } +""" + +_SPDX_GPL_DIFF = """\ +diff --git a/src/vendor/lib.py b/src/vendor/lib.py +--- /dev/null ++++ b/src/vendor/lib.py +@@ -0,0 +1,2 @@ ++# SPDX-License-Identifier: GPL-3.0-only ++def helper(): pass +""" + +_MIT_CLEAN_DIFF = """\ +diff --git a/requirements.txt b/requirements.txt +--- a/requirements.txt ++++ b/requirements.txt +@@ -1,2 +1,3 @@ + requests==2.31.0 ++flask==3.0.0 # MIT +""" + + +class TestLicenseGate: + def test_detects_gpl_in_requirements(self, gate): + diff_files = parse_diff(_GPL_PIP_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any( + "gpl" in f.description.lower() or "license" in f.description.lower() for f in findings + ) + + def test_detects_agpl_in_package_json(self, gate): + diff_files = parse_diff(_AGPL_PACKAGE_JSON_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_spdx_gpl_in_source(self, gate): + diff_files = parse_diff(_SPDX_GPL_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_mit_license_no_findings(self, gate): + diff_files = parse_diff(_MIT_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_GPL_PIP_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "license" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_GPL_PIP_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"SA-4", "SR-3"} + for f in findings: + assert f.control_id in valid_ids diff --git a/tests/test_gates/test_memsafe_gate.py b/tests/test_gates/test_memsafe_gate.py new file mode 100644 index 0000000..d004876 --- /dev/null +++ b/tests/test_gates/test_memsafe_gate.py @@ -0,0 +1,137 @@ +"""Tests for the Memory Safety Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.memsafe_gate import MemSafeGate + + +@pytest.fixture +def gate(catalog): + return MemSafeGate(catalog) + + +_EVAL_DYNAMIC_DIFF = """\ +diff --git a/app.py b/app.py +--- /dev/null ++++ b/app.py +@@ -0,0 +1,3 @@ ++def process(user_input): ++ result = eval(user_input) ++ return result +""" + +_EXEC_DYNAMIC_DIFF = """\ +diff --git a/template.py b/template.py +--- /dev/null ++++ b/template.py +@@ -0,0 +1,2 @@ ++code = f"x = {request.form['value']}" ++exec(code) +""" + +_UNSAFE_RUST_DIFF = """\ +diff --git a/src/lib.rs b/src/lib.rs +--- /dev/null ++++ b/src/lib.rs +@@ -0,0 +1,5 @@ ++pub fn read_ptr(ptr: *const u8) -> u8 { ++ unsafe { ++ *ptr ++ } ++} +""" + +_STRCPY_DIFF = """\ +diff --git a/handler.c b/handler.c +--- /dev/null ++++ b/handler.c +@@ -0,0 +1,4 @@ ++void copy_name(char *dest, char *src) { ++ strcpy(dest, src); ++} +""" + +_CLEAN_DIFF = """\ +diff --git a/app.py b/app.py +--- /dev/null ++++ b/app.py +@@ -0,0 +1,3 @@ ++import ast ++def process(user_input): ++ return ast.literal_eval(user_input) +""" + + +_CFFI_DIFF = """\ +diff --git a/bridge.py b/bridge.py +--- /dev/null ++++ b/bridge.py +@@ -0,0 +1,4 @@ ++import cffi ++ffi = cffi.FFI() ++buf = ffi.buffer(ptr, size) ++data = ffi.cast("uint8_t *", ptr) +""" + +_CFFI_FALSE_POSITIVE_DIFF = """\ +diff --git a/audio.py b/audio.py +--- /dev/null ++++ b/audio.py +@@ -0,0 +1,3 @@ ++# audio_ffi is a non-cffi library wrapper ++result = audio_ffi.cast("int", value) ++buf = mock_ffi.buffer(ptr, size) +""" + + +class TestMemSafeGate: + def test_detects_eval_dynamic(self, gate): + diff_files = parse_diff(_EVAL_DYNAMIC_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("eval" in f.description.lower() for f in findings) + + def test_detects_exec_dynamic(self, gate): + diff_files = parse_diff(_EXEC_DYNAMIC_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_unsafe_rust(self, gate): + diff_files = parse_diff(_UNSAFE_RUST_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_strcpy(self, gate): + diff_files = parse_diff(_STRCPY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_ast_literal_eval_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_EVAL_DYNAMIC_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "memsafe" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_EVAL_DYNAMIC_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"SI-16", "CM-7"} + for f in findings: + assert f.control_id in valid_ids + + def test_detects_cffi_memory_ops(self, gate): + diff_files = parse_diff(_CFFI_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("cffi" in f.description.lower() for f in findings) + + def test_no_false_positive_non_cffi_ffi_variable(self, gate): + diff_files = parse_diff(_CFFI_FALSE_POSITIVE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 diff --git a/tests/test_gates/test_observability_gate.py b/tests/test_gates/test_observability_gate.py new file mode 100644 index 0000000..d80718e --- /dev/null +++ b/tests/test_gates/test_observability_gate.py @@ -0,0 +1,103 @@ +"""Tests for the Observability Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.observability_gate import ObservabilityGate + + +@pytest.fixture +def gate(catalog): + return ObservabilityGate(catalog) + + +_MONITORING_FALSE_DIFF = """\ +diff --git a/main.tf b/main.tf +--- /dev/null ++++ b/main.tf +@@ -0,0 +1,4 @@ ++resource "aws_db_instance" "prod" { ++ monitoring_interval = 0 ++ enable_monitoring = false ++} +""" + +_LOG_DRIVER_NONE_DIFF = """\ +diff --git a/docker-compose.yml b/docker-compose.yml +--- /dev/null ++++ b/docker-compose.yml +@@ -0,0 +1,5 @@ ++services: ++ app: ++ image: myapp:1.0 ++ logging: ++ driver: none +""" + +_K8S_NO_PROBE_DIFF = """\ +diff --git a/deployment.yaml b/deployment.yaml +--- /dev/null ++++ b/deployment.yaml +@@ -0,0 +1,8 @@ ++apiVersion: apps/v1 ++kind: Deployment ++spec: ++ template: ++ spec: ++ containers: ++ - name: app ++ image: myapp:1.0 +""" + +_CLEAN_DIFF = """\ +diff --git a/deployment.yaml b/deployment.yaml +--- /dev/null ++++ b/deployment.yaml +@@ -0,0 +1,10 @@ ++apiVersion: apps/v1 ++kind: Deployment ++spec: ++ template: ++ spec: ++ containers: ++ - name: app ++ image: myapp:1.0 ++ livenessProbe: ++ httpGet: {path: /health, port: 8080} +""" + + +class TestObservabilityGate: + def test_detects_monitoring_false(self, gate): + diff_files = parse_diff(_MONITORING_FALSE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any("monitor" in f.description.lower() for f in findings) + + def test_detects_log_driver_none(self, gate): + diff_files = parse_diff(_LOG_DRIVER_NONE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_k8s_missing_liveness_probe(self, gate): + diff_files = parse_diff(_K8S_NO_PROBE_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_k8s_with_liveness_probe_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_MONITORING_FALSE_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "observability" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_MONITORING_FALSE_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"SI-4", "AU-12"} + for f in findings: + assert f.control_id in valid_ids diff --git a/tests/test_gates/test_privacy_gate.py b/tests/test_gates/test_privacy_gate.py new file mode 100644 index 0000000..6d30eb3 --- /dev/null +++ b/tests/test_gates/test_privacy_gate.py @@ -0,0 +1,90 @@ +"""Tests for the Data Privacy Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.privacy_gate import PrivacyGate + + +@pytest.fixture +def gate(catalog): + return PrivacyGate(catalog) + + +_PII_IN_LOG_DIFF = """\ +diff --git a/views.py b/views.py +--- a/views.py ++++ b/views.py +@@ -1,4 +1,5 @@ + def register(request): ++ logging.debug("User SSN: %s, DOB: %s", user.ssn, user.date_of_birth) + save(user) +""" + +_SERIALIZE_ALL_DIFF = """\ +diff --git a/serializers.py b/serializers.py +--- /dev/null ++++ b/serializers.py +@@ -0,0 +1,3 @@ ++class UserSerializer(ModelSerializer): ++ serialize_all_fields = True ++ model = User +""" + +_NO_EXPIRY_DIFF = """\ +diff --git a/models.py b/models.py +--- /dev/null ++++ b/models.py +@@ -0,0 +1,3 @@ ++class Session(Model): ++ token = CharField() ++ expires_at = None +""" + +_CLEAN_DIFF = """\ +diff --git a/views.py b/views.py +--- /dev/null ++++ b/views.py +@@ -0,0 +1,3 @@ ++def register(request): ++ logging.info("User registered: user_id=%s", user.id) ++ save(user) +""" + + +class TestPrivacyGate: + def test_detects_pii_in_log(self, gate): + diff_files = parse_diff(_PII_IN_LOG_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any( + "pii" in f.description.lower() or "log" in f.description.lower() for f in findings + ) + + def test_detects_serialize_all_fields(self, gate): + diff_files = parse_diff(_SERIALIZE_ALL_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_no_expiry(self, gate): + diff_files = parse_diff(_NO_EXPIRY_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_clean_code_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_PII_IN_LOG_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "privacy" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_PII_IN_LOG_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"PT-2", "PT-3", "SC-28"} + for f in findings: + assert f.control_id in valid_ids diff --git a/tests/test_gates/test_resilience_gate.py b/tests/test_gates/test_resilience_gate.py new file mode 100644 index 0000000..6c2fb6b --- /dev/null +++ b/tests/test_gates/test_resilience_gate.py @@ -0,0 +1,95 @@ +"""Tests for the Resilience & Backup Gate.""" + +import pytest + +from controlgate.diff_parser import parse_diff +from controlgate.gates.resilience_gate import ResilienceGate + + +@pytest.fixture +def gate(catalog): + return ResilienceGate(catalog) + + +_DELETION_PROTECTION_DIFF = """\ +diff --git a/main.tf b/main.tf +--- /dev/null ++++ b/main.tf +@@ -0,0 +1,5 @@ ++resource "aws_db_instance" "main" { ++ identifier = "prod-db" ++ deletion_protection = false ++ instance_class = "db.t3.micro" ++} +""" + +_SKIP_SNAPSHOT_DIFF = """\ +diff --git a/database.tf b/database.tf +--- /dev/null ++++ b/database.tf +@@ -0,0 +1,4 @@ ++resource "aws_db_instance" "prod" { ++ skip_final_snapshot = true ++ deletion_protection = false ++} +""" + +_MAX_RETRIES_ZERO_DIFF = """\ +diff --git a/config.py b/config.py +--- /dev/null ++++ b/config.py +@@ -0,0 +1,2 @@ ++MAX_RETRIES = 0 ++RETRY_DELAY = 1 +""" + +_CLEAN_DIFF = """\ +diff --git a/main.tf b/main.tf +--- /dev/null ++++ b/main.tf +@@ -0,0 +1,5 @@ ++resource "aws_db_instance" "main" { ++ identifier = "prod-db" ++ deletion_protection = true ++ skip_final_snapshot = false ++} +""" + + +class TestResilienceGate: + def test_detects_deletion_protection_false(self, gate): + diff_files = parse_diff(_DELETION_PROTECTION_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + assert any( + "deletion_protection" in f.description.lower() or "backup" in f.description.lower() + for f in findings + ) + + def test_detects_skip_final_snapshot(self, gate): + diff_files = parse_diff(_SKIP_SNAPSHOT_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_detects_max_retries_zero(self, gate): + diff_files = parse_diff(_MAX_RETRIES_ZERO_DIFF) + findings = gate.scan(diff_files) + assert len(findings) > 0 + + def test_clean_config_no_findings(self, gate): + diff_files = parse_diff(_CLEAN_DIFF) + findings = gate.scan(diff_files) + assert len(findings) == 0 + + def test_findings_have_gate_id(self, gate): + diff_files = parse_diff(_DELETION_PROTECTION_DIFF) + findings = gate.scan(diff_files) + for f in findings: + assert f.gate == "resilience" + + def test_findings_have_valid_control_ids(self, gate): + diff_files = parse_diff(_DELETION_PROTECTION_DIFF) + findings = gate.scan(diff_files) + valid_ids = {"CP-9", "CP-10", "SI-13"} + for f in findings: + assert f.control_id in valid_ids diff --git a/tests/test_init_command.py b/tests/test_init_command.py new file mode 100644 index 0000000..dd423e7 --- /dev/null +++ b/tests/test_init_command.py @@ -0,0 +1,246 @@ +"""Tests for the controlgate init command.""" + +import argparse +from unittest.mock import patch + +from controlgate.init_command import ( + _build_bitbucket_pipelines, + _build_controlgate_md, + _build_controlgate_yml, + _build_github_workflow, + _build_gitlab_ci, + _build_precommit_config, +) + + +class TestTemplates: + def test_controlgate_yml_contains_all_18_gates(self): + content = _build_controlgate_yml("moderate") + for gate in [ + "secrets", + "crypto", + "iam", + "sbom", + "iac", + "input_validation", + "audit", + "change_control", + "deps", + "api", + "privacy", + "resilience", + "incident", + "observability", + "memsafe", + "license", + "aiml", + "container", + ]: + assert gate in content, f"Gate '{gate}' missing from template" + + def test_controlgate_yml_has_chosen_baseline(self): + content = _build_controlgate_yml("high") + assert "baseline: high" in content + + def test_precommit_config_has_controlgate_hook(self): + content = _build_precommit_config() + assert "controlgate" in content + assert "pre-commit" in content + + def test_controlgate_md_has_all_modes(self): + content = _build_controlgate_md() + assert "--mode pre-commit" in content + assert "--mode pr" in content + assert "--mode full" in content + + def test_controlgate_md_has_18_gates_table(self): + content = _build_controlgate_md() + for gate_name in [ + "Secrets", + "Crypto", + "IAM", + "Supply Chain", + "IaC", + "Input Validation", + "Audit", + "Change Control", + "Dependencies", + "API Security", + "Privacy", + "Resilience", + "Incident Response", + "Observability", + "Memory Safety", + "License Compliance", + "AI/ML Security", + "Container Security", + ]: + assert gate_name in content, f"Gate '{gate_name}' missing from CONTROLGATE.md" + + def test_github_workflow_has_pr_trigger(self): + content = _build_github_workflow() + assert "pull_request" in content + assert "controlgate scan" in content + + def test_gitlab_ci_has_controlgate_job(self): + content = _build_gitlab_ci() + assert "controlgate" in content + assert "controlgate scan" in content + + def test_bitbucket_pipelines_has_controlgate_step(self): + content = _build_bitbucket_pipelines() + assert "controlgate" in content + assert "controlgate scan" in content + + +class TestInitCommand: + def test_creates_controlgate_yml(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert (tmp_path / ".controlgate.yml").exists() + + def test_creates_precommit_config(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert (tmp_path / ".pre-commit-config.yaml").exists() + + def test_creates_controlgate_md_by_default(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert (tmp_path / "CONTROLGATE.md").exists() + + def test_no_docs_skips_controlgate_md(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=True) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert not (tmp_path / "CONTROLGATE.md").exists() + + def test_creates_github_workflow_when_confirmed(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "y", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert (tmp_path / ".github" / "workflows" / "controlgate.yml").exists() + + def test_skips_github_workflow_when_denied(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert not (tmp_path / ".github" / "workflows" / "controlgate.yml").exists() + + def test_creates_gitlab_ci_when_confirmed(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "y", "n"]) # github=n, gitlab=y, bitbucket=n + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert (tmp_path / ".gitlab-ci.yml").exists() + + def test_skips_gitlab_ci_when_denied(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert not (tmp_path / ".gitlab-ci.yml").exists() + + def test_creates_bitbucket_pipelines_when_confirmed(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "y"]) # github=n, gitlab=n, bitbucket=y + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert (tmp_path / "bitbucket-pipelines.yml").exists() + + def test_skips_bitbucket_pipelines_when_denied(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + assert not (tmp_path / "bitbucket-pipelines.yml").exists() + + def test_overwrite_prompt_on_existing_file(self, tmp_path): + from controlgate.init_command import init_command + + (tmp_path / ".controlgate.yml").write_text("existing: true\n") + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + # overwrite .controlgate.yml prompt → n, then CI prompts → n, n, n + inputs = iter(["moderate", "n", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + # Original content preserved + assert "existing: true" in (tmp_path / ".controlgate.yml").read_text() + + def test_returns_zero_on_success(self, tmp_path): + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=False) + inputs = iter(["moderate", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + result = init_command(args) + assert result == 0 + + def test_invalid_baseline_falls_back_to_moderate(self, tmp_path): + """Covers lines 466-467: unknown baseline triggers fallback to 'moderate'.""" + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline=None, no_docs=True) + inputs = iter(["bogus_baseline", "n", "n", "n"]) + with patch("builtins.input", side_effect=inputs): + init_command(args) + content = (tmp_path / ".controlgate.yml").read_text() + assert "baseline: moderate" in content + + def test_prompt_yn_returns_default_on_empty_input(self, tmp_path): + """Covers line 425: _prompt_yn returns default when user presses Enter.""" + from controlgate.init_command import init_command + + args = argparse.Namespace(path=str(tmp_path), baseline="moderate", no_docs=True) + # Empty string for the three y/n CI prompts — all should use default (False) + inputs = iter(["moderate", "", "", ""]) + with patch("builtins.input", side_effect=inputs): + result = init_command(args) + assert result == 0 + assert not (tmp_path / ".github" / "workflows" / "controlgate.yml").exists() + + +class TestPromptHelpers: + def test_prompt_without_default(self): + """Covers line 417: _prompt returns raw input when no default is set.""" + from controlgate.init_command import _prompt + + with patch("builtins.input", return_value="hello"): + result = _prompt("Enter something") + assert result == "hello" + + def test_prompt_yn_true_default_on_empty(self): + """Covers line 425: _prompt_yn returns True default when input is empty.""" + from controlgate.init_command import _prompt_yn + + with patch("builtins.input", return_value=""): + assert _prompt_yn("Do it?", default=True) is True