Skip to content

Commit 54e6ec7

Browse files
jonathanStrange0Jonathan Muchaclaudedacoburn
authored
Mucha dev fail on any block (#149)
* feat: add --strict-blocking flag to fail on any existing security violations Introduces a new --strict-blocking flag that causes builds to fail on ANY security policy violations with blocking severity, not just new ones. This enables enforcement of a zero-tolerance policy on security issues. Key features: - Works in diff mode only (logs warning in API mode) - Only fails on error-level alerts (not warnings) - --disable-blocking takes precedence when both flags are set - Enhanced console output distinguishes NEW vs EXISTING violations - Comprehensive test coverage for all scenarios Implementation details: - Added unchanged_alerts and removed_alerts fields to Diff class - Created get_unchanged_alerts() method to extract alerts from unchanged packages - Updated report_pass() to check both new and unchanged alerts when enabled - Added validation warnings for conflicting flags and API mode limitations Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * chore: update uv.lock with version 2.2.63 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * docs: add --strict-blocking flag documentation to README Added comprehensive documentation for the new --strict-blocking feature: - Added flag to Advanced Configuration parameters table - Created dedicated "Strict Blocking Mode" section with: - Behavior comparison (standard vs strict) - Usage examples for different CI/CD platforms - Output examples showing NEW vs EXISTING violations - Common use cases and implementation strategies - Important notes about limitations and flag priority - Flag combination examples - Migration strategy guidance - Links to GitLab CI example files The documentation clearly explains: - Zero-tolerance security policy enforcement - Diff mode requirement - Error-level filtering (not warnings) - --disable-blocking precedence - First scan behavior Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * chore: bump version to 2.3.1 for --strict-blocking feature Bumped version from 2.2.63 to 2.3.1 following semantic versioning (minor version bump for new feature). This version number avoids conflict with the mucha-dev-gitlab-security-output branch which uses 2.3.0 for the GitLab Security Dashboard feature. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * version sync * Bumping version --------- Co-authored-by: Jonathan Mucha <jonathan@mucha.local> Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: Douglas Coburn <douglas@dactbc.com>
1 parent 0f0127a commit 54e6ec7

File tree

12 files changed

+474
-14
lines changed

12 files changed

+474
-14
lines changed

README.md

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,7 @@ The CLI will automatically install @coana-tech/cli if not present. Use `--reach`
193193
|:-------------------------|:---------|:--------|:----------------------------------------------------------------------|
194194
| --ignore-commit-files | False | False | Ignore commit files |
195195
| --disable-blocking | False | False | Disable blocking mode |
196+
| --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. |
196197
| --enable-diff | False | False | Enable diff mode even when using --integration api (forces diff mode without SCM integration) |
197198
| --scm | False | api | Source control management type |
198199
| --timeout | False | | Timeout in seconds for API requests |
@@ -328,6 +329,99 @@ Bot mode (`bot_configs` array items):
328329
- `alert_types` (array, optional): Only send specific alert types
329330
- `reachability_alerts_only` (boolean, default: false): Only send reachable vulnerabilities when using `--reach`
330331
332+
## Strict Blocking Mode
333+
334+
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.
335+
336+
### Standard vs Strict Blocking Behavior
337+
338+
**Standard Behavior (Default)**:
339+
- ✅ Passes if no NEW violations are introduced
340+
- ❌ Fails only on NEW violations from your changes
341+
- 🟡 Existing violations are ignored
342+
343+
**Strict Blocking Behavior (`--strict-blocking`)**:
344+
- ✅ Passes only if NO violations exist (new or existing)
345+
- ❌ Fails on ANY violation (new OR existing)
346+
- 🔴 Enforces zero-tolerance policy
347+
348+
### Usage Examples
349+
350+
**Basic strict blocking:**
351+
```bash
352+
socketcli --target-path ./my-project --strict-blocking
353+
```
354+
355+
**In GitLab CI:**
356+
```bash
357+
socketcli --target-path $CI_PROJECT_DIR --scm gitlab --pr-number ${CI_MERGE_REQUEST_IID:-0} --strict-blocking
358+
```
359+
360+
**In GitHub Actions:**
361+
```bash
362+
socketcli --target-path $GITHUB_WORKSPACE --scm github --pr-number $PR_NUMBER --strict-blocking
363+
```
364+
365+
### Output Differences
366+
367+
**Standard scan output:**
368+
```
369+
Security issues detected by Socket Security:
370+
- NEW blocking issues: 2
371+
- NEW warning issues: 1
372+
```
373+
374+
**Strict blocking scan output:**
375+
```
376+
Security issues detected by Socket Security:
377+
- NEW blocking issues: 2
378+
- NEW warning issues: 1
379+
- EXISTING blocking issues: 5 (causing failure due to --strict-blocking)
380+
- EXISTING warning issues: 3
381+
```
382+
383+
### Use Cases
384+
385+
1. **Zero-Tolerance Security Policy**: Enforce that no security violations exist in your codebase at any time
386+
2. **Gradual Security Improvement**: Use alongside standard scans to monitor existing violations while blocking new ones
387+
3. **Protected Branch Enforcement**: Require all violations to be resolved before merging to main/production
388+
4. **Security Audits**: Scheduled scans that fail if any violations accumulate
389+
390+
### Important Notes
391+
392+
- **Diff Mode Only**: The flag only works in diff mode (with SCM integration). In API mode, a warning is logged.
393+
- **Error-Level Only**: Only fails on `error=True` alerts (blocking severity), not warnings.
394+
- **Priority**: `--disable-blocking` takes precedence - if both flags are set, the build will always pass.
395+
- **First Scan**: On the very first scan of a repository, there are no "existing" violations, so behavior is identical to standard mode.
396+
397+
### Flag Combinations
398+
399+
**Strict blocking with debugging:**
400+
```bash
401+
socketcli --strict-blocking --enable-debug
402+
```
403+
404+
**Strict blocking with JSON output:**
405+
```bash
406+
socketcli --strict-blocking --enable-json > security-report.json
407+
```
408+
409+
**Override for testing** (passes even with violations):
410+
```bash
411+
socketcli --strict-blocking --disable-blocking
412+
```
413+
414+
### Migration Strategy
415+
416+
**Phase 1: Assessment** - Add strict scan with `allow_failure: true` in CI
417+
**Phase 2: Remediation** - Fix or triage all violations
418+
**Phase 3: Enforcement** - Set `allow_failure: false` to block merges
419+
420+
For complete GitLab CI/CD examples, see:
421+
- [`.gitlab-ci-strict-blocking-demo.yml`](.gitlab-ci-strict-blocking-demo.yml) - Comprehensive demo
422+
- [`.gitlab-ci-strict-blocking-production.yml`](.gitlab-ci-strict-blocking-production.yml) - Production-ready template
423+
- [`STRICT-BLOCKING-GITLAB-CI.md`](STRICT-BLOCKING-GITLAB-CI.md) - Full documentation
424+
331425
## Automatic Git Detection
332426
333427
The CLI now automatically detects repository information from your git environment, significantly simplifying usage in CI/CD pipelines:

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.2.65"
9+
version = "2.2.66"
1010
requires-python = ">= 3.10"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [

socketsecurity/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.2.65'
2+
__version__ = '2.2.66'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

socketsecurity/config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class CliConfig:
4545
files: str = None
4646
ignore_commit_files: bool = False
4747
disable_blocking: bool = False
48+
strict_blocking: bool = False
4849
integration_type: IntegrationType = "api"
4950
integration_org_slug: Optional[str] = None
5051
pending_head: bool = False
@@ -123,6 +124,7 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
123124
'files': args.files,
124125
'ignore_commit_files': args.ignore_commit_files,
125126
'disable_blocking': args.disable_blocking,
127+
'strict_blocking': args.strict_blocking,
126128
'integration_type': args.integration,
127129
'pending_head': args.pending_head,
128130
'timeout': args.timeout,
@@ -523,6 +525,12 @@ def create_argument_parser() -> argparse.ArgumentParser:
523525
action="store_true",
524526
help=argparse.SUPPRESS
525527
)
528+
advanced_group.add_argument(
529+
"--strict-blocking",
530+
dest="strict_blocking",
531+
action="store_true",
532+
help="Fail on ANY security policy violations (blocking severity), not just new ones. Only works in diff mode."
533+
)
526534
advanced_group.add_argument(
527535
"--enable-diff",
528536
dest="enable_diff",

socketsecurity/core/__init__.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1091,7 +1091,13 @@ def create_new_diff(
10911091
packages
10921092
) = self.get_added_and_removed_packages(head_full_scan_id, new_full_scan.id)
10931093

1094-
diff = self.create_diff_report(added_packages, removed_packages)
1094+
# Separate unchanged packages from added/removed for --strict-blocking support
1095+
unchanged_packages = {
1096+
pkg_id: pkg for pkg_id, pkg in packages.items()
1097+
if pkg_id not in added_packages and pkg_id not in removed_packages
1098+
}
1099+
1100+
diff = self.create_diff_report(added_packages, removed_packages, unchanged_packages)
10951101
diff.packages = packages
10961102

10971103
base_socket = "https://socket.dev/dashboard/org"
@@ -1114,6 +1120,7 @@ def create_diff_report(
11141120
self,
11151121
added_packages: Dict[str, Package],
11161122
removed_packages: Dict[str, Package],
1123+
unchanged_packages: Optional[Dict[str, Package]] = None,
11171124
direct_only: bool = True
11181125
) -> Diff:
11191126
"""
@@ -1123,10 +1130,12 @@ def create_diff_report(
11231130
1. Records new/removed packages (direct only by default)
11241131
2. Collects alerts from both sets of packages
11251132
3. Determines new capabilities introduced
1133+
4. Optionally collects alerts from unchanged packages for --strict-blocking
11261134
11271135
Args:
11281136
added_packages: Dict of packages added in new scan
11291137
removed_packages: Dict of packages removed in new scan
1138+
unchanged_packages: Dict of packages that didn't change (for --strict-blocking)
11301139
direct_only: If True, only direct dependencies are included in new/removed lists
11311140
(but alerts are still processed for all packages)
11321141
@@ -1137,6 +1146,7 @@ def create_diff_report(
11371146

11381147
alerts_in_added_packages: Dict[str, List[Issue]] = {}
11391148
alerts_in_removed_packages: Dict[str, List[Issue]] = {}
1149+
alerts_in_unchanged_packages: Dict[str, List[Issue]] = {}
11401150

11411151
seen_new_packages = set()
11421152
seen_removed_packages = set()
@@ -1169,11 +1179,34 @@ def create_diff_report(
11691179
packages=removed_packages
11701180
)
11711181

1182+
# Process unchanged packages for --strict-blocking support
1183+
if unchanged_packages:
1184+
for package_id, package in unchanged_packages.items():
1185+
# Skip packages that are in added or removed (they're already processed)
1186+
if package_id in added_packages or package_id in removed_packages:
1187+
continue
1188+
1189+
self.add_package_alerts_to_collection(
1190+
package=package,
1191+
alerts_collection=alerts_in_unchanged_packages,
1192+
packages=unchanged_packages
1193+
)
1194+
11721195
diff.new_alerts = Core.get_new_alerts(
11731196
alerts_in_added_packages,
11741197
alerts_in_removed_packages
11751198
)
11761199

1200+
# Get unchanged alerts (for --strict-blocking mode)
1201+
diff.unchanged_alerts = Core.get_unchanged_alerts(
1202+
alerts_in_unchanged_packages
1203+
)
1204+
1205+
# Get removed alerts (for completeness)
1206+
diff.removed_alerts = Core.get_removed_alerts(
1207+
alerts_in_removed_packages
1208+
)
1209+
11771210
diff.new_capabilities = Core.get_capabilities_for_added_packages(added_packages)
11781211

11791212
Core.add_purl_capabilities(diff)
@@ -1433,3 +1466,62 @@ def get_new_alerts(
14331466
consolidated_alerts.add(alert_str)
14341467

14351468
return alerts
1469+
1470+
@staticmethod
1471+
def get_unchanged_alerts(
1472+
unchanged_package_alerts: Dict[str, List[Issue]]
1473+
) -> List[Issue]:
1474+
"""
1475+
Extract all alerts from unchanged packages that are errors or warnings.
1476+
1477+
This is used for --strict-blocking mode to identify existing violations
1478+
that should cause builds to fail.
1479+
1480+
Args:
1481+
unchanged_package_alerts: Dictionary of alerts from packages that didn't change
1482+
1483+
Returns:
1484+
List of all error/warning alerts from unchanged packages
1485+
"""
1486+
alerts: List[Issue] = []
1487+
consolidated_alerts = set()
1488+
1489+
for alert_key in unchanged_package_alerts:
1490+
for alert in unchanged_package_alerts[alert_key]:
1491+
# Consolidate by package and alert type
1492+
alert_str = f"{alert.purl},{alert.type}"
1493+
1494+
# Only include error or warning alerts
1495+
if (alert.error or alert.warn) and alert_str not in consolidated_alerts:
1496+
alerts.append(alert)
1497+
consolidated_alerts.add(alert_str)
1498+
1499+
return alerts
1500+
1501+
@staticmethod
1502+
def get_removed_alerts(
1503+
removed_package_alerts: Dict[str, List[Issue]]
1504+
) -> List[Issue]:
1505+
"""
1506+
Extract all alerts from removed packages.
1507+
1508+
This is mainly for informational purposes - to show alerts that were removed.
1509+
1510+
Args:
1511+
removed_package_alerts: Dictionary of alerts from packages that were removed
1512+
1513+
Returns:
1514+
List of all alerts from removed packages
1515+
"""
1516+
alerts: List[Issue] = []
1517+
consolidated_alerts = set()
1518+
1519+
for alert_key in removed_package_alerts:
1520+
for alert in removed_package_alerts[alert_key]:
1521+
alert_str = f"{alert.purl},{alert.type}"
1522+
1523+
if alert_str not in consolidated_alerts:
1524+
alerts.append(alert)
1525+
consolidated_alerts.add(alert_str)
1526+
1527+
return alerts

socketsecurity/core/classes.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,8 @@ class Diff:
474474
packages: dict[str, Package]
475475
new_capabilities: Dict[str, List[str]]
476476
new_alerts: list[Issue]
477+
unchanged_alerts: list[Issue]
478+
removed_alerts: list[Issue]
477479
id: str
478480
sbom: str
479481
report_url: str
@@ -490,6 +492,10 @@ def __init__(self, **kwargs):
490492
self.removed_packages = []
491493
if not hasattr(self, "new_alerts"):
492494
self.new_alerts = []
495+
if not hasattr(self, "unchanged_alerts"):
496+
self.unchanged_alerts = []
497+
if not hasattr(self, "removed_alerts"):
498+
self.removed_alerts = []
493499
if not hasattr(self, "new_capabilities"):
494500
self.new_capabilities = {}
495501

@@ -508,6 +514,8 @@ def to_dict(self) -> dict:
508514
"new_capabilities": self.new_capabilities,
509515
"removed_packages": [p.to_dict() for p in self.removed_packages],
510516
"new_alerts": [alert.__dict__ for alert in self.new_alerts],
517+
"unchanged_alerts": [alert.__dict__ for alert in self.unchanged_alerts] if hasattr(self, "unchanged_alerts") else [],
518+
"removed_alerts": [alert.__dict__ for alert in self.removed_alerts] if hasattr(self, "removed_alerts") else [],
511519
"id": self.id,
512520
"sbom": self.sbom if hasattr(self, "sbom") else [],
513521
"packages": {k: v.to_dict() for k, v in self.packages.items()} if hasattr(self, "packages") else {},

socketsecurity/output.py

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,40 @@ def return_exit_code(self, diff_report: Diff) -> int:
7777

7878
def output_console_comments(self, diff_report: Diff, sbom_file_name: Optional[str] = None) -> None:
7979
"""Outputs formatted console comments"""
80-
if len(diff_report.new_alerts) == 0:
80+
has_new_alerts = len(diff_report.new_alerts) > 0
81+
has_unchanged_alerts = (
82+
self.config.strict_blocking and
83+
hasattr(diff_report, 'unchanged_alerts') and
84+
len(diff_report.unchanged_alerts) > 0
85+
)
86+
87+
if not has_new_alerts and not has_unchanged_alerts:
8188
self.logger.info("No issues found")
8289
return
8390

91+
# Count blocking vs warning alerts
92+
new_blocking = sum(1 for issue in diff_report.new_alerts if issue.error)
93+
new_warning = sum(1 for issue in diff_report.new_alerts if issue.warn)
94+
95+
unchanged_blocking = 0
96+
unchanged_warning = 0
97+
if has_unchanged_alerts:
98+
unchanged_blocking = sum(1 for issue in diff_report.unchanged_alerts if issue.error)
99+
unchanged_warning = sum(1 for issue in diff_report.unchanged_alerts if issue.warn)
100+
84101
console_security_comment = Messages.create_console_security_alert_table(diff_report)
102+
103+
# Build status message
85104
self.logger.info("Security issues detected by Socket Security:")
105+
if new_blocking > 0:
106+
self.logger.info(f" - NEW blocking issues: {new_blocking}")
107+
if new_warning > 0:
108+
self.logger.info(f" - NEW warning issues: {new_warning}")
109+
if unchanged_blocking > 0:
110+
self.logger.info(f" - EXISTING blocking issues: {unchanged_blocking} (causing failure due to --strict-blocking)")
111+
if unchanged_warning > 0:
112+
self.logger.info(f" - EXISTING warning issues: {unchanged_warning}")
113+
86114
self.logger.info(f"Diff Url: {diff_report.diff_url}")
87115
self.logger.info(f"\n{console_security_comment}")
88116

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

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

111-
if self.config.disable_blocking:
140+
# Check new alerts for blocking issues
141+
has_new_blocking_alerts = any(issue.error for issue in diff_report.new_alerts)
142+
143+
# Check unchanged alerts if --strict-blocking is enabled
144+
has_unchanged_blocking_alerts = False
145+
if self.config.strict_blocking and hasattr(diff_report, 'unchanged_alerts'):
146+
has_unchanged_blocking_alerts = any(
147+
issue.error for issue in diff_report.unchanged_alerts
148+
)
149+
150+
# If no alerts at all, pass
151+
if not diff_report.new_alerts and not (
152+
self.config.strict_blocking and
153+
hasattr(diff_report, 'unchanged_alerts') and
154+
diff_report.unchanged_alerts
155+
):
112156
return True
113157

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

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

0 commit comments

Comments
 (0)