Skip to content

Commit ff7702c

Browse files
authored
feat: add diff-only scan scoping (#77)
Signed-off-by: lelia <2418071+lelia@users.noreply.github.com>
1 parent aa80fce commit ff7702c

7 files changed

Lines changed: 413 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
88

99
## [Unreleased]
1010

11+
## [2.1.0] - 2026-06-02
12+
13+
### Added
14+
- Diff-only scan scoping now applies to SAST/OpenGrep via `changed_files` and
15+
`scan_files`.
16+
- Added GitHub Action inputs for `changed_files` and `scan_files`.
17+
18+
### Fixed
19+
- Delete-only changed-file scans now skip instead of falling back to a full
20+
workspace scan.
21+
- Updated parameter docs to reflect SAST/OpenGrep diff-only scoping.
22+
1123
## [2.0.3] - 2026-04-24
1224

1325
<!-- Release notes generated using configuration in .github/release.yml at main -->

action.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ runs:
99
# Core GitHub variables (these are automatically available, but we explicitly pass GITHUB_TOKEN)
1010
GITHUB_TOKEN: ${{ inputs.github_token }}
1111
INPUT_WORKSPACE: ${{ inputs.workspace }}
12+
# Scan scope
13+
INPUT_CHANGED_FILES: ${{ inputs.changed_files }}
14+
INPUT_SCAN_FILES: ${{ inputs.scan_files }}
1215
# Input mappings for all parameters
1316
INPUT_ALL_LANGUAGES_ENABLED: ${{ inputs.all_languages_enabled }}
1417
INPUT_ALL_RULES_ENABLED: ${{ inputs.all_rules_enabled }}
@@ -103,6 +106,25 @@ inputs:
103106
description: "Workspace directory to scan (defaults to GITHUB_WORKSPACE)"
104107
required: false
105108
default: ""
109+
changed_files:
110+
description: >-
111+
Diff-only mode: scope every scanner (SAST/OpenGrep, secrets, containers)
112+
to changed files only, instead of the whole repository. Accepts a
113+
comma-separated file list, a commit hash, 'auto' (diffs against the PR
114+
base branch in CI, else staged changes), 'pr' (diff against
115+
GITHUB_BASE_REF), or 'current-commit'. For PR/'auto' modes, check out with
116+
actions/checkout fetch-depth: 0 so the base branch is available. When the
117+
diff resolves to no existing files (e.g. a delete-only PR) the scanners
118+
are skipped rather than scanning the whole repo.
119+
required: false
120+
default: ""
121+
scan_files:
122+
description: >-
123+
Explicit comma-separated list of files to scan. Scopes SAST/OpenGrep,
124+
secret, and container scans to just these files. Used when changed_files
125+
is not set; changed_files takes precedence when both are provided.
126+
required: false
127+
default: ""
106128
socket_org:
107129
description: "Socket organization slug (required for Enterprise features)"
108130
required: false

docs/github-action.md

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,57 @@ Include these in your workflow's `jobs.<job_id>.permissions` section.
271271
verbose: 'true'
272272
```
273273

274+
## Diff-Only Mode (Changed Files)
275+
276+
By default the scanners run against the **entire repository**, so every PR
277+
re-reports the whole repo's existing findings. To report only on what the PR
278+
changed — the way Socket SCA Pull Request alerts behave — use the
279+
`changed_files` input. This scopes SAST/OpenGrep, secret, and container scans to
280+
the changed files and dramatically reduces PR finding volume.
281+
282+
```yaml
283+
name: Socket Basics (PR diff-only)
284+
on:
285+
pull_request:
286+
287+
jobs:
288+
socket-basics:
289+
permissions:
290+
contents: read
291+
pull-requests: write
292+
runs-on: ubuntu-latest
293+
steps:
294+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
295+
with:
296+
# Required so the PR base branch is available for the diff
297+
fetch-depth: 0
298+
299+
- name: Run Socket Basics (changed files only)
300+
uses: SocketDev/socket-basics@v2.0.3
301+
env:
302+
GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }}
303+
with:
304+
github_token: ${{ secrets.GITHUB_TOKEN }}
305+
# Diff-only: scope all scanners to files changed in this PR
306+
changed_files: 'auto'
307+
python_sast_enabled: 'true'
308+
javascript_sast_enabled: 'true'
309+
secret_scanning_enabled: 'true'
310+
```
311+
312+
`changed_files` accepts:
313+
314+
- `auto` — diff against the PR base branch in CI (`GITHUB_BASE_REF`), else staged changes
315+
- `pr` — diff against the PR base branch (`GITHUB_BASE_REF`)
316+
- a commit hash — files changed in that commit
317+
- a comma-separated file list — e.g. `src/app.py,src/utils.js`
318+
319+
> [!IMPORTANT]
320+
> For `auto`/`pr` modes, check out with `fetch-depth: 0` so the base branch is
321+
> available to diff against. Deletions are excluded, so a delete-only PR scans
322+
> nothing rather than falling back to the whole repo. To scan an explicit file
323+
> list regardless of git state, use the `scan_files` input instead.
324+
274325
## PR Comment Customization
275326

276327
Socket Basics automatically posts enhanced PR comments with **smart defaults that work out of the box** — clickable file links, collapsible sections, syntax highlighting, CVE links, CVSS scores, and auto-labels are all enabled by default.

docs/parameters.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -92,15 +92,33 @@ socket-basics --committers "user1@example.com,user2@example.com"
9292
```
9393

9494
### `--scan-files SCAN_FILES`
95-
Comma-separated list of files to scan.
95+
Explicit comma-separated list of files to scan. Scopes **all** scanners —
96+
SAST/OpenGrep, secrets, and container scanning — to just these files instead of
97+
the whole workspace. Used when `--changed-files` is not set (`--changed-files`
98+
takes precedence when both are provided). Paths that do not exist are skipped;
99+
if none exist, the scanners are skipped rather than scanning the whole repo.
96100

97101
**Example:**
98102
```bash
99103
socket-basics --scan-files "src/app.py,src/utils.js"
100104
```
101105

102106
### `--changed-files CHANGED_FILES`
103-
Comma-separated list of files to scan or 'auto' to detect changed files from git.
107+
Diff-only mode: scope **all** scanners (SAST/OpenGrep, secrets, containers) to
108+
changed files only, the way Socket SCA Pull Request alerts behave. Accepts:
109+
110+
- a comma-separated file list (e.g. `src/app.py,src/utils.js`)
111+
- a commit hash — files changed in that commit
112+
- `auto` — the PR base-ref diff when running in a PR CI context
113+
(`GITHUB_BASE_REF` is set), otherwise staged (`--cached`) changes
114+
- `pr` — diff against the PR base branch (`GITHUB_BASE_REF`)
115+
- `current-commit` — files in the `HEAD` commit
116+
117+
Deletions are excluded from PR/`auto`/`pr` diffs so removed paths never become
118+
scan targets. When the diff resolves to no existing files (e.g. a delete-only
119+
PR), the scanners are skipped rather than falling back to scanning the whole
120+
repository. For PR/`auto`/`pr` modes, check out with full history (e.g.
121+
`actions/checkout` with `fetch-depth: 0`) so the base branch is available.
104122

105123
**Example:**
106124
```bash
@@ -623,6 +641,9 @@ socket-basics \
623641

624642
### CI/CD Scan (Changed Files Only)
625643

644+
Scope every scanner — SAST/OpenGrep included — to only the files the PR changed,
645+
so each PR reports findings for its own changes rather than the whole repo:
646+
626647
```bash
627648
socket-basics \
628649
--changed-files auto \

socket_basics/core/config.py

Lines changed: 126 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -76,24 +76,58 @@ def _parse_scan_files(self, scan_files_str: str) -> List[str]:
7676
return [f.strip() for f in scan_files_str.split(',') if f.strip()]
7777

7878
def get_scan_targets(self) -> List[str]:
79-
"""Determine files to scan based on configuration"""
80-
# If explicit 'scan_all' set, return workspace directory
79+
"""Determine files to scan based on configuration.
80+
81+
Precedence (highest to lowest):
82+
1. ``scan_all`` -> scan the entire workspace (explicit override).
83+
2. ``changed_files`` -> scope the scan to the PR/diff changed files
84+
(diff-only mode; mirrors how Socket SCA Pull Request alerts behave).
85+
3. ``scan_files`` -> explicit user-provided file list.
86+
4. default -> scan the entire workspace.
87+
88+
For the scoped modes (2 and 3) an empty list may be returned when none
89+
of the requested paths exist (for example a delete-only PR). Callers
90+
MUST treat an empty result as "nothing to scan" and skip the scanner
91+
rather than falling back to scanning the whole workspace or their own
92+
working directory.
93+
"""
94+
# Explicit "scan everything" override.
8195
if self.get('scan_all', False):
8296
return [str(self.workspace)]
8397

84-
# If user provided specific files to scan, validate their existence
98+
# Diff-only mode: scope the scan to the files changed in the PR/commit.
99+
# Keep honoring the scope when git resolves to zero files, e.g. a
100+
# delete-only PR, so callers skip instead of scanning the workspace.
101+
changed_files = self.get('changed_files', []) or []
102+
if changed_files or self.get('changed_files_scope_requested', False):
103+
return self._resolve_file_targets(changed_files)
104+
105+
# Explicit list of files to scan.
85106
if self.scan_files:
86-
targets = [self.workspace / f for f in self.scan_files]
87-
valid = []
88-
for t in targets:
89-
if t.exists():
90-
valid.append(str(t))
91-
else:
92-
logging.getLogger(__name__).warning("Scan target does not exist: %s", str(t))
93-
return valid
94-
95-
# Default: scan the workspace itself
107+
return self._resolve_file_targets(self.scan_files)
108+
109+
# Default: scan the workspace itself.
96110
return [str(self.workspace)]
111+
112+
def _resolve_file_targets(self, files: List[str]) -> List[str]:
113+
"""Resolve a list of file paths to absolute scan targets.
114+
115+
Relative paths are resolved against the workspace; absolute paths are
116+
used as-is. Paths that do not exist are skipped with a warning (a
117+
common case for delete-only PRs). Returns an empty list when none of
118+
the provided paths exist, signalling callers that there is nothing to
119+
scan.
120+
"""
121+
valid: List[str] = []
122+
for f in files:
123+
p = Path(f)
124+
if not p.is_absolute():
125+
p = self.workspace / f
126+
if p.exists():
127+
valid.append(str(p))
128+
else:
129+
logging.getLogger(__name__).warning("Scan target does not exist: %s", str(p))
130+
return valid
97131

98132
def get_action_for_severity(self, severity: str) -> str:
99133
"""Map severity to action according to security policy"""
@@ -1096,7 +1130,10 @@ def add_dynamic_cli_args(parser: argparse.ArgumentParser):
10961130

10971131
# Add optional changed-files CLI argument to limit scans to changed files
10981132
parser.add_argument('--changed-files', type=str, default='',
1099-
help="Comma-separated list of files to scan or 'auto' to detect changed files from git")
1133+
help="Scope all scanners (SAST/OpenGrep, secrets, containers) to changed "
1134+
"files only. Accepts a comma-separated file list, a commit hash, "
1135+
"'auto' (PR base-ref diff in CI, else staged changes), 'pr' (diff "
1136+
"against GITHUB_BASE_REF), or 'current-commit'.")
11001137

11011138
# Also add CLI args for notification plugins declared in notifications.yaml
11021139
try:
@@ -1303,17 +1340,30 @@ def create_config_from_args(args) -> Config:
13031340
except Exception:
13041341
pass
13051342

1306-
# Handle changed-files: CLI overrides env/config. Accept 'auto' to detect via git
1343+
# Handle changed-files: CLI overrides env/config. Accept 'auto' to detect via git.
1344+
# When invoked via the GitHub Action (entrypoint passes no CLI args) the value
1345+
# arrives through the INPUT_CHANGED_FILES environment variable instead.
13071346
changed_files_arg = getattr(args, 'changed_files', '') if args is not None else ''
1347+
if not changed_files_arg:
1348+
changed_files_arg = os.getenv('INPUT_CHANGED_FILES', '')
13081349
if changed_files_arg:
13091350
val = str(changed_files_arg).strip()
1310-
# 'auto' defaults to staged changes (--cached)
1351+
config_dict['changed_files_scope_requested'] = True
1352+
# 'auto' resolves to the PR base-ref diff in CI, else staged changes.
13111353
if val.lower() == 'auto':
13121354
try:
1313-
git_changed = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), mode='staged')
1355+
git_changed = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), mode='auto')
13141356
config_dict['changed_files'] = git_changed
13151357
except Exception as e:
1316-
logging.getLogger(__name__).warning("Warning: failed to detect git changed files (staged): %s", e)
1358+
logging.getLogger(__name__).warning("Warning: failed to detect git changed files (auto): %s", e)
1359+
config_dict['changed_files'] = []
1360+
elif val.lower() == 'pr':
1361+
# Explicit PR diff against the base branch (GITHUB_BASE_REF).
1362+
try:
1363+
git_changed = _detect_git_changed_files(config_dict.get('workspace', os.getcwd()), mode='pr')
1364+
config_dict['changed_files'] = git_changed
1365+
except Exception as e:
1366+
logging.getLogger(__name__).warning("Warning: failed to detect git changed files (pr): %s", e)
13171367
config_dict['changed_files'] = []
13181368
elif val.lower() in ('current-commit', 'current_commit'):
13191369
try:
@@ -1370,27 +1420,34 @@ def create_config_from_args(args) -> Config:
13701420
return Config(config_dict)
13711421

13721422

1373-
def _detect_git_changed_files(workspace_path: str, mode: str = 'staged', commit: str | None = None) -> List[str]:
1423+
def _detect_git_changed_files(workspace_path: str, mode: str = 'staged', commit: str | None = None, base_ref: str | None = None) -> List[str]:
13741424
"""Detect changed files in a git repository.
13751425
13761426
mode:
1377-
- 'staged' -> files staged for commit (git diff --name-only --cached)
1378-
- 'current-commit' -> files included in HEAD commit
1379-
- 'commit' -> files included in the given commit hash (commit param required)
1380-
1381-
Returns a list of file paths relative to the workspace root. If not a git repo or detection fails, returns [].
1427+
- 'staged' -> files staged for commit (git diff --name-only --cached)
1428+
- 'current-commit' -> files included in the HEAD commit
1429+
- 'commit' -> files included in the given commit hash (commit param required)
1430+
- 'pr' -> files changed relative to a base ref (a GitHub PR).
1431+
Uses ``base_ref`` or ``GITHUB_BASE_REF`` and excludes
1432+
deletions so removed paths never become scan targets.
1433+
- 'auto' -> the PR base-ref diff when running in a PR CI context
1434+
(``GITHUB_BASE_REF`` is set), otherwise staged changes.
1435+
This is what ``--changed-files auto`` resolves to.
1436+
1437+
Returns a list of file paths relative to the workspace root. If not a git
1438+
repo or detection fails, returns [].
13821439
"""
13831440
try:
13841441
from subprocess import check_output, CalledProcessError
13851442
import subprocess
1386-
1443+
13871444
# Prefer GITHUB_WORKSPACE if set (GitHub Actions environment)
13881445
# Otherwise use the provided workspace_path
13891446
if os.environ.get('GITHUB_WORKSPACE'):
13901447
ws = Path(os.environ['GITHUB_WORKSPACE'])
13911448
else:
13921449
ws = Path(workspace_path) if workspace_path else Path.cwd()
1393-
1450+
13941451
if not ws.exists():
13951452
return []
13961453

@@ -1404,24 +1461,61 @@ def _detect_git_changed_files(workspace_path: str, mode: str = 'staged', commit:
14041461
original_cwd = os.getcwd()
14051462
try:
14061463
os.chdir(str(ws))
1407-
1408-
if mode == 'staged':
1464+
1465+
def _split(out: str) -> List[str]:
1466+
return [line.strip() for line in out.splitlines() if line.strip()]
1467+
1468+
def _diff_against_base(ref: str) -> Optional[List[str]]:
1469+
"""Diff changed files (excluding deletions) against a base ref.
1470+
1471+
Tries the remote-tracking ref (``origin/<ref>``) first, then the
1472+
bare ref. Returns None when neither ref can be resolved so the
1473+
caller can fall back to another detection strategy. The
1474+
``--diff-filter=ACMR`` excludes deleted paths so they never
1475+
become scan targets.
1476+
"""
1477+
if not ref:
1478+
return None
1479+
for candidate in (f'origin/{ref}', ref):
1480+
try:
1481+
out = check_output(
1482+
['git', 'diff', '--name-only', '--diff-filter=ACMR', f'{candidate}...HEAD'],
1483+
text=True, stderr=subprocess.DEVNULL,
1484+
)
1485+
return _split(out)
1486+
except CalledProcessError:
1487+
continue
1488+
return None
1489+
1490+
if mode == 'auto':
1491+
# Prefer the PR base-ref diff in CI; fall back to staged changes
1492+
# for local/pre-commit use.
1493+
base = base_ref or os.environ.get('GITHUB_BASE_REF', '')
1494+
pr_files = _diff_against_base(base)
1495+
if pr_files is not None:
1496+
return pr_files
1497+
out = check_output(['git', 'diff', '--name-only', '--cached'], text=True, stderr=subprocess.DEVNULL)
1498+
return _split(out)
1499+
elif mode == 'pr':
1500+
base = base_ref or os.environ.get('GITHUB_BASE_REF', '')
1501+
return _diff_against_base(base) or []
1502+
elif mode == 'staged':
14091503
# staged but not yet committed
14101504
out = check_output(['git', 'diff', '--name-only', '--cached'], text=True, stderr=subprocess.DEVNULL)
1505+
return _split(out)
14111506
elif mode == 'current-commit':
14121507
# files that are part of HEAD commit
14131508
out = check_output(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', 'HEAD'], text=True, stderr=subprocess.DEVNULL)
1509+
return _split(out)
14141510
elif mode == 'commit' and commit:
14151511
out = check_output(['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', commit], text=True, stderr=subprocess.DEVNULL)
1512+
return _split(out)
14161513
else:
14171514
return []
1418-
1419-
files = [line.strip() for line in out.splitlines() if line.strip()]
1420-
return files
14211515
finally:
14221516
# Always restore original working directory
14231517
os.chdir(original_cwd)
1424-
1518+
14251519
except CalledProcessError:
14261520
return []
14271521
except Exception:

0 commit comments

Comments
 (0)