Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ The CLI will automatically install @coana-tech/cli if not present. Use `--reach`
|:-------------------------|:---------|:--------|:----------------------------------------------------------------------|
| --ignore-commit-files | False | False | Ignore commit files |
| --disable-blocking | False | False | Disable blocking mode |
| --strict-blocking | False | False | Fail on ANY security policy violations (blocking severity), not just new ones. Only works in diff mode. See [Strict Blocking Mode](#strict-blocking-mode) for details. |
| --enable-diff | False | False | Enable diff mode even when using --integration api (forces diff mode without SCM integration) |
| --scm | False | api | Source control management type |
| --timeout | False | | Timeout in seconds for API requests |
Expand Down Expand Up @@ -328,6 +329,99 @@ Bot mode (`bot_configs` array items):
- `alert_types` (array, optional): Only send specific alert types
- `reachability_alerts_only` (boolean, default: false): Only send reachable vulnerabilities when using `--reach`

## Strict Blocking Mode

The `--strict-blocking` flag enforces a zero-tolerance security policy by failing builds when **ANY** security violations with blocking severity exist, not just new ones introduced in the current changes.

### Standard vs Strict Blocking Behavior

**Standard Behavior (Default)**:
- ✅ Passes if no NEW violations are introduced
- ❌ Fails only on NEW violations from your changes
- 🟡 Existing violations are ignored

**Strict Blocking Behavior (`--strict-blocking`)**:
- ✅ Passes only if NO violations exist (new or existing)
- ❌ Fails on ANY violation (new OR existing)
- 🔴 Enforces zero-tolerance policy

### Usage Examples

**Basic strict blocking:**
```bash
socketcli --target-path ./my-project --strict-blocking
```

**In GitLab CI:**
```bash
socketcli --target-path $CI_PROJECT_DIR --scm gitlab --pr-number ${CI_MERGE_REQUEST_IID:-0} --strict-blocking
```

**In GitHub Actions:**
```bash
socketcli --target-path $GITHUB_WORKSPACE --scm github --pr-number $PR_NUMBER --strict-blocking
```

### Output Differences

**Standard scan output:**
```
Security issues detected by Socket Security:
- NEW blocking issues: 2
- NEW warning issues: 1
```

**Strict blocking scan output:**
```
Security issues detected by Socket Security:
- NEW blocking issues: 2
- NEW warning issues: 1
- EXISTING blocking issues: 5 (causing failure due to --strict-blocking)
- EXISTING warning issues: 3
```

### Use Cases

1. **Zero-Tolerance Security Policy**: Enforce that no security violations exist in your codebase at any time
2. **Gradual Security Improvement**: Use alongside standard scans to monitor existing violations while blocking new ones
3. **Protected Branch Enforcement**: Require all violations to be resolved before merging to main/production
4. **Security Audits**: Scheduled scans that fail if any violations accumulate

### Important Notes

- **Diff Mode Only**: The flag only works in diff mode (with SCM integration). In API mode, a warning is logged.
- **Error-Level Only**: Only fails on `error=True` alerts (blocking severity), not warnings.
- **Priority**: `--disable-blocking` takes precedence - if both flags are set, the build will always pass.
- **First Scan**: On the very first scan of a repository, there are no "existing" violations, so behavior is identical to standard mode.

### Flag Combinations

**Strict blocking with debugging:**
```bash
socketcli --strict-blocking --enable-debug
```

**Strict blocking with JSON output:**
```bash
socketcli --strict-blocking --enable-json > security-report.json
```

**Override for testing** (passes even with violations):
```bash
socketcli --strict-blocking --disable-blocking
```

### Migration Strategy

**Phase 1: Assessment** - Add strict scan with `allow_failure: true` in CI
**Phase 2: Remediation** - Fix or triage all violations
**Phase 3: Enforcement** - Set `allow_failure: false` to block merges

For complete GitLab CI/CD examples, see:
- [`.gitlab-ci-strict-blocking-demo.yml`](.gitlab-ci-strict-blocking-demo.yml) - Comprehensive demo
- [`.gitlab-ci-strict-blocking-production.yml`](.gitlab-ci-strict-blocking-production.yml) - Production-ready template
- [`STRICT-BLOCKING-GITLAB-CI.md`](STRICT-BLOCKING-GITLAB-CI.md) - Full documentation

## Automatic Git Detection

The CLI now automatically detects repository information from your git environment, significantly simplifying usage in CI/CD pipelines:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"

[project]
name = "socketsecurity"
version = "2.2.63"
version = "2.3.1"
requires-python = ">= 3.10"
license = {"file" = "LICENSE"}
dependencies = [
Expand Down
8 changes: 8 additions & 0 deletions socketsecurity/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class CliConfig:
files: str = None
ignore_commit_files: bool = False
disable_blocking: bool = False
strict_blocking: bool = False
integration_type: IntegrationType = "api"
integration_org_slug: Optional[str] = None
pending_head: bool = False
Expand Down Expand Up @@ -123,6 +124,7 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
'files': args.files,
'ignore_commit_files': args.ignore_commit_files,
'disable_blocking': args.disable_blocking,
'strict_blocking': args.strict_blocking,
'integration_type': args.integration,
'pending_head': args.pending_head,
'timeout': args.timeout,
Expand Down Expand Up @@ -523,6 +525,12 @@ def create_argument_parser() -> argparse.ArgumentParser:
action="store_true",
help=argparse.SUPPRESS
)
advanced_group.add_argument(
"--strict-blocking",
dest="strict_blocking",
action="store_true",
help="Fail on ANY security policy violations (blocking severity), not just new ones. Only works in diff mode."
)
advanced_group.add_argument(
"--enable-diff",
dest="enable_diff",
Expand Down
94 changes: 93 additions & 1 deletion socketsecurity/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1091,7 +1091,13 @@ def create_new_diff(
packages
) = self.get_added_and_removed_packages(head_full_scan_id, new_full_scan.id)

diff = self.create_diff_report(added_packages, removed_packages)
# Separate unchanged packages from added/removed for --strict-blocking support
unchanged_packages = {
pkg_id: pkg for pkg_id, pkg in packages.items()
if pkg_id not in added_packages and pkg_id not in removed_packages
}

diff = self.create_diff_report(added_packages, removed_packages, unchanged_packages)
diff.packages = packages

base_socket = "https://socket.dev/dashboard/org"
Expand All @@ -1114,6 +1120,7 @@ def create_diff_report(
self,
added_packages: Dict[str, Package],
removed_packages: Dict[str, Package],
unchanged_packages: Optional[Dict[str, Package]] = None,
direct_only: bool = True
) -> Diff:
"""
Expand All @@ -1123,10 +1130,12 @@ def create_diff_report(
1. Records new/removed packages (direct only by default)
2. Collects alerts from both sets of packages
3. Determines new capabilities introduced
4. Optionally collects alerts from unchanged packages for --strict-blocking

Args:
added_packages: Dict of packages added in new scan
removed_packages: Dict of packages removed in new scan
unchanged_packages: Dict of packages that didn't change (for --strict-blocking)
direct_only: If True, only direct dependencies are included in new/removed lists
(but alerts are still processed for all packages)

Expand All @@ -1137,6 +1146,7 @@ def create_diff_report(

alerts_in_added_packages: Dict[str, List[Issue]] = {}
alerts_in_removed_packages: Dict[str, List[Issue]] = {}
alerts_in_unchanged_packages: Dict[str, List[Issue]] = {}

seen_new_packages = set()
seen_removed_packages = set()
Expand Down Expand Up @@ -1169,11 +1179,34 @@ def create_diff_report(
packages=removed_packages
)

# Process unchanged packages for --strict-blocking support
if unchanged_packages:
for package_id, package in unchanged_packages.items():
# Skip packages that are in added or removed (they're already processed)
if package_id in added_packages or package_id in removed_packages:
continue

self.add_package_alerts_to_collection(
package=package,
alerts_collection=alerts_in_unchanged_packages,
packages=unchanged_packages
)

diff.new_alerts = Core.get_new_alerts(
alerts_in_added_packages,
alerts_in_removed_packages
)

# Get unchanged alerts (for --strict-blocking mode)
diff.unchanged_alerts = Core.get_unchanged_alerts(
alerts_in_unchanged_packages
)

# Get removed alerts (for completeness)
diff.removed_alerts = Core.get_removed_alerts(
alerts_in_removed_packages
)

diff.new_capabilities = Core.get_capabilities_for_added_packages(added_packages)

Core.add_purl_capabilities(diff)
Expand Down Expand Up @@ -1433,3 +1466,62 @@ def get_new_alerts(
consolidated_alerts.add(alert_str)

return alerts

@staticmethod
def get_unchanged_alerts(
unchanged_package_alerts: Dict[str, List[Issue]]
) -> List[Issue]:
"""
Extract all alerts from unchanged packages that are errors or warnings.

This is used for --strict-blocking mode to identify existing violations
that should cause builds to fail.

Args:
unchanged_package_alerts: Dictionary of alerts from packages that didn't change

Returns:
List of all error/warning alerts from unchanged packages
"""
alerts: List[Issue] = []
consolidated_alerts = set()

for alert_key in unchanged_package_alerts:
for alert in unchanged_package_alerts[alert_key]:
# Consolidate by package and alert type
alert_str = f"{alert.purl},{alert.type}"

# Only include error or warning alerts
if (alert.error or alert.warn) and alert_str not in consolidated_alerts:
alerts.append(alert)
consolidated_alerts.add(alert_str)

return alerts

@staticmethod
def get_removed_alerts(
removed_package_alerts: Dict[str, List[Issue]]
) -> List[Issue]:
"""
Extract all alerts from removed packages.

This is mainly for informational purposes - to show alerts that were removed.

Args:
removed_package_alerts: Dictionary of alerts from packages that were removed

Returns:
List of all alerts from removed packages
"""
alerts: List[Issue] = []
consolidated_alerts = set()

for alert_key in removed_package_alerts:
for alert in removed_package_alerts[alert_key]:
alert_str = f"{alert.purl},{alert.type}"

if alert_str not in consolidated_alerts:
alerts.append(alert)
consolidated_alerts.add(alert_str)

return alerts
8 changes: 8 additions & 0 deletions socketsecurity/core/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,8 @@ class Diff:
packages: dict[str, Package]
new_capabilities: Dict[str, List[str]]
new_alerts: list[Issue]
unchanged_alerts: list[Issue]
removed_alerts: list[Issue]
id: str
sbom: str
report_url: str
Expand All @@ -490,6 +492,10 @@ def __init__(self, **kwargs):
self.removed_packages = []
if not hasattr(self, "new_alerts"):
self.new_alerts = []
if not hasattr(self, "unchanged_alerts"):
self.unchanged_alerts = []
if not hasattr(self, "removed_alerts"):
self.removed_alerts = []
if not hasattr(self, "new_capabilities"):
self.new_capabilities = {}

Expand All @@ -508,6 +514,8 @@ def to_dict(self) -> dict:
"new_capabilities": self.new_capabilities,
"removed_packages": [p.to_dict() for p in self.removed_packages],
"new_alerts": [alert.__dict__ for alert in self.new_alerts],
"unchanged_alerts": [alert.__dict__ for alert in self.unchanged_alerts] if hasattr(self, "unchanged_alerts") else [],
"removed_alerts": [alert.__dict__ for alert in self.removed_alerts] if hasattr(self, "removed_alerts") else [],
"id": self.id,
"sbom": self.sbom if hasattr(self, "sbom") else [],
"packages": {k: v.to_dict() for k, v in self.packages.items()} if hasattr(self, "packages") else {},
Expand Down
53 changes: 49 additions & 4 deletions socketsecurity/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,40 @@ def return_exit_code(self, diff_report: Diff) -> int:

def output_console_comments(self, diff_report: Diff, sbom_file_name: Optional[str] = None) -> None:
"""Outputs formatted console comments"""
if len(diff_report.new_alerts) == 0:
has_new_alerts = len(diff_report.new_alerts) > 0
has_unchanged_alerts = (
self.config.strict_blocking and
hasattr(diff_report, 'unchanged_alerts') and
len(diff_report.unchanged_alerts) > 0
)

if not has_new_alerts and not has_unchanged_alerts:
self.logger.info("No issues found")
return

# Count blocking vs warning alerts
new_blocking = sum(1 for issue in diff_report.new_alerts if issue.error)
new_warning = sum(1 for issue in diff_report.new_alerts if issue.warn)

unchanged_blocking = 0
unchanged_warning = 0
if has_unchanged_alerts:
unchanged_blocking = sum(1 for issue in diff_report.unchanged_alerts if issue.error)
unchanged_warning = sum(1 for issue in diff_report.unchanged_alerts if issue.warn)

console_security_comment = Messages.create_console_security_alert_table(diff_report)

# Build status message
self.logger.info("Security issues detected by Socket Security:")
if new_blocking > 0:
self.logger.info(f" - NEW blocking issues: {new_blocking}")
if new_warning > 0:
self.logger.info(f" - NEW warning issues: {new_warning}")
if unchanged_blocking > 0:
self.logger.info(f" - EXISTING blocking issues: {unchanged_blocking} (causing failure due to --strict-blocking)")
if unchanged_warning > 0:
self.logger.info(f" - EXISTING warning issues: {unchanged_warning}")

self.logger.info(f"Diff Url: {diff_report.diff_url}")
self.logger.info(f"\n{console_security_comment}")

Expand All @@ -105,13 +133,30 @@ def output_console_sarif(self, diff_report: Diff, sbom_file_name: Optional[str]

def report_pass(self, diff_report: Diff) -> bool:
"""Determines if the report passes security checks"""
if not diff_report.new_alerts:
# Priority 1: --disable-blocking always passes
if self.config.disable_blocking:
return True

if self.config.disable_blocking:
# Check new alerts for blocking issues
has_new_blocking_alerts = any(issue.error for issue in diff_report.new_alerts)

# Check unchanged alerts if --strict-blocking is enabled
has_unchanged_blocking_alerts = False
if self.config.strict_blocking and hasattr(diff_report, 'unchanged_alerts'):
has_unchanged_blocking_alerts = any(
issue.error for issue in diff_report.unchanged_alerts
)

# If no alerts at all, pass
if not diff_report.new_alerts and not (
self.config.strict_blocking and
hasattr(diff_report, 'unchanged_alerts') and
diff_report.unchanged_alerts
):
return True

return not any(issue.error for issue in diff_report.new_alerts)
# Fail if there are any blocking alerts (new or unchanged with --strict-blocking)
return not (has_new_blocking_alerts or has_unchanged_blocking_alerts)

def save_sbom_file(self, diff_report: Diff, sbom_file_name: Optional[str] = None) -> None:
"""Saves SBOM file if filename is provided"""
Expand Down
Loading
Loading