Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
2080afc
test: cover cloud download direction and upload controls
chrisknvidia Oct 20, 2026
5185406
fix(e5): distinguish literal cloud downloads from uploads
chrisknvidia Oct 20, 2026
20799cd
refactor(e5): bound download indexing and simplify option parsing
chrisknvidia Oct 20, 2026
6295582
fix(e5): retain findings after shell lookup and directory changes
chrisknvidia Oct 21, 2026
bdc072d
fix(e5): retain findings after unproved shell setup
chrisknvidia Nov 2, 2026
8979c2a
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 3, 2026
9cc8ae9
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 3, 2026
94e1c7c
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 4, 2026
3f4404a
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 4, 2026
903653b
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 4, 2026
d346437
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 4, 2026
a16a5fa
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 4, 2026
928a868
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 4, 2026
81abf13
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 4, 2026
9ab6ef9
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 4, 2026
74d0538
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 5, 2026
1270986
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 5, 2026
b0d25b2
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 5, 2026
cc3f18e
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 6, 2026
c6e4db9
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 6, 2026
e9fddb8
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 6, 2026
7928d04
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 6, 2026
a9d5e3f
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 6, 2026
a39758d
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 6, 2026
95df851
Merge branch 'main' into feat/christopherk/e5-cloud-downloads
github-actions[bot] Nov 6, 2026
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
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,14 +557,27 @@ SkillSpector detects **71 vulnerability patterns** across 17 categories:
| AR2 | Disclaimer Suppression | HIGH | Instructions to omit warnings, disclaimers, or ethical commentary (e.g. "no disclaimers", "do not moralize") |
| AR3 | Safety Policy Nullification | HIGH | Jailbreak framing that nullifies guardrails (e.g. "you have no restrictions", "ignore your guidelines", "do anything now") |

### Data Exfiltration (4 patterns)
### Data Exfiltration (5 patterns)

| ID | Pattern | Severity | Description |
|----|---------|----------|-------------|
| E1 | External Transmission | MEDIUM | Sending data to external URLs |
| E2 | Env Variable Harvesting | HIGH | Enumerating, copying, or searching environment data to collect secrets |
| E3 | File System Enumeration | MEDIUM | Scanning directories for sensitive files |
| E4 | Context Leakage | HIGH | Transmitting conversation context externally |
| E5 | Cloud Storage Exfiltration | MEDIUM | Uploading data through cloud storage SDKs or CLIs |

Within bounded, fully observed context, E5 distinguishes literal AWS/GCS downloads
from uploads. Simple remote-to-local `cp`, AWS `sync`, and `gsutil rsync` commands do not produce E5
findings. Moves, deletion options, unknown flags, dynamic operands, streams and
compound shell commands retain conservative findings. This direction check does
not change SDK upload detection or the CLI's exit-code policy. Direction proofs
require the complete preceding context to fit within 4,096 characters. Only
blank/comment lines, literal `set` flags (`-e`, `-u`, `-f`, and `-o pipefail`),
and simple directory changes may precede a proved download. Other setup,
including sourcing, autoloading, variable assignments, and earlier transfers,
retains the original finding. Relative destinations after directory changes
remain unproved; literal absolute file destinations are still eligible.

### Privilege Escalation (3 patterns)

Expand Down
128 changes: 127 additions & 1 deletion src/skillspector/nodes/analyzers/static_patterns_data_exfiltration.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from __future__ import annotations

import ast
import posixpath
import re
import sys
from collections.abc import Iterator
Expand All @@ -35,10 +36,16 @@
get_context,
get_context_from_lines,
get_line_number,
logical_line_starts,
resolve_call_name,
resolve_dotted_name,
)
from .pattern_defaults import PatternCategory
from .static_patterns_supply_chain import (
_sc2_has_unproved_compound_context,
_sc2_logical_command,
_shell_tokens,
)
from .static_patterns_tool_misuse import _bounded_shell_tokens

logger = get_logger(__name__)
Expand Down Expand Up @@ -162,6 +169,120 @@
(r"\.upload_blob\s*\(", 0.55), # Azure SDK
]

_CLOUD_DOWNLOAD_AWS_FLAGS = frozenset(
{"--quiet", "--only-show-errors", "--no-progress", "--no-sign-request", "--dryrun"}
)
_CLOUD_DOWNLOAD_AWS_VALUE_OPTIONS = frozenset({"--region", "--profile", "--include", "--exclude"})
_CLOUD_DOWNLOAD_SHELL_META = re.compile(r"[;&|<>(){}\[\]*?$\x60%!#~\x00-\x1f\x7f]")
_CLOUD_DOWNLOAD_BUCKET = re.compile(r"[a-z0-9][a-z0-9.-]*")
_CLOUD_DOWNLOAD_CONTEXT_CHARS = 4_096
_CLOUD_DOWNLOAD_SAFE_SET = re.compile(
r"set[ \t]+(?:-[efu]+[ \t]+)*(?:-[efu]+|-[efu]*o[ \t]+pipefail)"
)


def _is_literal_cloud_download(
content: str,
offset: int,
line_starts: tuple[int, ...],
file_path: str,
file_type: str,
) -> bool:
"""Prove only a bounded, standalone, non-destructive cloud download.

The existing shell helpers retain unknown context and cap logical commands
at 4096 characters. No line or enclosing match is rewritten: callers decide
separately for every E5 occurrence, including nested or later uploads.
"""
if file_type not in {"shell", "markdown", "text"} or file_path.lower().endswith(
(".ps1", ".psm1", ".psd1", ".bat", ".cmd")
):
return False
if offset > _CLOUD_DOWNLOAD_CONTEXT_CHARS or _sc2_has_unproved_compound_context(
content, offset, ()
):
return False
directory_changed = False
for line in content[:offset].splitlines():
setup = line.strip(" \t\r")
if not setup or setup.startswith("#"):
continue
if _CLOUD_DOWNLOAD_SAFE_SET.fullmatch(setup):
continue
if "\\" in setup or _CLOUD_DOWNLOAD_SHELL_META.search(setup):
return False
setup_tokens = _shell_tokens(setup)
if setup_tokens == ["popd"] or (
setup_tokens
and len(setup_tokens) == 2
and setup_tokens[0] in {"cd", "chdir", "pushd"}
and setup_tokens[1]
and not setup_tokens[1].startswith("-")
):
directory_changed = True
continue
# Unknown setup can load or replace the cloud command. Even an earlier
# download can overwrite an executable, so only the preambles above
# preserve this proof; do not enumerate unsafe command-loading forms.
return False
command = _sc2_logical_command(content, offset, line_starts)
if command is None or "\\" in command or _CLOUD_DOWNLOAD_SHELL_META.search(command) is not None:
return False
tokens = _shell_tokens(command)
if not tokens:
return False
if tokens[:3] in (["aws", "s3", "cp"], ["aws", "s3", "sync"]):
scheme = "s3://"
arguments = iter(tokens[3:])
flags = _CLOUD_DOWNLOAD_AWS_FLAGS
if tokens[2] == "cp":
flags = flags | {"--recursive"}
value_options = _CLOUD_DOWNLOAD_AWS_VALUE_OPTIONS
elif tokens[:2] in (["gsutil", "cp"], ["gsutil", "rsync"]):
scheme = "gs://"
arguments = iter(tokens[2:])
flags = frozenset({"-r"})
value_options = frozenset()
else:
# In particular, mv and sync deletion flags do more than copy data.
return False

operands: list[str] = []
for argument in arguments:
if argument in flags:
continue
if argument in value_options:
value = next(arguments, "")
if not value or value.startswith("-"):
return False
elif argument.startswith("-"):
return False
else:
operands.append(argument)
if len(operands) != 2:
return False
source, destination = operands
if not source.startswith(scheme):
return False
bucket = source[len(scheme) :].split("/", 1)[0]
if _CLOUD_DOWNLOAD_BUCKET.fullmatch(bucket) is None:
return False
if directory_changed and not destination.startswith("/"):
# A relative file may resolve to a stream after cd /dev (or a directory
# stack change). Absolute destinations do not depend on this setup.
return False
normalized_destination = posixpath.normpath(destination)
# A static local destination cannot be a cloud URI, stream or shell expansion.
# Colons remain conservative for other URL schemes and host-language paths.
return (
bool(destination)
and destination != "-"
and ":" not in destination
and not destination.startswith("//")
and normalized_destination not in {"/dev", "/proc", "/sys"}
and not normalized_destination.startswith(("/dev/", "/proc/", "/sys/"))
)


def _e1_shell_regions(content: str, file_type: str) -> tuple[tuple[int, int], ...]:
"""Identify source regions whose physical separators have shell semantics.
Expand Down Expand Up @@ -539,9 +660,14 @@ def ctx(start: int) -> str:
complete_match=match.group(0),
)
)
# E5: cloud-storage exfiltration. Example filtering is delegated to the runner.
# E5: retain uploads and uncertain directions; lower only proven downloads.
line_starts = logical_line_starts(content[: _CLOUD_DOWNLOAD_CONTEXT_CHARS + 1])
for pattern, confidence in E5_PATTERNS:
for match in re.finditer(pattern, content, re.IGNORECASE | re.MULTILINE):
if _is_literal_cloud_download(
content, match.start(), line_starts, file_path, file_type
):
continue
line_num = get_line_number(content, match.start())
findings.append(
AnalyzerFinding(
Expand Down
Loading