Skip to content

Commit b8b49f5

Browse files
authored
SARIF file output and reachability filtering (#165)
* Add support for SARIF file output Signed-off-by: lelia <lelia@socket.dev> * Ignore SARIF results Signed-off-by: lelia <lelia@socket.dev> * Add test for new SARIF output functionality Signed-off-by: lelia <lelia@socket.dev> * Document new CLI output flag and clarify intended usage Signed-off-by: lelia <lelia@socket.dev> * Bump version to prep for release Signed-off-by: lelia <lelia@socket.dev> * Bump version to account for new release Signed-off-by: lelia <lelia@socket.dev> * Add workflow for running unittests Signed-off-by: lelia <lelia@socket.dev> * Tweak workflow name Signed-off-by: lelia <lelia@socket.dev> * Install dev dependencies for testing Signed-off-by: lelia <lelia@socket.dev> * Update lockfile Signed-off-by: lelia <lelia@socket.dev> * Add configurable option for reachabilty filtering with SARIF Signed-off-by: lelia <lelia@socket.dev> * Implement reachabilty logic for SARIF output Signed-off-by: lelia <lelia@socket.dev> * Add unittests to cover new reachability filtering functionality Signed-off-by: lelia <lelia@socket.dev> * Update README to document new filtering options and required use of --reach flag Signed-off-by: lelia <lelia@socket.dev> * Update e2e tests to include SARIF workflow Signed-off-by: lelia <lelia@socket.dev> * Impove Slack bot mode debug logging to surface failures Signed-off-by: lelia <lelia@socket.dev> * Skip gitlab tests that pass incorrect mock client to constructor Signed-off-by: lelia <lelia@socket.dev> * Update old constructor to use current Mock(spec=CliConfig) pattern, plus other test fixes Signed-off-by: lelia <lelia@socket.dev> --------- Signed-off-by: lelia <lelia@socket.dev>
1 parent c281d6d commit b8b49f5

File tree

13 files changed

+461
-40
lines changed

13 files changed

+461
-40
lines changed

.github/workflows/e2e-test.yml

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,54 @@ jobs:
4747
exit 1
4848
fi
4949
50+
e2e-sarif:
51+
runs-on: ubuntu-latest
52+
steps:
53+
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871
54+
with:
55+
fetch-depth: 0
56+
57+
- uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3
58+
with:
59+
python-version: '3.12'
60+
61+
- name: Install CLI from local repo
62+
run: |
63+
python -m pip install --upgrade pip
64+
pip install .
65+
66+
- name: Verify --sarif-reachable-only without --reach exits non-zero
67+
run: |
68+
if socketcli --sarif-reachable-only --api-token dummy 2>&1; then
69+
echo "FAIL: Expected non-zero exit"
70+
exit 1
71+
else
72+
echo "PASS: Exited non-zero as expected"
73+
fi
74+
75+
- name: Run Socket CLI scan with --sarif-file
76+
env:
77+
SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_CLI_API_TOKEN }}
78+
run: |
79+
set -o pipefail
80+
socketcli \
81+
--target-path tests/e2e/fixtures/simple-npm \
82+
--sarif-file /tmp/results.sarif \
83+
--disable-blocking \
84+
2>&1 | tee /tmp/sarif-output.log
85+
86+
- name: Verify SARIF file is valid
87+
run: |
88+
python3 -c "
89+
import json, sys
90+
with open('/tmp/results.sarif') as f:
91+
data = json.load(f)
92+
assert data['version'] == '2.1.0', f'Invalid version: {data[\"version\"]}'
93+
assert '\$schema' in data, 'Missing \$schema'
94+
count = len(data['runs'][0]['results'])
95+
print(f'PASS: Valid SARIF 2.1.0 with {count} result(s)')
96+
"
97+
5098
e2e-reachability:
5199
runs-on: ubuntu-latest
52100
steps:
@@ -107,3 +155,41 @@ jobs:
107155
cat /tmp/reach-output.log
108156
exit 1
109157
fi
158+
159+
- name: Run scan with --sarif-file (all results)
160+
env:
161+
SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_CLI_API_TOKEN }}
162+
run: |
163+
socketcli \
164+
--target-path tests/e2e/fixtures/simple-npm \
165+
--reach \
166+
--sarif-file /tmp/sarif-all.sarif \
167+
--disable-blocking \
168+
2>/dev/null || true
169+
170+
- name: Run scan with --sarif-file --sarif-reachable-only (filtered results)
171+
env:
172+
SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_CLI_API_TOKEN }}
173+
run: |
174+
socketcli \
175+
--target-path tests/e2e/fixtures/simple-npm \
176+
--reach \
177+
--sarif-file /tmp/sarif-reachable.sarif \
178+
--sarif-reachable-only \
179+
--disable-blocking \
180+
2>/dev/null || true
181+
182+
- name: Verify reachable-only results are a subset of all results
183+
run: |
184+
python3 -c "
185+
import json
186+
with open('/tmp/sarif-all.sarif') as f:
187+
all_data = json.load(f)
188+
with open('/tmp/sarif-reachable.sarif') as f:
189+
reach_data = json.load(f)
190+
all_count = len(all_data['runs'][0]['results'])
191+
reach_count = len(reach_data['runs'][0]['results'])
192+
print(f'All results: {all_count}, Reachable-only results: {reach_count}')
193+
assert reach_count <= all_count, f'FAIL: reachable ({reach_count}) > all ({all_count})'
194+
print('PASS: Reachable-only results is a subset of all results')
195+
"

.github/workflows/python-tests.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
name: Unit Tests
2+
3+
env:
4+
PYTHON_VERSION: "3.12"
5+
6+
on:
7+
push:
8+
branches: [main]
9+
paths:
10+
- "socketsecurity/**/*.py"
11+
- "tests/unit/**/*.py"
12+
- "pyproject.toml"
13+
- "uv.lock"
14+
- ".github/workflows/python-tests.yml"
15+
pull_request:
16+
paths:
17+
- "socketsecurity/**/*.py"
18+
- "tests/unit/**/*.py"
19+
- "pyproject.toml"
20+
- "uv.lock"
21+
- ".github/workflows/python-tests.yml"
22+
workflow_dispatch:
23+
24+
permissions:
25+
contents: read
26+
27+
concurrency:
28+
group: python-tests-${{ github.ref }}
29+
cancel-in-progress: true
30+
31+
jobs:
32+
python-tests:
33+
runs-on: ubuntu-latest
34+
timeout-minutes: 20
35+
steps:
36+
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871
37+
with:
38+
fetch-depth: 1
39+
persist-credentials: false
40+
- name: 🐍 setup python
41+
uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3
42+
with:
43+
python-version: ${{ env.PYTHON_VERSION }}
44+
- name: 🛠️ install deps
45+
run: |
46+
python -m pip install --upgrade pip
47+
pip install uv
48+
uv sync --extra test
49+
- name: 🧪 run tests
50+
run: uv run pytest -q tests/unit/

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ run_container.sh
1313
bin
1414
scripts/*.py
1515
*.json
16+
*.sarif
1617
!tests/**/*.json
1718
markdown_overview_temp.md
1819
markdown_security_temp.md

README.md

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -94,18 +94,27 @@ This will:
9494
- Save to `gl-dependency-scanning-report.json`
9595
- Include all actionable security alerts (error/warn level)
9696

97+
**Save SARIF report to file (e.g. for GitHub Code Scanning, SonarQube, or VS Code):**
98+
```bash
99+
socketcli --sarif-file results.sarif \
100+
--repo owner/repo \
101+
--target-path .
102+
```
103+
97104
**Multiple output formats:**
98105
```bash
99106
socketcli --enable-json \
100-
--enable-sarif \
107+
--sarif-file results.sarif \
101108
--enable-gitlab-security \
102109
--repo owner/repo
103110
```
104111

105112
This will simultaneously generate:
106113
- JSON output to console
107-
- SARIF format to console
108-
- GitLab Security Dashboard report to file
114+
- SARIF report to `results.sarif` (and stdout)
115+
- GitLab Security Dashboard report to `gl-dependency-scanning-report.json`
116+
117+
> **Note:** `--enable-sarif` prints SARIF to stdout only. Use `--sarif-file <path>` to save to a file (this also implies `--enable-sarif`). Add `--sarif-reachable-only` (requires `--reach`) to filter results down to only reachable findings — useful for uploading to GitHub Code Scanning without noisy alerts on unreachable vulns. These flags are independent from `--enable-gitlab-security`, which produces a separate GitLab-specific Dependency Scanning report.
109118
110119
### Requirements
111120

@@ -121,7 +130,7 @@ socketcli [-h] [--api-token API_TOKEN] [--repo REPO] [--workspace WORKSPACE] [--
121130
[--target-path TARGET_PATH] [--sbom-file SBOM_FILE] [--license-file-name LICENSE_FILE_NAME] [--save-submitted-files-list SAVE_SUBMITTED_FILES_LIST]
122131
[--save-manifest-tar SAVE_MANIFEST_TAR] [--files FILES] [--sub-path SUB_PATH] [--workspace-name WORKSPACE_NAME]
123132
[--excluded-ecosystems EXCLUDED_ECOSYSTEMS] [--default-branch] [--pending-head] [--generate-license] [--enable-debug]
124-
[--enable-json] [--enable-sarif] [--enable-gitlab-security] [--gitlab-security-file <path>]
133+
[--enable-json] [--enable-sarif] [--sarif-file <path>] [--sarif-reachable-only] [--enable-gitlab-security] [--gitlab-security-file <path>]
125134
[--disable-overview] [--exclude-license-details] [--allow-unverified] [--disable-security-issue]
126135
[--ignore-commit-files] [--disable-blocking] [--enable-diff] [--scm SCM] [--timeout TIMEOUT] [--include-module-folders]
127136
[--reach] [--reach-version REACH_VERSION] [--reach-analysis-timeout REACH_ANALYSIS_TIMEOUT]
@@ -189,7 +198,9 @@ If you don't want to provide the Socket API Token every time then you can use th
189198
| --generate-license | False | False | Generate license information |
190199
| --enable-debug | False | False | Enable debug logging |
191200
| --enable-json | False | False | Output in JSON format |
192-
| --enable-sarif | False | False | Enable SARIF output of results instead of table or JSON format |
201+
| --enable-sarif | False | False | Enable SARIF output of results instead of table or JSON format (prints to stdout) |
202+
| --sarif-file | False | | Output file path for SARIF report (implies --enable-sarif). Use this to save SARIF output to a file for upload to GitHub Code Scanning, SonarQube, VS Code, or other SARIF-compatible tools |
203+
| --sarif-reachable-only | False | False | Filter SARIF output to only include reachable findings (requires --reach) |
193204
| --enable-gitlab-security | False | False | Enable GitLab Security Dashboard output format (Dependency Scanning report) |
194205
| --gitlab-security-file | False | gl-dependency-scanning-report.json | Output file path for GitLab Security report |
195206
| --disable-overview | False | False | Disable overview output |
@@ -725,13 +736,13 @@ socketcli --enable-gitlab-security --gitlab-security-file custom-path.json
725736
GitLab security reports can be generated alongside other output formats:
726737
727738
```bash
728-
socketcli --enable-json --enable-gitlab-security --enable-sarif
739+
socketcli --enable-json --enable-gitlab-security --sarif-file results.sarif
729740
```
730741
731742
This command will:
732743
- Output JSON format to console
733744
- Save GitLab Security Dashboard report to `gl-dependency-scanning-report.json`
734-
- Save SARIF report (if configured)
745+
- Save SARIF report to `results.sarif`
735746
736747
### Security Dashboard Features
737748

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.75"
9+
version = "2.2.76"
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.75'
2+
__version__ = '2.2.76'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

socketsecurity/config.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ class CliConfig:
4040
allow_unverified: bool = False
4141
enable_json: bool = False
4242
enable_sarif: bool = False
43+
sarif_file: Optional[str] = None
44+
sarif_reachable_only: bool = False
4345
enable_gitlab_security: bool = False
4446
gitlab_security_file: Optional[str] = None
4547
disable_overview: bool = False
@@ -103,6 +105,10 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
103105
args.api_token
104106
)
105107

108+
# --sarif-file implies --enable-sarif
109+
if args.sarif_file:
110+
args.enable_sarif = True
111+
106112
# Strip quotes from commit message if present
107113
commit_message = args.commit_message
108114
if commit_message and commit_message.startswith('"') and commit_message.endswith('"'):
@@ -126,6 +132,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
126132
'allow_unverified': args.allow_unverified,
127133
'enable_json': args.enable_json,
128134
'enable_sarif': args.enable_sarif,
135+
'sarif_file': args.sarif_file,
136+
'sarif_reachable_only': args.sarif_reachable_only,
129137
'enable_gitlab_security': args.enable_gitlab_security,
130138
'gitlab_security_file': args.gitlab_security_file,
131139
'disable_overview': args.disable_overview,
@@ -204,6 +212,11 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
204212
logging.error("--workspace-name requires --sub-path to be specified")
205213
exit(1)
206214

215+
# Validate that sarif_reachable_only requires reach
216+
if args.sarif_reachable_only and not args.reach:
217+
logging.error("--sarif-reachable-only requires --reach to be specified")
218+
exit(1)
219+
207220
# Validate that only_facts_file requires reach
208221
if args.only_facts_file and not args.reach:
209222
logging.error("--only-facts-file requires --reach to be specified")
@@ -471,6 +484,19 @@ def create_argument_parser() -> argparse.ArgumentParser:
471484
action="store_true",
472485
help="Enable SARIF output of results instead of table or JSON format"
473486
)
487+
output_group.add_argument(
488+
"--sarif-file",
489+
dest="sarif_file",
490+
metavar="<path>",
491+
default=None,
492+
help="Output file path for SARIF report (implies --enable-sarif)"
493+
)
494+
output_group.add_argument(
495+
"--sarif-reachable-only",
496+
dest="sarif_reachable_only",
497+
action="store_true",
498+
help="Filter SARIF output to only include reachable findings (requires --reach)"
499+
)
474500
output_group.add_argument(
475501
"--enable-gitlab-security",
476502
dest="enable_gitlab_security",

socketsecurity/output.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,20 @@ def handle_output(self, diff_report: Diff) -> None:
5858
slack_url = "Not configured"
5959
if self.config.slack_plugin.config and self.config.slack_plugin.config.get("url"):
6060
slack_url = self.config.slack_plugin.config.get("url")
61+
slack_mode = (self.config.slack_plugin.config or {}).get("mode", "webhook")
62+
bot_token = os.getenv("SOCKET_SLACK_BOT_TOKEN")
63+
bot_token_status = "Set" if bot_token else "Not set"
6164
self.logger.debug("=== Slack Webhook Debug Information ===")
6265
self.logger.debug(f"Slack Plugin Enabled: {self.config.slack_plugin.enabled}")
66+
self.logger.debug(f"Slack Mode: {slack_mode}")
6367
self.logger.debug(f"SOCKET_SLACK_ENABLED environment variable: {slack_enabled_env}")
6468
self.logger.debug(f"SOCKET_SLACK_CONFIG_JSON environment variable: {slack_config_env}")
6569
self.logger.debug(f"Slack Webhook URL: {slack_url}")
70+
self.logger.debug(f"SOCKET_SLACK_BOT_TOKEN: {bot_token_status}")
6671
self.logger.debug(f"Slack Alert Levels: {self.config.slack_plugin.levels}")
72+
if self.config.reach:
73+
facts_path = os.path.join(self.config.target_path or ".", self.config.reach_output_file or ".socket.facts.json")
74+
self.logger.debug(f"Reachability facts file: {facts_path} (exists: {os.path.exists(facts_path)})")
6775
self.logger.debug("=====================================")
6876

6977
if self.config.slack_plugin.enabled:
@@ -139,14 +147,38 @@ def output_console_json(self, diff_report: Diff, sbom_file_name: Optional[str] =
139147
def output_console_sarif(self, diff_report: Diff, sbom_file_name: Optional[str] = None) -> None:
140148
"""
141149
Generate SARIF output from the diff report and print to console.
150+
If --sarif-file is configured, also save to file.
151+
If --sarif-reachable-only is set, filters to blocking (reachable) alerts only.
142152
"""
143153
if diff_report.id != "NO_DIFF_RAN":
154+
# When --sarif-reachable-only is set, filter to error=True alerts only.
155+
# This mirrors the Slack plugin's reachability_alerts_only behaviou:
156+
# when --reach is used, error=True reflects Socket's reachability-aware policy.
157+
if self.config.sarif_reachable_only:
158+
filtered_alerts = [a for a in diff_report.new_alerts if getattr(a, "error", False)]
159+
diff_report = Diff(
160+
new_alerts=filtered_alerts,
161+
diff_url=getattr(diff_report, "diff_url", ""),
162+
new_packages=getattr(diff_report, "new_packages", []),
163+
removed_packages=getattr(diff_report, "removed_packages", []),
164+
packages=getattr(diff_report, "packages", {}),
165+
)
166+
diff_report.id = "filtered"
167+
144168
# Generate the SARIF structure using Messages
145169
console_security_comment = Messages.create_security_comment_sarif(diff_report)
146170
self.save_sbom_file(diff_report, sbom_file_name)
147171
# Print the SARIF output to the console in JSON format
148172
print(json.dumps(console_security_comment, indent=2))
149173

174+
# Save to file if --sarif-file is specified
175+
if self.config.sarif_file:
176+
sarif_path = Path(self.config.sarif_file)
177+
sarif_path.parent.mkdir(parents=True, exist_ok=True)
178+
with open(sarif_path, "w") as f:
179+
json.dump(console_security_comment, f, indent=2)
180+
self.logger.info(f"SARIF report saved to {self.config.sarif_file}")
181+
150182
def report_pass(self, diff_report: Diff) -> bool:
151183
"""Determines if the report passes security checks"""
152184
# Priority 1: --disable-blocking always passes

0 commit comments

Comments
 (0)