Skip to content

Commit 6f2d64a

Browse files
Jonathan Muchaclaude
andcommitted
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>
1 parent d0886a5 commit 6f2d64a

File tree

8 files changed

+377
-11
lines changed

8 files changed

+377
-11
lines changed

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"""

socketsecurity/socketcli.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,12 @@ def main_code():
4949
config = CliConfig.from_args()
5050
log.info(f"Starting Socket Security CLI version {config.version}")
5151
log.debug(f"config: {config.to_dict()}")
52-
52+
53+
# Warn if strict-blocking is used with disable-blocking
54+
if config.strict_blocking and config.disable_blocking:
55+
log.warning("Both --strict-blocking and --disable-blocking specified. "
56+
"--disable-blocking takes precedence and will always return exit code 0.")
57+
5358
# Validate API token
5459
if not config.api_token:
5560
log.info("Socket API Token not found. Please set it using either:\n"
@@ -625,9 +630,13 @@ def main_code():
625630
core.save_file(config.license_file_name, json.dumps(all_packages))
626631

627632
# If we forced API mode due to no supported files, behave as if --disable-blocking was set
628-
if force_api_mode and not config.disable_blocking:
629-
log.debug("Temporarily enabling disable_blocking due to no supported manifest files")
630-
config.disable_blocking = True
633+
if force_api_mode:
634+
if config.strict_blocking:
635+
log.warning("--strict-blocking is only supported in diff mode. "
636+
"API mode (no diff) cannot evaluate existing violations.")
637+
if not config.disable_blocking:
638+
log.debug("Temporarily enabling disable_blocking due to no supported manifest files")
639+
config.disable_blocking = True
631640

632641
sys.exit(output_handler.return_exit_code(diff))
633642

tests/core/test_diff_alerts.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import pytest
2+
from socketsecurity.core import Core
3+
from socketsecurity.core.classes import Issue
4+
5+
6+
class TestDiffAlerts:
7+
"""Test alert collection for diff reports"""
8+
9+
def test_get_unchanged_alerts_filters_errors(self):
10+
"""Test that get_unchanged_alerts only returns error/warn alerts"""
11+
alerts_dict = {
12+
'alert1': [
13+
Issue(error=True, warn=False, purl='npm/pkg1', type='malicious'),
14+
Issue(error=False, warn=False, purl='npm/pkg1', type='info', monitor=True)
15+
],
16+
'alert2': [
17+
Issue(error=False, warn=True, purl='npm/pkg2', type='typosquat')
18+
]
19+
}
20+
21+
result = Core.get_unchanged_alerts(alerts_dict)
22+
23+
# Should only include error=True and warn=True alerts
24+
assert len(result) == 2
25+
assert any(alert.error for alert in result)
26+
assert any(alert.warn for alert in result)
27+
assert not any(alert.monitor and not (alert.error or alert.warn) for alert in result)
28+
29+
def test_get_unchanged_alerts_deduplicates(self):
30+
"""Test that get_unchanged_alerts deduplicates by purl+type"""
31+
alerts_dict = {
32+
'alert1': [
33+
Issue(error=True, warn=False, purl='npm/pkg1', type='malicious'),
34+
Issue(error=True, warn=False, purl='npm/pkg1', type='malicious') # Duplicate
35+
]
36+
}
37+
38+
result = Core.get_unchanged_alerts(alerts_dict)
39+
40+
# Should deduplicate
41+
assert len(result) == 1
42+
43+
def test_get_unchanged_alerts_empty(self):
44+
"""Test that get_unchanged_alerts handles empty input"""
45+
result = Core.get_unchanged_alerts({})
46+
assert len(result) == 0
47+
48+
def test_get_removed_alerts_all_alerts(self):
49+
"""Test that get_removed_alerts returns all alerts from removed packages"""
50+
alerts_dict = {
51+
'alert1': [
52+
Issue(error=True, warn=False, purl='npm/pkg1', type='malicious'),
53+
Issue(error=False, warn=True, purl='npm/pkg1', type='typosquat')
54+
]
55+
}
56+
57+
result = Core.get_removed_alerts(alerts_dict)
58+
59+
# Should include all alerts, not just error/warn
60+
assert len(result) == 2
61+
62+
def test_get_removed_alerts_deduplicates(self):
63+
"""Test that get_removed_alerts deduplicates by purl+type"""
64+
alerts_dict = {
65+
'alert1': [
66+
Issue(error=True, warn=False, purl='npm/pkg1', type='malicious'),
67+
Issue(error=True, warn=False, purl='npm/pkg1', type='malicious') # Duplicate
68+
]
69+
}
70+
71+
result = Core.get_removed_alerts(alerts_dict)
72+
73+
# Should deduplicate
74+
assert len(result) == 1
75+
76+
def test_get_removed_alerts_empty(self):
77+
"""Test that get_removed_alerts handles empty input"""
78+
result = Core.get_removed_alerts({})
79+
assert len(result) == 0

0 commit comments

Comments
 (0)