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
1 change: 1 addition & 0 deletions .github/workflows/python-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ on:
branches: [main]
paths:
- "socketsecurity/**/*.py"
- "socketsecurity/**/*.mjs"
- "tests/unit/**/*.py"
- "tests/core/**/*.py"
- "pyproject.toml"
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 2.11.0

### Changed: download the Coana CLI from Socket instead of npm

- The Coana CLI is now downloaded from Socket's servers instead of npm, and is now cached in
`$XDG_CACHE_HOME/socket/coana` (`~/.cache/socket/coana` by default).
- The `SOCKET_CLI_COANA_LAUNCHER` environment variable no longer has an effect.

## 2.10.12

### Changed: bump pinned @coana-tech/cli to 15.12.8
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ RUN if [ "$TARGETARCH" = "amd64" ]; then \
fi

# Install additional tools
RUN npm install @coana-tech/cli socket -g && \
RUN npm install socket -g && \
gem install bundler && \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \
. ~/.cargo/env && \
Expand Down
13 changes: 3 additions & 10 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ If you don't want to provide the Socket API Token every time then you can use th
| Parameter | Required | Default | Description |
|:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------|
| `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). |
| `--reach-version` | False | 15.12.8 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. |
| `--reach-version` | False | 15.12.8 | Version of the Coana CLI to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. |
| `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` |
| `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` |
| `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. |
Expand All @@ -359,9 +359,7 @@ If you don't want to provide the Socket API Token every time then you can use th
**Reachability Analysis Requirements:**

The Python CLI verifies the following **up front** (before invoking the analysis engine) and exits with code **3** if any are unmet:
- `npm` - Required (verified up front; ships alongside `npx`)
- `npx` - Required to fetch (on first use) and run `@coana-tech/cli` (the analysis engine)
- `node` - Required to run the engine (used directly by the `npm install` fallback)
- `node` - Required to run the analysis engine (the Coana CLI). The engine needs Node.js 20 or later.
- `uv` - Required by the analysis engine
- An **Enterprise** Socket organization plan (any `enterprise*` plan, including Enterprise trials)

Expand Down Expand Up @@ -419,12 +417,7 @@ Sample config files:

For CI-specific examples and guidance, see [`ci-cd.md`](ci-cd.md).

The CLI runs a pinned `@coana-tech/cli` version via `npx --yes --force` (the same flags the Socket Node CLI passes for coana); it does **not** auto-update the engine or install it globally. `--yes` skips npx's interactive install prompt so non-interactive/CI runs don't hang. If the `npx` launcher is unavailable or fails before the engine starts, the CLI falls back to `npm install`-ing the pinned version into a temp directory and running it via `node`. Pass `--reach-version latest` to opt into the newest published version. Use `--reach` to enable reachability analysis during a full scan, or add `--only-facts-file` (with `--reach`) to submit only the reachability facts file (`.socket.facts.json`) when creating the full scan.

The launcher can be tuned via the `SOCKET_CLI_COANA_LAUNCHER` environment variable:
- `auto` (default when unset) — try `npx` first; fall back to `npm install` + `node` if the launcher fails before the engine starts.
- `npm-install` — skip `npx` entirely and always use the `npm install` + `node` path (useful where `npx` is known-broken).
- `npx` — never fall back; surface the `npx` failure directly.
The CLI runs a pinned version of the Coana CLI, the analysis engine; it does **not** auto-update the engine. It downloads the engine from the Socket API on first use and keeps it in `$XDG_CACHE_HOME/socket/coana/v1` (`~/.cache/socket/coana/v1` when `XDG_CACHE_HOME` is unset). Pass `--reach-version latest` to opt into the newest published version. Use `--reach` to enable reachability analysis during a full scan, or add `--only-facts-file` (with `--reach`) to submit only the reachability facts file (`.socket.facts.json`) when creating the full scan.

#### Advanced Configuration
| Parameter | Required | Default | Description |
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.10.12"
version = "2.11.0"
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.10.12'
__version__ = '2.11.0'
USER_AGENT = f'SocketPythonCLI/{__version__}'
2 changes: 1 addition & 1 deletion socketsecurity/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1069,7 +1069,7 @@ def create_argument_parser() -> argparse.ArgumentParser:
"--reach-version",
dest="reach_version",
metavar="<version|latest>",
help="Version of @coana-tech/cli to use. Defaults to the version pinned to this CLI "
help="Version of the Coana CLI to use. Defaults to the version pinned to this CLI "
"release; pass 'latest' to always use the newest published version (opt-in "
"auto-update), or an explicit version (e.g. '1.2.3') to pin it."
)
Expand Down
17 changes: 15 additions & 2 deletions socketsecurity/core/cli_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,13 +55,26 @@ def request(
return response

except requests.exceptions.RequestException as e:
logger.error(f"API request failed: {str(e)}")
server_message = self._server_error_message(e.response)
detail = f"{e}: {server_message}" if server_message else str(e)
logger.error(f"API request failed: {detail}")
# Carry the status forward. Callers that need to react to a specific
# code -- the GitLab auth fallback to the other token scheme, and
# APIFailure.is_transient_error -- have no other way to recover it
# once the requests exception has been translated.
status_code = e.response.status_code if e.response is not None else None
raise APIFailure(f"Request failed: {str(e)}", status_code=status_code) from e
raise APIFailure(f"Request failed: {detail}", status_code=status_code) from e

@staticmethod
def _server_error_message(response: Optional[requests.Response]) -> Optional[str]:
"""The explanation the Socket API gives in its ``{"error": {"message": ...}}`` body."""
if response is None:
return None
try:
message = response.json()["error"]["message"]
except (ValueError, KeyError, TypeError):
return None
return message if isinstance(message, str) and message else None

def post_telemetry_events(self, org_slug: str, events: List[Dict]) -> None:
"""Post telemetry events one at a time to the v0 telemetry API. Fire-and-forget — logs errors but never raises."""
Expand Down
198 changes: 198 additions & 0 deletions socketsecurity/core/tools/coana-cache.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Generated from coana-package-manager's packages/coana-cache. Don't edit it here.
// See "Launching Coana" in packages/cli/docs/gcs-releases.md there.

// src/cli.ts
import { cp, readdir as readdir2, rename as rename2, rm as rm3 } from "node:fs/promises";
import { join as join3 } from "node:path";

// src/cache.ts
import { mkdir as mkdir2, open, readdir, readFile, rm as rm2, stat, utimes } from "node:fs/promises";
import { homedir } from "node:os";
import { dirname as dirname2, isAbsolute, join as join2, relative } from "node:path";

// ../utils/src/atomic-fs.ts
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { chmod, mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
async function populateDirAtomically(targetDir, populate) {
if (existsSync(targetDir)) return;
const parentDir = dirname(targetDir);
await mkdir(parentDir, { recursive: true });
const tmpDir = await mkdtemp(join(parentDir, `.${basename(targetDir)}-`));
try {
await populate(tmpDir);
await chmod(tmpDir, 493);
await rename(tmpDir, targetDir).catch((e) => {
if (!existsSync(targetDir)) throw e;
});
} finally {
await rm(tmpDir, { recursive: true, force: true }).catch(() => {
});
}
}
async function writeFileAtomically(path, data) {
const tmpPath = `${path}.${randomUUID()}.tmp`;
try {
await writeFile(tmpPath, data);
await rename(tmpPath, path);
} finally {
await rm(tmpPath, { force: true }).catch(() => {
});
}
}

// src/cache.ts
var SHA256 = /^[0-9a-f]{64}$/;
var VERSION = /^[0-9A-Za-z][0-9A-Za-z.+-]*$/;
var COMPONENT = /^[A-Za-z][0-9A-Za-z-]*$/;
var TEMP_DIR = /^\.[0-9a-f]{64}-/;
var ABANDONED_TEMP_DIR_MS = 24 * 60 * 6e4;
function cacheRoot() {
const xdgCacheHome = process.env.XDG_CACHE_HOME;
const cacheHome = xdgCacheHome && isAbsolute(xdgCacheHome) ? xdgCacheHome : join2(homedir(), ".cache");
return join2(cacheHome, "socket", "coana", "v1");
}
var OUT_OF_SPACE_CODES = /* @__PURE__ */ new Set(["ENOSPC", "EDQUOT"]);
var NOT_WRITABLE_CODES = /* @__PURE__ */ new Set(["EACCES", "EPERM", "EROFS", ...OUT_OF_SPACE_CODES]);
var CacheNotWritable = class _CacheNotWritable extends Error {
constructor(outOfSpace, cause) {
super(
outOfSpace ? `The disk holding the Coana cache in ${cacheRoot()} is out of space (${cause.message}). Free some up, or set XDG_CACHE_HOME to a directory on another disk.` : `Could not write to the Coana cache in ${cacheRoot()} (${cause.message}). Set XDG_CACHE_HOME to a writable directory.`,
{ cause }
);
}
/** `e` as a {@link CacheNotWritable} when it is such a failure of a path in the cache. */
static from(e) {
if (e instanceof _CacheNotWritable) return e;
if (!(e instanceof Error)) return void 0;
const { code = "", path: source, dest } = e;
if (!NOT_WRITABLE_CODES.has(code)) return void 0;
const outOfSpace = OUT_OF_SPACE_CODES.has(code);
const paths = (outOfSpace && dest !== void 0 ? [dest] : [source, dest]).filter((p) => p !== void 0);
const ofCache = paths.length === 0 ? outOfSpace : paths.some((path) => within(cacheRoot(), path) || within(path, cacheRoot()));
return ofCache ? new _CacheNotWritable(outOfSpace, e) : void 0;
function within(dir, path) {
const fromDir = relative(dir, path);
return !fromDir.startsWith("..") && !isAbsolute(fromDir);
}
}
};
var InvalidCacheKey = class extends Error {
};
async function writing(f) {
try {
return await f();
} catch (e) {
throw CacheNotWritable.from(e) ?? e;
}
}
function objectDir(sha256) {
return join2(cacheRoot(), "objects", sha256);
}
function versionSegment(version) {
if (!VERSION.test(version)) throw new InvalidCacheKey(`Invalid Coana version: ${version}`);
return version;
}
function indexFile(version, component) {
if (!COMPONENT.test(component)) throw new InvalidCacheKey(`Invalid component name: ${component}`);
return join2(cacheRoot(), "versions", versionSegment(version), component);
}
async function lookup(version, component) {
const sha256 = (await readFile(indexFile(version, component), "utf8").catch(() => "")).trim();
if (!SHA256.test(sha256)) return void 0;
const dir = objectDir(sha256);
const isDirectory = await stat(dir).then(
(s) => s.isDirectory(),
() => false
);
return isDirectory ? dir : void 0;
}
async function install(version, component, sha256, populate) {
if (!SHA256.test(sha256)) throw new InvalidCacheKey(`Invalid SHA-256: ${sha256}`);
const index = indexFile(version, component);
const target = objectDir(sha256);
await writing(() => mkdir2(dirname2(index), { recursive: true }));
await writing(
() => populateDirAtomically(target, (dir) => {
void sweepAbandonedTempDirs();
return populate(dir);
})
);
await writing(() => writeFileAtomically(index, sha256));
return target;
}
var sweeps = /* @__PURE__ */ new Map();
function sweepAbandonedTempDirs() {
const objects = join2(cacheRoot(), "objects");
let sweep = sweeps.get(objects);
if (sweep === void 0) {
sweep = removeAbandoned();
sweeps.set(objects, sweep);
}
return sweep;
async function removeAbandoned() {
const names = await readdir(objects).catch(() => []);
await Promise.all(
names.filter((name) => TEMP_DIR.test(name)).map(async (name) => {
const dir = join2(objects, name);
const age = await ageMs(dir);
if (age !== void 0 && age > ABANDONED_TEMP_DIR_MS)
await rm2(dir, { recursive: true, force: true }).catch(() => {
});
})
);
}
}
async function ageMs(path) {
return stat(path).then(
(s) => Date.now() - s.ctimeMs,
() => void 0
);
}

// src/cli.ts
var USAGE = `usage: coana-cache.mjs lookup <version> <component>
coana-cache.mjs install <version> <component> <sha256> <extracted-dir>`;
var NOT_CACHED = 4;
async function main(args) {
const [command, ...rest] = args;
if (command === "lookup" && rest.length === 2) {
const [version, component] = rest;
const dir = await lookup(version, component);
if (dir === void 0) return NOT_CACHED;
process.stdout.write(`${dir}
`);
return 0;
}
if (command === "install" && rest.length === 4) {
const [version, component, sha256, extracted] = rest;
const dir = await install(version, component, sha256, (tmp) => moveInto(extracted, tmp));
await rm3(extracted, { recursive: true, force: true }).catch(() => {
});
process.stdout.write(`${dir}
`);
return 0;
}
process.stderr.write(`${USAGE}
`);
return 2;
async function moveInto(extractedDir, dir) {
for (const name of await readdir2(extractedDir)) {
const from = join3(extractedDir, name);
const to = join3(dir, name);
try {
await rename2(from, to);
} catch (e) {
if (e.code !== "EXDEV") throw e;
await cp(from, to, { recursive: true, verbatimSymlinks: true });
}
}
}
}
process.exitCode = await main(process.argv.slice(2)).catch((e) => {
process.stderr.write(`${e instanceof Error ? e.message : String(e)}
`);
if (e instanceof InvalidCacheKey) return 2;
return e instanceof CacheNotWritable ? 3 : 1;
});
Loading
Loading