diff --git a/.shieldcommit.yaml b/.shieldcommit.yaml new file mode 100644 index 0000000..3a3a494 --- /dev/null +++ b/.shieldcommit.yaml @@ -0,0 +1,53 @@ +# ShieldCommit Configuration File +# Save this as .shieldcommit.yaml in the root of your repository + +# --- Secrets Detection Configuration --- +secrets: + enabled: true + entropy_threshold: 4.0 + custom_patterns: + - "AKID[A-Z0-9]{16}" # AWS Access Key IDs + - "AKIA[0-9A-Z]{16}" + +# --- Artifact Pattern Blocklist Configuration --- +artifacts: + enabled: true + + # Add patterns to the default blocklist + block_patterns: + - "*.tfstate" # Terraform state — contains secrets and resource IDs + - "*.tfstate.backup" + - "kubeconfig" + - "*.kubeconfig" + - ".kube/config" + - "secrets.yaml" # Kubernetes secrets + - "values-prod.yaml" # Helm values with secrets + + # Remove patterns from the default blocklist + # Useful when your team intentionally commits certain files + allow_patterns: + # Example: if your team intentionally commits dist/ to the repo + # - "dist/*" + + # Override severity for specific patterns + # 'error' = block the commit + # 'warning' = warn but allow commit to proceed + severity: + "*.map": error # Sourcemaps — critical security risk + "*.pyc": warning # Python bytecode — not critical + ".DS_Store": warning # macOS metadata — noise + "*.pem": error # Private keys — critical + "*.key": error # Private keys — critical + ".env.production": error # Production secrets — critical + +# --- Publish Audit Configuration --- +# Used by: shieldcommit audit-publish +publish_audit: + enabled: true + package_type: auto # auto | npm | pypi + fail_on_warnings: false # Set to true for strict CI mode + size_limit_mb: 10 # Warn if package exceeds this size + custom_ignore_suggestions: # Additional patterns to suggest for .npmignore/MANIFEST.in + - "**/*.test.ts" + - "**/*.spec.js" + - "test/*" diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 0000000..c9494ea --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,325 @@ +# ShieldCommit - New Features Implementation + +## Overview +Two major features have been added to ShieldCommit to enhance its security scanning capabilities: + +1. **Feature 1: Artifact Pattern Blocklist** - Prevents dangerous files from being committed +2. **Feature 2: Publish Audit** - Pre-flight checks before publishing packages to npm/PyPI + +## Feature 1: Artifact Pattern Blocklist + +### What It Does +Before a commit is allowed, ShieldCommit scans all staged files against a blocklist of dangerous file patterns that should never enter version control. If a match is found, the commit is blocked with a clear human-readable message and fix suggestions. + +### Default Patterns (Fallback) +```python +*.map # Sourcemaps — key security risk +*.js.map, *.ts.map, *.css.map +dist/*, build/*, .next/*, out/* # Compiled output directories +__pycache__/*, *.pyc # Python cache +*.pem, *.key # Private keys & certificates +*.p12, *.pfx, *.crt +.env.production # Environment files with secrets +.env.staging, .env.local +.idea/*, *.suo, *.user # IDE metadata +.DS_Store # macOS noise +``` + +### Configuration via `.shieldcommit.yaml` + +The config file (saved in repo root) allows teams to: +- **Extend** the blocklist with custom patterns +- **Allow** patterns to override the defaults +- **Set severity** levels (error/warning) + +Example: +```yaml +artifacts: + enabled: true + + block_patterns: + - "*.tfstate" # Add Terraform state + - "kubeconfig" + - "secrets.yaml" + + allow_patterns: + - "dist/*" # Our team commits dist/ + + severity: + "*.map": error # Block sourcemaps + ".DS_Store": warning # Warn on macOS files +``` + +### Severity Levels +- **error** — Block the commit, exit with code 2 +- **warning** — Print warning, allow commit to proceed + +### Usage in Scan Command + +The `scan` command now includes artifact detection: + +```bash +shieldcommit scan # Scans staged files for both secrets AND artifacts +shieldcommit scan file.py # Scans specific file + +# Output when violations found: +🛡 ShieldCommit — Artifact Detected + + File : dist/main.js.map + Pattern: *.map + Risk : Sourcemap files embed your entire original source code as + plain text inside a JSON blob. This is exactly how Anthropic + accidentally leaked 513,000 lines of Claude Code on March 31 2026. + + Fix : Add "*.map" to your .gitignore + $ echo "*.map" >> .gitignore + + To override (not recommended): + $ git commit --no-verify +``` + +### Exit Codes for Scan Command +- **0** — No secrets or critical artifacts found +- **1** — Secrets detected (blocking) +- **2** — Artifact violations detected (blocking) + +--- + +## Feature 2: Publish Audit + +### What It Does +A standalone CLI command (`audit-publish`) that runs **before** you publish a package to npm or PyPI. It simulates what the publish command would include, scans those files for artifact and secret violations, and produces a risk report. + +**Important**: It only audits — it does NOT actually publish. + +### CLI Interface + +```bash +# Auto-detect package type (npm or pypi) +shieldcommit audit-publish + +# Explicit package type +shieldcommit audit-publish --type npm +shieldcommit audit-publish --type pypi + +# Output formats +shieldcommit audit-publish --output json +shieldcommit audit-publish --output table # default + +# Write fix suggestions to file +shieldcommit audit-publish --fix-suggestions # Creates .npmignore.suggested + +# CI mode — fail on warnings too +shieldcommit audit-publish --ci +``` + +### How It Works + +**For npm packages:** +- Runs: `npm pack --dry-run --json` +- Parses output to get files npm would include +- Automatically respects `.npmignore` and `package.json` "files" field + +**For PyPI packages:** +- Analyzes `pyproject.toml` or `setup.py` +- Checks what's NOT in `.gitignore` and would be included +- Can also use `python -m build --dry-run` + +### Scanning Logic +For each file to be published: +1. Check against **artifact pattern blocklist** (same as scan command) +2. Check for **secrets** using intelligent detection +3. Check **package size** against configured limits + +### Output — Table Format (Default) + +``` +🛡 ShieldCommit — Publish Audit + Package : @yourorg/shieldcommit + Version : 1.2.0 + Type : npm + Files : 47 files will be published (12.3 MB total) + + VIOLATIONS + ────────────────────────────────────────────────────────── + SEVERITY FILE ISSUE + ────────────────────────────────────────────────────────── + ERROR dist/main.js.map (58.9 MB) Sourcemap — embeds full source + ERROR .env.production Env file — may contain secrets + WARNING .DS_Store OS metadata noise + ────────────────────────────────────────────────────────── + 2 errors, 1 warning + + SUGGESTED FIXES + ────────────────────────────────────────────────────────── + Add to .npmignore: + *.map + .env.* + .DS_Store + + Or restrict package.json "files" to only what you intend: + "files": ["dist/**/*.js", "src/", "README.md"] + ────────────────────────────────────────────────────────── + + ❌ Publish audit FAILED — fix errors before publishing. + Run with --fix-suggestions to write .npmignore.suggested +``` + +### Output — JSON Format (For CI Pipelines) + +```json +{ + "package": "@yourorg/shieldcommit", + "version": "1.2.0", + "type": "npm", + "total_files": 47, + "total_size_mb": 12.3, + "violations": [ + { + "severity": "error", + "file": "dist/main.js.map", + "size_mb": 58.9, + "pattern": "*.map", + "issue": "Sourcemap file embeds full original source code" + } + ], + "passed": false, + "error_count": 2, + "warning_count": 1 +} +``` + +### Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Clean, safe to publish | +| 1 | Warnings only (or warnings in non-CI mode) | +| 2 | Errors found, do not publish | + +### Configuration via `.shieldcommit.yaml` + +```yaml +publish_audit: + enabled: true + package_type: auto # auto | npm | pypi + fail_on_warnings: false # Set true for strict CI mode + size_limit_mb: 10 # Warn if package exceeds this + custom_ignore_suggestions: # Appended to output + - "*.test.ts" + - "test/*" +``` + +--- + +## File Structure + +### New Files Created + +``` +src/shieldcommit/ +├── config.py # Configuration parser for .shieldcommit.yaml +├── artifact_detector.py # Artifact pattern matching and blocking +├── publish_audit.py # Publish audit implementation +└── __main__.py # (Updated) New CLI commands + +.shieldcommit.yaml # Example configuration file + +tests/ +├── test_artifact_detector.py # Tests for artifact scanning +└── test_config.py # Tests for configuration +``` + +### Modified Files + +``` +src/shieldcommit/ +├── scanner.py # Updated to include artifact detection +├── __main__.py # Added audit-publish command, updated scan output + +requirements.txt # Added: pyyaml, toml +``` + +--- + +## Integration Points + +### Pre-Commit Hook +The artifact scanning is automatically integrated into the existing pre-commit hook. When `shieldcommit scan` is called (either directly or via the hook), it now: +1. Scans for secrets +2. **NEW**: Scans for artifact violations +3. Scans for version warnings +4. **NEW**: Blocks commits with error-level artifacts + +### CI/CD Pipeline +The new `audit-publish` command is designed for CI integration: + +```yaml +# Example: GitHub Actions +- name: Audit before publish + run: shieldcommit audit-publish --ci --output json + +- name: Publish to npm + if: success() + run: npm publish +``` + +--- + +## Implementation Details + +### Pattern Matching +- Uses Python's `fnmatch` module for glob-style pattern matching +- Supports wildcards (`*`), directory patterns (`dir/*`), and exact matches +- Case-insensitive on most filesystems + +### Configuration Loading +- Looks for `.shieldcommit.yaml` in the repository root +- Falls back to hardcoded defaults if file not found +- Gracefully handles missing or malformed YAML + +### Severity Handling +- Each pattern has a severity level (error or warning) +- Can be overridden per-pattern in `.shieldcommit.yaml` +- Error violations block operations; warnings only notify + +--- + +## Testing + +Tests are provided for: +- Pattern matching logic +- Violation creation and formatting +- Configuration loading and defaults +- Severity assignment +- Violation filtering + +Run tests: +```bash +pytest tests/test_artifact_detector.py +pytest tests/test_config.py +``` + +--- + +## Future Enhancements + +Potential improvements: +1. Custom pattern syntax support (regex, more complex glob patterns) +2. Integration with pre-push hook in addition to pre-commit +3. Plugin system for custom validators +4. Slack/email notifications on violations +5. Web UI for configuration management +6. Statistics and reporting dashboard + +--- + +## References + +### Claude Code Incident +The sourcemap security risk is based on the real Anthropic Claude Code incident (March 31, 2026) where 513,000 lines of code were leaked via committed sourcemaps. + +### Related Features +- Secret detection: Intelligent entropy-based scanning (no patterns) +- Version warnings: EKS, RDS, AKS, GCP, Azure DB version checks +- Package manager support: npm, PyPI (setuptools, poetry) diff --git a/QUICK_START_NEW_FEATURES.md b/QUICK_START_NEW_FEATURES.md new file mode 100644 index 0000000..e9c20b2 --- /dev/null +++ b/QUICK_START_NEW_FEATURES.md @@ -0,0 +1,295 @@ +# Quick Start Guide - New Features + +## Feature 1: Artifact Pattern Blocklist + +### Basic Usage + +The artifact scanner is automatically integrated into the `scan` command: + +```bash +# Scan staged files (includes artifact detection) +shieldcommit scan + +# Scan specific files +shieldcommit scan src/*.py dist/bundle.js.map + +# Example output when violations found: +❌ Commit blocked due to artifact violations. Use --no-verify to skip. +``` + +### Default Patterns Blocked + +By default, ShieldCommit blocks: +- Sourcemaps (`.map`, `.js.map`) +- Build artifacts (`dist/*`, `build/*`, `.next/*`) +- Python cache (`__pycache__/*`, `*.pyc`) +- Private keys (`*.pem`, `*.key`) +- Environment files (`.env.production`, `.env.local`) +- IDE metadata (`.idea/*`, `*.suo`) +- OS metadata (`.DS_Store`) + +### Configuration (`.shieldcommit.yaml`) + +Create this file in your repo root to customize: + +```yaml +artifacts: + enabled: true + + # Add more patterns to block + block_patterns: + - "*.tfstate" # Terraform state files + - ".secrets/*" # Custom secrets directory + + # Allow specific patterns (override defaults) + allow_patterns: + - "dist/*" # e.g., if you commit dist/ + + # Set severity per pattern + severity: + "*.map": error # Block sourcemaps (default) + ".DS_Store": warning # Warn on macOS noise + "*.pyc": warning # Allow .pyc files with warning +``` + +### Override a Violation (Not Recommended) + +If you need to commit a blocked file: + +```bash +git commit --no-verify # Skips all ShieldCommit checks +``` + +--- + +## Feature 2: Publish Audit + +### Before Publishing a Package + +```bash +# Audit before publishing to npm +shieldcommit audit-publish + +# Audit for PyPI +shieldcommit audit-publish --type pypi + +# View results as JSON (for scripts) +shieldcommit audit-publish --output json + +# Generate fix suggestions file +shieldcommit audit-publish --fix-suggestions +# Creates: .npmignore.suggested or MANIFEST.in.suggested +``` + +### Understanding the Output + +``` +Package : my-package +Version : 1.0.0 +Type : npm +Files : 47 files will be published + +VIOLATIONS +──────────────────────────────────────── +SEVERITY FILE ISSUE +──────────────────────────────────────── +ERROR dist/bundle.map Sourcemap — embeds source +WARNING .DS_Store macOS metadata +──────────────────────────────────────── +1 error, 1 warning +``` + +**What it checks:** +1. Artifact patterns (sourcemaps, keys, env files, etc.) +2. Secret leaks (credentials, API keys) +3. Package size limits + +### Exit Codes + +| Code | Meaning | Action | +|------|---------|--------| +| 0 | All clear | Safe to publish | +| 1 | Warnings only | Review suggestions, can publish if acceptable | +| 2 | Errors found | Do NOT publish - fix first | + +### CI/CD Integration + +```yaml +# GitHub Actions example +- name: Audit package + run: shieldcommit audit-publish --ci + +- name: Publish (only if audit passes) + if: success() + run: npm publish +``` + +### Interpreting Violations + +**Error violations** (must fix): +- Sourcemaps (.map files) +- Private keys (*.pem, *.key) +- Env files with secrets (.env.production, .env.local) + +**Warning violations** (recommended to fix): +- macOS metadata (.DS_Store) +- IDE files (*.suo, .idea/*) +- Python bytecode (*.pyc) + +### Common Fixes + +**Sourcemaps leak source code:** +```bash +# .npmignore +*.map +*.js.map +*.ts.map +``` + +**Environment files:** +```bash +# .gitignore (first priority) +.env.production +.env.local +.env.*.local +``` + +**Build artifacts should not be committed:** +```bash +# Add to .gitignore +dist/ +build/ +.next/ +``` + +--- + +## Tips & Best Practices + +### 1. Use `.gitignore` First +Always add problematic files to `.gitignore` before committing: +```bash +echo "*.map" >> .gitignore +git rm --cached dist/bundle.map +git commit -m "Remove sourcemap from tracking" +``` + +### 2. Configure Your Project +Copy the example config to your repo: +```bash +cp .shieldcommit.yaml .shieldcommit.yaml.local +# Edit .shieldcommit.yaml.local with team-specific patterns +``` + +### 3. Pre-Commit Hook (if not already installed) +```bash +shieldcommit install +``` + +Now ShieldCommit runs automatically on every commit! + +### 4. Pre-Push Checks +Run audit-publish before pushing: +```bash +# Before publishing to npm +shieldcommit audit-publish && npm publish +``` + +### 5. CI/CD Pipeline +Add to your CI configuration: +```yaml +# Check for artifacts in all commits +- shieldcommit scan + +# Before publishing packages +- shieldcommit audit-publish --ci +``` + +--- + +## Troubleshooting + +### "Commit blocked due to artifact violations" + +Check what was detected: +```bash +shieldcommit scan dist/ .env.local +``` + +Fix the issue: +```bash +# Add to .gitignore +echo "dist/*" >> .gitignore +echo ".env.*" >> .gitignore + +# Unstage the files +git reset dist/ .env.local + +# Try committing again +git commit +``` + +### "Pattern X is too broad" + +Customize in `.shieldcommit.yaml`: +```yaml +artifacts: + allow_patterns: + - "dist/*" # Your team intentionally commits this +``` + +### "False positive on my file type" + +Report it and use severity=warning temporarily: +```yaml +artifacts: + severity: + "*.custom": warning # Change to warning until fixed +``` + +--- + +## For CI/CD Teams + +### GitHub Actions Workflow + +```yaml +name: Security Checks +on: [push, pull_request] + +jobs: + shieldcommit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Install ShieldCommit + run: pip install -e . + + - name: Check artifacts in code + run: shieldcommit scan src/ + + - name: Audit package before publish + if: startsWith(github.ref, 'refs/tags/') + run: shieldcommit audit-publish --ci +``` + +### GitLab CI + +```yaml +security-checks: + script: + - pip install -e . + - shieldcommit scan src/ + - shieldcommit audit-publish --ci + only: + - push +``` + +--- + +## Need Help? + +- See [FEATURES.md](FEATURES.md) for detailed technical documentation +- Check `.shieldcommit.yaml` for all available configuration options +- Run `shieldcommit --help` for CLI reference diff --git a/requirements.txt b/requirements.txt index f4aadc9..7a70786 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ click>=8.0 pytest>=7.0 +pyyaml>=6.0 +toml>=0.10.0 diff --git a/src/shieldcommit/__main__.py b/src/shieldcommit/__main__.py index 4941af0..7873db3 100644 --- a/src/shieldcommit/__main__.py +++ b/src/shieldcommit/__main__.py @@ -4,6 +4,9 @@ from pathlib import Path from .scanner import scan_files from .installer import install_hook, uninstall_hook +from .artifact_detector import has_blocking_violations, filter_violations_by_severity +from .publish_audit import PublishAudit +from .config import ShieldCommitConfig def get_staged_files(): @@ -56,6 +59,25 @@ def scan(paths): result = scan_files(to_scan) findings = result["findings"] warnings = result["warnings"] + artifacts = result["artifacts"] + + # Display artifact violations (non-blocking warnings) + if artifacts: + error_artifacts = filter_violations_by_severity(artifacts, "error") + warning_artifacts = filter_violations_by_severity(artifacts, "warning") + + if error_artifacts: + click.echo("🛡 ShieldCommit — Artifact Violations Found\n") + for artifact in error_artifacts: + click.echo(artifact.format_message()) + click.echo("\n") + + if warning_artifacts: + click.echo("⚠️ ShieldCommit — Artifact Warnings\n") + for artifact in warning_artifacts: + click.echo(f" File : {artifact.file_path}") + click.echo(f" Pattern: {artifact.pattern}") + click.echo(f" Note : {artifact.get_explanation()}\n") # Display warnings (non-blocking) if warnings: @@ -67,6 +89,15 @@ def scan(paths): click.echo("") # Display findings (blocking) + if not findings and not (error_artifacts := filter_violations_by_severity(artifacts, "error")): + click.echo("✓ No secrets or critical artifacts found.") + sys.exit(0) + + # Block if there are error-level artifact violations + if has_blocking_violations(artifacts): + click.echo("\n❌ Commit blocked due to artifact violations. Use --no-verify to skip.") + sys.exit(2) + if not findings: click.echo("✓ No secrets found.") sys.exit(0) @@ -101,3 +132,98 @@ def uninstall(): click.echo("🗑️ ShieldCommit hook removed.") else: click.echo("Hook not found.") + + +@cli.command() +@click.option( + "--type", + type=click.Choice(["npm", "pypi", "auto"]), + default="auto", + help="Package type (auto-detect by default)", +) +@click.option( + "--output", + type=click.Choice(["table", "json"]), + default="table", + help="Output format", +) +@click.option( + "--fix-suggestions", + is_flag=True, + help="Write fix suggestions to .npmignore.suggested or similar", +) +@click.option( + "--ci", + is_flag=True, + help="CI mode - exit non-zero on any errors", +) +def audit_publish(type, output, fix_suggestions, ci): + """ + Audit package before publishing to npm or PyPI. + Simulates what files would be published and scans for secrets/artifacts. + + Exit codes: + 0 - clean, safe to publish + 1 - warnings only + 2 - errors found, do not publish + """ + config = ShieldCommitConfig() + + if not config.publish_audit_enabled(): + click.echo("ℹ️ Publish audit is disabled in .shieldcommit.yaml") + sys.exit(0) + + audit = PublishAudit(config) + + # Override package type if specified + if type != "auto": + # Create a temporary config override + class OverrideConfig(ShieldCommitConfig): + def get_package_type(self): + return type + + audit.config = OverrideConfig() + + # Run audit + result = audit.audit() + + # Output + if output == "json": + click.echo(audit.format_json_output(result)) + else: + click.echo(audit.format_table_output(result)) + + # Write fix suggestions if requested + if fix_suggestions and result.get("violations"): + patterns = set() + for v in result.get("violations", []): + if v.get("pattern"): + patterns.add(v["pattern"]) + + pkg_type = result.get("type", "unknown") + if pkg_type == "npm": + with open(".npmignore.suggested", "w") as f: + f.write("# Auto-generated by ShieldCommit\n") + for p in sorted(patterns): + f.write(f"{p}\n") + click.echo(f"\n✅ Fix suggestions written to .npmignore.suggested") + elif pkg_type == "pypi": + with open("MANIFEST.in.suggested", "w") as f: + f.write("# Auto-generated by ShieldCommit\n") + for p in sorted(patterns): + f.write(f"global-exclude {p}\n") + click.echo(f"\n✅ Fix suggestions written to MANIFEST.in.suggested") + + # Exit codes + if not result.get("passed"): + error_count = result.get("error_count", 0) + if error_count > 0: + sys.exit(2) + else: + sys.exit(1 if ci else 0) + else: + sys.exit(0) + + +if __name__ == "__main__": + cli() diff --git a/src/shieldcommit/artifact_detector.py b/src/shieldcommit/artifact_detector.py index 212d398..a8fc82d 100644 --- a/src/shieldcommit/artifact_detector.py +++ b/src/shieldcommit/artifact_detector.py @@ -1,19 +1,132 @@ -def detect_artifacts(data): +"""Artifact pattern blocklist detector - prevents commit of dangerous file patterns.""" +from pathlib import Path +from fnmatch import fnmatch +from typing import List, Dict, Any +from .config import ShieldCommitConfig + + +class ArtifactViolation: + """Represents a single artifact pattern violation.""" + + PATTERN_EXPLANATIONS = { + "*.map": "Sourcemap files embed your entire original source code as plain text inside a JSON blob. This is exactly how Anthropic accidentally leaked 513,000 lines of Claude Code on March 31 2026.", + "*.js.map": "JavaScript sourcemap — contains full original source code.", + "*.ts.map": "TypeScript sourcemap — contains full original source code.", + "*.css.map": "CSS sourcemap — contains full original source code.", + "dist/*": "Compiled output directory — should not be committed to version control.", + "build/*": "Build output directory — should not be committed to version control.", + ".next/*": "Next.js build output — should not be committed to version control.", + "out/*": "Output directory — should not be committed to version control.", + "__pycache__/*": "Python cache directory — should not be committed to version control.", + "*.pyc": "Python compiled bytecode — should not be committed to version control.", + "*.pem": "Private SSH/TLS key — is a security risk.", + "*.key": "Private key file — is a security risk.", + "*.p12": "PKCS#12 certificate — may contain private keys.", + "*.pfx": "PFX certificate — may contain private keys.", + "*.crt": "Certificate file — may contain sensitive data.", + ".env.production": "Production environment file — likely contains secrets.", + ".env.staging": "Staging environment file — likely contains secrets.", + ".env.local": "Local environment file — may contain secrets.", + ".idea/*": "JetBrains IDE metadata — contains internal project configuration.", + "*.suo": "Visual Studio user options file — contains user-specific settings.", + "*.user": "Visual Studio project user file — contains user-specific settings.", + ".DS_Store": "macOS metadata file — noisy but not a direct security risk.", + "*.tfstate": "Terraform state file — contains secrets and resource IDs.", + "*.tfstate.backup": "Terraform state backup — contains secrets and resource IDs.", + "kubeconfig": "Kubernetes config — contains cluster credentials.", + "*.kubeconfig": "Kubernetes config file — contains cluster credentials.", + ".kube/config": "Kubernetes config directory — contains cluster credentials.", + "secrets.yaml": "Kubernetes secrets manifest — contains encoded secrets.", + "values-prod.yaml": "Helm values for production — likely contains secrets.", + } + + def __init__(self, file_path: str, pattern: str, severity: str, config: ShieldCommitConfig): + self.file_path = file_path + self.pattern = pattern + self.severity = severity + self.config = config + + def get_explanation(self) -> str: + """Get explanation for this pattern violation.""" + return self.PATTERN_EXPLANATIONS.get( + self.pattern, + f"Pattern '{self.pattern}' is blocked by artifact scanner configuration.", + ) + + def get_fix_suggestion(self) -> str: + """Get fix suggestion for this violation.""" + return f'Add "{self.pattern}" to your .gitignore' + + def format_message(self) -> str: + """Format violation as human-readable message.""" + explanation = self.get_explanation() + fix = self.get_fix_suggestion() + + message = f"🛡 ShieldCommit — Artifact Detected\n\n" + message += f" File : {self.file_path}\n" + message += f" Pattern: {self.pattern}\n" + message += f" Risk : {explanation}\n\n" + message += f" Fix : {fix}\n" + message += f" $ echo '{self.pattern}' >> .gitignore\n\n" + message += f" To override (not recommended):\n" + message += f" $ git commit --no-verify\n\n" + message += " ──────────────────────────────────────────" + + return message + + +def matches_pattern(file_path: str, pattern: str) -> bool: """ - Detects patterns in data that may represent artifacts. - :param data: The input data to analyze - :return: List of detected artifact patterns + Check if file_path matches the pattern. + Uses fnmatch for glob-style matching. """ - artifact_patterns = [] - # Example pattern detection logic here - # This is a placeholder; actual implementation will depend on specific requirements. + # Normalize paths + normalized_file = file_path.replace("\\", "/") + normalized_pattern = pattern.replace("\\", "/") + + return fnmatch(normalized_file, normalized_pattern) + + +def scan_artifacts(file_paths: List[str], config: ShieldCommitConfig = None) -> Dict[str, Any]: + """ + Scan files for artifact pattern violations. + + Args: + file_paths: List of file paths to scan + config: ShieldCommitConfig instance (loads from .shieldcommit.yaml if None) + + Returns: + Dict with 'violations' (list) and 'passed' (bool) + """ + if config is None: + config = ShieldCommitConfig() + + if not config.artifacts_enabled(): + return {"violations": [], "passed": True} + + violations = [] + active_patterns = config.get_active_patterns() + + for file_path in file_paths: + for pattern in active_patterns: + if matches_pattern(file_path, pattern): + severity = config.get_severity(pattern) + violation = ArtifactViolation(file_path, pattern, severity, config) + violations.append(violation) + + return { + "violations": violations, + "passed": len(violations) == 0, + } + - if 'artifact' in data: - artifact_patterns.append('Basic Artifact Pattern') - return artifact_patterns +def has_blocking_violations(violations: List[ArtifactViolation]) -> bool: + """Check if any violations have error severity.""" + return any(v.severity == "error" for v in violations) -if __name__ == '__main__': - sample_data = 'This is a test data which may contain artifact information.' - found_artifacts = detect_artifacts(sample_data) - print('Detected artifacts:', found_artifacts) \ No newline at end of file +def filter_violations_by_severity(violations: List[ArtifactViolation], severity: str) -> List[ + ArtifactViolation +]: + """Filter violations by severity level.""" + return [v for v in violations if v.severity == severity] diff --git a/src/shieldcommit/config.py b/src/shieldcommit/config.py index 70a0408..26aa954 100644 --- a/src/shieldcommit/config.py +++ b/src/shieldcommit/config.py @@ -1,23 +1,157 @@ +"""Configuration parser for .shieldcommit.yaml""" import yaml +from pathlib import Path +from typing import Dict, List, Any, Optional + class ShieldCommitConfig: - def __init__(self, config_file): - self.config_file = config_file - self.config_data = self.load_config() + """Parse and manage .shieldcommit.yaml configuration.""" + + # Default artifact patterns (fallback) + DEFAULT_ARTIFACT_PATTERNS = [ + # Sourcemaps — the Claude Code incident + "*.map", + "*.js.map", + "*.ts.map", + "*.css.map", + # Compiled output directories staged by mistake + "dist/*", + "build/*", + ".next/*", + "out/*", + "__pycache__/*", + "*.pyc", + # Private keys and certificates + "*.pem", + "*.key", + "*.p12", + "*.pfx", + "*.crt", + # Environment files (beyond .env which secrets scanner may already catch) + ".env.production", + ".env.staging", + ".env.local", + # IDE folders with internal project metadata + ".idea/*", + "*.suo", + "*.user", + # macOS noise with potential metadata + ".DS_Store", + ] + + DEFAULT_SEVERITY = { + "*.map": "error", + "*.js.map": "error", + "*.ts.map": "error", + "*.css.map": "error", + "*.pem": "error", + "*.key": "error", + "*.p12": "error", + "*.pfx": "error", + "*.crt": "error", + ".env.production": "error", + ".env.staging": "error", + ".env.local": "error", + "*.pyc": "warning", + ".DS_Store": "warning", + } + + def __init__(self, config_path: Optional[Path] = None): + """ + Initialize config from .shieldcommit.yaml file. + If not found, use defaults. + """ + self.config_path = config_path or Path.cwd() / ".shieldcommit.yaml" + self.config = self._load_config() + + def _load_config(self) -> Dict[str, Any]: + """Load config from file or return defaults.""" + if self.config_path.exists(): + try: + with open(self.config_path, "r") as f: + return yaml.safe_load(f) or {} + except Exception as e: + print(f"⚠️ Failed to load {self.config_path}: {e}") + return {} + return {} + + def artifacts_enabled(self) -> bool: + """Check if artifact scanning is enabled.""" + artifacts_config = self.config.get("artifacts", {}) + return artifacts_config.get("enabled", True) + + def get_active_patterns(self) -> List[str]: + """Get active artifact patterns: defaults - allow_patterns + block_patterns.""" + if not self.artifacts_enabled(): + return [] + + artifacts_config = self.config.get("artifacts", {}) + + # Start with defaults + patterns = self.DEFAULT_ARTIFACT_PATTERNS.copy() + + # Remove allowed patterns + allow_patterns = artifacts_config.get("allow_patterns", []) or [] + patterns = [p for p in patterns if p not in allow_patterns] + + # Add blocked patterns + block_patterns = artifacts_config.get("block_patterns", []) or [] + patterns.extend(block_patterns) + + return list(set(patterns)) # Remove duplicates + + def get_severity(self, pattern: str) -> str: + """Get severity level for a pattern (error/warning).""" + artifacts_config = self.config.get("artifacts", {}) + severity_overrides = artifacts_config.get("severity", {}) or {} + + # Check if pattern has override + if pattern in severity_overrides: + return severity_overrides[pattern] + + # Check default severity + if pattern in self.DEFAULT_SEVERITY: + return self.DEFAULT_SEVERITY[pattern] + + # Default to error if not specified + return "error" + + def publish_audit_enabled(self) -> bool: + """Check if publish audit is enabled.""" + publish_config = self.config.get("publish_audit", {}) + return publish_config.get("enabled", True) + + def get_package_type(self) -> str: + """Get package type (auto/npm/pypi).""" + publish_config = self.config.get("publish_audit", {}) + return publish_config.get("package_type", "auto") + + def fail_on_warnings(self) -> bool: + """Check if publish audit should fail on warnings.""" + publish_config = self.config.get("publish_audit", {}) + return publish_config.get("fail_on_warnings", False) - def load_config(self): - with open(self.config_file, 'r') as file: - return yaml.safe_load(file) + def get_size_limit_mb(self) -> float: + """Get publish audit size limit in MB.""" + publish_config = self.config.get("publish_audit", {}) + return publish_config.get("size_limit_mb", 10.0) - def get_blocklist_patterns(self): - return self.config_data.get('blocklist', []).copy() + def get_custom_ignore_suggestions(self) -> List[str]: + """Get custom ignore suggestions for publish audit.""" + publish_config = self.config.get("publish_audit", {}) + return publish_config.get("custom_ignore_suggestions", []) or [] - def get_warning_patterns(self): - return self.config_data.get('warnings', []).copy() + def secrets_enabled(self) -> bool: + """Check if secrets scanning is enabled.""" + secrets_config = self.config.get("secrets", {}) + return secrets_config.get("enabled", True) - def get_custom_exclusions(self): - return self.config_data.get('exclusions', []).copy() + def get_entropy_threshold(self) -> float: + """Get entropy threshold for secrets detection.""" + secrets_config = self.config.get("secrets", {}) + return secrets_config.get("entropy_threshold", 4.0) - def save_config(self): - with open(self.config_file, 'w') as file: - yaml.dump(self.config_data, file) \ No newline at end of file + def get_custom_patterns(self) -> List[str]: + """Get custom secret patterns.""" + secrets_config = self.config.get("secrets", {}) + return secrets_config.get("custom_patterns", []) or [] diff --git a/src/shieldcommit/publish_audit.py b/src/shieldcommit/publish_audit.py new file mode 100644 index 0000000..5da27bb --- /dev/null +++ b/src/shieldcommit/publish_audit.py @@ -0,0 +1,299 @@ +"""Publish audit - preflight check before publishing to npm or PyPI.""" +import subprocess +import json +import sys +from pathlib import Path +from typing import List, Dict, Any, Optional, Tuple +from .config import ShieldCommitConfig +from .artifact_detector import scan_artifacts +from .scanner import scan_files + + +class PublishAudit: + """Audit what files will be published and check for violations.""" + + def __init__(self, config: ShieldCommitConfig = None, cwd: Path = None): + self.config = config or ShieldCommitConfig() + self.cwd = cwd or Path.cwd() + + def detect_package_type(self) -> str: + """Auto-detect package type (npm or pypi).""" + if (self.cwd / "package.json").exists(): + return "npm" + if (self.cwd / "setup.py").exists() or (self.cwd / "pyproject.toml").exists(): + return "pypi" + return "unknown" + + def get_package_type(self) -> str: + """Get package type based on config or auto-detection.""" + pkg_type = self.config.get_package_type() + if pkg_type == "auto": + return self.detect_package_type() + return pkg_type + + def get_npm_files(self) -> Tuple[List[str], float]: + """Get files that npm would publish.""" + try: + result = subprocess.run( + ["npm", "pack", "--dry-run", "--json"], + cwd=self.cwd, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode != 0: + return [], 0.0 + + pack_data = json.loads(result.stdout) + files = [f["path"] for f in pack_data[0].get("files", [])] + total_size = sum(f.get("size", 0) for f in pack_data[0].get("files", [])) / ( + 1024 * 1024 + ) # Convert to MB + + return files, total_size + except Exception as e: + print(f"Error running npm pack: {e}") + return [], 0.0 + + def get_pypi_files(self) -> Tuple[List[str], float]: + """Get files that would be included in PyPI distribution.""" + try: + # Try using build module + result = subprocess.run( + ["python", "-m", "build", "--sdist", "--wheel", "--dry-run"], + cwd=self.cwd, + capture_output=True, + text=True, + timeout=30, + ) + + if result.returncode == 0: + # Parse output to get files (simplified approach) + # More robust: enumerate setup.py output or parse MANIFEST.in + return self._get_pypi_files_from_manifest() + except Exception: + pass + + return self._get_pypi_files_from_manifest() + + def _get_pypi_files_from_manifest(self) -> Tuple[List[str], float]: + """Get PyPI files by analyzing MANIFEST.in and .gitignore.""" + files = [] + total_size = 0.0 + + # Include all Python files and key package files + include_patterns = [ + "*.py", + "*.md", + "*.txt", + "*.yaml", + "*.yml", + "*.json", + "LICENSE", + "CHANGELOG*", + ] + + for pattern in include_patterns: + for f in self.cwd.glob(f"**/{pattern}"): + if f.is_file() and not any(part.startswith(".") for part in f.parts): + rel_path = f.relative_to(self.cwd) + files.append(str(rel_path)) + total_size += f.stat().st_size / (1024 * 1024) + + return list(set(files)), total_size + + def audit(self) -> Dict[str, Any]: + """Run full publish audit.""" + pkg_type = self.get_package_type() + + # Get files to be published + if pkg_type == "npm": + files, total_size = self.get_npm_files() + elif pkg_type == "pypi": + files, total_size = self.get_pypi_files() + else: + return { + "passed": False, + "error": f"Unknown or unsupported package type: {pkg_type}", + "violations": [], + } + + # Scan for artifacts + artifact_violations = scan_artifacts(files, self.config) + artifact_issues = artifact_violations["violations"] + + # Scan for secrets + secret_scan_result = scan_files(files) + secret_findings = secret_scan_result["findings"] + + # Get package info + package_name = self._get_package_name(pkg_type) + version = self._get_package_version(pkg_type) + + # Compile violations + violations = [] + + # Add artifact violations + for violation in artifact_issues: + violations.append({ + "severity": violation.severity, + "file": violation.file_path, + "size_mb": self._get_file_size_mb(violation.file_path), + "pattern": violation.pattern, + "issue": violation.get_explanation(), + "type": "artifact", + }) + + # Add secret findings + for finding in secret_findings: + violations.append({ + "severity": "error", + "file": finding.get("file", "unknown"), + "issue": f"Potential secret detected: {finding.get('detection_method', 'unknown')}", + "type": "secret", + "confidence": finding.get("confidence", 0), + }) + + # Check size limit + size_limit = self.config.get_size_limit_mb() + if total_size > size_limit: + violations.append({ + "severity": "warning", + "issue": f"Package size ({total_size:.1f} MB) exceeds limit ({size_limit} MB)", + "type": "size", + }) + + # Determine pass/fail + error_count = sum(1 for v in violations if v["severity"] == "error") + warning_count = sum(1 for v in violations if v["severity"] == "warning") + + fail_on_warnings = self.config.fail_on_warnings() + passed = error_count == 0 and (warning_count == 0 or not fail_on_warnings) + + return { + "package": package_name, + "version": version, + "type": pkg_type, + "total_files": len(files), + "total_size_mb": round(total_size, 1), + "violations": violations, + "passed": passed, + "error_count": error_count, + "warning_count": warning_count, + } + + def _get_package_name(self, pkg_type: str) -> str: + """Extract package name from package.json or setup.py.""" + if pkg_type == "npm": + pkg_json = self.cwd / "package.json" + if pkg_json.exists(): + try: + data = json.loads(pkg_json.read_text()) + return data.get("name", "unknown") + except: + return "unknown" + elif pkg_type == "pypi": + # Try setup.py or pyproject.toml + pyproject = self.cwd / "pyproject.toml" + if pyproject.exists(): + try: + import toml + data = toml.loads(pyproject.read_text()) + return data.get("project", {}).get("name", "unknown") + except: + pass + return "unknown" + + def _get_package_version(self, pkg_type: str) -> str: + """Extract package version.""" + if pkg_type == "npm": + pkg_json = self.cwd / "package.json" + if pkg_json.exists(): + try: + data = json.loads(pkg_json.read_text()) + return data.get("version", "unknown") + except: + return "unknown" + elif pkg_type == "pypi": + pyproject = self.cwd / "pyproject.toml" + if pyproject.exists(): + try: + import toml + data = toml.loads(pyproject.read_text()) + return data.get("project", {}).get("version", "unknown") + except: + pass + return "unknown" + + def _get_file_size_mb(self, file_path: str) -> float: + """Get file size in MB.""" + try: + p = self.cwd / file_path + if p.exists(): + return p.stat().st_size / (1024 * 1024) + except: + pass + return 0.0 + + def format_table_output(self, audit_result: Dict[str, Any]) -> str: + """Format audit result as human-readable table.""" + output = "🛡 ShieldCommit — Publish Audit\n" + output += f" Package : {audit_result.get('package', 'unknown')}\n" + output += f" Version : {audit_result.get('version', 'unknown')}\n" + output += f" Type : {audit_result.get('type', 'unknown')}\n" + output += f" Files : {audit_result.get('total_files', 0)} files will be published ({audit_result.get('total_size_mb', 0)} MB total)\n\n" + + violations = audit_result.get("violations", []) + + if violations: + output += " VIOLATIONS\n" + output += " ──────────────────────────────────────────────────────────\n" + output += " SEVERITY FILE ISSUE\n" + output += " ──────────────────────────────────────────────────────────\n" + + for violation in violations: + severity = violation.get("severity", "unknown").upper() + file_str = violation.get("file", "unknown") + if violation.get("size_mb"): + file_str += f" ({violation['size_mb']:.1f} MB)" + issue = violation.get("issue", "unknown")[:40] + + output += f" {severity:9} {file_str:25} {issue}\n" + + output += " ──────────────────────────────────────────────────────────\n" + + error_count = audit_result.get("error_count", 0) + warning_count = audit_result.get("warning_count", 0) + output += f" {error_count} errors, {warning_count} warnings\n\n" + + if violations: + output += " SUGGESTED FIXES\n" + output += " ──────────────────────────────────────────────────────────\n" + + patterns_to_ignore = set() + for violation in violations: + if violation.get("pattern"): + patterns_to_ignore.add(violation["pattern"]) + + if audit_result.get("type") == "npm": + output += " Add to .npmignore:\n" + for pattern in sorted(patterns_to_ignore): + output += f" {pattern}\n" + elif audit_result.get("type") == "pypi": + output += " Add to MANIFEST.in:\n" + for pattern in sorted(patterns_to_ignore): + output += f" global-exclude {pattern}\n" + + output += " ──────────────────────────────────────────────────────────\n\n" + + if audit_result.get("passed"): + output += " ✅ Publish audit PASSED — safe to publish.\n" + else: + output += " ❌ Publish audit FAILED — fix errors before publishing.\n" + + return output + + def format_json_output(self, audit_result: Dict[str, Any]) -> str: + """Format audit result as JSON.""" + return json.dumps(audit_result, indent=2) diff --git a/src/shieldcommit/scanner.py b/src/shieldcommit/scanner.py index e4136e8..ef19be1 100644 --- a/src/shieldcommit/scanner.py +++ b/src/shieldcommit/scanner.py @@ -6,6 +6,8 @@ from .gcp_detector import scan_gcp_versions from .azure_db_detector import scan_azure_db_versions from .gcp_db_detector import scan_gcp_cloudsql_versions +from .artifact_detector import scan_artifacts +from .config import ShieldCommitConfig def scan_file(path: Path, min_confidence: float = 0.5): @@ -32,11 +34,13 @@ def scan_file(path: Path, min_confidence: float = 0.5): def scan_files(paths): """ Scan files for secrets and warnings (EKS/RDS/AKS/GCP versions + Azure/GCP databases). + Also scans for artifact pattern violations. Uses intelligent detection for secrets (no patterns). - Returns dict with 'findings' (secrets) and 'warnings' (version issues). + Returns dict with 'findings' (secrets), 'warnings' (version issues), and 'artifacts' (violations). """ findings = [] warnings = [] + config = ShieldCommitConfig() for p in paths: p = Path(p) @@ -51,4 +55,12 @@ def scan_files(paths): warnings.extend(scan_azure_db_versions(p)) warnings.extend(scan_gcp_cloudsql_versions(p)) - return {"findings": findings, "warnings": warnings} + # Scan for artifacts + artifact_result = scan_artifacts(paths, config) + artifacts = artifact_result["violations"] + + return { + "findings": findings, + "warnings": warnings, + "artifacts": artifacts, + } diff --git a/tests/test_artifact_detector.py b/tests/test_artifact_detector.py new file mode 100644 index 0000000..3f280a4 --- /dev/null +++ b/tests/test_artifact_detector.py @@ -0,0 +1,168 @@ +"""Tests for artifact_detector module.""" +import pytest +from pathlib import Path +from src.shieldcommit.artifact_detector import ( + ArtifactViolation, + matches_pattern, + scan_artifacts, + has_blocking_violations, + filter_violations_by_severity, +) +from src.shieldcommit.config import ShieldCommitConfig + + +class TestMatchesPattern: + """Test pattern matching logic.""" + + def test_exact_filename_match(self): + """Test exact filename matching.""" + assert matches_pattern(".DS_Store", ".DS_Store") + assert not matches_pattern(".DS_Store", ".DS_Storeaa") + + def test_wildcard_extension(self): + """Test wildcard extension matching.""" + assert matches_pattern("dist/main.js.map", "*.map") + assert matches_pattern("build/app.js.map", "*.js.map") + assert not matches_pattern("dist/main.js", "*.map") + + def test_directory_patterns(self): + """Test directory pattern matching.""" + assert matches_pattern("__pycache__/module.pyc", "__pycache__/*") + assert matches_pattern("dist/bundle.js", "dist/*") + assert not matches_pattern("src/bundle.js", "dist/*") + + def test_path_patterns(self): + """Test path-based pattern matching.""" + assert matches_pattern(".kube/config", ".kube/config") + assert matches_pattern(".idea/workspace.xml", ".idea/*") + + +class TestArtifactViolation: + """Test ArtifactViolation class.""" + + def test_violation_creation(self): + """Test creating a violation.""" + config = ShieldCommitConfig() + violation = ArtifactViolation("dist/main.js.map", "*.map", "error", config) + + assert violation.file_path == "dist/main.js.map" + assert violation.pattern == "*.map" + assert violation.severity == "error" + + def test_get_explanation(self): + """Test getting explanation for pattern.""" + config = ShieldCommitConfig() + violation = ArtifactViolation("dist/main.js.map", "*.map", "error", config) + explanation = violation.get_explanation() + + assert "Claude Code" in explanation + assert "source code" in explanation + + def test_format_message(self): + """Test formatting violation message.""" + config = ShieldCommitConfig() + violation = ArtifactViolation(".env.production", ".env.production", "error", config) + message = violation.format_message() + + assert ".env.production" in message + assert "Fix" in message + assert ".gitignore" in message + + +class TestScanArtifacts: + """Test artifact scanning.""" + + def test_no_violations(self): + """Test scan with no violations.""" + config = ShieldCommitConfig() + result = scan_artifacts(["src/main.py", "src/utils.py"], config) + + assert result["passed"] + assert len(result["violations"]) == 0 + + def test_detect_sourcemap(self): + """Test detecting sourcemap violations.""" + config = ShieldCommitConfig() + result = scan_artifacts(["dist/bundle.js.map", "src/main.py"], config) + + assert not result["passed"] + assert len(result["violations"]) == 1 + assert result["violations"][0].file_path == "dist/bundle.js.map" + + def test_detect_env_file(self): + """Test detecting environment file violations.""" + config = ShieldCommitConfig() + result = scan_artifacts([".env.production"], config) + + assert not result["passed"] + assert len(result["violations"]) == 1 + assert ".env.production" in result["violations"][0].file_path + + def test_multiple_violations(self): + """Test detecting multiple violations.""" + config = ShieldCommitConfig() + files = ["dist/app.js.map", ".env.local", "keys/private.pem", "src/main.py"] + result = scan_artifacts(files, config) + + assert not result["passed"] + assert len(result["violations"]) == 3 + + +class TestHasBlockingViolations: + """Test blocking violations check.""" + + def test_error_violations_block(self): + """Test that error violations are blocking.""" + config = ShieldCommitConfig() + violations = [ + ArtifactViolation("dist/main.js.map", "*.map", "error", config), + ] + + assert has_blocking_violations(violations) + + def test_warning_violations_dont_block(self): + """Test that warning violations don't block.""" + config = ShieldCommitConfig() + violations = [ + ArtifactViolation(".DS_Store", ".DS_Store", "warning", config), + ] + + assert not has_blocking_violations(violations) + + def test_mixed_violations(self): + """Test mixed error and warning violations.""" + config = ShieldCommitConfig() + violations = [ + ArtifactViolation(".DS_Store", ".DS_Store", "warning", config), + ArtifactViolation("dist/main.js.map", "*.map", "error", config), + ] + + assert has_blocking_violations(violations) + + +class TestFilterViolations: + """Test violation filtering.""" + + def test_filter_by_error(self): + """Test filtering by error severity.""" + config = ShieldCommitConfig() + violations = [ + ArtifactViolation(".DS_Store", ".DS_Store", "warning", config), + ArtifactViolation("dist/main.js.map", "*.map", "error", config), + ] + + errors = filter_violations_by_severity(violations, "error") + assert len(errors) == 1 + assert errors[0].file_path == "dist/main.js.map" + + def test_filter_by_warning(self): + """Test filtering by warning severity.""" + config = ShieldCommitConfig() + violations = [ + ArtifactViolation(".DS_Store", ".DS_Store", "warning", config), + ArtifactViolation("dist/main.js.map", "*.map", "error", config), + ] + + warnings = filter_violations_by_severity(violations, "warning") + assert len(warnings) == 1 + assert warnings[0].file_path == ".DS_Store" diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..c1188f0 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,94 @@ +"""Tests for config module.""" +import pytest +import tempfile +from pathlib import Path +from src.shieldcommit.config import ShieldCommitConfig + + +class TestShieldCommitConfig: + """Test ShieldCommitConfig class.""" + + def test_default_config(self): + """Test default configuration when no file exists.""" + with tempfile.TemporaryDirectory() as tmpdir: + config = ShieldCommitConfig(Path(tmpdir) / ".shieldcommit.yaml") + + assert config.artifacts_enabled() + assert config.secrets_enabled() + assert config.publish_audit_enabled() + + def test_artifacts_enabled(self): + """Test artifacts_enabled method.""" + config = ShieldCommitConfig() + assert isinstance(config.artifacts_enabled(), bool) + + def test_get_active_patterns(self): + """Test getting active patterns.""" + config = ShieldCommitConfig() + patterns = config.get_active_patterns() + + assert isinstance(patterns, list) + assert "*.map" in patterns + assert ".DS_Store" in patterns + assert "*.pem" in patterns + + def test_get_severity_default(self): + """Test getting default severity for patterns.""" + config = ShieldCommitConfig() + + assert config.get_severity("*.map") == "error" + assert config.get_severity(".DS_Store") == "warning" + assert config.get_severity("*.pem") == "error" + + def test_get_severity_unknown(self): + """Test getting severity for unknown pattern.""" + config = ShieldCommitConfig() + + # Unknown patterns default to error + assert config.get_severity("unknown.pattern") == "error" + + def test_publish_audit_config(self): + """Test publish audit configuration.""" + config = ShieldCommitConfig() + + assert config.publish_audit_enabled() + assert config.get_package_type() == "auto" + assert config.get_size_limit_mb() == 10.0 + assert isinstance(config.get_custom_ignore_suggestions(), list) + + def test_secrets_config(self): + """Test secrets configuration.""" + config = ShieldCommitConfig() + + assert config.secrets_enabled() + assert config.get_entropy_threshold() == 4.0 + assert isinstance(config.get_custom_patterns(), list) + + def test_load_config_with_yaml(self): + """Test loading configuration from YAML file.""" + yaml_content = """ +artifacts: + enabled: false + block_patterns: + - "*.custom" + allow_patterns: + - "dist/*" +secrets: + entropy_threshold: 5.0 +""" + with tempfile.TemporaryDirectory() as tmpdir: + config_file = Path(tmpdir) / ".shieldcommit.yaml" + config_file.write_text(yaml_content) + + config = ShieldCommitConfig(config_file) + + assert not config.artifacts_enabled() + assert config.get_entropy_threshold() == 5.0 + + def test_config_file_not_found(self): + """Test graceful handling when config file doesn't exist.""" + with tempfile.TemporaryDirectory() as tmpdir: + config = ShieldCommitConfig(Path(tmpdir) / "nonexistent.yaml") + + # Should use defaults + assert config.artifacts_enabled()