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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **`socket manifest bazel [beta]`** — Generate Bazel JVM SBOM manifests by running `bazel query` against discovered Maven repos in a Bazel workspace. Closes the inline-Maven-declaration gap that lockfile-only parsing misses for repos like envoy, ray, tensorflow, tink-java, and or-tools. Auto-detects Bzlmod and legacy `WORKSPACE`.
- **`socket scan create --auto-manifest`** now covers Bazel workspaces in addition to Gradle/Scala/Kotlin/Conda. Repos with `MODULE.bazel`, `WORKSPACE`, or `WORKSPACE.bazel` are detected automatically and their Maven dependencies extracted as part of the standard scan-create flow.

## [1.1.98](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.98) - 2026-05-20

### Changed
- `socket scan create --reach` now uploads the reachability facts file as brotli on the wire, shrinking mono-repo upload sizes by roughly 85% with no change to the on-disk or stored format. Faster scan submissions on slow connections.

## [1.1.97](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.97) - 2026-05-18

### Changed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "socket",
"version": "1.1.97",
"version": "1.1.98",
"description": "CLI for Socket.dev",
"homepage": "https://github.com/SocketDev/socket-cli",
"license": "MIT AND OFL-1.1",
Expand Down
57 changes: 35 additions & 22 deletions src/commands/scan/handle-create-new-scan.mts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { outputCreateNewScan } from './output-create-new-scan.mts'
import { performReachabilityAnalysis } from './perform-reachability-analysis.mts'
import constants from '../../constants.mts'
import { checkCommandInput } from '../../utils/check-input.mts'
import { compressSocketFactsForUpload } from '../../utils/coana.mts'
import { findSocketYmlSync } from '../../utils/config.mts'
import { getPackageFilesForScan } from '../../utils/path-resolve.mts'
import { readOrDefaultSocketJson } from '../../utils/socket-json.mts'
Expand Down Expand Up @@ -279,28 +280,40 @@ export async function handleCreateNewScan({
tier1ReachabilityScanId = reachResult.data?.tier1ReachabilityScanId
}

const fullScanCResult = await fetchCreateOrgFullScan(
scanPaths,
orgSlug,
{
commitHash,
commitMessage,
committers,
pullRequest,
repoName,
branchName,
scanType: reach.runReachabilityAnalysis
? constants.SCAN_TYPE_SOCKET_TIER1
: constants.SCAN_TYPE_SOCKET,
workspace,
},
{
cwd,
defaultBranch,
pendingHead,
tmp,
},
)
// Brotli-compress any .socket.facts.json paths in scanPaths just before
// upload. depscan's api-v0 multipart boundary streams brotli decode based
// on the .br filename suffix. Coana keeps writing plain .socket.facts.json
// on disk, so the local read paths (extractTier1ReachabilityScanId,
// extractReachabilityErrors) stay correct. The cleanup() in the finally
// block removes the temp dirs whether the upload succeeded or threw.
const compressed = await compressSocketFactsForUpload(scanPaths)
let fullScanCResult: Awaited<ReturnType<typeof fetchCreateOrgFullScan>>
try {
fullScanCResult = await fetchCreateOrgFullScan(
compressed.paths,
orgSlug,
{
commitHash,
commitMessage,
committers,
pullRequest,
repoName,
branchName,
scanType: reach.runReachabilityAnalysis
? constants.SCAN_TYPE_SOCKET_TIER1
: constants.SCAN_TYPE_SOCKET,
workspace,
},
{
cwd,
defaultBranch,
pendingHead,
tmp,
},
)
} finally {
await compressed.cleanup()
}

const scanId = fullScanCResult.ok ? fullScanCResult.data?.id : undefined

Expand Down
102 changes: 102 additions & 0 deletions src/utils/coana.mts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@
* Manages reachability analysis via Coana tech CLI.
*
* Key Functions:
* - compressSocketFactsForUpload: Brotli-compress any .socket.facts.json
* entries in scanPaths just before upload, returning swapped paths plus a
* cleanup callback. Coana keeps writing plain JSON; the on-the-wire form
* to depscan is brotli (api-v0 decodes at the multipart boundary).
* - extractReachabilityErrors: Extract per-component reachability errors
* - extractTier1ReachabilityScanId: Extract scan ID from socket facts file
*
* Integration:
Expand All @@ -11,8 +16,105 @@
* - Extracts tier 1 reachability scan identifiers
*/

import { createReadStream, createWriteStream, existsSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import path from 'node:path'
import { pipeline } from 'node:stream/promises'
import { createBrotliCompress } from 'node:zlib'

import { readJsonSync } from '@socketsecurity/registry/lib/fs'

import constants from '../constants.mts'

const { DOT_SOCKET_DOT_FACTS_JSON } = constants

export type CompressedScanPaths = {
paths: string[]
cleanup: () => Promise<void>
}

/**
* For each `.socket.facts.json` in `scanPaths`, stream-brotli-compress a
* sibling `.socket.facts.json.br` next to the original file and swap its
* path in. Other paths pass through unchanged. Missing files also pass
* through unchanged (the upload will fail downstream with the same error
* it would have).
*
* Streaming + worker-thread compression keeps the event loop responsive:
* default brotli quality (11) on a 60+MB facts file takes multiple seconds
* of CPU, which would otherwise freeze the spinner / signal handlers /
* any concurrent work.
*
* The `.br` lives next to the source rather than under the OS temp dir
* because depscan's multipart ingest (`addStreamEntry`) rejects entries
* whose names contain `..` traversal segments. The SDK computes the
* multipart entry name via `path.relative(cwd, brPath)`, so an OS-tmpdir
* temp path turns into `../../../var/folders/...` and gets dropped as
* `unmatchedFiles`. Sibling-write keeps the relative path inside cwd, and
* keeps the directory shape symmetric with the plain `.socket.facts.json`
* upload (depscan strips only the `.br` suffix at ingest, so
* `<dir>/.socket.facts.json.br` and `<dir>/.socket.facts.json` resolve to
* the same storage path).
*
* Concurrent scans against the same source directory are already racy on
* `.socket.facts.json` itself (coana writes to a single path), so the
* sibling `.br` doesn't introduce a new race.
*
* Caller MUST `await cleanup()` (typically in a `finally` block) once the
* upload completes — successful or not — to remove the sibling files.
*/
export async function compressSocketFactsForUpload(
scanPaths: string[],
): Promise<CompressedScanPaths> {
const brPaths: string[] = []
const cleanup = async () => {
const targets = brPaths.splice(0)
// `recursive: true` defends against the (defensive) case where a sibling
// path was somehow created as a directory — `rm` would otherwise throw
// on the first such entry and skip the rest. `force: true` no-ops on
// missing paths so the function stays idempotent.
await Promise.all(targets.map(t => rm(t, { recursive: true, force: true })))
}
// Use `allSettled` (not `all`) so a failure in one entry doesn't leak the
// others' in-flight pipelines past our `catch`. If we used `all`, the
// first rejection would bubble out while sibling pipelines were still
// writing bytes — `cleanup()` would race with those writes and could
// remove a `.br` only to have it re-created after we returned.
const results = await Promise.allSettled(
scanPaths.map(async p => {
if (path.basename(p) !== DOT_SOCKET_DOT_FACTS_JSON) {
return p
}
if (!existsSync(p)) {
return p
}
const brPath = `${p}.br`
// Track the sibling path BEFORE the pipeline starts so a
// partially-written `.br` is removed even if the pipeline rejects.
// `rm({ force: true })` no-ops on missing files, so tracking before
// creation is safe.
brPaths.push(brPath)
await pipeline(
createReadStream(p),
createBrotliCompress(),
createWriteStream(brPath),
)
return brPath
}),
)
Comment thread
barslev marked this conversation as resolved.
const failure = results.find(
(r): r is PromiseRejectedResult => r.status === 'rejected',
)
if (failure) {
// All pipelines have settled, so cleanup() can safely remove every
// `.br` we tracked (succeeded or partial) without racing live writes.
await cleanup()
throw failure.reason
}
const paths = results.map(r => (r as PromiseFulfilledResult<string>).value)
return { paths, cleanup }
}

export type ReachabilityError = {
componentName: string
componentVersion: string
Expand Down
Loading
Loading