Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions templates/default/.ax/hooks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ compaction 뒤엔 4시간 TTL 이 다시 줄 여지를 남겨요. 세션 id 가
## Secrets 검출 — 패턴의 SSOT 는 `common.sh` 의 표 하나예요

시크릿의 형태를 아는 곳은 `.ax/scripts/bash/common.sh` 의 `goax_secret_rules` 표 **하나** 예요.
`pre-commit/critical-rule-grep.sh` 의 검출은 `goax_secret_patterns`(표의 `use=both|detect` 행)에서,
`pre-commit/critical-rule-grep.sh` 의 검출은 `goax_secret_scan_file`(표의 `use=both|detect` 행)에서,
`redact_secrets` 의 마스킹은 같은 표의 `use=both|mask` 행에서 나와요. 형태를 하나 더할 땐 표에만 행을
넣으세요 — 훅에 패턴을 다시 적으면 그 순간 두 곳이 어긋나요. 실제로 어긋나 있었고(웹훅은 마스킹만,
`pg_key` 는 마스킹만, `passwd`·`access_key` 는 검출만, AWS·Stripe·JWT 는 정량자가 서로 달랐어요),
Expand All @@ -88,7 +88,9 @@ compaction 뒤엔 4시간 TTL 이 다시 줄 여지를 남겨요. 세션 id 가
표는 두 갈래를 담아요. ① 발급처가 형식을 정해둔 토큰 — 대소문자를 그대로 봐야 오탐이 안 늘어요.
② `key=value` — 키 이름의 대소문자는 표가 브래킷으로 담고 있어서 `grep -i` 가 필요 없고, 검출 행과
마스킹 행이 키 목록 조각을 공유해요. `${GITHUB_TOKEN}`·`<from env>` 같은 참조 표기와 `secret: null`·
`token_count = 0` 은 값 첫 글자와 최소 길이로 걸러요. 검출되면 **파일 이름만** 찍어요 — 매칭된 줄을
`token_count = 0` 은 값 첫 글자와 최소 길이로 걸러요. TS·Kotlin·Swift 타입 자리(`token: string,` ·
`apiKey?: Foo` · `(token: string, …)`)는 `key=value` 를 보기 전에 줄에서 지워요 — 같은 줄의 값 대입은 그대로
남아 걸려요. 검출되면 **파일 이름만** 찍어요 — 매칭된 줄을
stderr 로 흘리면 검출한 의미가 없어요.

`common.sh` 가 없으면 검출할 패턴 자체가 없어요. 그때는 조용히 통과하지 않고
Expand Down
13 changes: 3 additions & 10 deletions templates/default/.ax/hooks/pre-commit/critical-rule-grep.sh
Original file line number Diff line number Diff line change
Expand Up @@ -89,21 +89,14 @@ echo "[goax] CRITICAL 룰 검사 (mode=$SENSOR_MODE) — 대상 $(echo "$STAGED"
# ② 일반 key=value — 값 첫 글자에서 `$`·`<`·`{`·`%`·`(` 를 빼서 `${GITHUB_TOKEN}`·`<from env>`
# 같은 참조 표기를 오탐하지 않아요. 값은 6자 이상이라 `secret: null`·`token_count = 0` 도 안 걸려요
# (키 이름의 대소문자는 표가 브래킷으로 담고 있어서 `grep -i` 가 필요 없어요)
# TS·Kotlin·Swift 타입 자리(`token: string,`)는 kv-detect 전에 지워요 — `goax_secret_scan_file` 이 해요
#
# `grep` 의 `-e` 는 필수예요 — PEM 행이 `-----` 로 시작해서, 빼면 옵션으로 읽혀 rc=2 가 나고
# 그 한 종이 조용히 미탐돼요.
SECRET_PATTERNS=$(goax_secret_patterns)

# 파일을 읽는 건 `goax_secret_scan_file` 하나예요 (줄번호 + 라벨만 내고 내용은 안 내요).
SECRET_FILES=""
while IFS= read -r f; do
[ -z "$f" ] || [ ! -f "$f" ] && continue
hit=""
while IFS= read -r pat; do
[ -z "$pat" ] && continue
if grep -qIE -e "$pat" "$f" 2>/dev/null; then hit="$pat"; break; fi
done <<< "$SECRET_PATTERNS"
# 매칭된 줄은 안 찍어요 — 시크릿을 stderr·로그로 다시 흘리면 검출한 의미가 없어요
[ -n "$hit" ] && SECRET_FILES="${SECRET_FILES}${f}"$'\n'
[ -n "$(goax_secret_scan_file "$f")" ] && SECRET_FILES="${SECRET_FILES}${f}"$'\n'
done <<< "$STAGED"
if [ -n "$SECRET_FILES" ]; then
SECRET_N=$(printf '%s' "$SECRET_FILES" | grep -c . || true)
Expand Down
2 changes: 1 addition & 1 deletion templates/default/.ax/scripts/bash/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

| 스크립트 | 용도 | 호출하는 skill |
|---|---|---|
| `common.sh` | 공통 함수 (find_project_root, json_output, [goax] log, `goax_inject_fresh` 세션 내 중복 주입 제거, `goax_lock`/`goax_unlock`/`goax_unlock_all` 원장 락, `goax_mktemp` 폴백 임시 파일, `goax_git_hook_path` git 없이도 도는 훅 경로, `goax_resolve_spec` `--spec` 축약 해석, `goax_secret_rules`/`goax_secret_patterns`/`redact_secrets` 시크릿 패턴 SSOT — 검출과 마스킹이 같은 표에서 나와요, `goax_hook_enabled`/`goax_hook_profile` 훅 끄기·프로필, `goax_session_mark`/`goax_session_marked`/`goax_session_count` 세션 마커, `goax_shell_scan` 따옴표·heredoc 을 셸처럼 읽는 명령 판정(bypass·destructive), `goax_module_rules_matching`·`goax_imported_paths` 주입 훅과 게이트가 공유하는 룰 매칭, `goax_doc_id`/`goax_doc_key`/`goax_doc_sort` spec·ADR ID) | (sourced by all) |
| `common.sh` | 공통 함수 (find_project_root, json_output, [goax] log, `goax_inject_fresh` 세션 내 중복 주입 제거, `goax_lock`/`goax_unlock`/`goax_unlock_all` 원장 락, `goax_mktemp` 폴백 임시 파일, `goax_git_hook_path` git 없이도 도는 훅 경로, `goax_resolve_spec` `--spec` 축약 해석, `goax_secret_rules`/`goax_secret_patterns`/`goax_secret_scan_file`/`redact_secrets` 시크릿 패턴 SSOT — 검출과 마스킹이 같은 표에서 나와요 (검출은 kv-detect 전에 TS·Kotlin·Swift 타입 자리를 지워요), `goax_hook_enabled`/`goax_hook_profile` 훅 끄기·프로필, `goax_session_mark`/`goax_session_marked`/`goax_session_count` 세션 마커, `goax_shell_scan` 따옴표·heredoc 을 셸처럼 읽는 명령 판정(bypass·destructive), `goax_module_rules_matching`·`goax_imported_paths` 주입 훅과 게이트가 공유하는 룰 매칭, `goax_doc_id`/`goax_doc_key`/`goax_doc_sort` spec·ADR ID) | (sourced by all) |
| `detect-model.sh` | 지금 돌고 있는 모델 식별 — override → `$GOAX_MODEL` → transcript 스캔 → unknown | (진단·로깅용) |
| `next-spec-num.sh` | 새 spec/ADR ID 발급 — `YYYY-MM-DD-<4hex>` (`--kind spec\|adr`, `--reserve --slug` 로 실물까지 O_EXCL 생성). 순번이 아니라 브랜치끼리 안 겹쳐요. `--check-duplicates` 는 옛 순번(`NNN`/`NNNN`) 중복 진단 | `spec`, `adr`, `doctor` |
| `tier-from-state.sh` | current-task.json + config.yml → tier 결정 + evaluator 필수 여부 + spec_review 필수 여부(Size 축만) (`--reset` 는 `reset-task.sh` 경유) | `spec`, `tasks-gate.sh`, `spec-review.sh`, `update-state.sh` |
Expand Down
45 changes: 45 additions & 0 deletions templates/default/.ax/scripts/bash/common.sh
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ goax_hook_exit() {
#
# goax_secret_rules() 표 자체. 한 행 = <use> TAB <label> TAB <ERE> TAB <sed 치환문>
# goax_secret_patterns() use=both|detect 행의 ERE 만 (검출용)
# goax_secret_scan_file() 파일 하나를 검출해 `줄번호 TAB 라벨` 출력 (kv-detect 는 타입 자리를 지운 뒤)
# redact_secrets() use=both|mask 행에서 sed 스크립트를 만들어 실행 (시그니처 불변)
#
# 행을 더할 때 지킬 것:
Expand Down Expand Up @@ -203,6 +204,50 @@ goax_secret_patterns() {
goax_secret_rules | awk -F'\t' '$1=="both"||$1=="detect"{print $3}'
}

# ─── kv-detect 의 타입 자리 제외 ─────────────────────────────────────
# kv-detect 는 키 이름 뒤 `:` 를 값의 시작으로 읽어요. 그래서 TS·Kotlin·Swift 의 **타입 자리**가 값으로 읽혀요
# — 실측(one-tenth): `token: string,` · `messageFor?(target: UserTarget, token: string, platform: Platform)`
# 이 CRITICAL 1건이 됐고, 그 프로젝트는 sensors.mode 를 warning 으로 내렸어요 (안전망 전체가 꺼진 셈이에요).
# ERE 에는 lookahead 가 없어서 행 하나로 "타입이면 빼라" 를 못 써요. 그래서 kv-detect 를 돌리기 **전에**
# 줄에서 타입 자리만 지워요. 지우는 건 `키: 타입` 조각뿐이라 같은 줄의 값 대입(`token: string = "…"`,
# `{ token: "…" }`)은 그대로 남아 걸려요. 줄 수는 바뀌지 않아요 (줄 번호가 원본과 같아요).
# ① 원시 타입 — `token: string` · `password: String?` · `secret: number[]` (뒤가 식별자 글자가 아니면)
# ② 선택 속성·인자 — `apiKey?: Foo` (`?:` 는 값 문법에 없어요)
# ③ 타입 이름 + 타입 문법 — `token: UserToken,` · `)` · `;` · `|` · `&` · `<` · `>` · `[`
# (`=` 는 일부러 빼요 — `token: Foo = "…"` 의 대입을 남기려고요)
# ④ 줄 끝의 대문자 낱말 — `password: Password` (세미콜론 없는 interface). 숫자가 섞이면 빼지 않아요
# (`secret: Abc123Secret` 같은 YAML 값을 놓치지 않으려고요)
# 대가: `{token: abc123def, …}` 처럼 따옴표 없는 값 뒤에 `,` 가 오는 YAML flow 표기는 ③ 에 걸려 빠져요.
# 발급처 형태(ghp_·sk-·AKIA 등)는 별도 행이라 영향 없어요.
_GOAX_SECRET_TS_TYPES='string|number|boolean|bigint|symbol|any|unknown|never|object|void|null|undefined|String|Number|Boolean|Int|Long|Double|Float|Bool|Char|Byte|Short|Any|Object|Unit'
goax_secret_type_strip_script() {
local kv="(${_GOAX_SECRET_KV_KEYS})" id='[[:alpha:]_$][[:alnum:]_$.]*'
printf 's#%s[?]?[[:space:]]*:[[:space:]]*(%s)([^[:alnum:]_$]|$)#\\1 \\3#g\n' "$kv" "$_GOAX_SECRET_TS_TYPES"
printf 's#%s[?][[:space:]]*:[[:space:]]*%s#\\1#g\n' "$kv" "$id"
printf 's#%s[[:space:]]*:[[:space:]]*%s([[:space:]]*[],;)|&<>[])#\\1\\2#g\n' "$kv" "$id"
printf 's#%s[[:space:]]*:[[:space:]]*[[:upper:]][[:alpha:]]*[[:space:]]*$#\\1#\n' "$kv"
}

# goax_secret_scan_file <file>
# 걸린 줄마다 `<줄번호> TAB <라벨>` 을 출력해요 (한 줄에 여러 행이 걸리면 표 순서상 첫 라벨 하나).
# 줄 **내용**은 내보내지 않아요 — 시크릿을 stderr·로그로 다시 흘리면 검출한 의미가 없어요.
# 바이너리는 건너뛰어요 (grep -I). 검출 폭은 표(goax_secret_rules)와 같고, kv-detect 만 타입 자리를 지운 뒤 봐요.
goax_secret_scan_file() {
local f="${1:-}" label pat strip
[ -f "$f" ] || return 0
grep -qI '' "$f" 2>/dev/null || return 0 # 바이너리·빈 파일
strip=$(goax_secret_type_strip_script)
goax_secret_rules | awk -F'\t' '$1=="both"||$1=="detect"{print $2 "\t" $3}' \
| while IFS=$'\t' read -r label pat; do
[ -n "$pat" ] || continue
if [ "$label" = "kv-detect" ]; then
sed -E "$strip" "$f" 2>/dev/null | grep -nE -e "$pat" 2>/dev/null
else
grep -nIE -e "$pat" -- "$f" 2>/dev/null
fi | awk -F: -v l="$label" '{print $1 "\t" l}'
done | sort -n -s -k1,1 | awk -F'\t' '!seen[$1]++'
}

# Redact secrets in stdin, print redacted to stdout.
# Strategy: known-prefix tokens (high confidence) + key=value with ≥12-char value (lower).
# Designed for init-mistake-file.sh / mistake skill DETAILS only — DO NOT apply to titles (signal loss).
Expand Down
3 changes: 2 additions & 1 deletion templates/zero/probe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ bash .ax/scripts/bash/zero-probe.sh --only secret-scan
## 시작하는 법

`.ax/_templates/zero/probes/` 의 예시를 `.ax/probes/` 로 복사해서 프로젝트에 맞게 고쳐요.
예시 넷이 네 가지 전형을 덮습니다.
예시 다섯이 네 가지 전형을 덮습니다.

| 예시 | 무엇을 재나 |
|---|---|
| `dependency-direction.sh` | 코드 위반을 만들고 **lint 가 막는지** |
| `secret-scan.sh` | 위반 파일을 스테이지하고 **pre-commit 훅이 막는지** |
| `secret-scan-typed.sh` | 타입 옆 시크릿 값은 **막고**, 타입 선언만 있는 파일은 **통과시키는지** (오탐이 게이트를 끄게 만들어요) |
| `version-bump.sh` | 릴리즈 없는 버전 증가를 스테이지하고 **pre-commit 훅이 막는지** |
| `ci-actually-ran.sh` | CI 가 성공했는지가 아니라 **게이트 출력이 로그에 있는지** |

Expand Down
58 changes: 58 additions & 0 deletions templates/zero/probe/examples/secret-scan-typed.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# probe: 타입 선언 옆의 시크릿 값은 막고, 타입 선언만 있는 파일은 통과시키는가
#
# 계약: 게이트가 막았으면 exit 0 · 못 막았으면 exit 1 · 돌릴 수 없으면 exit 2
#
# secret-scan.sh 의 짝이에요. 그쪽은 "막는가" 만 재요. 이쪽은 **막아야 할 것과 막으면 안 되는 것을 같이** 재요 —
# `token: string,` 같은 TS 타입 선언을 시크릿으로 잡는 게이트는 매 커밋 오탐을 내고, 결국 프로젝트가
# sensors.mode 를 warning 으로 내려 안전망 전체를 끄게 돼요 (실측). 그래서 둘 다 확인해요:
# 1) 타입 선언과 같은 파일·같은 줄에 있는 실제 값 대입은 여전히 막는가 (네거티브)
# 2) 타입 선언만 있는 파일은 통과하는가 (오탐 회귀)
#
# 워킹 트리를 건드리므로 스테이지·인덱스를 반드시 되돌려요. 커밋은 만들지 않아요.

set -uo pipefail

FIXTURE=".probe-secret-typed.ts"

command -v git >/dev/null 2>&1 || { echo "git 없음 — skip"; exit 2; }
git rev-parse --git-dir >/dev/null 2>&1 || { echo "git 저장소 아님 — skip"; exit 2; }
HOOK="$(git rev-parse --git-dir)/hooks/pre-commit"
[ -x "$HOOK" ] || { echo "pre-commit 훅 미설치 — skip (install-git-hooks.sh)"; exit 2; }

cleanup() {
git reset -q -- "$FIXTURE" 2>/dev/null || true
[ -f "$FIXTURE" ] && unlink "$FIXTURE"
}
trap cleanup EXIT

types_only() {
printf 'export interface PushTarget {\n token: string,\n apiKey?: string;\n'
printf ' messageFor?(target: UserTarget, token: string, platform: Platform): string;\n}\n'
}

# 1. 타입 선언 + 실제 값 — 막혀야 해요. 값은 공개 예시용 가짜예요.
{
types_only
printf 'export const token: string = "PROBEFIXTURE0not0a0real0secret";\n'
} > "$FIXTURE"
git add -f "$FIXTURE" 2>/dev/null || { echo "스테이지 실패 — skip"; exit 2; }
"$HOOK" > /dev/null 2>&1
code=$?
if [ "$code" -eq 0 ]; then
echo "PROBE FAILED — 타입 선언 옆의 시크릿 값이 스테이지를 통과했어요. secrets 게이트가 뚫려 있습니다"
exit 1
fi

# 2. 타입 선언만 — 통과해야 해요. 다른 훅이 이 파일을 막을 이유는 없어야 해요.
types_only > "$FIXTURE"
git add -f "$FIXTURE" 2>/dev/null || { echo "스테이지 실패 — skip"; exit 2; }
"$HOOK" > /dev/null 2>&1
code=$?
if [ "$code" -ne 0 ]; then
echo "PROBE FAILED — 타입 선언만 있는 파일을 막았어요 (exit $code). 오탐이 이어지면 게이트를 끄게 돼요"
exit 1
fi

echo "타입 옆 시크릿 값은 막고 (exit ≠ 0) 타입 선언만은 통과시켰어요"
exit 0
39 changes: 39 additions & 0 deletions tests/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3231,6 +3231,39 @@ else
printf '%s' "$SEC_ERR" | grep -q 'AKIAIOSFODNN7EXAMPLE' \
&& fail "critical-rule-grep — 매칭 줄을 그대로 출력 (시크릿이 컨텍스트·로그로 새요)" \
|| pass "critical-rule-grep — 파일 이름만 보고, 매칭 줄은 안 찍음"

# kv-detect 는 키 이름 뒤 `:` 를 값의 시작으로 읽어서 TS 타입 자리까지 시크릿으로 잡았어요.
# 실측(one-tenth): `token: string,` · `messageFor?(target: UserTarget, token: string, platform: Platform)`
# 이 "CRITICAL 위반 1건" 이 됐고, 그 프로젝트는 sensors.mode 를 warning 으로 내렸어요.
# 타입 자리(원시 타입 · `?:` · 시그니처/제네릭/유니온 안의 타입 이름)만 빼고, 값 대입은 그대로 잡아요.
{
printf 'export interface PushTarget {\n token: string,\n apiKey?: string;\n password: Password;\n'
printf ' messageFor?(target: UserTarget, token: string, platform: Platform): string;\n'
printf ' secret: Record<string, string>;\n accessKey: AccessKey | null;\n tokens: TokenPair[];\n}\n'
printf 'export function send(token: string, secret: SecretRef): void {}\n'
printf 'const cache = new Map<string, { token: string | undefined }>();\n'
printf 'val token: String\nlet password: String?\n'
} > fx/ts-types.ts
git reset -q >/dev/null 2>&1; git add -f fx/ts-types.ts >/dev/null 2>&1
TS_ERR=$(CLAUDE_PROJECT_DIR="$SEC" bash .ax/hooks/pre-commit/critical-rule-grep.sh 2>&1 >/dev/null); TS_RC=$?
[ "$TS_RC" -eq 0 ] \
&& pass "critical-rule-grep — TS·Kotlin·Swift 타입 선언 (token: string · ?: · 시그니처 인자 · 제네릭 · 유니온) 은 시크릿 아님" \
|| fail "critical-rule-grep — 타입 선언을 시크릿으로 차단 (rc=$TS_RC): $TS_ERR"
printf 'const token: string = "%s";\n' abc123def456ghi > fx/ts-hit1.ts
printf 'const cfg = { token: "%s", user: User };\n' abc123def456 > fx/ts-hit2.ts
printf 'const apiKey = "%s";\n' abc123def456ghi > fx/ts-hit3.ts
printf 'password: %s\n' hunter2xyz > fx/ts-hit4.yml
printf 'secret: %s\n' Abc123Secret > fx/ts-hit5.yml
printf 'send({ token: "%s" }, token: string)\n' abc123def456ghi > fx/ts-hit6.ts
ts_missed=""
for f in fx/ts-hit1.ts fx/ts-hit2.ts fx/ts-hit3.ts fx/ts-hit4.yml fx/ts-hit5.yml fx/ts-hit6.ts; do
git reset -q >/dev/null 2>&1; git add -f "$f" >/dev/null 2>&1
CLAUDE_PROJECT_DIR="$SEC" bash .ax/hooks/pre-commit/critical-rule-grep.sh >/dev/null 2>&1
[ $? -eq 2 ] || ts_missed="$ts_missed $f"
done
[ -z "$ts_missed" ] \
&& pass "critical-rule-grep — 타입 옆 값 대입 6종 (: string = \"…\" · { token: \"…\" } · = \"…\" · YAML 2종 · 같은 줄 타입+값) 은 그대로 차단" \
|| fail "critical-rule-grep — 타입 자리를 빼다가 값 대입까지 놓침:$ts_missed"
popd >/dev/null || true
rm -rf "$SEC"

Expand All @@ -3246,6 +3279,12 @@ else
[ $? -eq 0 ] && [ ! -f .probe-secret.txt ] \
&& pass "probe/secret-scan.sh — 게이트가 프로브 검체를 차단 (PASS) + 픽스처 정리" \
|| fail "probe/secret-scan.sh — PROBE FAILED (secrets 게이트가 뚫림) 또는 픽스처 잔존"
# 짝 프로브 — 타입 옆 값은 막고 타입 선언만은 통과 (kv-detect 오탐이 mode 를 warning 으로 내리게 했어요)
cp "$REPO/templates/zero/probe/examples/secret-scan-typed.sh" .ax/probes/
TYPED_OUT=$(CLAUDE_PROJECT_DIR="$PRB" bash .ax/probes/secret-scan-typed.sh 2>&1); TYPED_RC=$?
[ "$TYPED_RC" -eq 0 ] && [ ! -f .probe-secret-typed.ts ] \
&& pass "probe/secret-scan-typed.sh — 타입 옆 시크릿 값은 차단 · 타입 선언만은 통과 (PASS) + 픽스처 정리" \
|| fail "probe/secret-scan-typed.sh — rc=$TYPED_RC: $TYPED_OUT"
else
fail "probe/secret-scan.sh — provision 이 .git/hooks/pre-commit 을 안 깔아서 프로브를 못 돌림"
fi
Expand Down
Loading