@aldegad/safedeps 2.1.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,36 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # safedeps audit npm — generic npm lockfile audit.
5
+ # Absorbed from kuma-studio scripts/security/run-npm-audit.sh.
6
+ # Missing lockfile stays fail-closed (no reproducible verdict without it).
7
+
8
+ REPO_ROOT=""
9
+ AUDIT_LEVEL="${SAFEDEPS_NPM_AUDIT_LEVEL:-${KUMA_NPM_AUDIT_LEVEL:-moderate}}"
10
+
11
+ usage() {
12
+ printf 'Usage: safedeps audit [npm] [--root <repo>] [--level <low|moderate|high|critical>]\n' >&2
13
+ }
14
+
15
+ while [ $# -gt 0 ]; do
16
+ case "$1" in
17
+ npm) shift ;; # allow `audit npm`
18
+ --root) REPO_ROOT="${2:?--root needs a path}"; shift 2 ;;
19
+ --level) AUDIT_LEVEL="${2:?--level needs a value}"; shift 2 ;;
20
+ -h|--help) usage; exit 0 ;;
21
+ *) usage; exit 64 ;;
22
+ esac
23
+ done
24
+
25
+ if [ -z "$REPO_ROOT" ]; then REPO_ROOT="$(pwd)"; fi
26
+ REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
27
+ cd "$REPO_ROOT"
28
+
29
+ if [ ! -f package-lock.json ]; then
30
+ cat >&2 <<'EOF'
31
+ ERROR: package-lock.json is missing, so npm audit cannot produce a reproducible dependency verdict.
32
+ EOF
33
+ exit 1
34
+ fi
35
+
36
+ exec npm audit --audit-level="$AUDIT_LEVEL"
@@ -0,0 +1,93 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # safedeps hooks install|check — generic repo-local git hook activation.
5
+ # Absorbed from kuma-studio scripts/security/{install,check}-hooks.sh.
6
+ # The repo's privacy/secret policy lives in its own .githooks/pre-commit;
7
+ # this command only manages hook activation, not the policy content.
8
+
9
+ GATES_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ # shellcheck source=./repo-profile.sh
11
+ source "$GATES_LIB_DIR/repo-profile.sh"
12
+
13
+ SUB=""
14
+ REPO_ROOT=""
15
+ HOOKS_PATH=".githooks"
16
+ AUTO=0
17
+
18
+ usage() {
19
+ printf 'Usage: safedeps hooks <install|check> [--root <repo>] [--hooks-path <dir>] [--auto]\n' >&2
20
+ }
21
+
22
+ while [ $# -gt 0 ]; do
23
+ case "$1" in
24
+ install|check) SUB="$1"; shift ;;
25
+ --root) REPO_ROOT="${2:?--root needs a path}"; shift 2 ;;
26
+ --hooks-path) HOOKS_PATH="${2:?--hooks-path needs a dir}"; shift 2 ;;
27
+ --auto) AUTO=1; shift ;;
28
+ -h|--help) usage; exit 0 ;;
29
+ *) usage; exit 64 ;;
30
+ esac
31
+ done
32
+
33
+ if [ -z "$SUB" ]; then usage; exit 64; fi
34
+ if [ -z "$REPO_ROOT" ]; then REPO_ROOT="$(pwd)"; fi
35
+ REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
36
+
37
+ if ! git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
38
+ if [ "$SUB" = "install" ] && [ "$AUTO" -eq 1 ]; then
39
+ printf 'safedeps hooks: skipped install (not a git worktree)\n'
40
+ exit 0
41
+ fi
42
+ printf 'ERROR: not inside a git worktree: %s\n' "$REPO_ROOT" >&2
43
+ exit 1
44
+ fi
45
+
46
+ HOOK_FILE="$REPO_ROOT/$HOOKS_PATH/pre-commit"
47
+
48
+ case "$SUB" in
49
+ install)
50
+ if [ ! -f "$HOOK_FILE" ]; then
51
+ printf 'ERROR: hook file not found: %s\n' "$HOOK_FILE" >&2
52
+ printf ' the repo must provide its own %s/pre-commit policy.\n' "$HOOKS_PATH" >&2
53
+ exit 1
54
+ fi
55
+ chmod +x "$HOOK_FILE"
56
+ git -C "$REPO_ROOT" config core.hooksPath "$HOOKS_PATH"
57
+ printf 'safedeps hooks: installed repo-local git hooks at %s/%s\n' "$REPO_ROOT" "$HOOKS_PATH"
58
+ printf 'safedeps hooks: core.hooksPath = %s\n' "$(git -C "$REPO_ROOT" config --get core.hooksPath)"
59
+ ;;
60
+ check)
61
+ local_expected="$HOOKS_PATH"
62
+ actual="$(git -C "$REPO_ROOT" config --get core.hooksPath || true)"
63
+ if [ "$actual" != "$local_expected" ]; then
64
+ cat >&2 <<EOF
65
+ ERROR: repo-local git hooks are not active.
66
+
67
+ Expected core.hooksPath: $local_expected
68
+ Actual core.hooksPath: ${actual:-<unset>}
69
+
70
+ Run:
71
+ safedeps hooks install --root "$REPO_ROOT"
72
+ EOF
73
+ exit 1
74
+ fi
75
+ if [ ! -x "$HOOK_FILE" ]; then
76
+ printf 'ERROR: %s is not executable.\n' "$HOOK_FILE" >&2
77
+ exit 1
78
+ fi
79
+ if ! command -v gitleaks >/dev/null 2>&1; then
80
+ if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
81
+ cat >&2 <<'EOF'
82
+ ERROR: neither local gitleaks nor a running Docker daemon is available.
83
+
84
+ Choose one:
85
+ brew install gitleaks
86
+ open -a Docker
87
+ EOF
88
+ exit 1
89
+ fi
90
+ fi
91
+ printf 'safedeps hooks: active (core.hooksPath = %s)\n' "$local_expected"
92
+ ;;
93
+ esac
@@ -0,0 +1,60 @@
1
+ #!/bin/bash
2
+ # Generic repo security profile + gitleaks config resolution.
3
+ # Absorbed from kuma-studio scripts/security/repo-profile.sh and made generic:
4
+ # the private profile is detected by a "-private" suffix convention instead of a
5
+ # hard-coded repo name, and overrides accept both SAFEDEPS_* and legacy KUMA_* env.
6
+
7
+ safedeps_repo_profile() {
8
+ local repo_root="${1:?repo root required}"
9
+ local override="${SAFEDEPS_REPO_PROFILE:-${KUMA_SECURITY_REPO_PROFILE:-}}"
10
+
11
+ case "$override" in
12
+ public|private)
13
+ printf '%s\n' "$override"
14
+ return 0
15
+ ;;
16
+ "")
17
+ ;;
18
+ *)
19
+ printf 'ERROR: repo profile override must be "public" or "private", got: %s\n' "$override" >&2
20
+ return 64
21
+ ;;
22
+ esac
23
+
24
+ local origin_url repo_leaf
25
+ origin_url="$(git -C "$repo_root" remote get-url origin 2>/dev/null || true)"
26
+ repo_leaf="$(basename "$repo_root")"
27
+
28
+ # Convention: a repo whose origin slug or directory leaf ends in "-private" is
29
+ # the private profile (e.g. kuma-studio-private). Everything else is public.
30
+ if [[ "$origin_url" =~ (^|[/:-])[A-Za-z0-9._-]*-private(\.git)?$ ]] || [[ "$repo_leaf" == *-private ]]; then
31
+ printf 'private\n'
32
+ return 0
33
+ fi
34
+
35
+ printf 'public\n'
36
+ }
37
+
38
+ safedeps_gitleaks_config() {
39
+ local repo_root="${1:?repo root required}"
40
+ local profile="${2:?profile required}"
41
+ local override="${SAFEDEPS_GITLEAKS_CONFIG:-${KUMA_GITLEAKS_CONFIG:-}}"
42
+
43
+ if [ -n "$override" ]; then
44
+ printf '%s\n' "$override"
45
+ return 0
46
+ fi
47
+
48
+ case "$profile" in
49
+ private)
50
+ printf '%s/.gitleaks.private.toml\n' "$repo_root"
51
+ ;;
52
+ public)
53
+ printf '%s/.gitleaks.toml\n' "$repo_root"
54
+ ;;
55
+ *)
56
+ printf 'ERROR: unknown security profile: %s\n' "$profile" >&2
57
+ return 64
58
+ ;;
59
+ esac
60
+ }
@@ -0,0 +1,94 @@
1
+ #!/bin/bash
2
+ set -euo pipefail
3
+
4
+ # safedeps scan secrets — generic gitleaks runner.
5
+ # Absorbed from kuma-studio scripts/security/run-gitleaks.sh and made generic:
6
+ # repo root comes from --root (default: cwd), config from repo profile or override.
7
+ # Preference order: local gitleaks binary -> Docker image (explicit, printed).
8
+
9
+ GATES_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
10
+ # shellcheck source=./repo-profile.sh
11
+ source "$GATES_LIB_DIR/repo-profile.sh"
12
+
13
+ REPO_ROOT=""
14
+ MODE="repo"
15
+ CONFIG_OVERRIDE=""
16
+ IMAGE="${SAFEDEPS_GITLEAKS_IMAGE:-${KUMA_GITLEAKS_IMAGE:-ghcr.io/gitleaks/gitleaks:latest}}"
17
+
18
+ usage() {
19
+ printf 'Usage: safedeps scan secrets [--repo|--worktree|--staged] [--root <repo>] [--config <path>]\n' >&2
20
+ }
21
+
22
+ while [ $# -gt 0 ]; do
23
+ case "$1" in
24
+ --repo) MODE="repo"; shift ;;
25
+ --worktree) MODE="worktree"; shift ;;
26
+ --staged) MODE="staged"; shift ;;
27
+ --root) REPO_ROOT="${2:?--root needs a path}"; shift 2 ;;
28
+ --config) CONFIG_OVERRIDE="${2:?--config needs a path}"; shift 2 ;;
29
+ secrets) shift ;; # allow `scan secrets ...`
30
+ -h|--help) usage; exit 0 ;;
31
+ *) usage; exit 64 ;;
32
+ esac
33
+ done
34
+
35
+ if [ -z "$REPO_ROOT" ]; then REPO_ROOT="$(pwd)"; fi
36
+ REPO_ROOT="$(cd "$REPO_ROOT" && pwd)"
37
+
38
+ REPO_PROFILE="$(safedeps_repo_profile "$REPO_ROOT")"
39
+ if [ -n "$CONFIG_OVERRIDE" ]; then
40
+ CONFIG_PATH="$CONFIG_OVERRIDE"
41
+ else
42
+ CONFIG_PATH="$(safedeps_gitleaks_config "$REPO_ROOT" "$REPO_PROFILE")"
43
+ fi
44
+ CONFIG_BASENAME="$(basename "$CONFIG_PATH")"
45
+
46
+ cd "$REPO_ROOT"
47
+
48
+ if [ ! -f "$CONFIG_PATH" ]; then
49
+ printf 'ERROR: gitleaks config does not exist: %s\n' "$CONFIG_PATH" >&2
50
+ exit 1
51
+ fi
52
+
53
+ printf 'safedeps secret scan: profile=%s config=%s mode=%s\n' "$REPO_PROFILE" "$CONFIG_BASENAME" "$MODE" >&2
54
+
55
+ SCAN_ROOT="$REPO_ROOT"
56
+
57
+ LOCAL_ARGS=(git --no-banner --redact --verbose --config "$CONFIG_PATH")
58
+ DOCKER_ARGS=(git --no-banner --redact --verbose --config "/repo/$CONFIG_BASENAME")
59
+
60
+ if [ "$MODE" = "staged" ]; then
61
+ LOCAL_ARGS+=(--pre-commit --staged)
62
+ DOCKER_ARGS+=(--pre-commit --staged)
63
+ elif [ "$MODE" = "worktree" ]; then
64
+ LOCAL_ARGS=(dir --no-banner --redact --verbose --config "$CONFIG_PATH")
65
+ DOCKER_ARGS=(dir --no-banner --redact --verbose --config "/repo/$CONFIG_BASENAME")
66
+ fi
67
+
68
+ LOCAL_ARGS+=("$SCAN_ROOT")
69
+ if [ "$MODE" = "worktree" ]; then
70
+ DOCKER_ARGS+=("/repo")
71
+ else
72
+ DOCKER_ARGS+=(/repo)
73
+ fi
74
+
75
+ if command -v gitleaks >/dev/null 2>&1; then
76
+ gitleaks "${LOCAL_ARGS[@]}"
77
+ exit $?
78
+ fi
79
+
80
+ if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
81
+ docker run --rm -v "$REPO_ROOT:/repo" -w /repo "$IMAGE" "${DOCKER_ARGS[@]}"
82
+ exit $?
83
+ fi
84
+
85
+ cat >&2 <<EOF
86
+ ERROR: gitleaks is not available.
87
+
88
+ Choose one:
89
+ 1. Install locally: brew install gitleaks
90
+ 2. Or start Docker so the scan can use: $IMAGE
91
+
92
+ The scan is blocked (fail-closed) until a scanner is available.
93
+ EOF
94
+ exit 1
@@ -183,16 +183,23 @@ safedeps_ledger_check() {
183
183
 
184
184
  safedeps_ledger_atomic_write() {
185
185
  local target_path="$1"
186
- local temp_path="${target_path}.$$"
186
+ local target_dir
187
+ local target_base
188
+ local temp_path
187
189
 
188
190
  safedeps_ledger_init
191
+ target_dir=$(dirname "${target_path}")
192
+ target_base=$(basename "${target_path}")
193
+ mkdir -p "${target_dir}" || return 1
194
+ temp_path=$(mktemp "${target_dir}/.${target_base}.XXXXXX") || return 1
195
+
189
196
  cat > "${temp_path}"
190
197
  chmod 600 "${temp_path}" 2>/dev/null || true
191
198
  safedeps_ledger_validate_json "${temp_path}" || {
192
199
  rm -f "${temp_path}"
193
200
  return 1
194
201
  }
195
- mv "${temp_path}" "${target_path}"
202
+ mv -f "${temp_path}" "${target_path}"
196
203
  }
197
204
 
198
205
  safedeps_ledger_write_approved_spec() {
@@ -203,11 +210,13 @@ safedeps_ledger_write_approved_spec() {
203
210
  local approved_by="${5:-local}"
204
211
  local evidence_file="${6:-}"
205
212
  local ttl_days="${7:-${SAFEDEPS_LEDGER_DEFAULT_TTL_DAYS}}"
213
+ local transitive_specs_file="${8:-}"
206
214
  local approved_at
207
215
  local expires_at
208
216
  local hash
209
217
  local target_path
210
218
  local evidence_arg=()
219
+ local transitive_arg=()
211
220
 
212
221
  safedeps_ledger_require_jq || return 1
213
222
  safedeps_ledger_init
@@ -227,6 +236,20 @@ safedeps_ledger_write_approved_spec() {
227
236
  evidence_arg=(--argjson evidence '{}')
228
237
  fi
229
238
 
239
+ if [[ -n "${transitive_specs_file}" ]]; then
240
+ [[ -f "${transitive_specs_file}" ]] || {
241
+ printf 'safedeps ledger: transitive specs file not found: %s\n' "${transitive_specs_file}" >&2
242
+ return 1
243
+ }
244
+ jq -e 'type == "array"' "${transitive_specs_file}" >/dev/null || {
245
+ printf 'safedeps ledger: transitive specs file must be a JSON array: %s\n' "${transitive_specs_file}" >&2
246
+ return 1
247
+ }
248
+ transitive_arg=(--slurpfile transitive_specs "${transitive_specs_file}")
249
+ else
250
+ transitive_arg=(--argjson transitive_specs '[]')
251
+ fi
252
+
230
253
  if [[ -n "${evidence_file}" ]]; then
231
254
  jq -cn \
232
255
  --arg hash "${hash}" \
@@ -238,6 +261,7 @@ safedeps_ledger_write_approved_spec() {
238
261
  --arg expires_at "${expires_at}" \
239
262
  --arg approved_by "${approved_by}" \
240
263
  "${evidence_arg[@]}" \
264
+ "${transitive_arg[@]}" \
241
265
  '{
242
266
  hash: $hash,
243
267
  ecosystem: $ecosystem,
@@ -248,7 +272,11 @@ safedeps_ledger_write_approved_spec() {
248
272
  expires_at: $expires_at,
249
273
  approved_by: $approved_by,
250
274
  evidence: ($evidence[0] // {}),
251
- transitive_specs: []
275
+ transitive_specs: (($transitive_specs[0] // $transitive_specs) | map({
276
+ ecosystem: (.ecosystem // $ecosystem),
277
+ package: .package,
278
+ version: (.version | tostring)
279
+ }) | unique_by(.ecosystem + "\u0000" + .package + "\u0000" + .version))
252
280
  }' | safedeps_ledger_atomic_write "${target_path}"
253
281
  else
254
282
  jq -cn \
@@ -261,6 +289,7 @@ safedeps_ledger_write_approved_spec() {
261
289
  --arg expires_at "${expires_at}" \
262
290
  --arg approved_by "${approved_by}" \
263
291
  "${evidence_arg[@]}" \
292
+ "${transitive_arg[@]}" \
264
293
  '{
265
294
  hash: $hash,
266
295
  ecosystem: $ecosystem,
@@ -271,39 +300,84 @@ safedeps_ledger_write_approved_spec() {
271
300
  expires_at: $expires_at,
272
301
  approved_by: $approved_by,
273
302
  evidence: $evidence,
274
- transitive_specs: []
303
+ transitive_specs: (($transitive_specs[0] // $transitive_specs) | map({
304
+ ecosystem: (.ecosystem // $ecosystem),
305
+ package: .package,
306
+ version: (.version | tostring)
307
+ }) | unique_by(.ecosystem + "\u0000" + .package + "\u0000" + .version))
275
308
  }' | safedeps_ledger_atomic_write "${target_path}"
276
309
  fi
277
310
 
278
311
  cat "${target_path}"
279
312
  }
280
313
 
314
+ safedeps_ledger_effect_check() {
315
+ local ecosystem="$1"
316
+ local package_name="$2"
317
+ local version="$3"
318
+ local ledger_file
319
+ local now_iso
320
+
321
+ safedeps_ledger_require_jq || return 1
322
+ safedeps_ledger_init
323
+ now_iso=$(safedeps_ledger_now_iso)
324
+
325
+ while IFS= read -r -d '' ledger_file; do
326
+ safedeps_ledger_validate_json "${ledger_file}" || continue
327
+ safedeps_ledger_is_expired_file "${ledger_file}" && continue
328
+ if jq -e \
329
+ --arg ecosystem "${ecosystem}" \
330
+ --arg package "${package_name}" \
331
+ --arg version "${version}" \
332
+ '
333
+ (.revoked_at // "") == ""
334
+ and (
335
+ (.ecosystem == $ecosystem and .package == $package and .version == $version)
336
+ or (((.transitive_specs // []) | map(select(
337
+ (.ecosystem // $ecosystem) == $ecosystem
338
+ and .package == $package
339
+ and (.version | tostring) == $version
340
+ )) | length) > 0)
341
+ )
342
+ ' \
343
+ "${ledger_file}" >/dev/null; then
344
+ jq -cn \
345
+ --arg owner_hash "$(jq -r '.hash' "${ledger_file}")" \
346
+ --arg owner_package "$(jq -r '.package' "${ledger_file}")" \
347
+ --arg owner_version "$(jq -r '.version' "${ledger_file}")" \
348
+ --arg checked_at "${now_iso}" \
349
+ '{approved:true, reason:"hit", owner_hash:$owner_hash, owner_package:$owner_package, owner_version:$owner_version, checked_at:$checked_at}'
350
+ return 0
351
+ fi
352
+ done < <(find "${SAFEDEPS_LEDGER_DIR}" -maxdepth 1 -name '*.json' -type f -print0 2>/dev/null)
353
+
354
+ jq -cn \
355
+ --arg ecosystem "${ecosystem}" \
356
+ --arg package "${package_name}" \
357
+ --arg version "${version}" \
358
+ --arg checked_at "${now_iso}" \
359
+ '{approved:false, reason:"miss", ecosystem:$ecosystem, package:$package, version:$version, checked_at:$checked_at}'
360
+ return 1
361
+ }
362
+
281
363
  safedeps_ledger_revoke() {
282
364
  local ecosystem="$1"
283
365
  local package_name="$2"
284
366
  local version="$3"
285
367
  local reason="${4:-revoked}"
286
368
  local ledger_file
287
- local temp_path
288
369
  local revoked_at
289
370
 
290
371
  ledger_file=$(safedeps_ledger_path "${ecosystem}" "${package_name}" "${version}")
291
372
  [[ -f "${ledger_file}" ]] || return 1
292
373
  safedeps_ledger_validate_json "${ledger_file}" || return 1
293
374
 
294
- temp_path="${ledger_file}.$$"
295
375
  revoked_at=$(safedeps_ledger_now_iso)
296
376
  jq \
297
377
  --arg revoked_at "${revoked_at}" \
298
378
  --arg reason "${reason}" \
299
379
  '. + {revoked_at: $revoked_at, revoked_reason: $reason, expires_at: $revoked_at}' \
300
- "${ledger_file}" > "${temp_path}"
301
- chmod 600 "${temp_path}" 2>/dev/null || true
302
- safedeps_ledger_validate_json "${temp_path}" || {
303
- rm -f "${temp_path}"
304
- return 1
305
- }
306
- mv "${temp_path}" "${ledger_file}"
380
+ "${ledger_file}" | safedeps_ledger_atomic_write "${ledger_file}"
307
381
  cat "${ledger_file}"
308
382
  }
309
383
 
@@ -325,12 +399,16 @@ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
325
399
  safedeps_ledger_check "$@"
326
400
  ;;
327
401
  approve)
328
- if [[ "$#" -lt 3 || "$#" -gt 7 ]]; then
329
- printf 'usage: %s approve <ecosystem> <package> <version> [version_range] [approved_by] [evidence_file] [ttl_days]\n' "$0" >&2
402
+ if [[ "$#" -lt 3 || "$#" -gt 8 ]]; then
403
+ printf 'usage: %s approve <ecosystem> <package> <version> [version_range] [approved_by] [evidence_file] [ttl_days] [transitive_specs_file]\n' "$0" >&2
330
404
  exit 2
331
405
  fi
332
406
  safedeps_ledger_write_approved_spec "$@"
333
407
  ;;
408
+ effect-check)
409
+ [[ "$#" -eq 3 ]] || { printf 'usage: %s effect-check <ecosystem> <package> <version>\n' "$0" >&2; exit 2; }
410
+ safedeps_ledger_effect_check "$@"
411
+ ;;
334
412
  revoke)
335
413
  if [[ "$#" -lt 3 || "$#" -gt 4 ]]; then
336
414
  printf 'usage: %s revoke <ecosystem> <package> <version> [reason]\n' "$0" >&2
@@ -339,7 +417,7 @@ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
339
417
  safedeps_ledger_revoke "$@"
340
418
  ;;
341
419
  *)
342
- printf 'usage: %s {hash|path|check|approve|revoke} ...\n' "$0" >&2
420
+ printf 'usage: %s {hash|path|check|effect-check|approve|revoke} ...\n' "$0" >&2
343
421
  exit 2
344
422
  ;;
345
423
  esac
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bash
2
+ # npm dependency closure helpers for safedeps.
3
+
4
+ set -euo pipefail
5
+
6
+ safedeps_npm_require_jq() {
7
+ if ! command -v jq >/dev/null 2>&1; then
8
+ printf 'safedeps npm closure: jq is required\n' >&2
9
+ return 1
10
+ fi
11
+ }
12
+
13
+ safedeps_npm_lock_closure() {
14
+ local lockfile="$1"
15
+ local direct_package="${2:-}"
16
+
17
+ safedeps_npm_require_jq || return 1
18
+ [[ -f "${lockfile}" ]] || {
19
+ printf 'safedeps npm closure: lockfile not found: %s\n' "${lockfile}" >&2
20
+ return 1
21
+ }
22
+
23
+ jq -c --arg direct_package "${direct_package}" '
24
+ def package_name_from_path($path):
25
+ ($path | split("node_modules/") | last) as $tail
26
+ | if ($tail | startswith("@")) then
27
+ ($tail | split("/") | .[0:2] | join("/"))
28
+ else
29
+ ($tail | split("/") | .[0])
30
+ end;
31
+
32
+ if ((.packages // null) | type) == "object" then
33
+ [
34
+ .packages
35
+ | to_entries[]
36
+ | select(.key != "")
37
+ | select((.value.version // "") != "")
38
+ | {
39
+ ecosystem: "npm",
40
+ package: (.value.name // package_name_from_path(.key)),
41
+ version: (.value.version | tostring)
42
+ }
43
+ | select(.package != "" and .version != "")
44
+ | . + {direct: (.package == $direct_package)}
45
+ ]
46
+ | unique_by(.ecosystem + "\u0000" + .package + "\u0000" + .version)
47
+ | sort_by(.package, .version)
48
+ else
49
+ []
50
+ end
51
+ ' "${lockfile}"
52
+ }
53
+
54
+ safedeps_npm_fixture_closure() {
55
+ local package_name="$1"
56
+ local version="$2"
57
+ local fixture_file="${SAFEDEPS_NPM_CLOSURE_FIXTURE_JSON:-}"
58
+ local key="${package_name}@${version}"
59
+
60
+ [[ -n "${fixture_file}" && -f "${fixture_file}" ]] || return 1
61
+ jq -e -c --arg key "${key}" --arg package "${package_name}" '
62
+ if type == "object" and (.[$key] | type) == "array" then
63
+ .[$key]
64
+ elif type == "array" then
65
+ .
66
+ else
67
+ empty
68
+ end
69
+ | map(. + {ecosystem: (.ecosystem // "npm"), direct: ((.direct // false) or (.package == $package))})
70
+ | unique_by(.ecosystem + "\u0000" + .package + "\u0000" + (.version | tostring))
71
+ | sort_by(.package, .version)
72
+ ' "${fixture_file}"
73
+ }
74
+
75
+ safedeps_npm_resolve_spec_closure() {
76
+ local package_name="$1"
77
+ local version="$2"
78
+ local tmp_dir
79
+ local lockfile
80
+
81
+ safedeps_npm_require_jq || return 1
82
+
83
+ if safedeps_npm_fixture_closure "${package_name}" "${version}"; then
84
+ return 0
85
+ fi
86
+
87
+ if ! command -v npm >/dev/null 2>&1; then
88
+ printf 'safedeps npm closure: npm CLI is required\n' >&2
89
+ return 1
90
+ fi
91
+
92
+ tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/safedeps-npm-closure.XXXXXX") || return 1
93
+
94
+ printf '{"name":"safedeps-closure-probe","version":"0.0.0","private":true}\n' > "${tmp_dir}/package.json"
95
+ if ! (
96
+ cd "${tmp_dir}" &&
97
+ npm install "${package_name}@${version}" \
98
+ --package-lock-only \
99
+ --ignore-scripts \
100
+ --audit=false \
101
+ --fund=false \
102
+ --save-exact \
103
+ >/dev/null
104
+ ); then
105
+ printf 'safedeps npm closure: npm lockfile resolution failed for %s@%s\n' "${package_name}" "${version}" >&2
106
+ rm -rf "${tmp_dir}"
107
+ return 1
108
+ fi
109
+
110
+ lockfile="${tmp_dir}/package-lock.json"
111
+ safedeps_npm_lock_closure "${lockfile}" "${package_name}"
112
+ local status=$?
113
+ rm -rf "${tmp_dir}"
114
+ return "${status}"
115
+ }