Skip to content
Open
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
45 changes: 36 additions & 9 deletions .github/workflows/version-check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,43 @@ jobs:
MAIN_VERSION=$(git show origin/main:socketsecurity/__init__.py | grep -o "__version__.*" | awk '{print $3}' | tr -d "'")
echo "MAIN_VERSION=$MAIN_VERSION" >> $GITHUB_ENV

# Compare versions using Python
python3 -c "
export PR_VERSION
export MAIN_VERSION

# Compare against both main and latest published PyPI release.
python3 <<'PY'
import json
import os
import urllib.request
from packaging import version
pr_ver = version.parse('${PR_VERSION}')
main_ver = version.parse('${MAIN_VERSION}')
if pr_ver <= main_ver:
print(f'❌ Version must be incremented! Main: {main_ver}, PR: {pr_ver}')
exit(1)
print(f'✅ Version properly incremented from {main_ver} to {pr_ver}')
"

pr_ver = version.parse(os.environ["PR_VERSION"])
main_ver = version.parse(os.environ["MAIN_VERSION"])

with urllib.request.urlopen("https://pypi.org/pypi/socketsecurity/json") as response:
pypi_data = json.load(response)

published_versions = []
for raw in pypi_data.get("releases", {}).keys():
parsed = version.parse(raw)
if not parsed.is_prerelease and not parsed.is_devrelease:
published_versions.append(parsed)

pypi_ver = max(published_versions) if published_versions else version.parse("0.0.0")
required_floor = max(main_ver, pypi_ver)

if pr_ver <= required_floor:
print(
f"❌ Version must be greater than main and PyPI! "
f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}"
)
raise SystemExit(1)

print(
f"✅ Version properly incremented. "
f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}"
)
PY

- name: Require uv.lock update when pyproject changes
run: |
Expand Down
63 changes: 56 additions & 7 deletions .hooks/sync_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@

VERSION_PATTERN = re.compile(r"__version__\s*=\s*['\"]([^'\"]+)['\"]")
PYPROJECT_PATTERN = re.compile(r'^version\s*=\s*".*"$', re.MULTILINE)
PYPI_API = "https://test.pypi.org/pypi/socketsecurity/json"
STABLE_VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
PYPI_PROD_API = "https://pypi.org/pypi/socketsecurity/json"
PYPI_TEST_API = "https://test.pypi.org/pypi/socketsecurity/json"

def read_version_from_init(path: pathlib.Path) -> str:
content = path.read_text()
Expand All @@ -39,24 +41,59 @@ def bump_patch_version(version: str) -> str:
parts[-1] = str(int(parts[-1]) + 1)
return ".".join(parts)

def fetch_existing_versions() -> set:
def parse_stable_version(version: str):
if not STABLE_VERSION_PATTERN.fullmatch(version):
return None
return tuple(int(part) for part in version.split("."))


def format_stable_version(version_parts) -> str:
return ".".join(str(part) for part in version_parts)


def fetch_existing_versions(api_url: str) -> set:
try:
with urllib.request.urlopen(PYPI_API) as response:
with urllib.request.urlopen(api_url) as response:
data = json.load(response)
return set(data.get("releases", {}).keys())
except Exception as e:
print(f"⚠️ Warning: Failed to fetch existing versions from Test PyPI: {e}")
print(f"⚠️ Warning: Failed to fetch versions from {api_url}: {e}")
return set()


def fetch_latest_stable_pypi_version():
versions = fetch_existing_versions(PYPI_PROD_API)
stable_versions = []
for ver in versions:
parsed = parse_stable_version(ver)
if parsed is not None:
stable_versions.append(parsed)
if not stable_versions:
return None
return max(stable_versions)

def find_next_available_dev_version(base_version: str) -> str:
existing_versions = fetch_existing_versions()
existing_versions = fetch_existing_versions(PYPI_TEST_API)
for i in range(1, 100):
candidate = f"{base_version}.dev{i}"
if candidate not in existing_versions:
return candidate
print("❌ Could not find available .devN slot after 100 attempts.")
sys.exit(1)


def find_next_stable_patch_version(current_version: str) -> str:
current_stable = current_version.split(".dev")[0] if ".dev" in current_version else current_version
current_parts = parse_stable_version(current_stable)
if current_parts is None:
print(f"❌ Unsupported version format for stable bump: {current_version}")
sys.exit(1)

latest_pypi_parts = fetch_latest_stable_pypi_version()
base_parts = max([current_parts, latest_pypi_parts] if latest_pypi_parts else [current_parts])
next_parts = (base_parts[0], base_parts[1], base_parts[2] + 1)
return format_stable_version(next_parts)

def inject_version(version: str):
print(f"🔁 Updating version to: {version}")

Expand Down Expand Up @@ -105,13 +142,25 @@ def main():
print(f"⚠️ Version was unchanged — auto-bumped. Please git add{lock_hint} + commit again.")
sys.exit(0)
else:
new_version = bump_patch_version(current_version)
new_version = find_next_stable_patch_version(current_version)
inject_version(new_version)
uv_lock_changed = run_uv_lock()
lock_hint = " and uv.lock" if uv_lock_changed else ""
print(f"⚠️ Version was unchanged — auto-bumped. Please git add{lock_hint} + commit again.")
print(f"⚠️ Version was unchanged — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.")
sys.exit(1)
else:
if not dev_mode:
current_parts = parse_stable_version(current_version)
latest_pypi_parts = fetch_latest_stable_pypi_version()
if current_parts is not None and latest_pypi_parts is not None and current_parts <= latest_pypi_parts:
next_parts = (latest_pypi_parts[0], latest_pypi_parts[1], latest_pypi_parts[2] + 1)
new_version = format_stable_version(next_parts)
inject_version(new_version)
uv_lock_changed = run_uv_lock()
lock_hint = " and uv.lock" if uv_lock_changed else ""
print(f"⚠️ Version {current_version} is already published on PyPI — auto-bumped to {new_version}. Please git add{lock_hint} + commit again.")
sys.exit(1)

uv_lock_changed = run_uv_lock()
if uv_lock_changed:
print("⚠️ Version already bumped, but uv.lock was out of date and has been updated. Please git add uv.lock + commit again.")
Expand Down
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ socketcli \
| Use case | Recommended mode | Key flags |
|:--|:--|:--|
| Basic policy enforcement in CI | Diff-based policy check | `--strict-blocking` |
| Legal/compliance artifact generation | Legal preset | `--legal` |
| Reachable-focused SARIF for reporting | Full-scope grouped SARIF | `--reach --sarif-scope full --sarif-grouping alert --sarif-reachability reachable --sarif-file <path>` |
| Detailed reachability export for investigations | Full-scope instance SARIF | `--reach --sarif-scope full --sarif-grouping instance --sarif-reachability all --sarif-file <path>` |
| Net-new PR findings only | Diff-scope SARIF | `--reach --sarif-scope diff --sarif-reachability reachable --sarif-file <path>` |
Expand Down Expand Up @@ -134,6 +135,35 @@ Run:
socketcli --config .socketcli.toml --target-path .
```

Legal/compliance preset example:

```bash
socketcli --legal --target-path .
```

This preset enables license generation and writes default artifacts unless you override them:
- `socket-report.json`
- `socket-summary.txt`
- `socket-report-link.txt`
- `socket-sbom.json`
- `socket-license.json`

FOSSA-compatibility shaped legal artifacts:

```bash
socketcli --legal-format fossa --target-path .
```

This switches the JSON report and legal artifact payloads to FOSSA-style compatibility shapes:
- the analyze artifact becomes a `project` / `vulnerability` / `licensing` / `quality` report
- the SBOM artifact becomes a `project` / `dependencies` attribution-style payload

When `--legal-format fossa` is used without explicit output paths, the defaults are closer to the FOSSA pipeline contract:
- `fossa-analyze.json`
- `fossa-test.txt`
- `fossa-link.txt`
- `fossa-sbom.json`

Reference sample configs:

TOML:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"

[project]
name = "socketsecurity"
version = "2.2.89"
version = "2.2.91"
requires-python = ">= 3.11"
license = {"file" = "LICENSE"}
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion socketsecurity/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__author__ = 'socket.dev'
__version__ = '2.2.89'
__version__ = '2.2.91'
USER_AGENT = f'SocketPythonCLI/{__version__}'
70 changes: 70 additions & 0 deletions socketsecurity/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,16 @@ class CliConfig:
enable_debug: bool = False
allow_unverified: bool = False
enable_json: bool = False
json_file: Optional[str] = None
enable_sarif: bool = False
sarif_file: Optional[str] = None
sarif_scope: str = "diff"
sarif_grouping: str = "instance"
sarif_reachability: str = "all"
enable_gitlab_security: bool = False
gitlab_security_file: Optional[str] = None
summary_file: Optional[str] = None
report_link_file: Optional[str] = None
disable_overview: bool = False
disable_security_issue: bool = False
files: str = None
Expand Down Expand Up @@ -137,6 +140,8 @@ class CliConfig:
reach_continue_on_no_source_files: bool = False
max_purl_batch_size: int = 5000
enable_commit_status: bool = False
legal: bool = False
legal_format: str = "socket"
config_file: Optional[str] = None

@classmethod
Expand Down Expand Up @@ -194,13 +199,16 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
'enable_diff': args.enable_diff,
'allow_unverified': args.allow_unverified,
'enable_json': args.enable_json,
'json_file': args.json_file,
'enable_sarif': args.enable_sarif,
'sarif_file': args.sarif_file,
'sarif_scope': args.sarif_scope,
'sarif_grouping': args.sarif_grouping,
'sarif_reachability': args.sarif_reachability,
'enable_gitlab_security': args.enable_gitlab_security,
'gitlab_security_file': args.gitlab_security_file,
'summary_file': args.summary_file,
'report_link_file': args.report_link_file,
'disable_overview': args.disable_overview,
'disable_security_issue': args.disable_security_issue,
'files': args.files,
Expand Down Expand Up @@ -246,9 +254,40 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
'reach_continue_on_no_source_files': args.reach_continue_on_no_source_files,
'max_purl_batch_size': args.max_purl_batch_size,
'enable_commit_status': args.enable_commit_status,
'legal': args.legal or args.legal_format == "fossa",
'legal_format': args.legal_format,
'config_file': args.config_file,
'version': __version__
}

if config_args['legal']:
config_args['generate_license'] = True
if not config_args['json_file']:
config_args['json_file'] = "socket-report.json"
if not config_args['summary_file']:
config_args['summary_file'] = "socket-summary.txt"
if not config_args['report_link_file']:
config_args['report_link_file'] = "socket-report-link.txt"
if not config_args['sbom_file']:
config_args['sbom_file'] = "socket-sbom.json"
if config_args['license_file_name'] == "license_output.json":
config_args['license_file_name'] = "socket-license.json"

if config_args['legal_format'] == "fossa":
if not args.json_file:
config_args['json_file'] = "fossa-analyze.json"
if not args.summary_file:
config_args['summary_file'] = "fossa-test.txt"
if not args.report_link_file:
config_args['report_link_file'] = "fossa-link.txt"
if not args.license_file_name:
# argparse always provides a default, so this branch is defensive only
config_args['license_file_name'] = "fossa-sbom.json"
elif args.license_file_name == "license_output.json":
config_args['license_file_name'] = "fossa-sbom.json"
if not args.sbom_file:
# FOSSA's "SBOM" artifact is the attribution payload; suppress the extra Socket-only SBOM file by default.
config_args['sbom_file'] = None
excluded_ecosystems = config_args["excluded_ecosystems"]
if isinstance(excluded_ecosystems, list):
config_args["excluded_ecosystems"] = excluded_ecosystems
Expand Down Expand Up @@ -570,6 +609,12 @@ def create_argument_parser() -> argparse.ArgumentParser:
action="store_true",
help="Output in JSON format"
)
output_group.add_argument(
"--json-file",
dest="json_file",
metavar="<path>",
help="Output file path for JSON report"
)
output_group.add_argument(
"--enable-sarif",
dest="enable_sarif",
Expand Down Expand Up @@ -617,6 +662,18 @@ def create_argument_parser() -> argparse.ArgumentParser:
default="gl-dependency-scanning-report.json",
help="Output file path for GitLab Security report (default: gl-dependency-scanning-report.json)"
)
output_group.add_argument(
"--summary-file",
dest="summary_file",
metavar="<path>",
help="Output file path for a plain-text summary report"
)
output_group.add_argument(
"--report-link-file",
dest="report_link_file",
metavar="<path>",
help="Output file path for the Socket report link"
)
output_group.add_argument(
"--disable-overview",
dest="disable_overview",
Expand Down Expand Up @@ -746,6 +803,19 @@ def create_argument_parser() -> argparse.ArgumentParser:
action="store_true",
help="Disable SSL certificate verification for API requests"
)
advanced_group.add_argument(
"--legal",
dest="legal",
action="store_true",
help="Enable legal/compliance-friendly defaults and file outputs"
)
advanced_group.add_argument(
"--legal-format",
dest="legal_format",
choices=["socket", "fossa"],
default="socket",
help="Select the legal artifact format. 'socket' keeps Socket-native outputs; 'fossa' emits compatibility-shaped JSON artifacts."
)
config_group.add_argument(
"--include-module-folders",
dest="include_module_folders",
Expand Down
Loading