@aldegad/safedeps 2.2.0 → 2.4.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 +5 -1
- package/README.ko.md +42 -0
- package/README.md +42 -0
- package/ROADMAP.md +46 -2
- package/SECURITY.md +45 -0
- package/SKILL.md +84 -147
- package/bin/safedeps +36 -2
- package/lib/gates/doctor.sh +212 -0
- package/lib/gates/hooks.sh +40 -2
- package/lib/gates/templates/gitleaks.private.toml.tmpl +45 -0
- package/lib/gates/templates/gitleaks.toml.tmpl +43 -0
- package/lib/gates/templates/pre-commit.tmpl +49 -0
- package/lib/providers/providers.sh +4 -1
- package/package.json +2 -1
- package/scripts/install/install-safedeps-hooks.mjs +3 -0
- package/scripts/safedeps-post-verify.sh +18 -5
- package/scripts/safedeps-pre-guard.sh +39 -7
- package/scripts/test/e2e.sh +48 -0
- package/scripts/test/smoke.sh +79 -2
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
# safedeps doctor — repo security posture check.
|
|
5
|
+
#
|
|
6
|
+
# Diagnoses whether the per-repo secret-leak lane is set up (.gitleaks policy +
|
|
7
|
+
# .githooks/pre-commit + active core.hooksPath + an available scanner) and
|
|
8
|
+
# reports the global dependency-install gate too. Read-only by default; `--fix`
|
|
9
|
+
# scaffolds the missing pieces (hooks init) and activates them (hooks install).
|
|
10
|
+
#
|
|
11
|
+
# Exit codes: 0 = no gaps in the secret-leak lane, 1 = gaps remain.
|
|
12
|
+
|
|
13
|
+
GATES_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
14
|
+
# shellcheck source=./repo-profile.sh
|
|
15
|
+
source "$GATES_LIB_DIR/repo-profile.sh"
|
|
16
|
+
|
|
17
|
+
REPO_ROOT=""
|
|
18
|
+
FIX=0
|
|
19
|
+
JSON_MODE="${SAFEDEPS_JSON_MODE:-0}"
|
|
20
|
+
|
|
21
|
+
usage() {
|
|
22
|
+
printf 'Usage: safedeps doctor [--root <repo>] [--fix] [--json]\n' >&2
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
while [ $# -gt 0 ]; do
|
|
26
|
+
case "$1" in
|
|
27
|
+
--root) REPO_ROOT="${2:?--root needs a path}"; shift 2 ;;
|
|
28
|
+
--fix) FIX=1; shift ;;
|
|
29
|
+
--json) JSON_MODE=1; shift ;;
|
|
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
|
+
# --- check collection ---------------------------------------------------------
|
|
39
|
+
# Each check appends a row: <lane>\t<status>\t<label>\t<remedy>
|
|
40
|
+
# status ∈ ok | gap | na
|
|
41
|
+
CHECK_ROWS=()
|
|
42
|
+
GAPS=0
|
|
43
|
+
|
|
44
|
+
add_check() {
|
|
45
|
+
local lane="$1" status="$2" label="$3" remedy="${4:-}"
|
|
46
|
+
CHECK_ROWS+=("${lane}"$'\t'"${status}"$'\t'"${label}"$'\t'"${remedy}")
|
|
47
|
+
# The exit code reflects the per-repo secret-leak lane only. The global
|
|
48
|
+
# dependency-install gate is reported (✗) but is a per-machine concern, so it
|
|
49
|
+
# does not gate this repo's posture result.
|
|
50
|
+
if [ "$status" = "gap" ] && [ "$lane" = "secret" ]; then GAPS=$((GAPS + 1)); fi
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
scanner_available() {
|
|
54
|
+
if command -v gitleaks >/dev/null 2>&1; then printf 'gitleaks'; return 0; fi
|
|
55
|
+
if command -v docker >/dev/null 2>&1; then printf 'docker'; return 0; fi
|
|
56
|
+
return 1
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dependency_gate_root() {
|
|
60
|
+
# The dependency-install gate is installed globally as a skill symlink for
|
|
61
|
+
# whichever engine(s) are present.
|
|
62
|
+
local found=""
|
|
63
|
+
[ -e "$HOME/.claude/skills/safedeps" ] && found="${found:+${found}, }~/.claude/skills/safedeps"
|
|
64
|
+
[ -e "$HOME/.codex/skills/safedeps" ] && found="${found:+${found}, }~/.codex/skills/safedeps"
|
|
65
|
+
printf '%s' "$found"
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
run_checks() {
|
|
69
|
+
CHECK_ROWS=()
|
|
70
|
+
GAPS=0
|
|
71
|
+
|
|
72
|
+
local is_git=0
|
|
73
|
+
if git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then is_git=1; fi
|
|
74
|
+
|
|
75
|
+
local profile="(n/a)"
|
|
76
|
+
if [ "$is_git" = 1 ]; then
|
|
77
|
+
add_check secret ok "git worktree"
|
|
78
|
+
profile="$(safedeps_repo_profile "$REPO_ROOT")"
|
|
79
|
+
|
|
80
|
+
local config_path config_label
|
|
81
|
+
config_path="$(safedeps_gitleaks_config "$REPO_ROOT" "$profile")"
|
|
82
|
+
config_label="gitleaks config ($(basename "$config_path"))"
|
|
83
|
+
if [ -f "$config_path" ]; then
|
|
84
|
+
add_check secret ok "$config_label"
|
|
85
|
+
else
|
|
86
|
+
add_check secret gap "$config_label" "safedeps hooks init --root \"$REPO_ROOT\""
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
local hook_file="$REPO_ROOT/.githooks/pre-commit"
|
|
90
|
+
if [ -x "$hook_file" ]; then
|
|
91
|
+
add_check secret ok ".githooks/pre-commit (executable)"
|
|
92
|
+
elif [ -f "$hook_file" ]; then
|
|
93
|
+
add_check secret gap ".githooks/pre-commit (not executable)" "chmod +x \"$hook_file\""
|
|
94
|
+
else
|
|
95
|
+
add_check secret gap ".githooks/pre-commit (present)" "safedeps hooks init --root \"$REPO_ROOT\""
|
|
96
|
+
fi
|
|
97
|
+
|
|
98
|
+
local hooks_path
|
|
99
|
+
hooks_path="$(git -C "$REPO_ROOT" config --get core.hooksPath || true)"
|
|
100
|
+
if [ "$hooks_path" = ".githooks" ]; then
|
|
101
|
+
add_check secret ok "git hooks active (core.hooksPath=.githooks)"
|
|
102
|
+
else
|
|
103
|
+
add_check secret gap "git hooks active (core.hooksPath=${hooks_path:-<unset>})" "safedeps hooks install --root \"$REPO_ROOT\""
|
|
104
|
+
fi
|
|
105
|
+
else
|
|
106
|
+
add_check secret na "git worktree (secret lane needs git)"
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
local scanner
|
|
110
|
+
if scanner="$(scanner_available)"; then
|
|
111
|
+
add_check secret ok "secret scanner available (${scanner})"
|
|
112
|
+
else
|
|
113
|
+
add_check secret gap "secret scanner available (gitleaks or docker)" "brew install gitleaks"
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
local gate_root
|
|
117
|
+
gate_root="$(dependency_gate_root)"
|
|
118
|
+
if [ -n "$gate_root" ]; then
|
|
119
|
+
add_check deps ok "dependency-install gate installed (${gate_root})"
|
|
120
|
+
else
|
|
121
|
+
add_check deps gap "dependency-install gate installed" "node scripts/install/install-safedeps-hooks.mjs"
|
|
122
|
+
fi
|
|
123
|
+
|
|
124
|
+
DOCTOR_PROFILE="$profile"
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
emit_human() {
|
|
128
|
+
local sym
|
|
129
|
+
printf 'safedeps doctor — repo security posture\n'
|
|
130
|
+
printf 'repo: %s\n' "$REPO_ROOT"
|
|
131
|
+
printf 'profile: %s\n\n' "$DOCTOR_PROFILE"
|
|
132
|
+
|
|
133
|
+
printf 'Secret-leak lane (per-repo)\n'
|
|
134
|
+
local row lane status label remedy
|
|
135
|
+
for row in "${CHECK_ROWS[@]}"; do
|
|
136
|
+
IFS=$'\t' read -r lane status label remedy <<< "$row"
|
|
137
|
+
[ "$lane" = "secret" ] || continue
|
|
138
|
+
case "$status" in
|
|
139
|
+
ok) sym='✓' ;;
|
|
140
|
+
gap) sym='✗' ;;
|
|
141
|
+
*) sym='–' ;;
|
|
142
|
+
esac
|
|
143
|
+
if [ "$status" = "gap" ] && [ -n "$remedy" ]; then
|
|
144
|
+
printf ' %s %-44s → %s\n' "$sym" "$label" "$remedy"
|
|
145
|
+
else
|
|
146
|
+
printf ' %s %s\n' "$sym" "$label"
|
|
147
|
+
fi
|
|
148
|
+
done
|
|
149
|
+
|
|
150
|
+
printf '\nDependency-install gate (global, all repos)\n'
|
|
151
|
+
for row in "${CHECK_ROWS[@]}"; do
|
|
152
|
+
IFS=$'\t' read -r lane status label remedy <<< "$row"
|
|
153
|
+
[ "$lane" = "deps" ] || continue
|
|
154
|
+
case "$status" in
|
|
155
|
+
ok) sym='✓' ;;
|
|
156
|
+
gap) sym='✗' ;;
|
|
157
|
+
*) sym='–' ;;
|
|
158
|
+
esac
|
|
159
|
+
if [ "$status" = "gap" ] && [ -n "$remedy" ]; then
|
|
160
|
+
printf ' %s %-44s → %s\n' "$sym" "$label" "$remedy"
|
|
161
|
+
else
|
|
162
|
+
printf ' %s %s\n' "$sym" "$label"
|
|
163
|
+
fi
|
|
164
|
+
done
|
|
165
|
+
|
|
166
|
+
printf '\n'
|
|
167
|
+
if [ "$GAPS" -eq 0 ]; then
|
|
168
|
+
printf 'No gaps in the secret-leak lane. The agent is on guard.\n'
|
|
169
|
+
else
|
|
170
|
+
printf '%d gap(s) in the secret-leak lane.\n' "$GAPS"
|
|
171
|
+
printf 'Fix all at once: safedeps doctor --fix --root "%s"\n' "$REPO_ROOT"
|
|
172
|
+
fi
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
emit_json() {
|
|
176
|
+
local rows_json="[]" row lane status label remedy
|
|
177
|
+
for row in "${CHECK_ROWS[@]}"; do
|
|
178
|
+
IFS=$'\t' read -r lane status label remedy <<< "$row"
|
|
179
|
+
rows_json="$(jq -c \
|
|
180
|
+
--arg lane "$lane" --arg status "$status" --arg label "$label" --arg remedy "$remedy" \
|
|
181
|
+
'. + [{lane:$lane, status:$status, label:$label, remedy:($remedy|select(length>0))}]' \
|
|
182
|
+
<<< "$rows_json")"
|
|
183
|
+
done
|
|
184
|
+
jq -nc \
|
|
185
|
+
--arg repo "$REPO_ROOT" \
|
|
186
|
+
--arg profile "$DOCTOR_PROFILE" \
|
|
187
|
+
--argjson gaps "$GAPS" \
|
|
188
|
+
--argjson checks "$rows_json" \
|
|
189
|
+
'{command:"doctor", repo:$repo, profile:$profile, gaps:$gaps, ok:($gaps==0), checks:$checks}'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
# --- fix pass -----------------------------------------------------------------
|
|
193
|
+
if [ "$FIX" -eq 1 ]; then
|
|
194
|
+
if ! git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
195
|
+
printf 'ERROR: --fix needs a git worktree (the secret lane is git-scoped): %s\n' "$REPO_ROOT" >&2
|
|
196
|
+
exit 1
|
|
197
|
+
fi
|
|
198
|
+
[ "$JSON_MODE" -eq 1 ] || printf 'safedeps doctor --fix: scaffolding + activating the secret-leak lane...\n\n'
|
|
199
|
+
bash "$GATES_LIB_DIR/hooks.sh" init --root "$REPO_ROOT"
|
|
200
|
+
bash "$GATES_LIB_DIR/hooks.sh" install --root "$REPO_ROOT"
|
|
201
|
+
[ "$JSON_MODE" -eq 1 ] || printf '\n'
|
|
202
|
+
fi
|
|
203
|
+
|
|
204
|
+
run_checks
|
|
205
|
+
|
|
206
|
+
if [ "$JSON_MODE" -eq 1 ]; then
|
|
207
|
+
emit_json
|
|
208
|
+
else
|
|
209
|
+
emit_human
|
|
210
|
+
fi
|
|
211
|
+
|
|
212
|
+
[ "$GAPS" -eq 0 ]
|
package/lib/gates/hooks.sh
CHANGED
|
@@ -16,12 +16,12 @@ HOOKS_PATH=".githooks"
|
|
|
16
16
|
AUTO=0
|
|
17
17
|
|
|
18
18
|
usage() {
|
|
19
|
-
printf 'Usage: safedeps hooks <install|check> [--root <repo>] [--hooks-path <dir>] [--auto]\n' >&2
|
|
19
|
+
printf 'Usage: safedeps hooks <install|check|init> [--root <repo>] [--hooks-path <dir>] [--auto]\n' >&2
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
while [ $# -gt 0 ]; do
|
|
23
23
|
case "$1" in
|
|
24
|
-
install|check) SUB="$1"; shift ;;
|
|
24
|
+
install|check|init) SUB="$1"; shift ;;
|
|
25
25
|
--root) REPO_ROOT="${2:?--root needs a path}"; shift 2 ;;
|
|
26
26
|
--hooks-path) HOOKS_PATH="${2:?--hooks-path needs a dir}"; shift 2 ;;
|
|
27
27
|
--auto) AUTO=1; shift ;;
|
|
@@ -46,6 +46,44 @@ fi
|
|
|
46
46
|
HOOK_FILE="$REPO_ROOT/$HOOKS_PATH/pre-commit"
|
|
47
47
|
|
|
48
48
|
case "$SUB" in
|
|
49
|
+
init)
|
|
50
|
+
# Scaffold the repo's secret-leak policy from starter templates. Non-destructive:
|
|
51
|
+
# an existing file is reported and skipped, so repo-owned edits survive a re-run.
|
|
52
|
+
# `init` only drops files; `install` activates them (core.hooksPath).
|
|
53
|
+
TEMPLATES_DIR="$GATES_LIB_DIR/templates"
|
|
54
|
+
PROFILE="$(safedeps_repo_profile "$REPO_ROOT")"
|
|
55
|
+
created=0
|
|
56
|
+
|
|
57
|
+
scaffold() {
|
|
58
|
+
local dest="$1" tmpl="$2" mode="$3"
|
|
59
|
+
if [ -e "$dest" ]; then
|
|
60
|
+
printf 'safedeps hooks init: exists, skipped: %s\n' "$dest"
|
|
61
|
+
return 0
|
|
62
|
+
fi
|
|
63
|
+
if [ ! -f "$tmpl" ]; then
|
|
64
|
+
printf 'ERROR: template missing: %s\n' "$tmpl" >&2
|
|
65
|
+
exit 1
|
|
66
|
+
fi
|
|
67
|
+
mkdir -p "$(dirname "$dest")"
|
|
68
|
+
cp "$tmpl" "$dest"
|
|
69
|
+
chmod "$mode" "$dest"
|
|
70
|
+
printf 'safedeps hooks init: created %s\n' "$dest"
|
|
71
|
+
created=$((created + 1))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if [ "$PROFILE" = "private" ]; then
|
|
75
|
+
scaffold "$REPO_ROOT/.gitleaks.private.toml" "$TEMPLATES_DIR/gitleaks.private.toml.tmpl" 0644
|
|
76
|
+
else
|
|
77
|
+
scaffold "$REPO_ROOT/.gitleaks.toml" "$TEMPLATES_DIR/gitleaks.toml.tmpl" 0644
|
|
78
|
+
fi
|
|
79
|
+
scaffold "$REPO_ROOT/$HOOKS_PATH/pre-commit" "$TEMPLATES_DIR/pre-commit.tmpl" 0755
|
|
80
|
+
|
|
81
|
+
printf 'safedeps hooks init: profile=%s, %d file(s) created.\n' "$PROFILE" "$created"
|
|
82
|
+
if [ "$created" -gt 0 ]; then
|
|
83
|
+
printf ' Review the scaffolded .gitleaks policy, then activate:\n'
|
|
84
|
+
printf ' safedeps hooks install --root "%s"\n' "$REPO_ROOT"
|
|
85
|
+
fi
|
|
86
|
+
;;
|
|
49
87
|
install)
|
|
50
88
|
if [ ! -f "$HOOK_FILE" ]; then
|
|
51
89
|
printf 'ERROR: hook file not found: %s\n' "$HOOK_FILE" >&2
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# .gitleaks.private.toml — safedeps starter secret-scan policy (PRIVATE profile).
|
|
2
|
+
#
|
|
3
|
+
# Resolved when the repo's origin slug or directory leaf ends in "-private",
|
|
4
|
+
# or when SAFEDEPS_REPO_PROFILE=private. Use this for the stricter policy a
|
|
5
|
+
# private repo wants on top of the public defaults.
|
|
6
|
+
#
|
|
7
|
+
# safedeps OWNS execution; THIS FILE is the repo's POLICY — you own it.
|
|
8
|
+
# `safedeps hooks init` scaffolds this once and will NOT overwrite your edits.
|
|
9
|
+
|
|
10
|
+
title = "safedeps secret-scan policy (private)"
|
|
11
|
+
|
|
12
|
+
# Inherit gitleaks' built-in ruleset. To also layer the repo's public policy,
|
|
13
|
+
# point `path` at it instead of (or in addition to) useDefault.
|
|
14
|
+
[extend]
|
|
15
|
+
useDefault = true
|
|
16
|
+
# path = ".gitleaks.toml"
|
|
17
|
+
|
|
18
|
+
# --- Extra rules layered on top of the defaults -------------------------------
|
|
19
|
+
|
|
20
|
+
[[rules]]
|
|
21
|
+
id = "safedeps-dotenv-file"
|
|
22
|
+
description = "Committed .env file with an assigned secret value"
|
|
23
|
+
path = '''(^|/)\.env(\.[\w.-]+)?$'''
|
|
24
|
+
regex = '''(?i)(secret|token|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret)\s*[:=]\s*\S{6,}'''
|
|
25
|
+
[rules.allowlist]
|
|
26
|
+
paths = [
|
|
27
|
+
'''(^|/)\.env\.example$''',
|
|
28
|
+
'''(^|/)\.env\.sample$''',
|
|
29
|
+
'''(^|/)\.env\.template$''',
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
# Private repos often also flag internal identifiers (infra hosts, account ids).
|
|
33
|
+
# Add your own, e.g.:
|
|
34
|
+
# [[rules]]
|
|
35
|
+
# id = "safedeps-internal-host"
|
|
36
|
+
# description = "Internal hostname / infra endpoint"
|
|
37
|
+
# regex = '''(?i)\b[\w.-]+\.internal\.example\.com\b'''
|
|
38
|
+
|
|
39
|
+
# --- Repo-owned allowlist — CUSTOMIZE FOR THIS REPO ---------------------------
|
|
40
|
+
[allowlist]
|
|
41
|
+
description = "Repo-specific allowlist (tune me)"
|
|
42
|
+
paths = [
|
|
43
|
+
'''(^|/)\.gitleaks\.toml$''',
|
|
44
|
+
'''(^|/)\.gitleaks\.private\.toml$''',
|
|
45
|
+
]
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# .gitleaks.toml — safedeps starter secret-scan policy (public repo profile).
|
|
2
|
+
#
|
|
3
|
+
# safedeps OWNS execution (it runs gitleaks via `safedeps scan secrets`).
|
|
4
|
+
# THIS FILE is the repo's POLICY — you own it. Tune the [allowlist] below for
|
|
5
|
+
# this repo. `safedeps hooks init` scaffolds this file once and will NOT
|
|
6
|
+
# overwrite it on a re-run, so your edits are safe.
|
|
7
|
+
#
|
|
8
|
+
# gitleaks config reference: https://github.com/gitleaks/gitleaks#configuration
|
|
9
|
+
|
|
10
|
+
title = "safedeps secret-scan policy"
|
|
11
|
+
|
|
12
|
+
# Inherit gitleaks' built-in ruleset (AWS / GCP / private keys / OAuth tokens /
|
|
13
|
+
# generic high-entropy secrets, etc.). This is the bulk of the coverage.
|
|
14
|
+
[extend]
|
|
15
|
+
useDefault = true
|
|
16
|
+
|
|
17
|
+
# --- Extra rules layered on top of the defaults -------------------------------
|
|
18
|
+
|
|
19
|
+
# Flag a committed real dotenv file with an assigned secret. The .example /
|
|
20
|
+
# .sample / .template variants are allowlisted below so docs stay committable.
|
|
21
|
+
[[rules]]
|
|
22
|
+
id = "safedeps-dotenv-file"
|
|
23
|
+
description = "Committed .env file with an assigned secret value"
|
|
24
|
+
path = '''(^|/)\.env(\.[\w.-]+)?$'''
|
|
25
|
+
regex = '''(?i)(secret|token|password|passwd|api[_-]?key|access[_-]?key|private[_-]?key|client[_-]?secret)\s*[:=]\s*\S{6,}'''
|
|
26
|
+
[rules.allowlist]
|
|
27
|
+
paths = [
|
|
28
|
+
'''(^|/)\.env\.example$''',
|
|
29
|
+
'''(^|/)\.env\.sample$''',
|
|
30
|
+
'''(^|/)\.env\.template$''',
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# --- Repo-owned allowlist — CUSTOMIZE FOR THIS REPO ---------------------------
|
|
34
|
+
[allowlist]
|
|
35
|
+
description = "Repo-specific allowlist (tune me)"
|
|
36
|
+
paths = [
|
|
37
|
+
'''(^|/)\.gitleaks\.toml$''',
|
|
38
|
+
'''(^|/)\.gitleaks\.private\.toml$''',
|
|
39
|
+
# Add test fixtures / docs that legitimately contain secret-shaped strings:
|
|
40
|
+
# '''(^|/)test/fixtures/''',
|
|
41
|
+
]
|
|
42
|
+
# regexes = ['''EXAMPLE_PLACEHOLDER_[A-Z0-9]+''']
|
|
43
|
+
# stopwords = ["example", "dummy", "placeholder"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# .githooks/pre-commit — safedeps secret-leak gate (scaffolded by `safedeps hooks init`).
|
|
3
|
+
#
|
|
4
|
+
# Blocks a commit when gitleaks finds a secret in the STAGED changes, so a key
|
|
5
|
+
# or a real .env never enters the repo's history. The detection POLICY lives in
|
|
6
|
+
# this repo's .gitleaks.toml (public) / .gitleaks.private.toml (private);
|
|
7
|
+
# safedeps owns execution. This hook delegates to one canonical scanner path:
|
|
8
|
+
# `safedeps scan secrets --staged`.
|
|
9
|
+
#
|
|
10
|
+
# Intentional bypass (use sparingly — you own the risk): git commit --no-verify
|
|
11
|
+
set -euo pipefail
|
|
12
|
+
|
|
13
|
+
repo_root=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
|
14
|
+
|
|
15
|
+
# Resolve the safedeps CLI: PATH first, then the known skill install locations.
|
|
16
|
+
resolve_safedeps() {
|
|
17
|
+
if command -v safedeps >/dev/null 2>&1; then printf 'safedeps'; return 0; fi
|
|
18
|
+
local candidate
|
|
19
|
+
for candidate in \
|
|
20
|
+
"${SAFEDEPS_BIN:-}" \
|
|
21
|
+
"${HOME}/.claude/skills/safedeps/bin/safedeps" \
|
|
22
|
+
"${HOME}/.codex/skills/safedeps/bin/safedeps"; do
|
|
23
|
+
if [ -n "${candidate}" ] && [ -x "${candidate}" ]; then
|
|
24
|
+
printf '%s' "${candidate}"
|
|
25
|
+
return 0
|
|
26
|
+
fi
|
|
27
|
+
done
|
|
28
|
+
return 1
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if ! sd=$(resolve_safedeps); then
|
|
32
|
+
cat >&2 <<'EOF'
|
|
33
|
+
safedeps pre-commit: cannot locate the `safedeps` CLI.
|
|
34
|
+
|
|
35
|
+
The secret-scan gate is FAIL-CLOSED — the commit is blocked because the scanner
|
|
36
|
+
could not run (no silent skip). Fix one of:
|
|
37
|
+
- put `safedeps` on PATH, or
|
|
38
|
+
- export SAFEDEPS_BIN=/path/to/bin/safedeps, or
|
|
39
|
+
- install the skill: node scripts/install/install-safedeps-hooks.mjs
|
|
40
|
+
|
|
41
|
+
To bypass intentionally (you own the risk): git commit --no-verify
|
|
42
|
+
EOF
|
|
43
|
+
exit 1
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
# Delegate to the single canonical scanner. A non-zero exit means either a
|
|
47
|
+
# secret was found OR no scanner (gitleaks/docker) is available — both
|
|
48
|
+
# fail-closed and block the commit.
|
|
49
|
+
exec "${sd}" scan secrets --staged --root "${repo_root}"
|
|
@@ -93,7 +93,10 @@ safedeps_hash_text() {
|
|
|
93
93
|
safedeps_file_mtime() {
|
|
94
94
|
local path="$1"
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
# GNU coreutils (Linux) uses `-c %Y`; BSD/macOS uses `-f %m`. GNU must run
|
|
97
|
+
# first: on Linux `stat -f` means --file-system and prints filesystem info to
|
|
98
|
+
# stdout, which would pollute the mtime and break the arithmetic downstream.
|
|
99
|
+
stat -c %Y "${path}" 2>/dev/null || stat -f %m "${path}" 2>/dev/null
|
|
97
100
|
}
|
|
98
101
|
|
|
99
102
|
safedeps_cache_is_fresh() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aldegad/safedeps",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Dependency install safety gate with OSV-backed advisory checks, approved-spec ledger enforcement, and reorg rollback hooks",
|
|
5
5
|
"main": "bin/safedeps",
|
|
6
6
|
"bin": {
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"ARCHITECTURE.md",
|
|
17
17
|
"ROADMAP.md",
|
|
18
18
|
"SKILL.md",
|
|
19
|
+
"SECURITY.md",
|
|
19
20
|
"LICENSE"
|
|
20
21
|
],
|
|
21
22
|
"scripts": {
|
|
@@ -242,6 +242,9 @@ function main() {
|
|
|
242
242
|
log("uninstall done.");
|
|
243
243
|
} else {
|
|
244
244
|
log("install done. New hook events fire on the next session start.");
|
|
245
|
+
// The dependency-install gate is global. The secret-leak lane is per-repo
|
|
246
|
+
// and stays opt-in (its policy lives in each repo). Nudge, do not auto-write.
|
|
247
|
+
log("secret-leak lane is per-repo — in a repo run: safedeps doctor (then `safedeps doctor --fix` to scaffold + activate).");
|
|
245
248
|
}
|
|
246
249
|
}
|
|
247
250
|
|
|
@@ -35,8 +35,16 @@ SAFEDEPS_MANIFEST_FILES=(
|
|
|
35
35
|
umask 077
|
|
36
36
|
mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
|
|
37
37
|
|
|
38
|
+
# Observable record when the effect gate cannot run (AGENTS.md: no silent fallback).
|
|
39
|
+
# The install already happened by PostToolUse, so we cannot block it; what we can
|
|
40
|
+
# guarantee is that an un-runnable gate is recorded as UNVERIFIED, never a silent pass.
|
|
41
|
+
log_advisory() {
|
|
42
|
+
printf '%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "${GUARD_DIR}/advisory.log" 2>/dev/null || true
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
if ! command -v jq >/dev/null 2>&1; then
|
|
39
|
-
|
|
46
|
+
log_advisory "post-verify UNVERIFIED: jq missing — could not verify the install closure. Install jq to restore the effect gate."
|
|
47
|
+
echo "safedeps: jq is not installed — the post-install effect gate could not run; this install is UNVERIFIED (logged to advisory.log)." >&2
|
|
40
48
|
exit 0
|
|
41
49
|
fi
|
|
42
50
|
|
|
@@ -55,8 +63,10 @@ acquire_state_lock() {
|
|
|
55
63
|
# Detect stale locks left by SIGKILL/OOM (V-005)
|
|
56
64
|
if [[ -d "${STATE_LOCK_DIR}" ]]; then
|
|
57
65
|
local lock_mtime=""
|
|
58
|
-
|
|
59
|
-
|
|
66
|
+
# GNU (`-c %Y`, Linux) first, then BSD/macOS (`-f %m`): on Linux `stat -f`
|
|
67
|
+
# means --file-system and would not yield an mtime.
|
|
68
|
+
if lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null) || \
|
|
69
|
+
lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null); then
|
|
60
70
|
local now
|
|
61
71
|
now=$(date +%s)
|
|
62
72
|
if [[ $(( now - lock_mtime )) -gt 60 ]]; then
|
|
@@ -68,8 +78,11 @@ acquire_state_lock() {
|
|
|
68
78
|
fi
|
|
69
79
|
|
|
70
80
|
attempts=$((attempts + 1))
|
|
71
|
-
if [[ ${attempts} -ge 100 ]]; then
|
|
72
|
-
|
|
81
|
+
if [[ ${attempts} -ge ${SAFEDEPS_LOCK_MAX_ATTEMPTS:-100} ]]; then
|
|
82
|
+
# Install already ran; another safedeps run holds the lock. Record UNVERIFIED
|
|
83
|
+
# rather than silently passing — the other run, or a re-check, owns verification.
|
|
84
|
+
log_advisory "post-verify UNVERIFIED: state lock unavailable (another safedeps run active) — install not verified by this run."
|
|
85
|
+
echo "safedeps: could not acquire state lock; this install is UNVERIFIED by this run (logged to advisory.log)." >&2
|
|
73
86
|
exit 0
|
|
74
87
|
fi
|
|
75
88
|
sleep 0.1
|
|
@@ -36,8 +36,26 @@ SAFEDEPS_MANIFEST_FILES=(
|
|
|
36
36
|
umask 077
|
|
37
37
|
mkdir -p "${GUARD_DIR}" "${SNAPSHOT_DIR}"
|
|
38
38
|
|
|
39
|
+
# Observable record of any gate bypass / unavailability (AGENTS.md: no silent fallback —
|
|
40
|
+
# every bypass must be observable and logged).
|
|
41
|
+
log_advisory() {
|
|
42
|
+
printf '%s\t%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" >> "${GUARD_DIR}/advisory.log" 2>/dev/null || true
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
if ! command -v jq >/dev/null 2>&1; then
|
|
40
|
-
|
|
46
|
+
# jq is required to parse the hook payload. Without it we cannot read the exact
|
|
47
|
+
# command, so do a best-effort fail-closed: read the raw payload and, if it
|
|
48
|
+
# looks like a dependency install, DENY (an install we cannot verify must not
|
|
49
|
+
# proceed). Non-install commands are allowed — jq absence must not block `ls`.
|
|
50
|
+
# Either branch is recorded in advisory.log; never a silent skip.
|
|
51
|
+
raw_input=$(cat)
|
|
52
|
+
log_advisory "pre-guard: jq missing — gate cannot parse the payload."
|
|
53
|
+
if printf '%s' "${raw_input}" | grep -qiE '(npm|pnpm|yarn|bun)([^"]*)(install|add|dlx)|[^a-z]npx[[:space:]]|pip[0-9]*[[:space:]]+install|poetry[[:space:]]+add|uv[[:space:]]+(add|pip[[:space:]]+install)|pipenv[[:space:]]+install|cargo[[:space:]]+(add|install)|go[[:space:]]+(get|install)|gem[[:space:]]+install|bundle[[:space:]]+add|mvn([^"]*)dependency:get|dotnet[[:space:]]+add[[:space:]]+package'; then
|
|
54
|
+
log_advisory "pre-guard DENY: jq missing on a likely dependency-install command — fail-closed."
|
|
55
|
+
printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"safedeps: jq is required to gate dependency installs and is not installed — install blocked fail-closed. Install jq, then retry."}}\n'
|
|
56
|
+
exit 0
|
|
57
|
+
fi
|
|
58
|
+
echo "safedeps: jq is not installed — install gate disabled (non-install commands still allowed); logged to advisory.log." >&2
|
|
41
59
|
exit 0
|
|
42
60
|
fi
|
|
43
61
|
|
|
@@ -48,8 +66,10 @@ acquire_state_lock() {
|
|
|
48
66
|
# Detect stale locks left by SIGKILL/OOM (V-005)
|
|
49
67
|
if [[ -d "${STATE_LOCK_DIR}" ]]; then
|
|
50
68
|
local lock_mtime=""
|
|
51
|
-
|
|
52
|
-
|
|
69
|
+
# GNU (`-c %Y`, Linux) first, then BSD/macOS (`-f %m`): on Linux `stat -f`
|
|
70
|
+
# means --file-system and would not yield an mtime.
|
|
71
|
+
if lock_mtime=$(stat -c %Y "${STATE_LOCK_DIR}" 2>/dev/null) || \
|
|
72
|
+
lock_mtime=$(stat -f %m "${STATE_LOCK_DIR}" 2>/dev/null); then
|
|
53
73
|
local now
|
|
54
74
|
now=$(date +%s)
|
|
55
75
|
if [[ $(( now - lock_mtime )) -gt 60 ]]; then
|
|
@@ -61,8 +81,11 @@ acquire_state_lock() {
|
|
|
61
81
|
fi
|
|
62
82
|
|
|
63
83
|
attempts=$((attempts + 1))
|
|
64
|
-
if [[ ${attempts} -ge 100 ]]; then
|
|
65
|
-
|
|
84
|
+
if [[ ${attempts} -ge ${SAFEDEPS_LOCK_MAX_ATTEMPTS:-100} ]]; then
|
|
85
|
+
# acquire_state_lock is only reached for install candidates, so failing to
|
|
86
|
+
# serialize/snapshot means this install cannot be gated — fail CLOSED (deny).
|
|
87
|
+
log_advisory "pre-guard DENY: state lock unavailable for an install command — fail-closed."
|
|
88
|
+
jq -nc '{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:"safedeps: could not acquire the state lock (another safedeps run may be active). Install blocked fail-closed — retry in a moment."}}'
|
|
66
89
|
exit 0
|
|
67
90
|
fi
|
|
68
91
|
sleep 0.1
|
|
@@ -438,7 +461,7 @@ fi
|
|
|
438
461
|
# Conservative: only block when at least one pkg@spec token is parseable. Bare
|
|
439
462
|
# `npm install` (lockfile install) falls through to the v1 reorg checks.
|
|
440
463
|
|
|
441
|
-
SAFEDEPS_LEDGER_LIB="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh"
|
|
464
|
+
SAFEDEPS_LEDGER_LIB="${SAFEDEPS_LEDGER_LIB:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/lib/ledger/ledger.sh}"
|
|
442
465
|
SAFEDEPS_REPO_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/safedeps"
|
|
443
466
|
|
|
444
467
|
guard_detect_ecosystem() {
|
|
@@ -596,7 +619,16 @@ while IFS= read -r ledger_spec_line; do
|
|
|
596
619
|
LEDGER_SPECS+=("${ledger_spec_line}")
|
|
597
620
|
done < <(guard_extract_specs "${COMMAND}")
|
|
598
621
|
|
|
599
|
-
if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0
|
|
622
|
+
if [[ -n "${LEDGER_ECOSYSTEM}" && ${#LEDGER_SPECS[@]} -gt 0 ]]; then
|
|
623
|
+
if [[ ! -f "${SAFEDEPS_LEDGER_LIB}" ]]; then
|
|
624
|
+
# The ledger library is the gate for direct install specs. If it is missing
|
|
625
|
+
# (broken install / moved repo) the gate cannot run — fail CLOSED, observably,
|
|
626
|
+
# instead of falling through to allow.
|
|
627
|
+
log_advisory "pre-guard DENY: ledger library missing (${SAFEDEPS_LEDGER_LIB}) — cannot enforce ${LEDGER_ECOSYSTEM} install, fail-closed."
|
|
628
|
+
jq -nc --arg eco "${LEDGER_ECOSYSTEM}" \
|
|
629
|
+
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:("safedeps: the ledger library is missing, so the " + $eco + " install gate cannot run — install blocked fail-closed. Reinstall safedeps: node scripts/install/install-safedeps-hooks.mjs")}}'
|
|
630
|
+
exit 0
|
|
631
|
+
fi
|
|
600
632
|
# shellcheck source=../lib/ledger/ledger.sh
|
|
601
633
|
source "${SAFEDEPS_LEDGER_LIB}"
|
|
602
634
|
|
package/scripts/test/e2e.sh
CHANGED
|
@@ -280,4 +280,52 @@ if jq -e '[.. | strings] | any(contains("npm-reorg-guard"))' "${installer_home}/
|
|
|
280
280
|
fi
|
|
281
281
|
pass "installer legacy hook cleanup"
|
|
282
282
|
|
|
283
|
+
# --- Secret-leak lane: pre-commit gate must DENY a secret, PASS clean/example -
|
|
284
|
+
# The real bypass harness for the secret lane. Needs a scanner (gitleaks or
|
|
285
|
+
# docker) and openssl for a synthetic high-entropy secret; skip explicitly
|
|
286
|
+
# (not silently) when either is missing.
|
|
287
|
+
secret_repo="${tmp_root}/secret-repo"
|
|
288
|
+
mkdir -p "${secret_repo}"
|
|
289
|
+
git -C "${secret_repo}" init -q
|
|
290
|
+
git -C "${secret_repo}" config user.email t@safedeps.test
|
|
291
|
+
git -C "${secret_repo}" config user.name safedeps-e2e
|
|
292
|
+
|
|
293
|
+
# doctor flags gaps on the bare repo, then --fix scaffolds + activates the lane.
|
|
294
|
+
if HOME="${tmp_root}/doc-home" "${ROOT_DIR}/bin/safedeps" doctor --root "${secret_repo}" >/dev/null 2>&1; then
|
|
295
|
+
fail "doctor flags gaps on an unconfigured repo"
|
|
296
|
+
fi
|
|
297
|
+
HOME="${tmp_root}/doc-home" "${ROOT_DIR}/bin/safedeps" doctor --fix --root "${secret_repo}" >/dev/null
|
|
298
|
+
[[ -f "${secret_repo}/.gitleaks.toml" ]] || fail "doctor --fix scaffolds .gitleaks.toml"
|
|
299
|
+
[[ -x "${secret_repo}/.githooks/pre-commit" ]] || fail "doctor --fix scaffolds executable pre-commit"
|
|
300
|
+
[[ "$(git -C "${secret_repo}" config --get core.hooksPath)" == ".githooks" ]] || fail "doctor --fix activates core.hooksPath"
|
|
301
|
+
pass "doctor --fix scaffolds + activates the secret lane"
|
|
302
|
+
|
|
303
|
+
# The scaffolded pre-commit resolves `safedeps` via PATH, then SAFEDEPS_BIN, then
|
|
304
|
+
# the skill install paths. In CI none of those exist, so point it at this repo's
|
|
305
|
+
# binary; the git commit subprocess inherits the env and the hook resolves it.
|
|
306
|
+
export SAFEDEPS_BIN="${ROOT_DIR}/bin/safedeps"
|
|
307
|
+
|
|
308
|
+
if command -v gitleaks >/dev/null 2>&1 && command -v openssl >/dev/null 2>&1; then
|
|
309
|
+
# Regression: a clean file commits cleanly.
|
|
310
|
+
echo "hello" > "${secret_repo}/readme.txt"
|
|
311
|
+
git -C "${secret_repo}" add readme.txt
|
|
312
|
+
git -C "${secret_repo}" commit -q -m "clean" || fail "pre-commit allows a clean commit"
|
|
313
|
+
|
|
314
|
+
# Threat: a literal .env with an assigned (synthetic) secret must be blocked.
|
|
315
|
+
printf 'API_KEY=%s\n' "$(openssl rand -hex 20)" > "${secret_repo}/.env"
|
|
316
|
+
git -C "${secret_repo}" add .env
|
|
317
|
+
if git -C "${secret_repo}" commit -q -m "leak" 2>/dev/null; then
|
|
318
|
+
fail "pre-commit blocks a committed .env secret"
|
|
319
|
+
fi
|
|
320
|
+
git -C "${secret_repo}" reset -q HEAD .env >/dev/null 2>&1 || true
|
|
321
|
+
|
|
322
|
+
# Regression: the .env.example placeholder is allowlisted and commits.
|
|
323
|
+
printf 'API_KEY=your_api_key_here\n' > "${secret_repo}/.env.example"
|
|
324
|
+
git -C "${secret_repo}" add .env.example
|
|
325
|
+
git -C "${secret_repo}" commit -q -m "example" || fail "pre-commit allows the .env.example placeholder"
|
|
326
|
+
pass "pre-commit gate denies a secret, passes clean and example commits"
|
|
327
|
+
else
|
|
328
|
+
printf 'ok - pre-commit gate behavior SKIPPED (needs gitleaks + openssl)\n'
|
|
329
|
+
fi
|
|
330
|
+
|
|
283
331
|
printf 'e2e passed\n'
|