@aldegad/safedeps 2.1.1 → 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 +273 -463
- package/README.ko.md +76 -12
- package/README.md +107 -38
- package/ROADMAP.md +123 -84
- package/SECURITY.md +45 -0
- package/SKILL.md +86 -143
- package/bin/safedeps +419 -52
- package/lib/gates/audit.sh +36 -0
- package/lib/gates/doctor.sh +212 -0
- package/lib/gates/hooks.sh +131 -0
- package/lib/gates/repo-profile.sh +60 -0
- package/lib/gates/scan.sh +94 -0
- 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/ledger/ledger.sh +94 -16
- package/lib/npm/closure.sh +115 -0
- package/lib/providers/providers.sh +248 -26
- package/package.json +2 -1
- package/scripts/install/install-safedeps-hooks.mjs +65 -23
- package/scripts/release-gates.sh +252 -0
- package/scripts/safedeps-post-verify.sh +185 -15
- package/scripts/safedeps-pre-guard.sh +309 -39
- package/scripts/test/e2e.sh +228 -4
- package/scripts/test/fixture-provider.mjs +21 -0
- package/scripts/test/smoke.sh +212 -10
|
@@ -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 ]
|
|
@@ -0,0 +1,131 @@
|
|
|
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|init> [--root <repo>] [--hooks-path <dir>] [--auto]\n' >&2
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
while [ $# -gt 0 ]; do
|
|
23
|
+
case "$1" in
|
|
24
|
+
install|check|init) 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
|
+
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
|
+
;;
|
|
87
|
+
install)
|
|
88
|
+
if [ ! -f "$HOOK_FILE" ]; then
|
|
89
|
+
printf 'ERROR: hook file not found: %s\n' "$HOOK_FILE" >&2
|
|
90
|
+
printf ' the repo must provide its own %s/pre-commit policy.\n' "$HOOKS_PATH" >&2
|
|
91
|
+
exit 1
|
|
92
|
+
fi
|
|
93
|
+
chmod +x "$HOOK_FILE"
|
|
94
|
+
git -C "$REPO_ROOT" config core.hooksPath "$HOOKS_PATH"
|
|
95
|
+
printf 'safedeps hooks: installed repo-local git hooks at %s/%s\n' "$REPO_ROOT" "$HOOKS_PATH"
|
|
96
|
+
printf 'safedeps hooks: core.hooksPath = %s\n' "$(git -C "$REPO_ROOT" config --get core.hooksPath)"
|
|
97
|
+
;;
|
|
98
|
+
check)
|
|
99
|
+
local_expected="$HOOKS_PATH"
|
|
100
|
+
actual="$(git -C "$REPO_ROOT" config --get core.hooksPath || true)"
|
|
101
|
+
if [ "$actual" != "$local_expected" ]; then
|
|
102
|
+
cat >&2 <<EOF
|
|
103
|
+
ERROR: repo-local git hooks are not active.
|
|
104
|
+
|
|
105
|
+
Expected core.hooksPath: $local_expected
|
|
106
|
+
Actual core.hooksPath: ${actual:-<unset>}
|
|
107
|
+
|
|
108
|
+
Run:
|
|
109
|
+
safedeps hooks install --root "$REPO_ROOT"
|
|
110
|
+
EOF
|
|
111
|
+
exit 1
|
|
112
|
+
fi
|
|
113
|
+
if [ ! -x "$HOOK_FILE" ]; then
|
|
114
|
+
printf 'ERROR: %s is not executable.\n' "$HOOK_FILE" >&2
|
|
115
|
+
exit 1
|
|
116
|
+
fi
|
|
117
|
+
if ! command -v gitleaks >/dev/null 2>&1; then
|
|
118
|
+
if ! command -v docker >/dev/null 2>&1 || ! docker info >/dev/null 2>&1; then
|
|
119
|
+
cat >&2 <<'EOF'
|
|
120
|
+
ERROR: neither local gitleaks nor a running Docker daemon is available.
|
|
121
|
+
|
|
122
|
+
Choose one:
|
|
123
|
+
brew install gitleaks
|
|
124
|
+
open -a Docker
|
|
125
|
+
EOF
|
|
126
|
+
exit 1
|
|
127
|
+
fi
|
|
128
|
+
fi
|
|
129
|
+
printf 'safedeps hooks: active (core.hooksPath = %s)\n' "$local_expected"
|
|
130
|
+
;;
|
|
131
|
+
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
|
|
@@ -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}"
|