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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Changed
- Updated to @socketsecurity/[email protected].
- Updated Coana CLI to v14.12.148.

### Fixed
Expand Down
160 changes: 88 additions & 72 deletions packages/build-infra/lib/github-releases.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Shared utilities for fetching GitHub releases.
*/

import { createTtlCache } from '@socketsecurity/lib/cache-with-ttl'
import { safeMkdir } from '@socketsecurity/lib/fs'
import { httpDownload, httpRequest } from '@socketsecurity/lib/http-request'
import { getDefaultLogger } from '@socketsecurity/lib/logger'
Expand All @@ -12,6 +13,13 @@ const logger = getDefaultLogger()
const OWNER = 'SocketDev'
const REPO = 'socket-btm'

// Cache GitHub API responses for 1 hour to avoid rate limiting.
const cache = createTtlCache({
memoize: true,
prefix: 'github-releases',
ttl: 60 * 60 * 1000, // 1 hour.
})

/**
* Get GitHub authentication headers if token is available.
*
Expand All @@ -38,52 +46,56 @@ function getAuthHeaders() {
* @returns {Promise<string|null>} - Latest release tag or null if not found.
*/
export async function getLatestRelease(tool, { quiet = false } = {}) {
return await pRetry(
async () => {
const response = await httpRequest(
`https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=100`,
{
headers: getAuthHeaders(),
},
)

if (!response.ok) {
throw new Error(`Failed to fetch releases: ${response.status}`)
}
const cacheKey = `latest-release:${tool}`

return await cache.getOrFetch(cacheKey, async () => {
return await pRetry(
async () => {
const response = await httpRequest(
`https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=100`,
{
headers: getAuthHeaders(),
},
)

if (!response.ok) {
throw new Error(`Failed to fetch releases: ${response.status}`)
}

const releases = JSON.parse(response.body)
const releases = JSON.parse(response.body)

// Find the first release matching the tool prefix.
for (const release of releases) {
const { tag_name: tag } = release
if (tag.startsWith(`${tool}-`)) {
if (!quiet) {
logger.info(` Found release: ${tag}`)
// Find the first release matching the tool prefix.
for (const release of releases) {
const { tag_name: tag } = release
if (tag.startsWith(`${tool}-`)) {
if (!quiet) {
logger.info(` Found release: ${tag}`)
}
return tag
}
return tag
}
}

// No matching release found in the list.
if (!quiet) {
logger.info(` No ${tool} release found in latest 100 releases`)
}
return null
},
{
backoffFactor: 1,
baseDelayMs: 5000,
onRetry: (attempt, error) => {

// No matching release found in the list.
if (!quiet) {
logger.info(
` Retry attempt ${attempt + 1}/3 for ${tool} release list...`,
)
logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`)
logger.info(` No ${tool} release found in latest 100 releases`)
}
return null
},
retries: 2,
},
)
{
backoffFactor: 1,
baseDelayMs: 5000,
onRetry: (attempt, error) => {
if (!quiet) {
logger.info(
` Retry attempt ${attempt + 1}/3 for ${tool} release list...`,
)
logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`)
}
},
retries: 2,
},
)
})
}

/**
Expand All @@ -103,46 +115,50 @@ export async function getReleaseAssetUrl(
assetName,
{ quiet = false } = {},
) {
return await pRetry(
async () => {
const response = await httpRequest(
`https://api.github.com/repos/${OWNER}/${REPO}/releases/tags/${tag}`,
{
headers: getAuthHeaders(),
},
)

if (!response.ok) {
throw new Error(`Failed to fetch release ${tag}: ${response.status}`)
}

const release = JSON.parse(response.body)
const cacheKey = `asset-url:${tag}:${assetName}`

return await cache.getOrFetch(cacheKey, async () => {
return await pRetry(
async () => {
const response = await httpRequest(
`https://api.github.com/repos/${OWNER}/${REPO}/releases/tags/${tag}`,
{
headers: getAuthHeaders(),
},
)

if (!response.ok) {
throw new Error(`Failed to fetch release ${tag}: ${response.status}`)
}

// Find the matching asset.
const asset = release.assets.find(a => a.name === assetName)
const release = JSON.parse(response.body)

if (!asset) {
throw new Error(`Asset ${assetName} not found in release ${tag}`)
}
// Find the matching asset.
const asset = release.assets.find(a => a.name === assetName)

if (!quiet) {
logger.info(` Found asset: ${assetName}`)
}
if (!asset) {
throw new Error(`Asset ${assetName} not found in release ${tag}`)
}

return asset.browser_download_url
},
{
backoffFactor: 1,
baseDelayMs: 5000,
onRetry: (attempt, error) => {
if (!quiet) {
logger.info(` Retry attempt ${attempt + 1}/3 for asset URL...`)
logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`)
logger.info(` Found asset: ${assetName}`)
}

return asset.browser_download_url
},
retries: 2,
},
)
{
backoffFactor: 1,
baseDelayMs: 5000,
onRetry: (attempt, error) => {
if (!quiet) {
logger.info(` Retry attempt ${attempt + 1}/3 for asset URL...`)
logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`)
}
},
retries: 2,
},
)
})
}

/**
Expand Down
18 changes: 14 additions & 4 deletions packages/cli/.config/esbuild.index.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Builds the index loader that executes the CLI.
*/

import { writeFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

Expand All @@ -20,10 +21,19 @@ const config = createIndexConfig({

// Run build if invoked directly.
if (fileURLToPath(import.meta.url) === process.argv[1]) {
build(config).catch(error => {
console.error('Index loader build failed:', error)
process.exitCode = 1
})
build(config)
.then(result => {
// Write the transformed output (build had write: false).
if (result.outputFiles && result.outputFiles.length > 0) {
for (const output of result.outputFiles) {
writeFileSync(output.path, output.contents)
}
}
})
.catch(error => {
console.error('Index loader build failed:', error)
process.exitCode = 1
})
}

export default config
5 changes: 5 additions & 0 deletions packages/cli/.env.test
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ SOCKET_CLI_BIN_PATH="./build/cli.js"
SOCKET_CLI_JS_PATH="./dist/cli.js"
# RUN_E2E_TESTS=1
# SOCKET_CLI_BIN_PATH="./dist/sea/socket-macos-arm64"

# External tool versions (from external-tools.json)
INLINED_SOCKET_CLI_COANA_VERSION="14.12.148"
INLINED_SOCKET_CLI_SFW_VERSION="2.0.4"
INLINED_SOCKET_CLI_SOCKET_PATCH_VERSION="1.2.0"
6 changes: 6 additions & 0 deletions packages/cli/external-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,12 @@
"package": "socketsecurity",
"version": "^2.2.15"
},
"socket-patch": {
"description": "Socket Patch CLI for applying security patches",
"type": "npm",
"package": "@socketsecurity/socket-patch",
"version": "1.2.0"
},
"sfw": {
"description": "Socket Firewall (sfw)",
"type": "standalone",
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/scripts/build-js.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
* Orchestrates extraction, building, and validation.
*/

import { copyFileSync } from 'node:fs'

import { getDefaultLogger } from '@socketsecurity/lib/logger'
import { spawn } from '@socketsecurity/lib/spawn'

Expand Down Expand Up @@ -34,7 +36,10 @@ async function main() {
return
}

// Step 3: Validate bundle.
// Step 3: Copy bundle to dist/.
copyFileSync('build/cli.js', 'dist/cli.js')

// Step 4: Validate bundle.
logger.step('Validating bundle')
const validateResult = await spawn(
'node',
Expand Down
3 changes: 1 addition & 2 deletions packages/cli/scripts/build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
* Options: --quiet, --verbose, --force, --watch
*/

import { copyFileSync } from 'node:fs'
import { promises as fs } from 'node:fs'
import { copyFileSync, promises as fs } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'

Expand Down
43 changes: 37 additions & 6 deletions packages/cli/scripts/esbuild-shared.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const rootPath = path.join(__dirname, '..')
* @returns {Object} esbuild configuration object
*/
export function createIndexConfig({ entryPoint, minify = false, outfile }) {
// Get inlined environment variables for build-time constant replacement.
const inlinedEnvVars = getInlinedEnvVars()

const config = {
banner: {
js: '#!/usr/bin/env node',
Expand All @@ -33,6 +36,15 @@ export function createIndexConfig({ entryPoint, minify = false, outfile }) {
platform: 'node',
target: 'node18',
treeShaking: true,
// Define environment variables for inlining.
define: {
'process.env.NODE_ENV': '"production"',
...createDefineEntries(inlinedEnvVars),
},
// Add plugin for post-bundle env var replacement.
plugins: [envVarReplacementPlugin(inlinedEnvVars)],
// Plugin needs to transform output.
write: false,
}

if (minify) {
Expand Down Expand Up @@ -137,12 +149,30 @@ export function getInlinedEnvVars() {
const externalTools = JSON.parse(
readFileSync(path.join(rootPath, 'external-tools.json'), 'utf-8'),
)
const cdxgenVersion = externalTools['@cyclonedx/cdxgen']?.version || ''
const coanaVersion = externalTools['@coana-tech/cli']?.version || ''
const pyCliVersion = externalTools['socketsecurity']?.version || ''
const pythonBuildTag = externalTools['python']?.buildTag || ''
const pythonVersion = externalTools['python']?.version || ''
const sfwVersion = externalTools['sfw']?.version || ''

function getExternalToolVersion(key, field = 'version') {
const tool = externalTools[key]
if (!tool) {
throw new Error(
`External tool "${key}" not found in external-tools.json. Please add it to the configuration.`,
)
}
const value = tool[field]
if (!value) {
throw new Error(
`External tool "${key}" is missing required field "${field}" in external-tools.json.`,
)
}
return value
}

const cdxgenVersion = getExternalToolVersion('@cyclonedx/cdxgen')
const coanaVersion = getExternalToolVersion('@coana-tech/cli')
const pyCliVersion = getExternalToolVersion('socketsecurity')
const pythonBuildTag = getExternalToolVersion('python', 'buildTag')
const pythonVersion = getExternalToolVersion('python')
const sfwVersion = getExternalToolVersion('sfw')
const socketPatchVersion = getExternalToolVersion('socket-patch')

// Build-time constants that can be overridden by environment variables.
const publishedBuild =
Expand All @@ -166,6 +196,7 @@ export function getInlinedEnvVars() {
INLINED_SOCKET_CLI_CYCLONEDX_CDXGEN_VERSION: JSON.stringify(cdxgenVersion),
INLINED_SOCKET_CLI_PYCLI_VERSION: JSON.stringify(pyCliVersion),
INLINED_SOCKET_CLI_SFW_VERSION: JSON.stringify(sfwVersion),
INLINED_SOCKET_CLI_SOCKET_PATCH_VERSION: JSON.stringify(socketPatchVersion),
INLINED_SOCKET_CLI_SYNP_VERSION: JSON.stringify(synpVersion),
INLINED_SOCKET_CLI_PUBLISHED_BUILD: JSON.stringify(
publishedBuild ? '1' : '',
Expand Down
Loading