@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.
- package/ARCHITECTURE.md +268 -462
- package/README.ko.md +34 -12
- package/README.md +65 -38
- package/ROADMAP.md +82 -87
- package/SKILL.md +13 -7
- package/bin/safedeps +385 -52
- package/lib/gates/audit.sh +36 -0
- package/lib/gates/hooks.sh +93 -0
- package/lib/gates/repo-profile.sh +60 -0
- package/lib/gates/scan.sh +94 -0
- package/lib/ledger/ledger.sh +94 -16
- package/lib/npm/closure.sh +115 -0
- package/lib/providers/providers.sh +244 -25
- package/package.json +1 -1
- package/scripts/install/install-safedeps-hooks.mjs +62 -23
- package/scripts/release-gates.sh +252 -0
- package/scripts/safedeps-post-verify.sh +167 -10
- package/scripts/safedeps-pre-guard.sh +270 -32
- package/scripts/test/e2e.sh +180 -4
- package/scripts/test/fixture-provider.mjs +21 -0
- package/scripts/test/smoke.sh +135 -10
|
@@ -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
|
package/lib/ledger/ledger.sh
CHANGED
|
@@ -183,16 +183,23 @@ safedeps_ledger_check() {
|
|
|
183
183
|
|
|
184
184
|
safedeps_ledger_atomic_write() {
|
|
185
185
|
local target_path="$1"
|
|
186
|
-
local
|
|
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}"
|
|
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
|
|
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
|
+
}
|