diff --git a/.github/dependabot.yml b/.github/dependabot.yml index df0f126d..139b975e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -74,6 +74,10 @@ updates: - "trufflesecurity/trufflehog" - "anchore/sbom-action" - "sigstore/cosign-installer" + # Only reachable if SocketDev/action is ever referenced from a workflow; + # its current use is in src/security/socket-firewall, which the + # github-actions ecosystem does not scan (see note at the top). + - "SocketDev/action" update-types: - "minor" - "patch" diff --git a/.github/workflows/frontend-pr-analysis.yml b/.github/workflows/frontend-pr-analysis.yml index 000e2767..a793bf0a 100644 --- a/.github/workflows/frontend-pr-analysis.yml +++ b/.github/workflows/frontend-pr-analysis.yml @@ -52,6 +52,21 @@ on: description: 'Package manager to use (npm, yarn, pnpm)' type: string default: 'npm' + enable_socket_firewall: + description: >- + Route every dependency install through Socket Firewall (free tier, no token), so a malicious package + is refused before any install script runs. When false, installs restore the package-manager cache and + behave as they did before Socket was introduced. + type: boolean + default: true + socket_firewall_version: + description: 'Socket Firewall binary version. Empty or "latest" tracks the newest release.' + type: string + default: 'latest' + socket_fail_on_block: + description: 'Fail the job when Socket Firewall blocks a package. Set to false to report the block as a warning instead.' + type: boolean + default: true eslint_args: description: 'Additional arguments for ESLint' type: string @@ -231,29 +246,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Run ESLint working-directory: ${{ matrix.app.working_dir }} @@ -284,29 +285,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Run TypeScript compiler working-directory: ${{ matrix.app.working_dir }} @@ -330,29 +317,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Run npm audit working-directory: ${{ matrix.app.working_dir }} @@ -384,29 +357,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Run tests working-directory: ${{ matrix.app.working_dir }} @@ -596,29 +555,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Build working-directory: ${{ matrix.app.working_dir }} @@ -649,29 +594,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Check i18n keys (extraction parity) id: i18n_extraction @@ -731,29 +662,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Check accessibility working-directory: ${{ matrix.app.working_dir }} @@ -785,29 +702,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Install Playwright browsers if: inputs.custom_checks_needs_browsers @@ -864,29 +767,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Check bundle budget working-directory: ${{ matrix.app.working_dir }} @@ -918,29 +807,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Install Playwright browsers working-directory: ${{ matrix.app.working_dir }} @@ -983,29 +858,15 @@ jobs: with: persist-credentials: false - - name: Set up pnpm - if: inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Install Playwright browsers working-directory: ${{ matrix.app.working_dir }} @@ -1119,31 +980,16 @@ jobs: echo "::error::Health check at ${HEALTH_PATH}:${PORT} did not pass within ${TIMEOUT}s" exit 1 - - name: Set up pnpm - if: inputs.docker_smoke_test_script != '' && inputs.package_manager == 'pnpm' - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - package_json_file: ${{ matrix.app.working_dir }}/package.json - - - name: Setup Node.js + - name: Setup Node.js and install dependencies if: inputs.docker_smoke_test_script != '' - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 with: + package-manager: ${{ inputs.package_manager }} node-version: ${{ inputs.node_version }} - cache: ${{ inputs.package_manager }} - cache-dependency-path: ${{ matrix.app.working_dir }}/${{ inputs.package_manager == 'yarn' && 'yarn.lock' || (inputs.package_manager == 'pnpm' && 'pnpm-lock.yaml' || 'package-lock.json') }} - - - name: Install dependencies - if: inputs.docker_smoke_test_script != '' - working-directory: ${{ matrix.app.working_dir }} - env: - PKG_MANAGER: ${{ inputs.package_manager }} - run: | - case "$PKG_MANAGER" in - yarn) yarn install --frozen-lockfile ;; - pnpm) pnpm install --frozen-lockfile ;; - *) npm ci ;; - esac + working-dir: ${{ matrix.app.working_dir }} + guard: ${{ inputs.enable_socket_firewall }} + firewall-version: ${{ inputs.socket_firewall_version }} + fail-on-block: ${{ inputs.socket_fail_on_block }} - name: Install Playwright browsers if: inputs.docker_smoke_test_script != '' diff --git a/.github/workflows/js-pr-validation.yml b/.github/workflows/js-pr-validation.yml index e89ccee2..7503d2f6 100644 --- a/.github/workflows/js-pr-validation.yml +++ b/.github/workflows/js-pr-validation.yml @@ -37,6 +37,10 @@ on: description: 'Run the security scan pipeline (Trivy, CodeQL, prerelease checks)' type: boolean default: true + run_socket: + description: 'Run the Socket supply-chain pipeline (Socket Firewall and, when enabled, the Socket CLI scan)' + type: boolean + default: true # ----------------- Change gate ----------------- ignore_globs: @@ -282,14 +286,111 @@ on: description: 'Comma-separated directories to skip in every Trivy filesystem scan (appended to the built-in skip list).' type: string default: '' + + # ----------------- Socket supply chain (src/security/socket-*) ----------------- + socket_enable_firewall: + description: 'Run Socket Firewall (free tier, no token) and install dependencies through it, blocking malicious packages at install time.' + type: boolean + default: true + socket_working_dir: + description: 'Directory holding the package.json and lockfile scanned by the Socket jobs' + type: string + default: '.' + socket_firewall_version: + description: 'Socket Firewall binary version. Empty or "latest" tracks the newest release.' + type: string + default: 'latest' + socket_job_summary: + description: 'Socket Firewall job summary verbosity (all, errors, none)' + type: string + default: 'all' + socket_use_cache: + description: 'Cache the sfw binary between runs. Unrelated to the package-manager cache, which is always purged before the guarded install.' + type: boolean + default: true + socket_fail_on_block: + description: 'Fail the Socket job when Socket Firewall blocks a package. Set to false to report the block as a warning instead.' + type: boolean + default: true + socket_enable_app_gate: + description: >- + Turn the Socket GitHub App checks into an enforceable gate. The App analyses the dependency graph and + posts advisory checks; this makes an adverse verdict block the pull request. Needs no token. + type: boolean + default: true + socket_app_slug: + description: 'GitHub App slug whose checks the gate reads' + type: string + default: 'socket-security' + socket_app_timeout: + description: 'Seconds to wait for the Socket App checks to complete before treating the result as inconclusive' + type: number + default: 300 + socket_app_fail_on_findings: + description: 'Fail the Socket job when the App reports adverse checks' + type: boolean + default: true + socket_app_on_inconclusive: + description: >- + What to do when the App ran but reached no verdict (neutral/skipped/timeout): "block" (default) or "warn". + A conflicted pull request is the common cause, and it must not read as clean. + type: string + default: 'block' + socket_enable_api_report: + description: >- + Read the full scan the Socket App already computed and report per-package alerts, vulnerabilities and + scores in the pull request comment. Requires SOCKET_SECURITY_API_KEY with the full-scans:list scope. + Advisory only — it never gates the merge. + type: boolean + default: true + socket_api_max_rows: + description: 'Maximum package rows kept in the dependency findings table' + type: number + default: 25 + socket_api_include_actions: + description: >- + Comma-separated Socket alert actions reported as findings. Socket marks roughly 99% of alerts as "ignore" + (capability observations such as envVars), so reporting unfiltered buries a handful of findings in + thousands of lines. + type: string + default: 'error,warn,monitor' + socket_comment_when: + description: >- + "findings" (the default) posts the Socket comment only when there is something to act on. "always" posts + every run. Operational state — whether the scan ran, which checks passed — is never in the comment; it + lives in the job log and the Socket status check. + type: string + default: 'findings' + socket_api_fail_on_actions: + description: >- + Comma-separated Socket alert actions that block the pull request. Empty (the default) reports without + blocking. Set to e.g. "error" to own the merge decision in this workflow rather than deferring it to the + Socket App's verdict. + type: string + default: '' + socket_app_on_missing: + description: >- + What to do when the App published no checks, which is what a repository without it installed looks like: + "warn" (default) keeps those repositories green, "block" requires the App. + type: string + default: 'warn' secrets: MANAGE_TOKEN: required: false SLACK_WEBHOOK_URL: required: false + # Declared but not consumed by any job today: the Socket layers here run + # token-free (the firewall inspects traffic locally, the App gate reads the + # GitHub checks API). It is declared so an organization secret reaches this + # workflow through `secrets: inherit` without a release, which a reusable + # workflow cannot do for an undeclared secret. Do not delete as dead — + # Socket Firewall enterprise is the intended consumer. + SOCKET_SECURITY_API_KEY: + required: false permissions: actions: read + checks: read contents: read id-token: write issues: write @@ -351,6 +452,9 @@ jobs: path_level: ${{ inputs.path_level }} normalize_to_filter: ${{ inputs.normalize_to_filter }} app_name_prefix: ${{ inputs.app_name_prefix }} + enable_socket_firewall: ${{ inputs.socket_enable_firewall }} + socket_firewall_version: ${{ inputs.socket_firewall_version }} + socket_fail_on_block: ${{ inputs.socket_fail_on_block }} enable_lint: ${{ inputs.enable_lint }} enable_typecheck: ${{ inputs.enable_typecheck }} enable_security: ${{ inputs.enable_security }} @@ -427,6 +531,169 @@ jobs: result: ${{ needs.changes.result != 'success' && needs.changes.result || needs.security.result }} label: Security + # ----------------- Socket Supply Chain ----------------- + socket: + name: Socket (checks) + needs: changes + if: inputs.run_socket && needs.changes.outputs.code == 'true' + runs-on: ${{ vars.GENERAL_RUNNERS || inputs.runner_type }} + permissions: + contents: read + checks: read + issues: write + pull-requests: write + env: + SOCKET_APP_NAME: ${{ inputs.app_name_prefix != '' && inputs.app_name_prefix || github.event.repository.name }} + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 + with: + persist-credentials: false + + # This install is the reporting source of truth for the Socket Firewall + # row below. Enforcement itself is broader: every install in + # frontend-pr-analysis.yml also runs through the firewall, so a malicious + # package cannot execute install scripts in any analysis job either. + - name: Socket Firewall + id: firewall + if: inputs.socket_enable_firewall + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-firewall@v1 + with: + package-manager: ${{ inputs.package_manager }} + node-version: ${{ inputs.node_version }} + working-dir: ${{ inputs.socket_working_dir }} + firewall-version: ${{ inputs.socket_firewall_version }} + job-summary: ${{ inputs.socket_job_summary }} + use-cache: ${{ inputs.socket_use_cache }} + fail-on-block: ${{ inputs.socket_fail_on_block }} + dry-run: ${{ inputs.dry_run }} + + - name: Socket App Gate + id: app-gate + if: inputs.socket_enable_app_gate && github.event_name == 'pull_request' + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-app-gate@v1 + with: + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + # The App reports against the pull request head, not the merge commit. + commit-sha: ${{ github.event.pull_request.head.sha }} + app-slug: ${{ inputs.socket_app_slug }} + timeout-seconds: ${{ inputs.socket_app_timeout }} + fail-on-findings: ${{ inputs.socket_app_fail_on_findings }} + on-inconclusive: ${{ inputs.socket_app_on_inconclusive }} + on-missing-app: ${{ inputs.socket_app_on_missing }} + + # Reads the scan the App already produced, addressed by the dashboard URL the + # gate captured — no new scan, one quota unit, no duplicate analysis. + # The App publishes the diff scan id in its own comment. Looking it up by + # after_full_scan_id does not work: the App diffs against a different full + # scan than the one its Project Report check links to. + - name: Resolve Socket diff scan id + id: diff-scan + if: always() && inputs.socket_enable_api_report && github.event_name == 'pull_request' + continue-on-error: true + env: + GH_TOKEN: ${{ secrets.MANAGE_TOKEN || github.token }} + PR: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + run: | + JQ='[.[] | select(.user.login == "socket-security[bot]") | .body] | last // ""' + BODY=$(gh api "repos/$REPO/issues/$PR/comments" --paginate --jq "$JQ") + ID=$(printf '%s' "$BODY" \ + | grep -oE 'diff-scan/[0-9a-fA-F-]{36}' | head -1 | cut -d/ -f2 || true) + if [ -n "$ID" ]; then + echo "Resolved diff scan $ID from the Socket App comment." + else + echo "No diff scan id in the Socket App comment — it only posts one when dependencies change." + fi + echo "id=$ID" >> "$GITHUB_OUTPUT" + + - name: Socket API Report + id: api-report + if: always() && inputs.socket_enable_api_report && github.event_name == 'pull_request' + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-api-report@v1 + with: + socket-api-key: ${{ secrets.SOCKET_SECURITY_API_KEY }} + report-url: ${{ steps.app-gate.outputs.report-url }} + max-rows: ${{ inputs.socket_api_max_rows }} + include-actions: ${{ inputs.socket_api_include_actions }} + fail-on-actions: ${{ inputs.socket_api_fail_on_actions }} + # The baseline: findings already on the target branch are reported as + # pre-existing and never block, so a repository carrying debt does not + # fail every pull request for something none of them caused. + base-branch: ${{ github.base_ref }} + head-sha: ${{ github.event.pull_request.head.sha }} + diff-scan-id: ${{ steps.diff-scan.outputs.id }} + + - name: Post Socket report to PR + if: always() && github.event_name == 'pull_request' && !inputs.dry_run + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-reporter@v1 + with: + github-token: ${{ secrets.MANAGE_TOKEN || github.token }} + app-name: ${{ env.SOCKET_APP_NAME }} + comment-when: ${{ inputs.socket_comment_when }} + firewall-blocked: ${{ steps.firewall.outputs.blocked || 'false' }} + firewall-fail-on-block: ${{ inputs.socket_fail_on_block }} + firewall-findings-file: ${{ steps.firewall.outputs.findings-file }} + api-findings-file: ${{ steps.api-report.outputs.findings-file }} + api-blocking-count: ${{ steps.api-report.outputs.blocking-count || '0' }} + # yamllint disable-line rule:line-length + run-url: ${{ format('{0}/{1}/actions/runs/{2}', github.server_url, github.repository, github.run_id) }} + + # continue-on-error above keeps the report reachable even when a layer + # fails, so the verdict is re-applied here. + # dry_run is a preview across the whole umbrella, so the gate reports the + # verdict it would have applied instead of applying it. + - name: Gate - Fail on Socket findings + if: always() && !inputs.dry_run + env: + FIREWALL_OUTCOME: ${{ steps.firewall.outcome }} + APP_GATE_OUTCOME: ${{ steps.app-gate.outcome }} + API_BLOCKING: ${{ steps.api-report.outputs.blocking-count }} + run: | + FAILED="" + [ "$FIREWALL_OUTCOME" = "failure" ] && FAILED="$FAILED Socket-Firewall" + [ "$APP_GATE_OUTCOME" = "failure" ] && FAILED="$FAILED Socket-App-Gate" + # Only real findings from the API layer block. Its API-failure paths + # report zero here by construction, so an unreachable Socket cannot + # fail a pull request. + if [ "${API_BLOCKING:-0}" -gt 0 ] 2>/dev/null; then + FAILED="$FAILED Socket-Dependency-Findings" + fi + if [ -n "$FAILED" ]; then + echo "::error::Socket supply chain gate failed:$FAILED. See the Socket comment on the pull request." + exit 1 + fi + echo "Socket supply chain gate passed." + + - name: Gate - Socket findings (dry run) + if: always() && inputs.dry_run + env: + FIREWALL_OUTCOME: ${{ steps.firewall.outcome }} + APP_GATE_OUTCOME: ${{ steps.app-gate.outcome }} + API_BLOCKING: ${{ steps.api-report.outputs.blocking-count }} + run: | + echo "::notice::DRY RUN — Socket gate not enforced" + echo " Socket Firewall : $FIREWALL_OUTCOME" + echo " Socket App gate : $APP_GATE_OUTCOME" + echo " Blocking findings: ${API_BLOCKING:-0}" + + socket-gate: + name: Socket + needs: [changes, socket] + if: always() + runs-on: ${{ vars.GENERAL_RUNNERS || inputs.runner_type }} + permissions: + contents: read + steps: + - name: Aggregate Socket result + uses: LerianStudio/github-actions-shared-workflows/src/validate/result-gate@v1 + with: + result: ${{ needs.changes.result != 'success' && needs.changes.result || needs.socket.result }} + label: Socket + # ----------------- Org ruleset compatibility stubs ----------------- # Go Analysis and Lib Version are required status checks in the Lerian org # branch-protection ruleset (designed for Go repos). These stubs are diff --git a/.gitignore b/.gitignore index dde360c2..0de76f05 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ go.list # ── Temporary files ──────────────────────────────────────────────────────────── tmp/ temp/ + +# Linter binaries fetched ad hoc for local verification +/typos diff --git a/docs/frontend-pr-analysis-workflow.md b/docs/frontend-pr-analysis-workflow.md index 2d484bf3..0df2cf54 100644 --- a/docs/frontend-pr-analysis-workflow.md +++ b/docs/frontend-pr-analysis-workflow.md @@ -140,6 +140,14 @@ jobs: secrets: inherit ``` +## Guarded dependency installs + +Every job that installs dependencies does so through [`setup-node-guarded`](../src/setup/setup-node-guarded/README.md), which runs `sfw npm ci` (or the `yarn`/`pnpm` equivalent) under [Socket Firewall](https://github.com/SocketDev/sfw-free) instead of a bare install. A malicious package is refused mid-fetch, so it never reaches disk and its install scripts never execute — in **any** of these jobs, not just one. + +Guarded installs deliberately run **without a package-manager cache**: Socket Firewall only inspects what crosses the network, and a cached tarball produces no request. The cache is also purged before each install, since runner images can arrive pre-warmed. Measured cost is roughly 20s for a cold install of ~2000 packages. + +Set `enable_socket_firewall: false` to restore the previous behaviour (cached, unguarded) — necessary for repositories that install from a private registry, which the free edition does not support. + ## Inputs | Input | Description | Required | Default | @@ -152,6 +160,9 @@ jobs: | `app_name_prefix` | Prefix for app names in matrix output | No | `''` | | `node_version` | Node.js version to use | No | `22` | | `package_manager` | Package manager (npm, yarn, pnpm) | No | `npm` | +| `enable_socket_firewall` | Route every dependency install through Socket Firewall (free tier, no token) so a malicious package is refused before any install script runs. `false` restores the previous behaviour, package-manager cache included | No | `true` | +| `socket_firewall_version` | Socket Firewall binary version | No | `latest` | +| `socket_fail_on_block` | Fail the job when Socket Firewall blocks a package | No | `true` | | `eslint_args` | Additional ESLint arguments | No | `''` | | `audit_level` | npm audit severity level (low, moderate, high, critical) | No | `high` | | `coverage_threshold` | Minimum coverage percentage (0-100) | No | `80` | diff --git a/docs/js-pr-validation.md b/docs/js-pr-validation.md index 7ea873bd..60752530 100644 --- a/docs/js-pr-validation.md +++ b/docs/js-pr-validation.md @@ -12,8 +12,9 @@ Umbrella reusable workflow for JavaScript/TypeScript repositories. A caller refe 3. **Change gate** — detects whether the PR touches anything beyond docs/meta (`src/config/non-doc-changes`); documentation-only PRs skip the heavy pipelines. 4. **Frontend analysis** — lint, typecheck, npm audit, tests, coverage and build (delegates to `frontend-pr-analysis.yml`), opt-in via `run_frontend_analysis`. 5. **Security scan** — Trivy, CodeQL, prerelease checks (delegates to `pr-security-scan.yml`), opt-in via `run_security`. +6. **Socket supply chain** — refuses malicious packages at install time, turns the Socket App's advisory verdict into an enforceable check, and reports per-package findings split into what this pull request introduced and what the tree already carried. Enabled by default; disable with `run_socket: false`. -The `frontend-analysis` and `security` pipelines each have a `*-gate` aggregator job that exposes a single stable status-check name (`Frontend Analysis`, `Security`) for branch protection, regardless of the internal job names. Both are gated by the change detector, so documentation-only PRs skip them (and the aggregators still report success). If the change detector (`changes`) job itself fails, the aggregators propagate that failure instead of passing. +The `frontend-analysis`, `security` and `socket` pipelines each have a `*-gate` aggregator job that exposes a single stable status-check name (`Frontend Analysis`, `Security`, `Socket`) for branch protection, regardless of the internal job names. All are gated by the change detector, so documentation-only PRs skip them (and the aggregators still report success). If the change detector (`changes`) job itself fails, the aggregators propagate that failure instead of passing. ## Inputs @@ -23,6 +24,7 @@ The `frontend-analysis` and `security` pipelines each have a `*-gate` aggregator | `dry_run` | Preview metadata validations without posting comments/labels | boolean | `false` | | `run_frontend_analysis` | Run the frontend analysis pipeline | boolean | `true` | | `run_security` | Run the security scan pipeline | boolean | `true` | +| `run_socket` | Run the Socket supply-chain pipeline | boolean | `true` | | `ignore_globs` | Space-separated globs treated as docs/meta for the change gate | string | `*.md docs/* .github/* LICENSE* .gitignore` | | `pr_title_types` | Allowed commit types (pipe-separated) | string | `feat\|fix\|docs\|style\|refactor\|perf\|test\|chore\|ci\|build\|revert` | | `pr_title_scopes` | Allowed scopes (pipe-separated, empty = any) | string | `''` | @@ -79,6 +81,23 @@ The `frontend-analysis` and `security` pipelines each have a `*-gate` aggregator | `codeql_languages` | CodeQL languages (comma-separated, e.g. `javascript-typescript`) | string | `''` | | `ignore_file` | Path to Trivy ignore file (e.g. `.trivyignore.yaml`) | string | `''` | | `trivy_skip_dirs` | Comma-separated directories to skip in every Trivy filesystem scan | string | `''` | +| `socket_enable_firewall` | Run Socket Firewall (free tier, no token) and install dependencies through it | boolean | `true` | +| `socket_working_dir` | Directory holding the `package.json` and lockfile scanned by the Socket job | string | `.` | +| `socket_firewall_version` | Socket Firewall binary version | string | `latest` | +| `socket_job_summary` | Socket Firewall job summary verbosity (`all`, `errors`, `none`) | string | `all` | +| `socket_use_cache` | Cache the Socket Firewall binaries between runs (the `sfw` binary only) | boolean | `true` | +| `socket_fail_on_block` | Fail the Socket job when Socket Firewall blocks a package | boolean | `true` | +| `socket_enable_app_gate` | Turn the Socket GitHub App checks into an enforceable gate (no token needed) | boolean | `true` | +| `socket_app_slug` | GitHub App slug whose checks the gate reads | string | `socket-security` | +| `socket_app_timeout` | Seconds to wait for the App checks before treating the result as inconclusive | number | `300` | +| `socket_app_fail_on_findings` | Fail the Socket job when the App reports adverse checks | boolean | `true` | +| `socket_app_on_inconclusive` | `block` or `warn` when the App reached no verdict | string | `block` | +| `socket_app_on_missing` | `warn` or `block` when the App published no checks | string | `warn` | +| `socket_enable_api_report` | Read the App's full scan and report per-package alerts, vulnerabilities and scores (advisory) | boolean | `true` | +| `socket_api_max_rows` | Maximum package rows per findings section | number | `25` | +| `socket_api_include_actions` | Socket alert actions reported as findings | string | `error,warn,monitor` | +| `socket_api_fail_on_actions` | Actions that block the PR — **introduced findings only**. Empty blocks nothing | string | `''` | +| `socket_comment_when` | `findings` posts the comment only when there is something to act on; `always` posts every run | string | `findings` | > **Monorepo note:** `filter_paths`/`shared_paths`/`path_level`/`normalize_to_filter` scope the `frontend-analysis` job only. They are not passed to the `security` job because `frontend-pr-analysis.yml` and `pr-security-scan.yml` use different formats for that input (JSON array vs. newline-separated). For a path-scoped security scan too, call `pr-security-scan.yml` directly. @@ -112,6 +131,7 @@ Caller triggers must include the five activity types in the usage example. `edit |--------|-------------|----------| | `MANAGE_TOKEN` | Token for PR operations and private package access | No | | `SLACK_WEBHOOK_URL` | Slack webhook for pipeline notifications | No | +| `SOCKET_SECURITY_API_KEY` | Socket API token. Not consumed by any job today — declared so an org secret reaches this workflow via `secrets: inherit` without a release. See below | No | All other secrets required by the underlying primitives (e.g. `DOCKER_USERNAME`, `DOCKERHUB_IMAGE_PULL_TOKEN`, `NPMRC_TOKEN`) are forwarded automatically via `secrets: inherit`. @@ -125,6 +145,7 @@ on: permissions: actions: read + checks: read # required by the Socket App gate contents: read id-token: write issues: write @@ -174,9 +195,97 @@ jobs: secrets: inherit ``` +## Socket supply chain + +`npm audit`, Trivy and CodeQL find known CVEs and insecure code. None of them find a **supply-chain attack** — a package with a malicious install script, a typosquat, a dependency hijacked in a patch release. [Socket](https://socket.dev) covers that gap by analyzing package behavior, and it is wired here in three layers that do different jobs: one refuses the install, one turns the App's verdict into a gate, and one reports the findings per package. + +> Not to be confused with `socket.io`, the WebSocket library. Unrelated project, no scanning capability. + +### Layer 1 — Socket Firewall, on every install + +[`setup-node-guarded`](../src/setup/setup-node-guarded/README.md) installs Socket Firewall's free edition (no token, no account) and runs `sfw npm ci` — or the `yarn`/`pnpm` equivalent — instead of a bare install. A malicious package is refused mid-fetch, so it is never written to disk and its install scripts never run. + +This applies to **every** install in the pipeline: all twelve analysis jobs in `frontend-pr-analysis.yml` plus the dedicated `socket` job. That breadth is the point. A firewall shim only protects installs in its own job, so guarding one job would leave the others executing `postinstall` scripts with the runner's tokens in scope. + +Two consequences worth knowing: + +- **No package-manager cache on guarded installs.** Socket Firewall only sees what crosses the network; per its docs, *"if there are no network requests, as is the case when artifacts are cached locally, there is nothing for `sfw` to block"*. So `cache:` is not passed to `actions/setup-node` and the cache is purged before each install. Measured cost is small — a cold `sfw npm ci` over ~2000 packages takes about 20s. +- **No private registries.** The free edition does not support custom registries. A repository that needs one must set `socket_enable_firewall: false`, which restores the previous behaviour, cache included. + +```yaml +with: + socket_enable_firewall: true # default + socket_fail_on_block: false # report blocks as warnings instead of failing +``` + +An install that fails for an ordinary reason always fails the job — `socket_fail_on_block` only softens confirmed Socket blocks. + +### Layer 2 — Socket App gate, no token required + +The organization already runs the [Socket GitHub App](https://github.com/marketplace/socket-security), which analyses the dependency graph and posts `Socket Security: Project Report` and `Socket Security: Pull Request Alerts`. What it does not do is enforce: its checks land as `success`, `neutral` or `skipped`, and neither `neutral` nor `skipped` blocks a merge. + +[`socket-app-gate`](../src/security/socket-app-gate/README.md) waits for those checks on the PR **head** SHA and converts them into a verdict this workflow owns. It re-scans nothing, needs no API token and consumes no Socket quota — running `socketcli` in CI instead would duplicate the same analysis and post a second, competing report. + +| Verdict | Meaning | Default | +|---|---|---| +| `pass` | Every App check completed non-adversely | Passes | +| `findings` | A check concluded `failure`/`action_required`/`cancelled`/`timed_out` | **Blocks** | +| `inconclusive` | Checks exist but are `neutral`/`skipped`, or the wait timed out | **Blocks** | +| `missing` | The App published no checks — it is not installed here | Warns | + +`inconclusive` blocking is deliberate. On a pull request with merge conflicts the App reports *"Skipped un-mergeable pull request"*, meaning no diff was analysed at all — treating that as clean would wave through exactly the wrong pull request. `missing` only warns, so repositories without the App stay green and rely on layer 1. + +```yaml +with: + socket_app_on_inconclusive: 'warn' # default 'block' + socket_app_on_missing: 'block' # default 'warn' — require the App +``` + +### Layer 3 — Dependency findings + +The first two layers answer narrow questions. The firewall reports what it **refused**, never what it allowed. The App's checks carry a status and a dashboard link — `Project Report`'s `output.text` is literally `null`. Neither can say *which package has which problem, and whether this pull request caused it*. + +[`socket-api-report`](../src/security/socket-api-report/README.md) closes that. It reads the scan Socket already computed for the commit, plus the diff scan the App computed for the pull request, and reports per-package findings split by origin. + +**Filtering.** Measured on a real 1897-package tree: of 4636 alerts, `ignore` accounted for 4594 and only 42 carried an action. Severity is not a usable filter — 118 alerts were `high` and still ignored. Capabilities like `envVars` or `networkAccess` are normal in isolation; Socket has already judged them by the time it assigns an action, so action is the axis used here. + +**Attribution, and why it matters for gating.** `socket_api_fail_on_actions` applies **only to findings this pull request introduces**. On `product-console` the tree already carries 43 actioned findings; gating on those would fail every pull request in the repository for something none of them caused, and the gate would be switched off within a week. Pre-existing findings are collapsed into a `
` and never block. + +**Provenance.** Transitive findings name the direct dependency that reaches them — `oauth@0.9.15` reads as `via next-auth`, because `oauth` is nobody's decision and `next-auth` is. Coverage on the reference tree was 1755 of 1897 artifacts; the rest are direct dependencies, which have no ancestor. + +**Scopes.** `full-scans:list` and `diff-scans:list` on `SOCKET_SECURITY_API_KEY`. Without the token the layer skips with a notice. + +**Advisory by construction.** Every API failure path exits `0` and reports zero blocking findings. A Socket outage or an exhausted quota must never read as a security finding — enforcement stays with layer 2's verdict, layer 1's refusal to install, and `socket_api_fail_on_actions` when a repository opts in. + +```yaml +with: + socket_api_fail_on_actions: 'error' # default '' — report only + socket_api_include_actions: 'error,warn' +``` + +> **Known coupling.** The diff scan id is read from the Socket App's own pull request comment: looking it up by `after_full_scan_id` returns nothing, because the App diffs against a different full scan than the one its `Project Report` check links to. Disabling the App's comments therefore breaks attribution — everything reverts to pre-existing and `socket_api_fail_on_actions` goes inert, silently. + +### The PR comment + +One upserted comment per pull request, under ``, carrying **findings only**. Whether the scan ran, which App checks passed and how many alerts were filtered out live in the job log and in the `Socket` status check — see [`socket-reporter`](../src/security/socket-reporter/README.md). + +With `socket_comment_when: findings` (the default) nothing is posted when there is nothing to act on, and a comment from an earlier run collapses to a resolved note once its findings are gone. `dry_run: true` skips posting entirely. + +It is a separate comment from both the security scan comment and the Socket App's, and duplicates neither: the App shows version transitions and score deltas for changed direct dependencies, this one shows action, severity, remediation and provenance. + +### Turning it off + +```yaml +with: + run_socket: false # the socket job (gate + report) + socket_enable_firewall: false # also unguards the twelve analysis installs +``` + +Note these are independent: `run_socket: false` removes the gate job and the comment but leaves the guarded installs in place. + ## Branch protection -Require the aggregator checks `Frontend Analysis` and `Security` (plus the PR metadata checks from `pr-validation.yml`). Breaking-change enforcement remains inside the existing `Blocking Checks` status; it does not add a branch-protection check. These names are stable even when the underlying analysis steps change. +Require the aggregator checks `Frontend Analysis`, `Security` and `Socket` (plus the PR metadata checks from `pr-validation.yml`). Breaking-change enforcement remains inside the existing `Blocking Checks` status; it does not add a branch-protection check. These names are stable even when the underlying analysis steps change. ## Related diff --git a/src/security/socket-api-report/README.md b/src/security/socket-api-report/README.md new file mode 100644 index 00000000..3a923d55 --- /dev/null +++ b/src/security/socket-api-report/README.md @@ -0,0 +1,179 @@ + + + + + +
Lerian

socket-api-report

+ +Composite action that reads the Socket scan for a commit and separates the dependency findings **this pull request introduces** from the debt the tree already carried. + +It is the only layer that can answer *"what is wrong with which package, and is it ours"*. The other two cannot, by construction: + +- **Socket Firewall free** reports what it **refused**. Its JSON is `{blocked, parseFail}` — silent about everything that passed. +- **The App's check runs** carry a status and a link. `Project Report`'s `output.text` is `null`; the detail lives on the dashboard. + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `socket-api-key` | Socket API token. Empty skips with a notice | No | `''` | +| `report-url` | Dashboard URL of the scan, from [`socket-app-gate`](../socket-app-gate/README.md)'s `report-url`. The org slug and scan id are parsed from it | No | `''` | +| `org-slug` | Overrides the org slug parsed from `report-url` | No | `''` | +| `full-scan-id` | Overrides the scan id parsed from `report-url` | No | `''` | +| `diff-scan-id` | Socket diff scan id for this pull request. See [Attribution](#attribution) | No | `''` | +| `base-branch` | Target branch, reported as the comparison point | No | `''` | +| `head-sha` | Head commit, used to label the scan link | No | `''` | +| `include-actions` | Alert actions treated as findings | No | `error,warn,monitor` | +| `fail-on-actions` | Actions that make an **introduced** finding blocking. Empty blocks nothing | No | `''` | +| `max-rows` | Maximum package rows kept per section | No | `25` | +| `findings-file` | Path where the extracted JSON is written for `socket-reporter` | No | `socket-api-findings.json` | +| `debug-sample` | Log the alert object shape, for adapting to an API change | No | `false` | + +## Outputs + +| Output | Description | +|---|---| +| `skipped` | `true` when nothing was produced (no token, no resolvable scan, or an API error) | +| `findings-file` | Path to the extracted findings JSON | +| `introduced-count` | Actioned findings this pull request introduces | +| `preexisting-count` | Actioned findings already present on the target branch | +| `blocking-count` | Introduced findings matching `fail-on-actions`. Zero when it is empty | + +## Filtering: why 4636 alerts become 42 + +Measured on a real 1897-artifact tree, alert actions came back as `ignore` 4594, `monitor` 24, `warn` 18. Reporting unfiltered rendered the same `envVars` hundreds of times in a single table cell — technically complete, unreadable. + +**Severity is not a usable filter**: 118 alerts were `high` and still `ignore`d. Action is the axis Socket's own dashboard filters on, so it is the axis used here. + +Capabilities like `envVars`, `networkAccess` and `filesystemAccess` are marked `ignore` because in isolation they are normal — an HTTP client reads the proxy from the environment. Hostility is a combination, and Socket has already made that judgement by the time it assigns an action. + +Repeats are collapsed per package and alert type into a count with the first file, since an alert fires once per source location. + +## Attribution + +`fail-on-actions` applies **only to introduced findings**. Gating on inherited debt fails every pull request in a repository for something none of them caused, and the gate gets switched off in its first week. + +Attribution comes from Socket's own diff scan. Two home-grown baselines failed first, and both failures are worth knowing: + +1. Comparing against the newest full scan of the target branch produced **38 false attributions** on a change that touched a single types package. +2. Filtering candidates by `scan_state` rejected all ten. Every scan in this organization persists as `pending` or `resolve` — the head scan included, whose data is demonstrably complete. **`scan_state` carries no completion meaning here.** + +Measuring package overlap then showed why no baseline was usable: the newest target-branch scan shared only **31.8%** of the head tree's `package@version` set despite holding 82% as many artifacts. An artifact-count threshold would have accepted it — which is exactly how the 38 false attributions happened. + +So the diff is read, not approximated. `added`, `updated` and `replaced` define what the pull request is responsible for; `updated` and `replaced` count because a version or source change makes those findings this change's problem too. + +**The diff scan id comes from the App's own comment**, passed in as `diff-scan-id`. Looking it up by `after_full_scan_id` returns nothing: the App diffs against a different full scan than the one its `Project Report` check links to. This is a dependency on a third-party bot's comment — if its format changes, or its comments are disabled, attribution degrades silently to "everything pre-existing" and `fail-on-actions` goes inert. Replacing it with a `diff-scans?repository_id=` lookup removes that coupling. + +Without a diff scan nothing is attributed and everything is reported as pre-existing. No attribution is honest; a confident wrong one is not. + +## The blame chain + +A transitive finding is not actionable on its own — nobody installed `brace-expansion` on purpose. `topLevelAncestors` carries artifact **references**, so an id-to-name map is built from the same scan and resolved through it, turning a row into `via eslint, jest +1`. + +Measured coverage on the reference tree: **1755 of 1897** artifacts carry the field, and all 1897 ids resolve. The remainder are direct dependencies, which have no ancestor by definition. + +## Token scopes + +| Scope | For | +|---|---| +| `full-scans:list` | Reading the scan for the commit | +| `diff-scans:list` | Reading the diff scan that drives attribution | + +Grant nothing else — this action only reads. + +## Why this does not use curl + +`api.socket.dev` sits behind Cloudflare, which **fingerprints the HTTP client**. `curl` is answered with a managed challenge — `403`, an HTML interstitial, `cf-mitigated: challenge` — which by status code alone is indistinguishable from a scope denial and sends you auditing token permissions that were never the cause. + +Reproduced with `Authorization: Bearer`, with HTTP Basic, with a custom User-Agent, with the exact header shape `socket-sdk-python` sends, on `/v0/quota` as well as the full-scan endpoint, from a developer machine and a Blacksmith runner, three spaced retries each. + +Python's stdlib client is not challenged. It is also the stack the official SDK uses, and the reason `sfw` reaches Socket from the very runner where `curl` is blocked. The `cf-mitigated` check is kept as a regression guard. + +## Advisory by construction + +Every failure path exits `0`, and HTTP statuses are classified separately (`401` invalid, `403` missing scope, `404` wrong scan, `429` quota, plus the Cloudflare case) because they need different fixes. Only real findings can block: a Socket outage or an exhausted quota must never read as a security finding. + +## Extracted shape + +```json +{ + "orgSlug": "lerian", + "scanId": "26e58204-…", + "headSha": "272b8f9…", + "diffScanId": "05460112-…", + "hasBaseline": true, + "baseBranch": "develop", + "artifactCount": 1897, + "alertCountRaw": 4636, + "includedActions": ["error", "monitor", "warn"], + "introducedCount": 0, + "preexistingCount": 43, + "alertsByType": [{ "type": "obfuscatedFile", "action": "warn", "count": 17 }], + "introduced": [], + "preexisting": [ + { + "name": "oauth", "version": "0.9.15", + "artifactId": 51509, "ancestors": ["next-auth@4.24.15"], + "direct": false, "dev": false, "overallScore": 0.8, + "worstAction": "warn", "worstSeverity": "high", + "findings": [{ "type": "obfuscatedFile", "action": "warn", "severity": "high", + "category": "supplyChainRisk", "count": 1, + "file": "dist/oauth.js", "fix": null }] + } + ], + "introducedTruncated": false, + "preexistingTruncated": true +} +``` + +Alert objects carry `action`, `actionSource`, `category`, `severity`, `type`, `key`, `file`, `start`, `end`, `fix` — verified against a real scan. `fix` is an **object**, not a string. Use `debug-sample: true` to log the real shape when adapting. + +## Usage + +### As a composite step + +```yaml +- id: app-gate + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-app-gate@v1 + with: + github-token: ${{ github.token }} + commit-sha: ${{ github.event.pull_request.head.sha }} + +- id: api-report + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-api-report@v1 + with: + socket-api-key: ${{ secrets.SOCKET_SECURITY_API_KEY }} + report-url: ${{ steps.app-gate.outputs.report-url }} + diff-scan-id: ${{ steps.diff-scan.outputs.id }} + base-branch: ${{ github.base_ref }} + head-sha: ${{ github.event.pull_request.head.sha }} +``` + +It reads `report-url` from the gate, so the gate must run first. + +### Via the reusable workflow + +```yaml +jobs: + pr-validation: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/js-pr-validation.yml@v1.x.x + with: + socket_enable_api_report: true # default + socket_api_include_actions: 'error,warn,monitor' # default + socket_api_fail_on_actions: 'error' # default '' — blocks nothing + secrets: inherit # carries SOCKET_SECURITY_API_KEY +``` + +## Permissions required + +```yaml +permissions: + contents: read +``` + +No GitHub scope beyond checkout — it talks to the Socket API, not to GitHub. + +## Third-party actions used + +None. It uses the preinstalled `python3` (stdlib only, no `pip install`) and `jq`. The token is read from the environment inside the Python process, never passed as an argument, so it never appears in the process table of a shared runner. diff --git a/src/security/socket-api-report/action.yml b/src/security/socket-api-report/action.yml new file mode 100644 index 00000000..76078b10 --- /dev/null +++ b/src/security/socket-api-report/action.yml @@ -0,0 +1,622 @@ +name: Socket API Report +description: Reads the Socket full scan for this commit and for the target branch, separating findings this pull request introduces from pre-existing tree debt. + +inputs: + socket-api-key: + description: 'Socket API token. Empty (the default) skips with a notice, so repositories without one are unaffected.' + required: false + default: '' + report-url: + description: >- + Socket dashboard URL of the scan to read, as exposed by socket-app-gate + (https://socket.dev/dashboard/org//sbom/). The org slug and scan id are parsed from it, + so no new scan is created and the App's own analysis is reused. + required: false + default: '' + org-slug: + description: 'Socket organization slug. Overrides the value parsed from report-url.' + required: false + default: '' + full-scan-id: + description: 'Full scan id for this commit. Overrides the value parsed from report-url.' + required: false + default: '' + base-branch: + description: >- + Target branch of the pull request. Its most recent full scan becomes the baseline, so findings already present + there are reported as pre-existing rather than as something this pull request introduced. Empty skips the + comparison, and everything is then reported as pre-existing. + required: false + default: '' + head-sha: + description: 'Head commit SHA, used to label the scan link in the pull request comment.' + required: false + default: '' + diff-scan-id: + description: >- + Socket diff scan id for this pull request. The App publishes it in its own comment; looking it up by + after_full_scan_id does not work, because the App diffs against a different full scan than the one its + Project Report check links to. Empty falls back to that lookup, then to no attribution. + required: false + default: '' + probe-package: + description: 'Diagnostic only: log whether this package name is present in the head scan.' + required: false + default: '' + include-actions: + description: >- + Comma-separated Socket alert actions to treat as findings. Socket marks roughly 99% of alerts as "ignore" + (capability observations such as envVars or filesystemAccess), so reporting unfiltered buries a handful of + findings in thousands of lines. + required: false + default: 'error,warn,monitor' + fail-on-actions: + description: >- + Comma-separated actions that make an **introduced** finding blocking. Pre-existing findings never block: + gating a pull request on inherited debt fails every pull request for something none of them caused, and + guarantees the gate gets switched off. Empty (the default) blocks nothing. + required: false + default: '' + max-rows: + description: 'Maximum package rows kept per section, highest action first' + required: false + default: '25' + findings-file: + description: 'Path where the extracted findings JSON is written for socket-reporter' + required: false + default: 'socket-api-findings.json' + debug-sample: + description: 'Log the alert object shape. Useful when adapting to an API change.' + required: false + default: 'false' + +outputs: + skipped: + description: 'true when no report was produced (no token, no resolvable scan, or an API error)' + value: ${{ steps.fetch.outputs.skipped }} + findings-file: + description: 'Path to the extracted findings JSON' + value: ${{ steps.fetch.outputs.findings_file }} + introduced-count: + description: 'Number of actioned findings this pull request introduces' + value: ${{ steps.fetch.outputs.introduced_count }} + preexisting-count: + description: 'Number of actioned findings already present on the target branch' + value: ${{ steps.fetch.outputs.preexisting_count }} + blocking-count: + description: 'Introduced findings whose action matches fail-on-actions. Zero when fail-on-actions is empty.' + value: ${{ steps.fetch.outputs.blocking_count }} + +runs: + using: composite + steps: + # ----------------- Fetch, classify & extract ----------------- + - name: Read Socket scans and classify findings + id: fetch + shell: bash + env: + SOCKET_API_KEY: ${{ inputs.socket-api-key }} + REPORT_URL: ${{ inputs.report-url }} + ORG_SLUG_IN: ${{ inputs.org-slug }} + SCAN_ID_IN: ${{ inputs.full-scan-id }} + BASE_BRANCH: ${{ inputs.base-branch }} + DIFF_SCAN_ID_IN: ${{ inputs.diff-scan-id }} + PROBE_PACKAGE: ${{ inputs.probe-package }} + HEAD_SHA: ${{ inputs.head-sha }} + INCLUDE_ACTIONS: ${{ inputs.include-actions }} + FAIL_ON_ACTIONS: ${{ inputs.fail-on-actions }} + MAX_ROWS: ${{ inputs.max-rows }} + FINDINGS_FILE: ${{ inputs.findings-file }} + DEBUG_SAMPLE: ${{ inputs.debug-sample }} + run: | + echo "findings_file=$FINDINGS_FILE" >> "$GITHUB_OUTPUT" + echo '{}' > "$FINDINGS_FILE" + + # Deliberately python, not curl. Cloudflare fingerprints the HTTP client + # in front of api.socket.dev and answers curl with a managed challenge — + # 403, an HTML interstitial, cf-mitigated: challenge — regardless of + # credentials, headers, auth form, endpoint or egress. Python's stdlib + # client is not challenged; it is the stack the official SDK uses, and the + # reason sfw reaches Socket from the very runner where curl is blocked. + python3 - <<'PYEOF' + import base64, json, os, sys, urllib.parse, urllib.request, urllib.error + + API = "https://api.socket.dev/v0" + out_path = os.environ["FINDINGS_FILE"] + + def emit(**kv): + with open(os.environ["GITHUB_OUTPUT"], "a") as fh: + for k, v in kv.items(): + fh.write(f"{k}={v}\n") + + def bail(msg, level="notice"): + print(f"::{level}::{msg}") + emit(skipped="true", introduced_count=0, preexisting_count=0, blocking_count=0) + sys.exit(0) + + token = os.environ.get("SOCKET_API_KEY", "") + if not token: + bail("Socket API report skipped — no Socket API token configured.") + + report_url = os.environ.get("REPORT_URL", "") + org = os.environ.get("ORG_SLUG_IN") or "" + scan = os.environ.get("SCAN_ID_IN") or "" + if not org or not scan: + # The dashboard URL the App publishes carries both identifiers, so the + # scan it already computed is reused rather than creating a second one. + parts = urllib.parse.urlparse(report_url).path.strip("/").split("/") + for marker, target in (("org", "org"), ("sbom", "scan")): + if marker in parts: + i = parts.index(marker) + val = parts[i + 1] if len(parts) > i + 1 else "" + if target == "org" and not org: + org = val + elif target == "scan" and not scan: + scan = val + if not org or not scan: + bail(f"Socket API report skipped — could not resolve org/scan id from '{report_url}'.", "warning") + + # Printed as full URLs, not bare ids: the Actions log viewer linkifies + # them, so a reader can open the exact scan or diff from the log instead + # of hand-assembling a dashboard path. + DASH = f"https://socket.dev/dashboard/org/{org}" + def scan_url(i): + return f"{DASH}/sbom/{i}" if i else "" + def diff_url(i): + return f"{DASH}/diff-scan/{i}?tab=dependencies" if i else "" + + auth = base64.b64encode(f"{token}:".encode()).decode("ascii") + HEADERS = { + "Authorization": f"Basic {auth}", + "User-Agent": "LerianStudio-github-actions-shared-workflows", + "accept": "application/x-ndjson", + } + + # urllib re-sends Authorization on redirect, so a redirect off api.socket.dev + # would hand the Socket token to another origin — or to plain HTTP. Same-host + # HTTPS redirects are still needed: on_duplicate=redirect answers a duplicate + # diff-scan creation with a 302 back to the existing resource. + API_HOST = urllib.parse.urlparse(API).netloc + + class SameHostRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + parts = urllib.parse.urlparse(newurl) + if parts.scheme != "https" or parts.netloc != API_HOST: + raise urllib.error.HTTPError( + newurl, code, + f"refused redirect to {parts.scheme}://{parts.netloc} " + f"(credential is only sent to https://{API_HOST})", + headers, fp) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + opener = urllib.request.build_opener(SameHostRedirect) + + def get(path): + req = urllib.request.Request(f"{API}/{path}", headers=HEADERS) + try: + with opener.open(req, timeout=180) as r: + return r.status, r.read(), (r.headers.get("cf-mitigated") or "") + except urllib.error.HTTPError as e: + return e.code, e.read(), (e.headers.get("cf-mitigated") or "") + except Exception as e: + return 0, f"{type(e).__name__}: {e}".encode(), "" + + def explain(status, body, mitigated): + if mitigated: + print(f"::warning::Socket API blocked by a Cloudflare challenge (HTTP {status}, cf-mitigated: {mitigated}).") + print("::warning::This is NOT a token or scope problem — the request never reached Socket's API.") + return + hint = { + 0: "the request did not complete (network or timeout)", + 401: "the token is invalid or revoked", + 403: "the token lacks the full-scans:list scope", + 404: "not found", + 429: "quota or rate limit exhausted", + }.get(status, "unexpected status") + print(f"::warning::Socket API returned HTTP {status} — {hint}.") + # The status alone is a guess; the body is where Socket says what is wrong. + print("--- Socket API response ---") + print(body[:1000].decode("utf-8", "replace")) + + keep = {a.strip().lower() for a in os.environ.get("INCLUDE_ACTIONS", "").split(",") if a.strip()} \ + or {"error", "warn", "monitor"} + fail_on = {a.strip().lower() for a in os.environ.get("FAIL_ON_ACTIONS", "").split(",") if a.strip()} + try: + max_rows = max(1, int(os.environ.get("MAX_ROWS") or 25)) + except ValueError: + print("::warning::max-rows is not a number; falling back to 25.") + max_rows = 25 + + def as_map(raw): + """Decode to a mapping, or {}. A JSON array or null is valid JSON and + would otherwise raise AttributeError on .get, killing the process before + it emits its outputs.""" + try: + v = json.loads(raw.decode("utf-8", "replace")) + except json.JSONDecodeError: + return {} + return v if isinstance(v, dict) else {} + + def as_results(raw): + """The results list, keeping only mapping entries.""" + v = as_map(raw).get("results") + return [r for r in v if isinstance(r, dict)] if isinstance(v, list) else [] + + def parse_scan(raw): + """NDJSON: one artifact per line plus a trailing {"_type":"scores"} event.""" + arts, scores = [], None + for line in raw.decode("utf-8", "replace").splitlines(): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + if obj.get("_type") == "scores": + scores = obj.get("score") or obj.get("scores") or obj + elif obj.get("name"): + arts.append(obj) + return arts, scores + + def pkg_name(art): + ns = art.get("namespace") + return f"{ns}/{art['name']}" if ns else art.get("name") + + status, body, mitigated = get(f"orgs/{org}/full-scans/{scan}") + if status != 200: + explain(status, body, mitigated) + # Advisory by construction: an unreachable API must never look like a + # finding, so every failure path reports zero and exits 0. + emit(skipped="true", introduced_count=0, preexisting_count=0, blocking_count=0) + sys.exit(0) + head_arts, head_scores = parse_scan(body) + + # Cross-check: is a package this pull request added actually in the scan? + # The diff reporting added=0 while the scan grew by exactly one artifact + # points at either the diff's before side or its semantics, and this + # separates the two. + probe = os.environ.get("PROBE_PACKAGE", "") + if probe: + hits = [f"{pkg_name(a)}@{a.get('version')}" for a in head_arts + if pkg_name(a) == probe] + print(f"Probe '{probe}' in {scan_url(scan)}: {hits or 'ABSENT'}") + + # The blame chain. A transitive finding is not actionable on its own — + # nobody installed brace-expansion on purpose — so the direct dependency + # that pulls it in is the part a reviewer can act on. topLevelAncestors is + # documented as carrying artifact references; whether it is populated in + # this organization's scans is logged rather than assumed. + by_id = {} + for art in head_arts: + if art.get("id") is not None: + by_id[str(art["id"])] = f"{pkg_name(art)}@{art.get('version')}" + + with_ancestors = [a for a in head_arts if a.get("topLevelAncestors")] + print(f"Blame chain: {len(with_ancestors)}/{len(head_arts)} artifact(s) carry topLevelAncestors; " + f"{len(by_id)} artifact id(s) available for resolution.") + if with_ancestors: + sample = with_ancestors[0] + raw = sample.get("topLevelAncestors") or [] + resolved = [by_id.get(str(r)) for r in raw[:3]] + print(f" sample {pkg_name(sample)}@{sample.get('version')}: raw={raw[:3]} " + f"resolved={resolved}") + + def ancestors_of(art): + """Direct dependencies that pull this artifact in, as name@version.""" + out = [] + for ref in (art.get("topLevelAncestors") or []): + name = by_id.get(str(ref)) + if name and name not in out: + out.append(name) + return out + + if os.environ.get("DEBUG_SAMPLE") == "true": + alerts = [a for art in head_arts for a in (art.get("alerts") or [])] + print("--- alert keys ---", sorted({k for a in alerts for k in a})) + print("--- first fix ---", json.dumps(next((a["fix"] for a in alerts if a.get("fix")), None))[:400]) + + # Attribution comes from Socket's own diff scan, not from a baseline this + # action picks. Two home-grown attempts failed instructively: + # + # 1. Comparing against the newest scan of the target branch produced 38 + # false attributions on a change to a single types package. + # 2. Filtering candidates by scan_state rejected all ten. Every scan in + # this organization persists as 'pending' or 'resolve', the head scan + # included, so scan_state carries no completion meaning here. + # + # Measuring package overlap then showed why no baseline was usable: the + # newest target-branch scan shared only 31.8% of this tree's + # package@version set despite holding 82% as many artifacts. The Socket App + # already computes the correct diff per pull request, so it is read instead + # of approximated — the diff scan is keyed on the head full scan, which + # makes the lookup exact rather than heuristic. + introduced_pkgs = set() + has_baseline = False + base_branch = os.environ.get("BASE_BRANCH") or "" + + # Starting point: the id the App published, if the workflow could read it. + # It is a hint, not a source of truth — the verification below decides. + diff_scan_id = os.environ.get("DIFF_SCAN_ID_IN") or None + if diff_scan_id: + print(f"Diff scan supplied by the Socket App comment: {diff_url(diff_scan_id)}") + + # The diff scan id from the App's comment is only usable if it is keyed on + # this commit's scan. The App publishes that comment asynchronously, so a + # job that starts first reads the diff for a *previous* push: observed with + # a newly added package present in the scan (1898 artifacts against 1897) + # while its diff reported added=0, filing the new package as pre-existing + # debt and letting fail-on-actions pass a pull request it should have + # blocked. Silent wrong attribution is the worst outcome here, so the diff + # is verified and, when stale, rebuilt rather than trusted. + def post(path): + req = urllib.request.Request(f"{API}/{path}", headers=HEADERS, method="POST") + try: + with opener.open(req, timeout=180) as r: + return r.status, r.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + except Exception as e: + return 0, f"{type(e).__name__}: {e}".encode() + + def diff_for_head(): + """The diff scan Socket already holds for this exact scan, if any.""" + q = urllib.parse.urlencode({"after_full_scan_id": scan, "per_page": 1}) + st, bd, _ = get(f"orgs/{org}/diff-scans?{q}") + if st != 200: + return None + res = as_results(bd) + return res[0].get("id") if res else None + + def base_scan_from(known_id): + """The before-side of a known diff scan, i.e. the base Socket itself chose. + + Preferred over the newest scan of the target branch: that one is only a + guess at the comparison point, and a stale guess inflates the added set, + which would attribute other people's packages to this pull request and + block it for them. + """ + # Paginated: the list is org-wide and ordered by recency, so a busy + # organization pushes this pull request's diff past any single page. + # Falling off the end returned None, which classified every finding as + # pre-existing and silently zeroed the blocking count. + cursor, pages = None, 0 + while pages < 10: + q = {"per_page": 100} + if cursor: + q["cursor"] = cursor + st, bd, _ = get(f"orgs/{org}/diff-scans?{urllib.parse.urlencode(q)}") + if st != 200: + return None + for r in as_results(bd): + if r.get("id") == known_id: + return r.get("before_full_scan_id") + cursor = as_map(bd).get("next_cursor") + pages += 1 + if not cursor: + break + print(f"::warning::Diff scan {known_id} not found in {pages} page(s) of the " + f"diff-scan list; its base could not be resolved.") + return None + + fresh_id = diff_for_head() + if fresh_id: + if fresh_id != diff_scan_id: + print(f"Using the diff scan keyed on this commit: {diff_url(fresh_id)}") + diff_scan_id = fresh_id + elif diff_scan_id: + before = base_scan_from(diff_scan_id) + if before: + # on_duplicate=redirect makes reruns idempotent instead of 409. + q = urllib.parse.urlencode({"before": before, "after": scan, + "on_duplicate": "redirect", + "description": "js-pr-validation supply chain gate"}) + st, bd = post(f"orgs/{org}/diff-scans/from-ids?{q}") + if st in (200, 201): + body = as_map(bd) + obj = body.get("diff_scan") or body + diff_scan_id = obj.get("id") or diff_scan_id + # The create response names both sides with commit and branch. + # Logged because the before side is inherited from the App's + # diff and has never been confirmed to be the target branch + # rather than an earlier head of this one. + def side(k): + v = obj.get(k) or {} + if isinstance(v, dict): + return (f"{scan_url(v.get('id'))} " + f"(branch {v.get('branch')}, " + f"commit {str(v.get('commit_hash'))[:7]})") + return str(v) + if not diff_scan_id: + print("::warning::The created diff scan carried no id.") + else: + print(f"Created diff scan: {diff_url(diff_scan_id)}") + print(f" before: {side('before_full_scan')}") + print(f" after: {side('after_full_scan')}") + else: + print(f"::warning::Could not create a diff scan (HTTP {st}). " + f"Needs the diff-scans:create scope.") + print(bd[:500].decode("utf-8", "replace")) + diff_scan_id = None + else: + print(f"::warning::The diff scan is not keyed on this commit's scan and its base " + f"could not be resolved: {diff_url(diff_scan_id)}") + diff_scan_id = None + + if not diff_scan_id: + print("::warning::No diff scan for this commit — nothing is attributed to this pull " + "request, findings are reported as pre-existing, and fail-on-actions cannot fire.") + + if diff_scan_id: + # omit_unchanged was passed to keep the payload small. The dashboard + # shows this exact diff populated while the API returned every bucket + # empty, so it is dropped: a smaller payload is worth nothing if it + # arrives empty. + st, bd, mit = get(f"orgs/{org}/diff-scans/{diff_scan_id}") + if st == 200: + diff = as_map(bd) + # added: new to this pull request. updated/replaced: same package at + # a different version or source, so its findings are this change's + # responsibility too. removed needs no reporting. + # Log the shape, not just the key names: the previous diagnostic + # confirmed added/updated/replaced exist and said nothing about + # them being empty arrays versus the payload living elsewhere. + def shape(v): + if isinstance(v, list): + return f"list[{len(v)}]" + if isinstance(v, dict): + return f"dict{{{','.join(sorted(v.keys())[:6])}}}" + return type(v).__name__ + print("Diff scan shape: " + ", ".join( + f"{k}={shape(v)}" for k, v in sorted(diff.items()))) + # The buckets are nested under diff_scan.artifacts, not at the top + # level. Reading the top level returned None for every bucket and + # therefore attributed nothing, while the dashboard rendered the + # same diff correctly. omit_unchanged=true made it worse by + # returning a flattened shape whose buckets were present and empty, + # which looked like a genuine "nothing changed". + # A malformed payload decodes to {} and would fall through to the + # last fallback, yielding empty buckets, has_baseline=true and a + # blocking count of zero — a gate that passes because it could not + # read the answer. Only a mapping that actually carries a bucket + # counts as a readable diff. + def buckets_of(d): + for cand in ((d.get("diff_scan") or {}).get("artifacts"), + d.get("artifacts"), d): + if isinstance(cand, dict) and any( + isinstance(cand.get(b), list) + for b in ("added", "updated", "replaced", "removed")): + return cand + return None + + buckets = buckets_of(diff) if isinstance(diff, dict) else None + if buckets is None: + print("::warning::The diff scan payload carried no readable " + "added/updated/replaced buckets; nothing is attributed to " + "this pull request.") + diff_scan_id = None + counts = {} + for bucket in ("added", "updated", "replaced") if buckets else (): + arts = buckets.get(bucket) or [] + counts[bucket] = len(arts) + for art in arts: + introduced_pkgs.add(f"{pkg_name(art)}@{art.get('version')}") + print("Diff buckets: " + ", ".join(f"{k}={v}" for k, v in counts.items()) + + f" — {len(introduced_pkgs)} package(s) attributed to this pull request.") + has_baseline = buckets is not None + else: + explain(st, bd, mit) + else: + print(f"::warning::No Socket diff scan found for full scan {scan}. Nothing can be " + f"attributed to this pull request, so every finding is reported as pre-existing.") + + # Kept for the report: which branch the comparison is against. + if not has_baseline: + print("::warning::No diff scan available — every finding is reported as pre-existing " + "and nothing is attributed to this pull request.") + + ACT_RANK = {"error": 0, "warn": 1, "monitor": 2} + SEV_RANK = {"critical": 0, "high": 1, "middle": 2, "medium": 2, "low": 3, "info": 4} + + introduced, preexisting, raw_alerts = [], [], 0 + for art in head_arts: + raw_alerts += len(art.get("alerts") or []) + name = pkg_name(art) + key_pkg = f"{name}@{art.get('version')}" + kept = [a for a in (art.get("alerts") or []) if (a.get("action") or "").lower() in keep] + if not kept: + continue + + # Collapse repeats: an alert fires per source location, so one type + # recurs many times for the same package. Rendering them raw produced + # the same envVars hundreds of times in a single table cell. + by_type = {} + for a in kept: + t = a.get("type") or a.get("key") or "unknown" + e = by_type.setdefault(t, {"type": t, "action": a.get("action") or "", + "severity": a.get("severity"), "count": 0, + "file": None, "fix": None}) + e["count"] += 1 + e["file"] = e["file"] or a.get("file") + e["fix"] = e["fix"] or a.get("fix") + + # A package is introduced when Socket's diff scan lists it as added, + # updated or replaced. Without a diff scan nothing is attributed, and + # everything is reported as pre-existing — no attribution is honest, a + # confident wrong one is not. + pkg_is_new = has_baseline and key_pkg in introduced_pkgs + for is_new in (True, False): + sel = list(by_type.values()) if is_new == pkg_is_new else [] + if not sel: + continue + sel.sort(key=lambda e: (ACT_RANK.get((e["action"] or "").lower(), 3), + SEV_RANK.get((e["severity"] or "info").lower(), 4))) + (introduced if is_new else preexisting).append({ + "name": name, "version": art.get("version"), + "artifactId": art.get("id"), + "ancestors": ancestors_of(art), + "direct": bool(art.get("direct")), "dev": bool(art.get("dev")), + "overallScore": (art.get("score") or {}).get("overall"), + "worstAction": sel[0]["action"], "worstSeverity": sel[0]["severity"], + "findings": sel, + }) + + def rank(rows): + rows.sort(key=lambda r: (ACT_RANK.get((r["worstAction"] or "").lower(), 3), + SEV_RANK.get((r["worstSeverity"] or "info").lower(), 4), + -len(r["findings"]))) + return rows + + introduced, preexisting = rank(introduced), rank(preexisting) + n_intro = sum(f["count"] for r in introduced for f in r["findings"]) + n_pre = sum(f["count"] for r in preexisting for f in r["findings"]) + # Only introduced findings can block. + blocking = sum(f["count"] for r in introduced for f in r["findings"] + if (f["action"] or "").lower() in fail_on) if fail_on else 0 + + by_type = {} + for r in introduced + preexisting: + for f in r["findings"]: + e = by_type.setdefault(f["type"], {"type": f["type"], "action": f["action"], "count": 0}) + e["count"] += f["count"] + alerts_by_type = sorted(by_type.values(), + key=lambda e: (ACT_RANK.get((e["action"] or "").lower(), 3), -e["count"])) + + with open(out_path, "w") as fh: + json.dump({ + "orgSlug": org, + "scanId": scan, + "headSha": os.environ.get("HEAD_SHA", ""), + "scanScores": head_scores, + "artifactCount": len(head_arts), + "alertCountRaw": raw_alerts, + "includedActions": sorted(keep), + "baseBranch": base_branch, + "diffScanId": diff_scan_id, + "hasBaseline": has_baseline, + "introducedCount": n_intro, + "preexistingCount": n_pre, + "alertsByType": alerts_by_type, + # The introduced set is never truncated: it is what this pull + # request answers for, and a monitor-level row silently dropped + # below the cap is exactly the one someone needed to see. + "introduced": introduced, + "preexisting": preexisting[:max_rows], + "introducedTruncated": False, + "preexistingHidden": max(0, len(preexisting) - max_rows), + "preexistingHiddenFindings": sum( + f["count"] for r in preexisting[max_rows:] for f in r["findings"]), + }, fh) + + emit(skipped="false", introduced_count=n_intro, + preexisting_count=n_pre, blocking_count=blocking) + + print(f"Scan {scan_url(scan)}\n {len(head_arts)} artifact(s), {raw_alerts} raw alert(s).") + print(f" actioned: {n_intro} introduced by this PR, {n_pre} pre-existing on " + f"'{base_branch or ''}'.") + for e in alerts_by_type[:25]: + print(f" [{e['action']}] {e['count']}x {e['type']}") + if blocking: + print(f"::error::{blocking} introduced finding(s) match the blocking policy " + f"(fail-on-actions: {os.environ.get('FAIL_ON_ACTIONS')}).") + PYEOF diff --git a/src/security/socket-app-gate/README.md b/src/security/socket-app-gate/README.md new file mode 100644 index 00000000..00221382 --- /dev/null +++ b/src/security/socket-app-gate/README.md @@ -0,0 +1,103 @@ + + + + + +
Lerian

socket-app-gate

+ +Composite action that waits for the [Socket Security GitHub App](https://github.com/marketplace/socket-security) check runs on a commit and turns their conclusions into a verdict the workflow controls. + +The App already does the dependency-graph analysis and posts `Socket Security: Project Report` and `Socket Security: Pull Request Alerts`. What it does not do is **enforce**: its checks land as `success`, `neutral` or `skipped`, and neither `neutral` nor `skipped` blocks a pull request under branch protection. This action closes that gap without duplicating the analysis, without an API token and without consuming Socket quota — the alternative, running `socketcli` in CI, would re-scan the same dependency graph and post a second, competing report. + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `github-token` | Token used to read check runs on the commit | **Yes** | — | +| `commit-sha` | Commit whose check runs are inspected. Must be the PR **head** SHA, not the merge SHA | **Yes** | — | +| `app-slug` | GitHub App slug that owns the checks | No | `socket-security` | +| `timeout-seconds` | How long to wait for the checks to complete | No | `300` | +| `poll-interval-seconds` | Delay between polls | No | `15` | +| `fail-on-findings` | Fail when a check concludes adversely | No | `true` | +| `on-inconclusive` | `block` or `warn` when the App reached no verdict | No | `block` | +| `on-missing-app` | `warn` or `block` when the App published no checks at all | No | `warn` | +| `findings-file` | Path where the JSON verdict is written for `socket-reporter` | No | `socket-app-findings.json` | + +## Outputs + +| Output | Description | +|---|---| +| `verdict` | `pass` \| `findings` \| `inconclusive` \| `missing` | +| `report-url` | Link to the Socket dashboard report, when a check exposed one | +| `findings-file` | Path to the JSON verdict file | + +## The four verdicts + +| Verdict | When | Default behaviour | +|---|---|---| +| `pass` | Every App check completed with a non-adverse conclusion | Passes | +| `findings` | A check concluded `failure`, `action_required`, `cancelled` or `timed_out` | **Fails** (`fail-on-findings`) | +| `inconclusive` | Checks exist but concluded `neutral`/`skipped`, or the wait timed out | **Fails** (`on-inconclusive`) | +| `missing` | The App published no checks on this commit | Warns (`on-missing-app`) | + +`inconclusive` and `missing` are deliberately separate. They look similar and mean opposite things: + +- **`inconclusive`** is the App declining to judge. On a pull request with merge conflicts the `Pull Request Alerts` check reports *"Skipped un-mergeable pull request"* — no diff against the target branch was analysed at all. Treating that as clean would let exactly the wrong pull request through, so the default blocks and names the likely cause. +- **`missing`** is simply a repository without the App installed. Blocking there would break every such repository for a reason its authors cannot act on, so the default warns and relies on install-time protection from [`setup-node-guarded`](../../setup/setup-node-guarded/README.md). + +## Failure modes it refuses to swallow + +An unreadable check-runs API returns no checks, which would be classified as `missing` and merely warn — so a job without the `checks: read` scope would pass silently while gating nothing. The action therefore treats a non-zero `gh` exit as a hard error naming the likely cause, instead of folding it into an empty result. + +For the same reason the caller must grant `checks: read`. The `js-pr-validation` umbrella declares it at both workflow and job level, but a caller that pins its own `permissions:` block has to include it: a reusable workflow's permissions are intersected with the caller's, never expanded. + +## Why it polls + +The App publishes its checks asynchronously, so this job routinely starts before they exist. The action polls until every check owned by `app-slug` has completed, or until `timeout-seconds`. A timeout is classified as `inconclusive` — never as success. + +## Usage + +### As a composite step + +```yaml +jobs: + socket: + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + checks: read + steps: + - name: Socket App Gate + id: app-gate + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-app-gate@v1 + with: + github-token: ${{ github.token }} + commit-sha: ${{ github.event.pull_request.head.sha }} +``` + +`commit-sha` must be the head SHA: for a `pull_request` event `github.sha` is the merge commit, which carries no App checks. + +### Via the reusable workflow + +```yaml +jobs: + pr-validation: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/js-pr-validation.yml@v1.x.x + with: + socket_enable_app_gate: true # default + socket_app_on_inconclusive: 'block' # default + socket_app_on_missing: 'warn' # default + secrets: inherit +``` + +## Permissions required + +```yaml +permissions: + contents: read + checks: read +``` + +## Third-party actions used + +None. The action calls the GitHub REST check-runs API through the preinstalled `gh` CLI, so there is no dependency to pin. diff --git a/src/security/socket-app-gate/action.yml b/src/security/socket-app-gate/action.yml new file mode 100644 index 00000000..59aba9af --- /dev/null +++ b/src/security/socket-app-gate/action.yml @@ -0,0 +1,196 @@ +name: Socket App Gate +description: Waits for the Socket Security GitHub App check runs on a commit and turns their advisory conclusions into an enforceable verdict. + +inputs: + github-token: + description: 'Token used to read check runs on the commit' + required: true + commit-sha: + description: 'Commit SHA whose check runs are inspected. Should be the pull request head SHA, not the merge SHA.' + required: true + app-slug: + description: 'GitHub App slug that owns the checks to gate on' + required: false + default: 'socket-security' + timeout-seconds: + description: 'How long to wait for the App checks to complete before giving up' + required: false + default: '300' + poll-interval-seconds: + description: 'Delay between check-run polls' + required: false + default: '15' + fail-on-findings: + description: 'Fail the step when an App check concludes as failure, action_required, cancelled or timed_out' + required: false + default: 'true' + on-inconclusive: + description: >- + What to do when the App ran but reached no verdict (neutral, skipped, or the wait timed out): "block" treats + it as a failure because "could not analyse" is not "clean", "warn" annotates and passes. + required: false + default: 'block' + on-missing-app: + description: >- + What to do when the App published no checks at all, which is what a repository without the Socket App + installed looks like: "warn" (the default) keeps such repositories green, "block" requires the App. + required: false + default: 'warn' + findings-file: + description: 'Path where the JSON verdict is written for consumption by socket-reporter' + required: false + default: 'socket-app-findings.json' + +outputs: + verdict: + description: 'pass | findings | inconclusive | missing' + value: ${{ steps.evaluate.outputs.verdict }} + report-url: + description: 'Link to the Socket dashboard report, when one of the checks exposed it' + value: ${{ steps.evaluate.outputs.report_url }} + findings-file: + description: 'Path to the JSON verdict file' + value: ${{ steps.evaluate.outputs.findings_file }} + +runs: + using: composite + steps: + # ----------------- Wait for the App ----------------- + - name: Wait for Socket App checks + id: wait + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + REPO: ${{ github.repository }} + SHA: ${{ inputs.commit-sha }} + APP_SLUG: ${{ inputs.app-slug }} + TIMEOUT: ${{ inputs.timeout-seconds }} + INTERVAL: ${{ inputs.poll-interval-seconds }} + run: | + # The App publishes its checks asynchronously, so this job can easily + # start before they exist. Poll until every check owned by the App has + # completed, or until the timeout — a timeout is reported as pending and + # classified downstream, never silently as success. + RAW_FILE="$RUNNER_TEMP/socket-app-checks.json" + DEADLINE=$(( $(date +%s) + TIMEOUT )) + + while :; do + API_ERR="$RUNNER_TEMP/socket-app-api.err" + API_RC=0 + gh api "repos/$REPO/commits/$SHA/check-runs?per_page=100" \ + --jq "[.check_runs[] | select(.app.slug == \"$APP_SLUG\") + | {name, status, conclusion, url: .details_url, + title: (.output.title // \"\"), + summary: (.output.summary // \"\")}]" > "$RAW_FILE" 2>"$API_ERR" || API_RC=$? + + # An unreadable API must never look like an empty result: "no checks" + # is classified downstream as "App not installed", which only warns. + # A missing checks:read scope would then pass silently. + if [ "$API_RC" -ne 0 ]; then + echo "::error::Could not read check runs for $REPO@$SHA (gh exited $API_RC). Does the job grant 'checks: read'?" + cat "$API_ERR" >&2 || true + exit 1 + fi + + TOTAL=$(jq 'length' "$RAW_FILE") + PENDING=$(jq '[.[] | select(.status != "completed")] | length' "$RAW_FILE") + + # Completing on the first all-done snapshot is a time-of-check race: the + # App creates its checks independently, so Project Report can finish + # before Pull Request Alerts exists, and the gate would pass having never + # seen the adverse one. Require the set to be complete AND unchanged + # across two consecutive polls, which needs no hardcoded check names. + SIG=$(jq -r '[.[].name] | sort | join(",")' "$RAW_FILE") + if [ "$TOTAL" -gt 0 ] && [ "$PENDING" -eq 0 ]; then + if [ "$SIG" = "${PREV_SIG:-}" ]; then + echo "timed_out=false" >> "$GITHUB_OUTPUT" + break + fi + echo "All $TOTAL check(s) complete; confirming the set is stable before deciding." + PREV_SIG="$SIG" + sleep "$INTERVAL" + continue + fi + PREV_SIG="" + if [ "$(date +%s)" -ge "$DEADLINE" ]; then + echo "::warning::Timed out after ${TIMEOUT}s waiting for '$APP_SLUG' checks (found $TOTAL, $PENDING still running)." + echo "timed_out=true" >> "$GITHUB_OUTPUT" + break + fi + echo "Waiting for $APP_SLUG checks — found $TOTAL, $PENDING pending. Retrying in ${INTERVAL}s." + sleep "$INTERVAL" + done + + echo "raw_file=$RAW_FILE" >> "$GITHUB_OUTPUT" + jq -r '.[] | " \(.name): \(.status)/\(.conclusion // "-")"' "$RAW_FILE" + + # ----------------- Verdict ----------------- + - name: Evaluate Socket App verdict + id: evaluate + shell: bash + env: + RAW_FILE: ${{ steps.wait.outputs.raw_file }} + TIMED_OUT: ${{ steps.wait.outputs.timed_out }} + APP_SLUG: ${{ inputs.app-slug }} + FAIL_ON_FINDINGS: ${{ inputs.fail-on-findings }} + ON_INCONCLUSIVE: ${{ inputs.on-inconclusive }} + ON_MISSING_APP: ${{ inputs.on-missing-app }} + FINDINGS_FILE: ${{ inputs.findings-file }} + run: | + TOTAL=$(jq 'length' "$RAW_FILE") + # failure/action_required/cancelled/timed_out are real adverse verdicts. + # neutral/skipped mean the App declined to judge — on this repo that is + # what an un-mergeable pull request produces, and it must not read as clean. + BAD=$(jq '[.[] | select(.conclusion == "failure" or .conclusion == "action_required" + or .conclusion == "cancelled" or .conclusion == "timed_out")] | length' "$RAW_FILE") + UNDECIDED=$(jq '[.[] | select(.conclusion == "neutral" or .conclusion == "skipped")] | length' "$RAW_FILE") + REPORT_URL=$(jq -r '[.[] | select(.url != null and (.url | test("dashboard")))] | .[0].url // ""' "$RAW_FILE") + + if [ "$TOTAL" -eq 0 ]; then + VERDICT=missing + elif [ "$BAD" -gt 0 ]; then + VERDICT=findings + elif [ "$TIMED_OUT" = "true" ] || [ "$UNDECIDED" -gt 0 ]; then + VERDICT=inconclusive + else + VERDICT=pass + fi + + echo "verdict=$VERDICT" >> "$GITHUB_OUTPUT" + echo "report_url=$REPORT_URL" >> "$GITHUB_OUTPUT" + echo "findings_file=$FINDINGS_FILE" >> "$GITHUB_OUTPUT" + + jq --arg verdict "$VERDICT" --arg app "$APP_SLUG" --arg url "$REPORT_URL" \ + --argjson timedOut "$([ "$TIMED_OUT" = "true" ] && echo true || echo false)" \ + '{app: $app, verdict: $verdict, reportUrl: $url, timedOut: $timedOut, checks: .}' \ + "$RAW_FILE" > "$FINDINGS_FILE" + + case "$VERDICT" in + pass) + echo "Socket App reported no adverse findings across $TOTAL check(s)." + ;; + missing) + MSG="No '$APP_SLUG' check found on this commit — the Socket GitHub App does not appear to be installed." + if [ "$ON_MISSING_APP" = "block" ]; then + echo "::error::$MSG" + exit 1 + fi + echo "::warning::$MSG Install-time protection still applies via Socket Firewall." + ;; + inconclusive) + MSG="Socket App reached no verdict ($UNDECIDED neutral/skipped check(s), timed out: $TIMED_OUT). This is not the same as clean." + if [ "$ON_INCONCLUSIVE" = "block" ]; then + echo "::error::$MSG Resolve the cause (a conflicted pull request is the common one) and re-run." + exit 1 + fi + echo "::warning::$MSG" + ;; + findings) + MSG="Socket App reported $BAD adverse check(s)." + if [ "$FAIL_ON_FINDINGS" = "true" ]; then + echo "::error::$MSG Review the Socket report${REPORT_URL:+ at $REPORT_URL}." + exit 1 + fi + echo "::warning::$MSG (advisory — fail-on-findings is false)" + ;; + esac diff --git a/src/security/socket-firewall/README.md b/src/security/socket-firewall/README.md new file mode 100644 index 00000000..743b37b9 --- /dev/null +++ b/src/security/socket-firewall/README.md @@ -0,0 +1,119 @@ + + + + + +
Lerian

socket-firewall

+ +Composite action that installs [Socket Firewall](https://github.com/SocketDev/sfw-free) (free edition, no account required) and then runs the project's dependency install through it as `sfw npm ci` (or the `yarn`/`pnpm` equivalent). `sfw` intercepts the package manager's network traffic, inspects each package as it is fetched and refuses the ones whose behavior matches a supply-chain attack — malicious install scripts, credential exfiltration, typosquats, hijacked patch releases. + +This is the install-time layer. It refuses a malicious package before it reaches disk, but reports nothing about what it allowed and enforces no policy of its own — for the verdict see [`socket-app-gate`](../socket-app-gate/README.md), and for per-package findings see [`socket-api-report`](../socket-api-report/README.md). + +> Most installs in the pipeline go through [`setup-node-guarded`](../../setup/setup-node-guarded/README.md), which wraps this same firewall around each analysis job's install. This composite is the dedicated one in the `socket` job, used as the reporting source. + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `package-manager` | Package manager used to install dependencies (`npm`, `yarn`, `pnpm`) | No | `npm` | +| `node-version` | Node.js version used for the guarded install | No | `22` | +| `working-dir` | Directory holding the `package.json` and lockfile | No | `.` | +| `firewall-version` | Socket Firewall binary version. `latest` tracks the newest release | No | `latest` | +| `job-summary` | Socket Firewall job summary verbosity (`all`, `errors`, `none`) | No | `all` | +| `use-cache` | Cache the `sfw` binary between runs. Unrelated to the package-manager cache, which is always purged | No | `true` | +| `github-token` | Token used by Socket Firewall to download its binaries. Empty falls back to `github.token` | No | `''` | +| `fail-on-block` | Fail the step when Socket Firewall blocks a package | No | `true` | +| `dry-run` | Print the resolved configuration and never fail the step | No | `false` | + +## Outputs + +| Output | Description | +|---|---| +| `skipped` | `true` when no lockfile was found in `working-dir`, so nothing was installed or inspected | +| `blocked` | `true` when the install failed and the output carries a Socket Firewall block marker | +| `install-exit-code` | Exit code returned by the package manager install | +| `report-path` | Path to the Socket Firewall report JSON produced by the underlying action | + +## Two things that would silently defeat this + +Socket Firewall free works as a wrapper: it only sees what the package manager sends over the network, and only for processes it actually parents. Two easy mistakes turn the whole check into a no-op that still reports success. + +**1. Running the package manager without the `sfw` prefix.** The pinned release (`v1.3.2`) installs the `sfw` binary onto `PATH` and nothing else — it declares no `shims` input and creates no wrapper scripts. A bare `npm ci` therefore never involves `sfw`. Every command here is prefixed, which is the only supported form in that release. + +**2. Installing from a warm package-manager cache.** Per Socket's documentation, "if there are no network requests, as is the case when artifacts are cached locally, there is nothing for `sfw` to block". This action therefore does **not** pass `cache:` to `actions/setup-node`, and purges the selected package manager's cache (`npm cache clean --force`, `yarn cache clean`, `pnpm store prune`) immediately before the install. A pre-warmed runner image is otherwise enough to hide a malicious package. + +The `use-cache` input is unrelated to either: it caches the `sfw` binary itself, not packages. + +## No lockfile, no run + +Before touching the toolchain the action checks for the lockfile matching `package-manager` (`package-lock.json`, `yarn.lock` or `pnpm-lock.yaml`) inside `working-dir`. If it is absent, everything is skipped with a `::warning::` and `skipped=true`. + +That keeps monorepos — whose manifests live under a subdirectory — from going red on a configuration gap: they get a warning pointing at `working-dir` instead of a failed install. + +## How the verdict is decided + +A blocked package surfaces as a non-zero exit from the package manager running under `sfw`, so a failing install is either a Socket block or an ordinary dependency resolution problem. The action separates the two by looking for a Socket block marker in the install output: + +| Install exit | Block marker | `fail-on-block` | Result | +|---|---|---|---| +| `0` | — | any | Step passes | +| non-zero | present | `true` | `::error::` + step fails | +| non-zero | present | `false` | `::warning::` + step passes | +| non-zero | absent | any | `::error::` + step fails | + +A broken install is **always** a failure. Swallowing it would hide a genuine problem behind a security toggle, so `fail-on-block: false` only softens confirmed Socket blocks. + +With `dry-run: true` the install still runs through the firewall and everything is reported, but the step never fails. + +## Usage + +### As a composite step + +```yaml +jobs: + socket: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Socket Firewall + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-firewall@v1 + with: + package-manager: 'npm' + node-version: '22' + working-dir: '.' +``` + +### Via the reusable workflow + +Socket Firewall is wired into the `js-pr-validation` umbrella and enabled by default: + +```yaml +jobs: + pr-validation: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/js-pr-validation.yml@v1.x.x + with: + run_socket: true # default + socket_enable_firewall: true # default + socket_fail_on_block: true # default + secrets: inherit +``` + +## Permissions required + +```yaml +permissions: + contents: read +``` + +## Third-party actions used + +| Action | Why | +|---|---| +| [`SocketDev/action`](https://github.com/SocketDev/action) | Vendor-maintained installer for the `sfw` binary, including version resolution and checksum verification. Reimplementing the download inline would mean hand-rolling that verification. Pinned by commit SHA (`v1.3.2`). | +| [`actions/setup-node`](https://github.com/actions/setup-node) | Provides the Node.js runtime the package manager needs. Used **without** its cache feature, for the reason above. | +| [`pnpm/action-setup`](https://github.com/pnpm/action-setup) | `pnpm` is not preinstalled on the runner; only used when `package-manager: pnpm`. | + +This composite adds what the vendor action does not cover: the toolchain, the cache purge, the `sfw`-prefixed install, block-versus-failure attribution and the advisory mode. diff --git a/src/security/socket-firewall/action.yml b/src/security/socket-firewall/action.yml new file mode 100644 index 00000000..c6e5ed14 --- /dev/null +++ b/src/security/socket-firewall/action.yml @@ -0,0 +1,246 @@ +name: Socket Firewall +description: Installs Socket Firewall (free edition) and runs the project dependency install through it, blocking malicious packages at install time. + +inputs: + package-manager: + description: 'Package manager used to install dependencies (npm, yarn, pnpm)' + required: false + default: 'npm' + node-version: + description: 'Node.js version used for the guarded install' + required: false + default: '22' + working-dir: + description: 'Directory holding the package.json and lockfile' + required: false + default: '.' + firewall-version: + description: 'Socket Firewall binary version. Empty or "latest" tracks the newest release.' + required: false + default: 'latest' + job-summary: + description: "Socket Firewall job summary verbosity (all, errors, none)" + required: false + default: 'all' + use-cache: + description: 'Cache the Socket Firewall binaries between runs' + required: false + default: 'true' + github-token: + description: 'Token used by Socket Firewall to download its binaries' + required: false + default: '' + fail-on-block: + description: 'Fail the step when Socket Firewall blocks a package. Set to false to report the block as a warning instead.' + required: false + default: 'true' + dry-run: + description: 'Print the resolved configuration and never fail the step' + required: false + default: 'false' + +outputs: + skipped: + description: 'true when no lockfile was found in working-dir, so nothing was installed or inspected' + value: ${{ steps.guard.outputs.skipped }} + blocked: + description: 'true when the Socket Firewall report records at least one blocked package' + value: ${{ steps.evaluate.outputs.blocked }} + blocked-count: + description: 'Number of packages Socket Firewall refused' + value: ${{ steps.evaluate.outputs.blocked_count }} + findings-file: + description: 'Path to the Socket Firewall report JSON, for consumption by socket-reporter' + value: ${{ steps.evaluate.outputs.findings_file }} + install-exit-code: + description: 'Exit code returned by the package manager install' + value: ${{ steps.install.outputs.exit_code }} + report-path: + description: 'Path to the Socket Firewall report JSON produced by the underlying action' + value: ${{ steps.firewall.outputs.firewall-path-report }} + +runs: + using: composite + steps: + # ----------------- Guard ----------------- + - name: Resolve lockfile presence + id: guard + shell: bash + env: + PKG_MANAGER: ${{ inputs.package-manager }} + WORKING_DIR: ${{ inputs.working-dir }} + run: | + # Without a lockfile there is nothing to install and therefore nothing to + # inspect. Skipping with a warning keeps monorepos — whose manifests live + # under a subdirectory — from failing until they set working-dir, instead + # of turning the check red on a configuration gap. + case "$PKG_MANAGER" in + yarn) LOCKFILE='yarn.lock' ;; + pnpm) LOCKFILE='pnpm-lock.yaml' ;; + *) LOCKFILE='package-lock.json' ;; + esac + echo "lockfile=$LOCKFILE" >> "$GITHUB_OUTPUT" + + if [ -f "$WORKING_DIR/$LOCKFILE" ]; then + echo "skipped=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + echo "skipped=true" >> "$GITHUB_OUTPUT" + echo "::warning::Socket Firewall skipped — no $LOCKFILE found in '$WORKING_DIR'. Point working-dir at the directory holding the lockfile." + + # ----------------- Toolchain ----------------- + - name: Set up pnpm + if: steps.guard.outputs.skipped != 'true' && inputs.package-manager == 'pnpm' + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: ${{ inputs.working-dir }}/package.json + + # Deliberately no `cache:` here. Socket Firewall free works by intercepting + # the package manager's network requests, and per its documentation "if there + # are no network requests, as is the case when artifacts are cached locally, + # there is nothing for sfw to block". Restoring a package-manager cache would + # let cached tarballs install uninspected. Dropping `cache:` also keeps this + # job from writing a post-run cache entry that other jobs would restore. + - name: Setup Node.js + if: steps.guard.outputs.skipped != 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: ${{ inputs.node-version }} + + # ----------------- Socket Firewall ----------------- + - name: Dry run summary + if: steps.guard.outputs.skipped != 'true' && inputs.dry-run == 'true' + shell: bash + env: + PKG_MANAGER: ${{ inputs.package-manager }} + WORKING_DIR: ${{ inputs.working-dir }} + FIREWALL_VERSION: ${{ inputs.firewall-version }} + FAIL_ON_BLOCK: ${{ inputs.fail-on-block }} + run: | + echo "::notice::DRY RUN — nothing is installed and no package is fetched" + echo " package manager : $PKG_MANAGER" + echo " working dir : $WORKING_DIR" + echo " firewall version : $FIREWALL_VERSION" + echo " fail on block : $FAIL_ON_BLOCK (would apply outside dry-run)" + + - name: Install Socket Firewall + id: firewall + if: steps.guard.outputs.skipped != 'true' && inputs.dry-run != 'true' + uses: SocketDev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2 + with: + mode: firewall-free + firewall-version: ${{ inputs.firewall-version }} + # 'none' is coerced away: the pinned action exports SFW_JSON_REPORT_PATH + # only when job-summary != none, and without the report a real block is + # misreported as an ordinary install failure. + job-summary: ${{ inputs.job-summary == 'none' && 'errors' || inputs.job-summary }} + use-cache: ${{ inputs.use-cache }} + github-token: ${{ inputs.github-token != '' && inputs.github-token || github.token }} + + # ----------------- Guarded install ----------------- + - name: Clear package manager cache + if: steps.guard.outputs.skipped != 'true' && inputs.dry-run != 'true' + shell: bash + env: + PKG_MANAGER: ${{ inputs.package-manager }} + run: | + # sfw can only inspect what crosses the network. Anything already in the + # package manager's cache installs without a request and therefore + # uninspected, so the cache is purged first — the exact remedy Socket + # documents. A pre-warmed runner image is enough to hide a package + # otherwise. Failures here are non-fatal: an empty cache is also fine. + case "$PKG_MANAGER" in + yarn) yarn cache clean || true ;; + pnpm) pnpm store prune || true ;; + *) npm cache clean --force || true ;; + esac + + - name: Install dependencies through Socket Firewall + id: install + if: steps.guard.outputs.skipped != 'true' && inputs.dry-run != 'true' + shell: bash + working-directory: ${{ inputs.working-dir }} + env: + PKG_MANAGER: ${{ inputs.package-manager }} + run: | + # Every command is prefixed with sfw so the package manager runs as its + # child and sfw sees the traffic. This is the only supported form in the + # pinned release — it exposes no shims input, so a bare `npm ci` would + # install completely uninspected while still reporting success. + # The exit code is captured instead of failing here so the evaluate step + # can tell a Socket block apart from an ordinary install failure. + LOG_FILE="$RUNNER_TEMP/socket-firewall-install.log" + EXIT_CODE=0 + set -o pipefail + case "$PKG_MANAGER" in + yarn) sfw yarn install --frozen-lockfile 2>&1 | tee "$LOG_FILE" || EXIT_CODE=$? ;; + pnpm) sfw pnpm install --frozen-lockfile 2>&1 | tee "$LOG_FILE" || EXIT_CODE=$? ;; + *) sfw npm ci 2>&1 | tee "$LOG_FILE" || EXIT_CODE=$? ;; + esac + echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" + echo "log_file=$LOG_FILE" >> "$GITHUB_OUTPUT" + + - name: Evaluate Socket Firewall result + id: evaluate + if: always() && steps.guard.outputs.skipped != 'true' && inputs.dry-run != 'true' + shell: bash + env: + EXIT_CODE: ${{ steps.install.outputs.exit_code }} + REPORT_PATH: ${{ steps.firewall.outputs.firewall-path-report }} + FINDINGS_FILE: socket-firewall-findings.json + FAIL_ON_BLOCK: ${{ inputs.fail-on-block }} + run: | + # Socket Firewall always writes a JSON report, whose schema (verified + # against the v1.15.0 binary) is: + # {"blocked":[{"name","namespace","version","purlString","registryFqdn", + # "alerts":[{"alert","action","actionSource","inputPurl"}]}]|null, + # "parseFail":[{"registryFqdn","urlPath"}]|null} + # Reading it is deterministic; the previous approach grepped the install + # output for block wording, which could misattribute a real block as an + # ordinary failure and report blocked=false on a genuine finding. + EXIT_CODE="${EXIT_CODE:-1}" + echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" + + BLOCKED=false + BLOCKED_COUNT=0 + REPORT="${REPORT_PATH:-$SFW_JSON_REPORT_PATH}" + if [ -n "$REPORT" ] && [ -f "$REPORT" ]; then + BLOCKED_COUNT=$(jq '(.blocked // []) | length' "$REPORT" 2>/dev/null || echo 0) + PARSE_FAIL=$(jq '(.parseFail // []) | length' "$REPORT" 2>/dev/null || echo 0) + [ "$BLOCKED_COUNT" -gt 0 ] && BLOCKED=true + if [ "$PARSE_FAIL" -gt 0 ]; then + echo "::warning::Socket Firewall could not parse $PARSE_FAIL request(s); those packages were not inspected." + fi + cp "$REPORT" "$FINDINGS_FILE" 2>/dev/null || echo '{}' > "$FINDINGS_FILE" + else + echo '{}' > "$FINDINGS_FILE" + if [ "$EXIT_CODE" -ne 0 ]; then + # No report to read on a failed guarded install: refuse to call it clean. + echo "::warning::Socket Firewall report not found at '${REPORT:-}'; cannot attribute the install failure." + fi + fi + echo "blocked=$BLOCKED" >> "$GITHUB_OUTPUT" + echo "blocked_count=$BLOCKED_COUNT" >> "$GITHUB_OUTPUT" + echo "findings_file=$FINDINGS_FILE" >> "$GITHUB_OUTPUT" + + if [ "$BLOCKED" = "true" ]; then + echo "Socket Firewall blocked $BLOCKED_COUNT package(s):" + jq -r '(.blocked // [])[] | " \(.purlString) [\((.alerts // []) | map(.alert) | join(", "))]"' "$REPORT" || true + fi + + if [ "$EXIT_CODE" -eq 0 ] && [ "$BLOCKED" = "false" ]; then + echo "Socket Firewall allowed every package in the lockfile." + exit 0 + fi + + if [ "$BLOCKED" = "true" ]; then + if [ "$FAIL_ON_BLOCK" = "true" ]; then + echo "::error::Socket Firewall blocked $BLOCKED_COUNT package(s) during install." + exit 1 + fi + echo "::warning::Socket Firewall blocked $BLOCKED_COUNT package(s) (advisory — fail-on-block is false)." + exit 0 + fi + + echo "::error::Dependency install failed with exit code $EXIT_CODE (no Socket Firewall block recorded in the report)." + exit 1 diff --git a/src/security/socket-reporter/README.md b/src/security/socket-reporter/README.md new file mode 100644 index 00000000..b92efe22 --- /dev/null +++ b/src/security/socket-reporter/README.md @@ -0,0 +1,146 @@ + + + + + +
Lerian

socket-reporter

+ +Composite action that posts Socket supply-chain **findings** as a single upserted pull request comment. + +## Findings only + +Whether the scan ran, which App checks passed, and how many alerts were filtered out are operational facts. They belong in the job log and in the `Socket` status check, not in a comment people are asked to read. An earlier version led with a Stage/Status/Blocking table reporting `✅ Clean` for each layer — accurate, and pure noise on the overwhelming majority of pull requests. + +What that means concretely: + +| Not in the comment | Where it lives | +|---|---| +| "Socket Firewall allowed every package" | Job log | +| App check conclusions | The App's own checks | +| "42 of 4636 alerts carry an action" | Job log | +| An unparsed request (`parseFail`) — a coverage gap | Job log warning | +| App verdict `inconclusive` / `missing` | Job log + the `Socket` check going red | + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `github-token` | Token used to post the comment | **Yes** | — | +| `app-name` | Name in the heading and the comment marker | **Yes** | — | +| `comment-when` | `findings` posts only when there is something to act on; `always` posts every run | No | `findings` | +| `firewall-blocked` | `true` when Socket Firewall refused a package | No | `false` | +| `firewall-fail-on-block` | Whether a block is blocking for this repository | No | `true` | +| `firewall-findings-file` | Socket Firewall report JSON (`blocked[]`, `parseFail[]`) | No | `''` | +| `api-findings-file` | JSON from [`socket-api-report`](../socket-api-report/README.md) | No | `''` | +| `api-blocking-count` | Introduced findings matching the blocking policy | No | `0` | +| `run-url` | Workflow run, linked in the footer | No | `''` | + +## Outputs + +| Output | Description | +|---|---| +| `has-findings` | `true` when any layer reported a finding, blocking or not | +| `posted` | `true` when a comment was created or updated | + +## Shape of the comment + +``` +## 🛡️ Socket Supply Chain — `app` + +### ❌ 2 blocking finding(s) introduced ← or ⚠️ introduced, none blocking + ← or ✅ No new findings in this pull request + +### Refused at install ← only when the firewall blocked +| Package | Version | Registry | Findings | + +### Introduced by this pull request ← only when the diff attributes something +🟠 2 warn · 🟡 1 monitor — across 3 package(s) +| Package | Version | Reached via | Score | Findings | +▶ Suggested fixes (3) + +--- +### Existing tree debt ← collapsed, never blocking +▶ Show the 43 pre-existing finding(s) + +--- +🔍 What this PR changed · Socket scan for `272b8f9` · Workflow logs +``` + +Three header states, because the middle one used to swallow the good case: a pull request that introduces nothing and only inherits debt is a **pass**, and marking it `⚠️` trains people to ignore the warning that matters. + +**Reached via** names the direct dependency that pulls a transitive finding in. `oauth@0.9.15` is nobody's decision — `next-auth` is. Direct dependencies keep `direct, prod`; anything Socket gave no ancestors for falls back to the plain scope. + +**Existing tree debt** is collapsed and never gates. It is identical on every pull request in the repository, so promoting it would drown the part this change is responsible for. Each section carries an action breakdown before the collapsed table, so a reader can judge the scale without expanding. + +**Suggested fixes** are rendered for both sections. Identical remedies are listed once — one advisory reaches several packages, so the raw list repeats the same `npx socket fix --id`. + +## Its own comment, on purpose + +The marker is ``, distinct from `pr-security-reporter`'s ``. Two markers means two comments that upsert independently and never contend for the same body. + +Folding the Socket rows into the security comment is not reachable: `pr-security-reporter` runs inside the `security_scan` job of `pr-security-scan.yml`, while the Socket layers run in a separate, parallel job. Step outputs do not cross jobs, so merging them would mean moving the Socket steps into a pipeline shared with Go repositories that have no npm install to guard. + +It also does not replace the **Socket App's** comment, which stays useful and is not duplicated here: the App shows version transitions and score deltas for changed direct dependencies (`@types/lodash@4.17.24 ⏵ 4.17.25`, Quality `+1`). This action shows action, severity, remediation and provenance. Two different questions. + +> The App's comment is also where `socket-api-report` reads its diff scan id, so disabling it breaks attribution. + +## Stale comments + +With `comment-when: findings` (the default), nothing is posted when there is nothing to act on. If a previous run **did** report findings and they are now gone, the existing comment is collapsed to a resolved note rather than left in place — a stale body keeps showing fixed findings as current. It is updated, not deleted, so the history stays auditable. + +## Usage + +### As a composite step + +Both upstream steps need `continue-on-error: true` so the report stays reachable when a layer fails, with the verdict re-applied by a gate step afterwards: + +```yaml +- id: firewall + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-firewall@v1 + +- id: api-report + continue-on-error: true + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-api-report@v1 + with: + socket-api-key: ${{ secrets.SOCKET_SECURITY_API_KEY }} + report-url: ${{ steps.app-gate.outputs.report-url }} + +- name: Post Socket findings to PR + if: always() + uses: LerianStudio/github-actions-shared-workflows/src/security/socket-reporter@v1 + with: + github-token: ${{ github.token }} + app-name: ${{ github.event.repository.name }} + firewall-blocked: ${{ steps.firewall.outputs.blocked || 'false' }} + firewall-findings-file: ${{ steps.firewall.outputs.findings-file }} + api-findings-file: ${{ steps.api-report.outputs.findings-file }} + api-blocking-count: ${{ steps.api-report.outputs.blocking-count || '0' }} +``` + +### Via the reusable workflow + +Wired into the `socket` job of `js-pr-validation`. Skipped when `dry_run: true`, since posting a comment is exactly the side effect a dry run must not have. + +```yaml +jobs: + pr-validation: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/js-pr-validation.yml@v1.x.x + with: + socket_comment_when: 'findings' # default; 'always' posts every run + secrets: inherit +``` + +## Permissions required + +```yaml +permissions: + issues: write + pull-requests: write +``` + +## Third-party actions used + +| Action | Why | +|---|---| +| [`actions/github-script`](https://github.com/actions/github-script) | Builds the comment and upserts it through the authenticated Octokit client, matching how `pr-security-reporter` posts its own. Pinned by commit SHA (`v8`). `result-encoding: string` is required — without it the action JSON-encodes the returned string and the output arrives quoted. | diff --git a/src/security/socket-reporter/action.yml b/src/security/socket-reporter/action.yml new file mode 100644 index 00000000..1b34feb3 --- /dev/null +++ b/src/security/socket-reporter/action.yml @@ -0,0 +1,369 @@ +name: Socket Supply Chain Reporter +description: Posts Socket supply-chain findings as a single upserted pull request comment. Reports findings only — whether the scan ran belongs in the job log and the status check. + +inputs: + github-token: + description: 'Token used to post the pull request comment' + required: true + app-name: + description: 'Application name used in the comment heading and the comment marker' + required: true + comment-when: + description: >- + "findings" (the default) posts only when there is something to act on, and collapses an existing comment to a + resolved note once the findings are gone so a stale one never reads as current. "always" posts every run. + required: false + default: 'findings' + firewall-blocked: + description: 'true when Socket Firewall refused at least one package' + required: false + default: 'false' + firewall-fail-on-block: + description: 'Whether a Socket Firewall block is blocking for this repository' + required: false + default: 'true' + firewall-findings-file: + description: 'Path to the Socket Firewall report JSON (schema: blocked[], parseFail[])' + required: false + default: '' + api-findings-file: + description: 'Path to the JSON written by socket-api-report (introduced and pre-existing findings)' + required: false + default: '' + api-blocking-count: + description: 'Number of introduced dependency findings matching the blocking policy' + required: false + default: '0' + run-url: + description: 'Link to the workflow run, shown in the comment footer' + required: false + default: '' + +outputs: + has-findings: + description: 'true when any layer reported a finding, blocking or not' + value: ${{ steps.parse.outputs.has_findings }} + posted: + description: 'true when a comment was created or updated' + value: ${{ steps.parse.outputs.posted }} + +runs: + using: composite + steps: + # ----------------- PR Comment ----------------- + - name: Post Socket findings to PR + id: report + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 + env: + APP_NAME: ${{ inputs.app-name }} + COMMENT_WHEN: ${{ inputs.comment-when }} + FIREWALL_BLOCKED: ${{ inputs.firewall-blocked }} + FIREWALL_FAIL_ON_BLOCK: ${{ inputs.firewall-fail-on-block }} + FIREWALL_FINDINGS_FILE: ${{ inputs.firewall-findings-file }} + API_FINDINGS_FILE: ${{ inputs.api-findings-file }} + API_BLOCKING_COUNT: ${{ inputs.api-blocking-count }} + RUN_URL: ${{ inputs.run-url }} + with: + github-token: ${{ inputs.github-token }} + # Without this the action JSON-encodes the returned string, so the output + # arrives as a quoted string and the parse step below cannot read it. + result-encoding: string + script: | + const fs = require('fs'); + const appName = process.env.APP_NAME; + const bool = (v) => v === 'true'; + const readJson = (p) => { + if (!p || !fs.existsSync(p)) return null; + try { return JSON.parse(fs.readFileSync(p, 'utf8')); } + catch (e) { core.warning(`Could not parse ${p}: ${e.message}`); return null; } + }; + + const runUrl = process.env.RUN_URL; + const always = process.env.COMMENT_WHEN === 'always'; + + // ── Socket Firewall: refused packages ── + // parseFail is read by the composite but not rendered here: an unparsed + // request is a coverage gap for the job log to warn about, not a finding + // about a package. + const fwReport = readJson(process.env.FIREWALL_FINDINGS_FILE); + const blockedPkgs = (fwReport && fwReport.blocked) || []; + const hasFirewallFinding = bool(process.env.FIREWALL_BLOCKED) || blockedPkgs.length > 0; + const fwBlocking = bool(process.env.FIREWALL_FAIL_ON_BLOCK); + + // ── Dependency findings, split by origin ── + const api = readJson(process.env.API_FINDINGS_FILE) || {}; + const introduced = api.introduced || []; + const preexisting = api.preexisting || []; + const apiBlocking = parseInt(process.env.API_BLOCKING_COUNT || '0', 10) || 0; + + const hasFindings = hasFirewallFinding || introduced.length > 0 || preexisting.length > 0; + const blockingCount = (hasFirewallFinding && fwBlocking ? 1 : 0) + apiBlocking; + + const ACT = { + error: { icon: '🔴', label: 'error' }, + warn: { icon: '🟠', label: 'warn' }, + monitor: { icon: '🟡', label: 'monitor' }, + }; + const pct = (v) => (typeof v === 'number' ? `${Math.round(v * 100)}` : '—'); + const marker = ``; + + // Every value below originates in a Socket finding, i.e. in metadata + // published by the very dependency being reported on. A pipe breaks the + // table it sits in and a bracket can restructure a link, so a crafted + // package or alert field could rewrite rows of a security report. + // Registry naming rules make that unlikely, not impossible, and a + // security report is the wrong place to rely on someone else's validation. + // Brackets and angle brackets matter as much as pipes: `fix` is free text + // from Socket, so `[approved fix](https://attacker.example)` would render + // as a link in the remediation list, and raw HTML renders in a comment. + const cell = (v) => String(v ?? '') + .replace(/[|\\]/g, '\\$&') + .replace(/[\r\n]+/g, ' ') + .replace(/`/g, '\u2018') + .replace(//g, '>') + .replace(/\[/g, '[') + .replace(/\]/g, ']'); + const urlPart = (v) => encodeURIComponent(String(v ?? '')); + + // Dashboard URLs, in the shapes the Socket App itself emits — the only + // ones observed to resolve. A package row deep-links into the scan when + // Socket gave the artifact an id; without one the name stays plain + // rather than pointing somewhere that may not exist. + const org = api.orgSlug; + const base = org ? `https://socket.dev/dashboard/org/${urlPart(org)}` : null; + const diffUrl = base && api.diffScanId ? `${base}/diff-scan/${urlPart(api.diffScanId)}?tab=dependencies` : null; + const scanUrl = base && api.scanId ? `${base}/sbom/${urlPart(api.scanId)}` : null; + const pkgUrl = (p, root) => (root && p.artifactId + ? `${root}${root.includes('?') ? '&' : '?'}dependency_item_key=${urlPart(p.artifactId)}` + : null); + + const pkgTable = (rows, root) => { + const out = ['| Package | Version | Reached via | Score | Findings |', + '|---------|---------|-------------|-------|----------|']; + for (const p of rows) { + const m = ACT[(p.worstAction || '').toLowerCase()] || { icon: '•' }; + const href = pkgUrl(p, root); + const label = href ? `[\`${cell(p.name)}\`](${href})` : `\`${cell(p.name)}\``; + // For a transitive package the direct dependency that pulls it in is + // the actionable part: "bump eslint" instead of "someone fix + // brace-expansion". Falls back to the plain scope when Socket gave + // no ancestors. + const ancestors = (p.ancestors || []).filter(a => a !== `${p.name}@${p.version}`); + const scope = p.direct + ? (p.dev ? 'direct, dev' : 'direct, prod') + : ancestors.length + ? `via ${ancestors.slice(0, 2).map(a => `\`${cell(a.replace(/@[^@]*$/, ''))}\``).join(', ')}` + + (ancestors.length > 2 ? ` +${ancestors.length - 2}` : '') + : (p.dev ? 'transitive, dev' : 'transitive, prod'); + const findings = (p.findings || []).map(f => { + const fm = ACT[(f.action || '').toLowerCase()] || { icon: '•' }; + const n = f.count > 1 ? ` ×${f.count}` : ''; + const sev = f.severity ? ` (${cell(f.severity)})` : ''; + return `${fm.icon} \`${cell(f.type)}\`${n}${sev}`; + }).join('
'); + out.push(`| ${m.icon} ${label} | \`${cell(p.version || '?')}\` | ${scope} | ${pct(p.overallScore)} | ${findings} |`); + } + return out; + }; + + // fix is an object, not a string; its shape is undocumented, so prefer + // the readable fields and fall back to compact JSON rather than losing + // the most actionable field Socket returns. + const fixText = (fix) => { + if (typeof fix === 'string') return fix; + if (fix && typeof fix === 'object') { + const pick = fix.description || fix.message || fix.target || fix.upgrade || fix.type; + if (typeof pick === 'string') return pick; + return '`' + JSON.stringify(fix).slice(0, 300) + '`'; + } + return String(fix); + }; + // Magnitude by action, so the reader sees the shape of a section before + // deciding to expand it. A bare collapsed line gives no sense of scale. + const actionCounts = (rows) => { + const acc = {}; + for (const p of rows) for (const f of (p.findings || [])) { + const k = (f.action || 'unknown').toLowerCase(); + acc[k] = (acc[k] || 0) + (f.count || 1); + } + return Object.entries(acc) + .sort((a, b) => (ACT[a[0]] ? Object.keys(ACT).indexOf(a[0]) : 9) + - (ACT[b[0]] ? Object.keys(ACT).indexOf(b[0]) : 9)) + .map(([k, n]) => `${(ACT[k] || { icon: '•' }).icon} ${n} ${k}`) + .join(' · '); + }; + + const fixList = (rows) => rows + .flatMap(p => (p.findings || []).filter(f => f.fix).map(f => ({ pkg: p.name, ...f }))); + + // Remediation is the most actionable field Socket returns, and it was + // only wired into the introduced section — so on a pull request that + // introduces nothing, which is the common case, no fix ever rendered. + const fixBlock = (rows, label) => { + const fixes = fixList(rows); + if (!fixes.length) return []; + const seen = new Set(); + const out = ['', `
${label} (${fixes.length})`, '']; + for (const f of fixes) { + const line = `- \`${cell(f.pkg)}\` — ${cell(f.type)}: ${cell(fixText(f.fix))}`; + // The same advisory reaches several packages, so identical lines + // repeat; listing each once keeps the block scannable. + if (seen.has(line)) continue; + seen.add(line); + out.push(line); + } + out.push('', '
'); + return out; + }; + + // ── Body: findings only ── + // Whether the scan ran, which checks passed, and how many alerts were + // filtered out are operational facts. They belong in the job log and the + // Socket status check, not in a comment people are asked to read. + const lines = []; + if (hasFindings) { + lines.push(`## 🛡️ Socket Supply Chain — \`${appName}\``); + lines.push(''); + // The header states what this pull request is responsible for. Only + // pre-existing debt is a pass: nothing here was introduced, so a + // warning icon would train people to ignore the one that matters. + lines.push(blockingCount > 0 + ? `### ❌ ${blockingCount} blocking finding(s) introduced` + : introduced.length + ? `### ⚠️ ${api.introducedCount || introduced.length} finding(s) introduced — none blocking` + : '### ✅ No new findings in this pull request'); + lines.push(''); + + if (hasFirewallFinding) { + lines.push('### Refused at install'); + lines.push(''); + lines.push('These never reached disk, so their install scripts never ran.'); + lines.push(''); + if (blockedPkgs.length) { + lines.push('| Package | Version | Registry | Findings |'); + lines.push('|---------|---------|----------|----------|'); + for (const p of blockedPkgs) { + const name = p.namespace ? `${p.namespace}/${p.name}` : p.name; + const f = (p.alerts || []).map(a => `\`${cell(a.alert)}\``).join(', ') || '—'; + lines.push(`| 🔴 \`${cell(name)}\` | \`${cell(p.version || '?')}\` | ${cell(p.registryFqdn || '—')} | ${f} |`); + } + lines.push(''); + } + lines.push('There is no ignore file for install-time blocks — a false positive needs the package reviewed, not suppressed.'); + lines.push(''); + } + + if (introduced.length) { + lines.push('### Introduced by this pull request'); + lines.push(''); + lines.push(`${actionCounts(introduced)} — across ${introduced.length} package(s).`); + lines.push(''); + lines.push(...pkgTable(introduced, diffUrl || scanUrl)); + lines.push(...fixBlock(introduced, 'Suggested fixes')); + lines.push(''); + } + + // Pre-existing debt is collapsed and never blocking. It is the same on + // every pull request in the repository, so promoting it would drown the + // part that this change is actually responsible for. + if (preexisting.length) { + const n = api.preexistingCount || 0; + const base = api.baseBranch ? `\`${api.baseBranch}\`` : 'the target branch'; + lines.push('---'); + lines.push(''); + lines.push('### Existing tree debt'); + lines.push(''); + lines.push(introduced.length + ? `The tree also carries **${n}** finding(s) already present on ${base}, untouched by this change.` + : `This pull request introduces nothing. The tree already carries **${n}** finding(s) on ${base}, unchanged here.`); + lines.push(''); + const hidden = api.preexistingHidden || 0; + lines.push(`${actionCounts(preexisting)} — across ${preexisting.length} package(s) shown` + + (hidden ? `, ${hidden} more package(s) with ${api.preexistingHiddenFindings || 0} finding(s) not listed.` : '.')); + lines.push(''); + lines.push(`
Show the ${n} pre-existing finding(s)`); + lines.push(''); + lines.push(...pkgTable(preexisting, scanUrl)); + lines.push(...fixBlock(preexisting, 'Suggested fixes for the above')); + lines.push(''); + lines.push('
'); + lines.push(''); + } + + if (!api.hasBaseline && (introduced.length || preexisting.length)) { + lines.push('> No baseline scan was available for the target branch, so nothing could be attributed to this pull request specifically.'); + lines.push(''); + } + + lines.push('---'); + lines.push(''); + // "Full project report" read as project-wide when it is in fact the + // scan of this pull request's commit. The short sha removes the doubt. + const sha = (api.headSha || '').slice(0, 7); + const refs = []; + if (diffUrl) refs.push(`[What this PR changed](${diffUrl})`); + if (scanUrl) refs.push(`[Socket scan for ${sha ? `\`${sha}\`` : 'this PR'}](${scanUrl})`); + if (runUrl) refs.push(`[Workflow logs](${runUrl})`); + if (refs.length) lines.push(`🔍 ${refs.join(' · ')}`); + } else if (always) { + lines.push(`## 🛡️ Socket Supply Chain — \`${appName}\``); + lines.push(''); + lines.push('✅ No supply-chain findings.'); + } + + // ── Upsert ── + let posted = false; + if (context.issue.number) { + try { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + const existing = comments.find(c => c.body?.includes(marker)); + const params = { owner: context.repo.owner, repo: context.repo.repo }; + + if (hasFindings || always) { + const body = marker + '\n' + lines.join('\n'); + if (existing) { + await github.rest.issues.updateComment({ ...params, comment_id: existing.id, body }); + } else { + await github.rest.issues.createComment({ ...params, issue_number: context.issue.number, body }); + } + posted = true; + } else if (existing) { + // A previous run reported findings and they are gone. Leaving the + // old body would keep showing resolved findings as current, so it + // is collapsed rather than deleted — the history stays auditable. + const body = marker + '\n### 🛡️ Socket Supply Chain — `' + appName + '`\n\n' + + '✅ Previously reported findings are no longer present.'; + await github.rest.issues.updateComment({ ...params, comment_id: existing.id, body }); + posted = true; + } + } catch (e) { + core.warning(`Could not post Socket PR comment: ${e.message}`); + } + } + + if (lines.length) await core.summary.addRaw(lines.join('\n')).write(); + return JSON.stringify({ has_findings: hasFindings, posted }); + + - name: Parse outputs + id: parse + shell: bash + env: + RESULT: ${{ steps.report.outputs.result }} + run: | + set -euo pipefail + if [ -z "$RESULT" ]; then + echo "::error::socket-reporter: github-script produced no output. The script step likely failed — check the previous step's logs." + exit 1 + fi + if ! echo "$RESULT" | jq -e 'has("has_findings") and (.has_findings | type == "boolean")' >/dev/null 2>&1; then + echo "::error::socket-reporter: invalid output. Raw: $RESULT" + exit 1 + fi + echo "has_findings=$(echo "$RESULT" | jq -r '.has_findings')" >> "$GITHUB_OUTPUT" + echo "posted=$(echo "$RESULT" | jq -r '.posted')" >> "$GITHUB_OUTPUT" diff --git a/src/setup/setup-node-guarded/README.md b/src/setup/setup-node-guarded/README.md new file mode 100644 index 00000000..8213016d --- /dev/null +++ b/src/setup/setup-node-guarded/README.md @@ -0,0 +1,112 @@ + + + + + +
Lerian

setup-node-guarded

+ +Composite action that sets up Node (and pnpm when needed) and installs dependencies **through [Socket Firewall](https://github.com/SocketDev/sfw-free)**, so a malicious package is refused before any install script runs. + +It replaces the pnpm-setup + node-setup + install trio that `frontend-pr-analysis.yml` repeats in twelve jobs. That duplication is why the gap existed: a firewall shim only protects installs in its own job, so guarding one job left eleven others running `npm ci` unprotected — with the runner's tokens in scope for any `postinstall`. + +## Inputs + +| Input | Description | Required | Default | +|---|---|:---:|---| +| `package-manager` | Package manager used to install dependencies (`npm`, `yarn`, `pnpm`) | No | `npm` | +| `node-version` | Node.js version to set up | No | `22` | +| `working-dir` | Directory holding the `package.json` and lockfile | No | `.` | +| `guard` | Route the install through Socket Firewall. `false` restores the pre-Socket behaviour, cache included | No | `true` | +| `firewall-version` | Socket Firewall binary version | No | `latest` | +| `job-summary` | Socket Firewall job summary verbosity (`all`, `errors`, `none`) | No | `errors` | +| `use-cache` | Cache the `sfw` binary. Unrelated to the package-manager cache | No | `true` | +| `github-token` | Token used by Socket Firewall to download its binaries. Empty falls back to `github.token` | No | `''` | +| `fail-on-block` | Fail the step when Socket Firewall blocks a package | No | `true` | + +## Outputs + +| Output | Description | +|---|---| +| `guarded` | `true` when the install actually ran through Socket Firewall | +| `blocked` | `true` when the guarded install failed and the output carries a Socket block marker | +| `install-exit-code` | Exit code returned by the package manager install | +| `report-path` | Path to the Socket Firewall report JSON, empty when unguarded | + +## Guarded and unguarded modes + +The two modes differ in one deliberate way: **the guarded path has no package-manager cache.** + +Socket Firewall free is a wrapper around the package manager and only sees what crosses the network. Per its documentation, *"if there are no network requests, as is the case when artifacts are cached locally, there is nothing for `sfw` to block"*. A restored cache would therefore let tarballs install uninspected. So the guarded path: + +- does not pass `cache:` to `actions/setup-node` (which also stops this job from writing a post-run cache entry other jobs would restore); +- purges the package manager's cache (`npm cache clean --force`, `yarn cache clean`, `pnpm store prune`) before installing, since a runner image can arrive pre-warmed anyway; +- prefixes the install with `sfw`, which is the only supported form in the pinned `SocketDev/action` release — it exposes no `shims` input, so a bare `npm ci` would install completely uninspected while still reporting success. + +The measured cost of losing the cache is small: a cold `sfw npm ci` over ~2000 packages runs in roughly 20s. + +`guard` also degrades to unguarded on its own when no lockfile is present in `working-dir`, with a `::warning::` — nothing is fetched, so there is nothing to inspect. + +## How the verdict is decided + +| Install exit | Block marker | `fail-on-block` | Result | +|---|---|---|---| +| `0` | — | any | Step passes | +| non-zero | present | `true` | `::error::` + step fails | +| non-zero | present | `false` | `::warning::` + step passes | +| non-zero | absent | any | `::error::` + step fails | + +A broken install is **always** a failure — swallowing it would hide a genuine problem behind a security toggle, so `fail-on-block: false` only softens confirmed Socket blocks. + +## Usage + +### As a composite step + +```yaml +jobs: + lint: + runs-on: blacksmith-4vcpu-ubuntu-2404 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - name: Setup Node.js and install dependencies + uses: LerianStudio/github-actions-shared-workflows/src/setup/setup-node-guarded@v1 + with: + package-manager: 'npm' + node-version: '22' + working-dir: '.' + + - run: npx eslint . +``` + +### Via the reusable workflow + +Every install job in `frontend-pr-analysis` uses it, and `js-pr-validation` forwards the toggle: + +```yaml +jobs: + pr-validation: + uses: LerianStudio/github-actions-shared-workflows/.github/workflows/js-pr-validation.yml@v1.x.x + with: + socket_enable_firewall: true # default — forwarded as `guard` + socket_fail_on_block: true # default + secrets: inherit +``` + +Set `socket_enable_firewall: false` for a repository that cannot install without a private registry: the firewall free edition does not support custom registries. + +## Permissions required + +```yaml +permissions: + contents: read +``` + +## Third-party actions used + +| Action | Why | +|---|---| +| [`SocketDev/action`](https://github.com/SocketDev/action) | Vendor-maintained installer for the `sfw` binary, with version resolution and checksum verification. Pinned by commit SHA (`v1.3.2`). | +| [`actions/setup-node`](https://github.com/actions/setup-node) | Provides the Node.js runtime. Invoked twice under mutually exclusive conditions so the guarded path can omit the cache. | +| [`pnpm/action-setup`](https://github.com/pnpm/action-setup) | `pnpm` is not preinstalled on the runner; only used when `package-manager: pnpm`. | diff --git a/src/setup/setup-node-guarded/action.yml b/src/setup/setup-node-guarded/action.yml new file mode 100644 index 00000000..6600ba93 --- /dev/null +++ b/src/setup/setup-node-guarded/action.yml @@ -0,0 +1,251 @@ +name: Setup Node with Guarded Install +description: Sets up Node (and pnpm), then installs dependencies through Socket Firewall so a malicious package is refused before any install script runs. + +inputs: + package-manager: + description: 'Package manager used to install dependencies (npm, yarn, pnpm)' + required: false + default: 'npm' + node-version: + description: 'Node.js version to set up' + required: false + default: '22' + working-dir: + description: 'Directory holding the package.json and lockfile' + required: false + default: '.' + guard: + description: 'Route the install through Socket Firewall. When false, installs normally with the package-manager cache restored — the pre-Socket behaviour.' + required: false + default: 'true' + firewall-version: + description: 'Socket Firewall binary version. Empty or "latest" tracks the newest release.' + required: false + default: 'latest' + job-summary: + description: "Socket Firewall job summary verbosity (all, errors, none)" + required: false + default: 'errors' + use-cache: + description: 'Cache the sfw binary between runs. Unrelated to the package-manager cache.' + required: false + default: 'true' + github-token: + description: 'Token used by Socket Firewall to download its binaries. Empty falls back to github.token.' + required: false + default: '' + fail-on-block: + description: 'Fail the step when Socket Firewall blocks a package. Set to false to report the block as a warning instead.' + required: false + default: 'true' + +outputs: + skipped: + description: 'true when no lockfile was found in working-dir, so nothing was installed or inspected' + value: ${{ steps.resolve.outputs.skipped }} + guarded: + description: 'true when the install actually ran through Socket Firewall' + value: ${{ steps.resolve.outputs.guarded }} + blocked: + description: 'true when the Socket Firewall report records at least one blocked package' + value: ${{ steps.evaluate.outputs.blocked }} + blocked-count: + description: 'Number of packages Socket Firewall refused' + value: ${{ steps.evaluate.outputs.blocked_count }} + findings-file: + description: 'Path to the Socket Firewall report JSON, for consumption by socket-reporter' + value: ${{ steps.evaluate.outputs.findings_file }} + install-exit-code: + description: 'Exit code returned by the package manager install' + value: ${{ steps.evaluate.outputs.exit_code }} + report-path: + description: 'Path to the Socket Firewall report JSON, empty when unguarded' + value: ${{ steps.firewall.outputs.firewall-path-report }} + +runs: + using: composite + steps: + # ----------------- Resolve mode ----------------- + - name: Resolve install mode + id: resolve + shell: bash + env: + GUARD: ${{ inputs.guard }} + PKG_MANAGER: ${{ inputs.package-manager }} + WORKING_DIR: ${{ inputs.working-dir }} + run: | + # Guarding is skipped when there is no lockfile: nothing is fetched, so + # there is nothing for sfw to inspect, and setup-node's cache would fail + # on the missing cache-dependency-path anyway. + case "$PKG_MANAGER" in + yarn) LOCKFILE='yarn.lock' ;; + pnpm) LOCKFILE='pnpm-lock.yaml' ;; + *) LOCKFILE='package-lock.json' ;; + esac + echo "lockfile=$LOCKFILE" >> "$GITHUB_OUTPUT" + + if [ "$GUARD" != "true" ]; then + echo "guarded=false" >> "$GITHUB_OUTPUT" + echo "skipped=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ ! -f "$WORKING_DIR/$LOCKFILE" ]; then + # Distinct from an opt-out: with no lockfile there is nothing to install. + # Falling through to the cached setup would point cache-dependency-path + # at a file that does not exist and fail the action, and `npm ci` needs a + # lockfile anyway — so the documented "skip with a warning" has to skip + # the install too, not just the guarding. + echo "guarded=false" >> "$GITHUB_OUTPUT" + echo "skipped=true" >> "$GITHUB_OUTPUT" + echo "::warning::No $LOCKFILE in '$WORKING_DIR' — nothing installed and nothing inspected." + exit 0 + fi + echo "skipped=false" >> "$GITHUB_OUTPUT" + echo "guarded=true" >> "$GITHUB_OUTPUT" + + # ----------------- Toolchain ----------------- + - name: Set up pnpm + if: steps.resolve.outputs.skipped != 'true' && inputs.package-manager == 'pnpm' + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + package_json_file: ${{ inputs.working-dir }}/package.json + + # Two mutually exclusive setup steps. Socket Firewall free inspects network + # traffic, and per its documentation "if there are no network requests, as is + # the case when artifacts are cached locally, there is nothing for sfw to + # block" — so a guarded install must not restore a package-manager cache. + # Unguarded installs keep the cache, preserving the pre-Socket behaviour. + - name: Setup Node.js (guarded, no package cache) + if: steps.resolve.outputs.guarded == 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: ${{ inputs.node-version }} + + - name: Setup Node.js (cached) + if: steps.resolve.outputs.skipped != 'true' && steps.resolve.outputs.guarded != 'true' + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: ${{ inputs.node-version }} + cache: ${{ inputs.package-manager }} + cache-dependency-path: ${{ inputs.working-dir }}/${{ steps.resolve.outputs.lockfile }} + + # ----------------- Socket Firewall ----------------- + - name: Install Socket Firewall + id: firewall + if: steps.resolve.outputs.guarded == 'true' + uses: SocketDev/action@ba6de6cc0565af1f42295590380973573297e31f # v1.3.2 + with: + mode: firewall-free + firewall-version: ${{ inputs.firewall-version }} + job-summary: ${{ inputs.job-summary }} + use-cache: ${{ inputs.use-cache }} + github-token: ${{ inputs.github-token != '' && inputs.github-token || github.token }} + + - name: Clear package manager cache + if: steps.resolve.outputs.guarded == 'true' + shell: bash + env: + PKG_MANAGER: ${{ inputs.package-manager }} + run: | + # A runner image can arrive pre-warmed even without setup-node caching, + # and anything served from cache installs without a network request and + # therefore uninspected. Failures are non-fatal: an empty cache is fine. + case "$PKG_MANAGER" in + yarn) yarn cache clean || true ;; + pnpm) pnpm store prune || true ;; + *) npm cache clean --force || true ;; + esac + + # ----------------- Install ----------------- + - name: Install dependencies + id: install + if: steps.resolve.outputs.skipped != 'true' + shell: bash + working-directory: ${{ inputs.working-dir }} + env: + PKG_MANAGER: ${{ inputs.package-manager }} + GUARDED: ${{ steps.resolve.outputs.guarded }} + run: | + # The sfw prefix is what routes the package manager through the firewall: + # the pinned SocketDev/action release exposes no shims input, so a bare + # `npm ci` would install completely uninspected while reporting success. + # The exit code is captured so the evaluate step can tell a Socket block + # apart from an ordinary install failure. + PREFIX="" + [ "$GUARDED" = "true" ] && PREFIX="sfw" + LOG_FILE="$RUNNER_TEMP/guarded-install.log" + EXIT_CODE=0 + set -o pipefail + case "$PKG_MANAGER" in + yarn) $PREFIX yarn install --frozen-lockfile 2>&1 | tee "$LOG_FILE" || EXIT_CODE=$? ;; + pnpm) $PREFIX pnpm install --frozen-lockfile 2>&1 | tee "$LOG_FILE" || EXIT_CODE=$? ;; + *) $PREFIX npm ci 2>&1 | tee "$LOG_FILE" || EXIT_CODE=$? ;; + esac + echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" + echo "log_file=$LOG_FILE" >> "$GITHUB_OUTPUT" + + - name: Evaluate install result + id: evaluate + if: always() && steps.resolve.outputs.skipped != 'true' && steps.install.conclusion != 'skipped' + shell: bash + env: + EXIT_CODE: ${{ steps.install.outputs.exit_code }} + GUARDED: ${{ steps.resolve.outputs.guarded }} + REPORT_PATH: ${{ steps.firewall.outputs.firewall-path-report }} + FINDINGS_FILE: socket-firewall-findings.json + FAIL_ON_BLOCK: ${{ inputs.fail-on-block }} + run: | + # Socket Firewall always writes a JSON report, whose schema (verified + # against the v1.15.0 binary) is: + # {"blocked":[{"name","namespace","version","purlString","registryFqdn", + # "alerts":[{"alert","action","actionSource","inputPurl"}]}]|null, + # "parseFail":[{"registryFqdn","urlPath"}]|null} + # Reading it is deterministic; the previous approach grepped the install + # output for block wording, which could misattribute a real block as an + # ordinary failure and report blocked=false on a genuine finding. + EXIT_CODE="${EXIT_CODE:-1}" + echo "exit_code=$EXIT_CODE" >> "$GITHUB_OUTPUT" + + BLOCKED=false + BLOCKED_COUNT=0 + REPORT="${REPORT_PATH:-$SFW_JSON_REPORT_PATH}" + if [ "$GUARDED" = "true" ] && [ -n "$REPORT" ] && [ -f "$REPORT" ]; then + BLOCKED_COUNT=$(jq '(.blocked // []) | length' "$REPORT" 2>/dev/null || echo 0) + PARSE_FAIL=$(jq '(.parseFail // []) | length' "$REPORT" 2>/dev/null || echo 0) + [ "$BLOCKED_COUNT" -gt 0 ] && BLOCKED=true + if [ "$PARSE_FAIL" -gt 0 ]; then + echo "::warning::Socket Firewall could not parse $PARSE_FAIL request(s); those packages were not inspected." + fi + cp "$REPORT" "$FINDINGS_FILE" 2>/dev/null || echo '{}' > "$FINDINGS_FILE" + else + echo '{}' > "$FINDINGS_FILE" + if [ "$GUARDED" = "true" ] && [ "$EXIT_CODE" -ne 0 ]; then + # No report to read on a failed guarded install: refuse to call it clean. + echo "::warning::Socket Firewall report not found at '${REPORT:-}'; cannot attribute the install failure." + fi + fi + echo "blocked=$BLOCKED" >> "$GITHUB_OUTPUT" + echo "blocked_count=$BLOCKED_COUNT" >> "$GITHUB_OUTPUT" + echo "findings_file=$FINDINGS_FILE" >> "$GITHUB_OUTPUT" + + if [ "$BLOCKED" = "true" ]; then + echo "Socket Firewall blocked $BLOCKED_COUNT package(s):" + jq -r '(.blocked // [])[] | " \(.purlString) [\((.alerts // []) | map(.alert) | join(", "))]"' "$REPORT" || true + fi + + if [ "$EXIT_CODE" -eq 0 ] && [ "$BLOCKED" = "false" ]; then + [ "$GUARDED" = "true" ] && echo "Socket Firewall allowed every package in the lockfile." + exit 0 + fi + + if [ "$BLOCKED" = "true" ]; then + if [ "$FAIL_ON_BLOCK" = "true" ]; then + echo "::error::Socket Firewall blocked $BLOCKED_COUNT package(s) during install." + exit 1 + fi + echo "::warning::Socket Firewall blocked $BLOCKED_COUNT package(s) (advisory — fail-on-block is false)." + exit 0 + fi + + echo "::error::Dependency install failed with exit code $EXIT_CODE (no Socket Firewall block recorded in the report)." + exit 1