Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .github/secret_scanning.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Paths excluded from secret scanning alerts.
#
# Scope this as narrowly as possible. An entry here means a real credential
# committed to that path would not raise an alert, so it is only appropriate
# for a file whose contents are credential-shaped by definition.
#
# The redaction tests assert that values in published credential formats are
# masked, which requires holding examples of those formats. They are assembled
# from a prefix and a body at runtime so no literal appears in the file, and
# this entry covers anything that slips past that.
#
# Note: this suppresses alerts only. Push protection is evaluated separately
# and ignores this file, so a credential-shaped literal will still block a
# push; assembling the value at runtime is what avoids that.
paths-ignore:
- "tests/test_secret_redaction.py"
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,42 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Fixed
- **A finding's snippet no longer reproduces the value it reports.** A SAST
finding's `codeSnippet` is the source line the rule matched. For nearly every
rule that line is the code the finding is about; for the hardcoded-credential
rules it contains the credential, so the finding carried the value into
`.socket.facts.json`, the uploaded facts and the configured notifiers.
Snippets for those rules now keep the assignment target, the syntax, the file
and the line, and mask the literal's contents. This covers 20 rules across all
fifteen bundled language rule sets, not only the Python and JavaScript ones:
`*-hardcoded-secret(s)`, `*-hardcoded-credentials`,
`*-hardcoded-password-default`, `*-default-credentials`,
`*-plain-text-password`, `*-weak-jwt-secret` and `*-empty-password`. Rules
whose match is not a credential keep their snippets verbatim.
- Every snippet, dataflow-trace step and detailed report, whatever rule produced
it, is now masked of values matching a well-known credential format: AWS key
IDs, GitHub tokens, Stripe keys, Slack tokens, Google API keys, npm and PyPI
tokens, JWTs, PEM private key bodies, and credentials in a URL authority. A
rule unrelated to secrets can still match a line that carries one.
- TruffleHog's `redactedValue` kept the first and last four characters of any
value longer than eight, which left most of a short password readable. Values
under sixteen characters are now masked in full.
- TruffleHog no longer scans the facts file the run writes. That file lands
inside the scan target, so a previous run's output was on disk during the walk
and its contents were reported as findings of their own, pointing at the
output file rather than the source line.

### Changed
- `load_explicit_env_config` builds its "API key sources detected" debug line by
iterating a tuple of variable names rather than a dict of presence booleans.
The line is unchanged, including the exclusion of an exported-but-empty
variable.

### Added
- A `redact` rule-metadata key. Set it on a custom SAST rule to mark the match
as a credential, or to opt a rule out; without it, the rule name decides.

## [3.3.0] - 2026-09-15

Small release pairing a CLI parity addition with a notification fix. The fix
Expand Down
31 changes: 31 additions & 0 deletions docs/parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,37 @@ Custom rule file notes:
- Files ending in `.test.yml` or `.test.yaml` are ignored.
- Rules without `languages` are skipped.

#### Masking a custom rule's matched line

A finding's `codeSnippet` is the source line the rule matched. For most rules
that line is vulnerable code and is shown as written; for a rule that matches a
hardcoded credential, the line contains the credential, so Socket Basics masks
the string literals in it before the finding is written to the facts file,
uploaded, or sent to a notifier. The file path and line number are kept either
way.

Bundled rules are recognized by name (`*-hardcoded-secret`,
`*-hardcoded-credentials`, `*-hardcoded-password`, `*-default-credentials`,
`*-plain-text-password`, `*-weak-jwt-secret`). A custom rule can say so
directly with a `redact` metadata key, which also works to opt a rule out:

```yaml
rules:
- id: acme-internal-token
message: "Internal service token committed to source"
severity: HIGH
languages: [python]
pattern: $VAR = "acme_tok_..."
metadata:
redact: true
```

Independently of this setting, every snippet is scrubbed of values matching a
well-known credential format (AWS key IDs, GitHub tokens, Stripe keys, Slack
tokens, Google API keys, npm and PyPI tokens, JWTs, PEM private key bodies, and
credentials embedded in a URL), so a rule unrelated to secrets that matches a
line carrying one does not pass it through.

### Language-Specific Rule Configuration

For each language, you can enable or disable specific rules:
Expand Down
16 changes: 9 additions & 7 deletions socket_basics/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1121,13 +1121,15 @@ def load_explicit_env_config() -> Dict[str, Any]:
logger = logging.getLogger(__name__)
config = {}

# Log which API key sources are available for debugging
api_key_sources = {
'SOCKET_SECURITY_API_KEY': bool(os.environ.get('SOCKET_SECURITY_API_KEY')),
'SOCKET_SECURITY_API_TOKEN': bool(os.environ.get('SOCKET_SECURITY_API_TOKEN')),
'INPUT_SOCKET_SECURITY_API_KEY': bool(os.environ.get('INPUT_SOCKET_SECURITY_API_KEY')),
}
found_sources = [k for k, v in api_key_sources.items() if v]
# Log which API key sources are available for debugging. The list holds the
# variable names, which come from the tuple below; a variable's value is
# only ever tested for emptiness and is never carried into the log line.
api_key_env_vars = (
'SOCKET_SECURITY_API_KEY',
'SOCKET_SECURITY_API_TOKEN',
'INPUT_SOCKET_SECURITY_API_KEY',
)
found_sources = [name for name in api_key_env_vars if os.environ.get(name)]
if found_sources:
logger.debug(f"API key sources detected: {', '.join(found_sources)}")

Expand Down
28 changes: 27 additions & 1 deletion socket_basics/core/connector/opengrep/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@
from . import github_pr, slack, ms_teams, ms_sentinel, sumologic, console, jira, webhook, json_notifier
from .custom_rules import CustomRulesBuilder
from .cwe_catalog import CWE_CATALOG
from ...utils.redaction import (
is_credential_finding,
redact_dataflow_trace,
redact_message,
redact_snippet,
)

# Import shared formatters
from ...formatters import get_all_formatters
Expand Down Expand Up @@ -407,6 +413,14 @@ def _convert_to_socket_facts(self, raw_results: Any) -> Dict[str, Any]:
severity = ((r.get('extra') or {}).get('severity') or r.get('severity') or '')
severity_norm = str(severity).lower() if severity is not None else ''
message = (r.get('extra') or {}).get('message') or r.get('message') or ''
credential_finding = is_credential_finding(
check_id, (r.get('extra') or {}).get('metadata') or {}
)
message = redact_message(
message,
(r.get('extra') or {}).get('metavars') or {},
credential_finding=credential_finding,
)
start = (r.get('start') or {}).get('line')
end = (r.get('end') or {}).get('line')

Expand All @@ -423,7 +437,15 @@ def _convert_to_socket_facts(self, raw_results: Any) -> Dict[str, Any]:
# default if unknown
sev_label = 'medium'

# Mask here, not at the destinations: everything below
# reads `code_snippet`, and the props it lands in are
# read in turn by the facts file, the upload and every
# notifier. See socket_basics.core.utils.redaction.
code_snippet = (r.get('extra') or {}).get('lines') or (r.get('extra') or {}).get('snippet') or ''
code_snippet = redact_snippet(
code_snippet,
credential_finding=credential_finding,
)

alert = {
'title': check_id,
Expand Down Expand Up @@ -527,7 +549,11 @@ def _fmt_trace_loc(trace_item):
if _intermediates:
_trace_data['intermediates'] = [_fmt_trace_loc(v) for v in _intermediates]

alert['props']['dataflowTrace'] = _trace_data
# Trace steps quote source lines the same way the
# snippet does, so they get the same treatment.
alert['props']['dataflowTrace'] = redact_dataflow_trace(
_trace_data, credential_finding
)
except Exception:
pass # Skip trace if structure is unexpected

Expand Down
37 changes: 33 additions & 4 deletions socket_basics/core/connector/trufflehog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
# coerce_bool lives in the config layer because the environment loader, a
# Socket dashboard config, and a JSON config each deliver booleans differently.
from ...config import coerce_bool
from ...utils.redaction import mask_value

# Import individual notifier modules
from . import github_pr, slack, ms_teams, ms_sentinel, sumologic, console, jira, webhook, json_notifier
Expand Down Expand Up @@ -169,6 +170,31 @@ def _build_exclude_patterns(self, exclude_dirs: Any) -> List[str]:

return patterns

def _output_file_patterns(self) -> List[str]:
"""Exclude the facts file this run writes into the scanned workspace.

The facts file lands inside the scan target, so a previous run's output
is on disk by the time TruffleHog walks the tree. Whatever another
scanner recorded there gets re-detected as a finding of its own,
pointing at the output file instead of the source line. Exclude both
the file and the temporary name it is staged under.
"""
output_name = self.config.get('output', '.socket.facts.json') or '.socket.facts.json'
output_dir = getattr(self.config, 'output_dir', None) or self._workspace_root()
if not output_dir:
return []
try:
output_path = Path(
self._absolute_scan_target(Path(output_dir) / output_name)
)
except (TypeError, ValueError):
return []

patterns = []
for candidate in (output_path, output_path.with_name(output_path.name + '.tmp')):
patterns.append(rf'^{self._path_regex(str(candidate))}$')
return patterns

def _write_exclude_file(self, exclude_dirs: Any) -> str | None:
"""Write exclude regexes to a temporary file for TruffleHog."""
patterns = self._build_exclude_patterns(exclude_dirs)
Expand Down Expand Up @@ -304,6 +330,8 @@ def scan(self) -> Dict[str, Any]:
exclude_dirs = self.config.get('trufflehog_exclude_dir', '')
if exclude_dirs:
exclude_patterns = self._build_exclude_patterns(exclude_dirs)
exclude_patterns.extend(self._output_file_patterns())
if exclude_patterns:
logger.debug("TruffleHog exclude patterns: %s", exclude_patterns)
exclude_file_path = self._write_exclude_patterns(exclude_patterns)
if exclude_file_path:
Expand Down Expand Up @@ -546,10 +574,11 @@ def _create_alert(self, finding: Dict[str, Any]) -> Dict[str, Any]:

file_path = self._workspace_relative_path(file_path)

# Redact the actual secret
raw_secret = finding.get('Raw', '')
redacted_secret = raw_secret[:4] + '*' * (len(raw_secret) - 8) + raw_secret[-4:] if len(raw_secret) > 8 else '*' * len(raw_secret)

# TruffleHog reports the match verbatim in `Raw` and leaves its own
# `Redacted` field empty for most detectors, so neither field can be
# passed through and the masking has to happen here.
redacted_secret = mask_value(finding.get('Raw', ''))

markdown_content = f"""## Secret Detected: {detector_name}

### Detection Details
Expand Down
Loading
Loading