@mmerterden/multi-agent-pipeline 11.4.0 → 11.5.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/CHANGELOG.md +118 -0
- package/README.md +1 -0
- package/index.js +9 -2
- package/install/_common.mjs +107 -5
- package/install/_telemetry.mjs +5 -23
- package/install/claude.mjs +70 -10
- package/install/copilot.mjs +75 -6
- package/package.json +4 -10
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
- package/pipeline/lib/account-resolver.sh +61 -23
- package/pipeline/lib/channels-multi-repo.sh +7 -2
- package/pipeline/lib/count-lib.sh +27 -0
- package/pipeline/lib/credential-store.sh +23 -6
- package/pipeline/lib/extract-conventions.sh +27 -21
- package/pipeline/lib/fetch-confluence.sh +25 -13
- package/pipeline/lib/fetch-crashlytics.sh +30 -8
- package/pipeline/lib/fetch-fortify.sh +11 -2
- package/pipeline/lib/fetch-swagger.sh +18 -7
- package/pipeline/lib/figma-mcp-refresh.sh +26 -11
- package/pipeline/lib/figma-screenshot.sh +11 -2
- package/pipeline/lib/issue-fetcher.sh +31 -6
- package/pipeline/lib/multi-repo-pipeline.sh +84 -20
- package/pipeline/lib/plan-todos.sh +12 -3
- package/pipeline/lib/post-pr-review.sh +5 -3
- package/pipeline/lib/repo-cache.sh +53 -9
- package/pipeline/lib/review-watch.sh +26 -6
- package/pipeline/lib/shadow-git.sh +45 -4
- package/pipeline/lib/vercel-deploy.sh +10 -1
- package/pipeline/scripts/audit-log-rotate.sh +38 -10
- package/pipeline/scripts/check-md-links.mjs +34 -9
- package/pipeline/scripts/diff-risk-score.mjs +4 -1
- package/pipeline/scripts/evidence-gate.mjs +10 -1
- package/pipeline/scripts/fixtures/install-layout.tsv +4 -4
- package/pipeline/scripts/fixtures/pack-expected-count.txt +1 -0
- package/pipeline/scripts/keychain-save.sh +13 -9
- package/pipeline/scripts/lint-skills.mjs +40 -2
- package/pipeline/scripts/migrate-prefs.mjs +26 -7
- package/pipeline/scripts/phase-tracker.sh +71 -0
- package/pipeline/scripts/pre-commit-check.sh +39 -0
- package/pipeline/scripts/scan-skills.sh +217 -127
- package/pipeline/scripts/smoke-bitbucket-contract.sh +21 -5
- package/pipeline/scripts/smoke-fetchers-offline.sh +382 -0
- package/pipeline/scripts/smoke-install-layout.sh +11 -3
- package/pipeline/scripts/smoke-lib-scripts.sh +54 -1
- package/pipeline/scripts/smoke-pack-contents.sh +140 -0
- package/pipeline/scripts/smoke-plugin-validate.sh +64 -0
- package/pipeline/scripts/smoke-shadow-git.sh +48 -1
- package/pipeline/scripts/smoke-skill-authoring.sh +1 -1
- package/pipeline/scripts/smoke-workflow-audit.sh +69 -0
- package/pipeline/scripts/test-gap-scan.mjs +12 -1
- package/pipeline/scripts/triage-memory.mjs +10 -1
- package/pipeline/scripts/uninstall.mjs +105 -36
- package/pipeline/scripts/update-issue-progress.sh +60 -41
- package/pipeline/scripts/write-state.mjs +14 -4
|
@@ -289,6 +289,61 @@ save_state() {
|
|
|
289
289
|
mv "${TRACKER_FILE}.tmp" "$TRACKER_FILE"
|
|
290
290
|
}
|
|
291
291
|
|
|
292
|
+
# --- read-modify-write lock ---------------------------------------------------
|
|
293
|
+
# tmp+rename makes each save atomic, but two concurrent invocations (e.g. two
|
|
294
|
+
# parallel Phase 4 reviewers reporting `tokens`) both load the same state and
|
|
295
|
+
# the second save silently drops the first delta. Guard every load->save
|
|
296
|
+
# sequence with a portable mkdir spinlock (macOS bash 3.2: no flock builtin).
|
|
297
|
+
# The wait is bounded and the lock FAILS OPEN with a warning so a stuck lock
|
|
298
|
+
# can never hang the pipeline; a dead owner pid or a lock older than 30s is
|
|
299
|
+
# reclaimed.
|
|
300
|
+
TRACKER_LOCK_DIR=""
|
|
301
|
+
TRACKER_LOCK_HELD=0
|
|
302
|
+
|
|
303
|
+
state_lock_stale() {
|
|
304
|
+
local pid mtime now age
|
|
305
|
+
pid=$(cat "$TRACKER_LOCK_DIR/pid" 2>/dev/null || true)
|
|
306
|
+
if [ -n "$pid" ]; then
|
|
307
|
+
kill -0 "$pid" 2>/dev/null && return 1
|
|
308
|
+
return 0
|
|
309
|
+
fi
|
|
310
|
+
mtime=$(stat -f %m "$TRACKER_LOCK_DIR" 2>/dev/null || stat -c %Y "$TRACKER_LOCK_DIR" 2>/dev/null || echo "")
|
|
311
|
+
[ -z "$mtime" ] && return 1
|
|
312
|
+
now=$(date +%s)
|
|
313
|
+
age=$((now - mtime))
|
|
314
|
+
[ "$age" -gt 30 ]
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
acquire_state_lock() {
|
|
318
|
+
TRACKER_LOCK_DIR="${TRACKER_FILE}.lock"
|
|
319
|
+
mkdir -p "$TRACKER_DIR" 2>/dev/null
|
|
320
|
+
local tries=0
|
|
321
|
+
# ~5s bound: 50 tries x 0.1s.
|
|
322
|
+
while ! mkdir "$TRACKER_LOCK_DIR" 2>/dev/null; do
|
|
323
|
+
if state_lock_stale; then
|
|
324
|
+
rm -rf "$TRACKER_LOCK_DIR" 2>/dev/null
|
|
325
|
+
continue
|
|
326
|
+
fi
|
|
327
|
+
tries=$((tries + 1))
|
|
328
|
+
if [ "$tries" -ge 50 ]; then
|
|
329
|
+
echo "phase-tracker: WARN - state lock busy ($TRACKER_LOCK_DIR); proceeding without lock" >&2
|
|
330
|
+
return 0
|
|
331
|
+
fi
|
|
332
|
+
sleep 0.1
|
|
333
|
+
done
|
|
334
|
+
echo "$$" > "$TRACKER_LOCK_DIR/pid" 2>/dev/null
|
|
335
|
+
TRACKER_LOCK_HELD=1
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
release_state_lock() {
|
|
339
|
+
if [ "$TRACKER_LOCK_HELD" = "1" ]; then
|
|
340
|
+
rm -rf "$TRACKER_LOCK_DIR" 2>/dev/null
|
|
341
|
+
TRACKER_LOCK_HELD=0
|
|
342
|
+
fi
|
|
343
|
+
}
|
|
344
|
+
# Never leave a lock behind if a mutating action dies mid-critical-section.
|
|
345
|
+
trap release_state_lock EXIT
|
|
346
|
+
|
|
292
347
|
render() {
|
|
293
348
|
need_jq
|
|
294
349
|
local state
|
|
@@ -509,8 +564,10 @@ case "$ACTION" in
|
|
|
509
564
|
TRACKER_DIR="$(dirname "$TRACKER_FILE")"
|
|
510
565
|
mkdir -p "$TRACKER_DIR" 2>/dev/null
|
|
511
566
|
NOW=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
567
|
+
acquire_state_lock
|
|
512
568
|
save_state "$(jq -nc --arg id "$TASK_ID" --arg ts "$NOW" \
|
|
513
569
|
'{task_id:$id,started_at:$ts,phases:[]}')"
|
|
570
|
+
release_state_lock
|
|
514
571
|
# Update pointer so subsequent calls without env can find this tracker.
|
|
515
572
|
# NOTE: the pointer is global; for concurrent runs on the same user account,
|
|
516
573
|
# callers should set MULTI_AGENT_TASK_ID in their shell or prefix every
|
|
@@ -528,6 +585,7 @@ case "$ACTION" in
|
|
|
528
585
|
need_jq
|
|
529
586
|
[ "$#" -ge 2 ] || { echo "add needs <phase_id> <name>" >&2; exit 64; }
|
|
530
587
|
PID="$1"; PNAME="$2"
|
|
588
|
+
acquire_state_lock
|
|
531
589
|
state=$(load_state)
|
|
532
590
|
# Idempotent: a phase id that already exists is a no-op (name, status, and
|
|
533
591
|
# token history are preserved). This is what lets continuation commands
|
|
@@ -538,6 +596,7 @@ case "$ACTION" in
|
|
|
538
596
|
else .phases += [{"id":$id,"name":$name,"status":"pending","subs":[]}]
|
|
539
597
|
end')
|
|
540
598
|
save_state "$new"
|
|
599
|
+
release_state_lock
|
|
541
600
|
render
|
|
542
601
|
;;
|
|
543
602
|
|
|
@@ -550,6 +609,7 @@ case "$ACTION" in
|
|
|
550
609
|
*) echo "bad status: $STATUS" >&2; exit 64 ;;
|
|
551
610
|
esac
|
|
552
611
|
NOW_ISO=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
612
|
+
acquire_state_lock
|
|
553
613
|
state=$(load_state)
|
|
554
614
|
# Set status, stamp started_at on first transition to in_progress,
|
|
555
615
|
# stamp completed_at on transition to terminal states (completed/failed/skipped).
|
|
@@ -563,6 +623,7 @@ case "$ACTION" in
|
|
|
563
623
|
)
|
|
564
624
|
')
|
|
565
625
|
save_state "$new"
|
|
626
|
+
release_state_lock
|
|
566
627
|
emit_otel_span "phase.update" "$PID" "$STATUS" "{\"multi_agent.status\": \"$STATUS\"}"
|
|
567
628
|
render
|
|
568
629
|
;;
|
|
@@ -575,6 +636,7 @@ case "$ACTION" in
|
|
|
575
636
|
pending|in_progress|completed|failed|skipped) ;;
|
|
576
637
|
*) echo "bad status: $SSTATUS" >&2; exit 64 ;;
|
|
577
638
|
esac
|
|
639
|
+
acquire_state_lock
|
|
578
640
|
state=$(load_state)
|
|
579
641
|
# If sub with this id exists under this phase, update; else append.
|
|
580
642
|
new=$(echo "$state" | jq \
|
|
@@ -590,6 +652,7 @@ case "$ACTION" in
|
|
|
590
652
|
else . end
|
|
591
653
|
)')
|
|
592
654
|
save_state "$new"
|
|
655
|
+
release_state_lock
|
|
593
656
|
emit_otel_span "phase.sub" "$PID" "$SNAME" "{\"multi_agent.sub_id\": \"$SID\", \"multi_agent.status\": \"$SSTATUS\"}"
|
|
594
657
|
render
|
|
595
658
|
;;
|
|
@@ -602,6 +665,7 @@ case "$ACTION" in
|
|
|
602
665
|
case "$T_IN$T_OUT$T_CACHED" in
|
|
603
666
|
*[!0-9]*) echo "tokens: in/out/cached must be non-negative integers" >&2; exit 64 ;;
|
|
604
667
|
esac
|
|
668
|
+
acquire_state_lock
|
|
605
669
|
state=$(load_state)
|
|
606
670
|
# Additive: add to existing totals so multiple LLM calls accumulate.
|
|
607
671
|
new=$(echo "$state" | jq --arg id "$PID" --argjson ti "$T_IN" --argjson to "$T_OUT" --argjson tc "$T_CACHED" '
|
|
@@ -614,6 +678,7 @@ case "$ACTION" in
|
|
|
614
678
|
)
|
|
615
679
|
')
|
|
616
680
|
save_state "$new"
|
|
681
|
+
release_state_lock
|
|
617
682
|
emit_otel_span "phase.tokens" "$PID" "" "{\"multi_agent.tokens_in_delta\": $T_IN, \"multi_agent.tokens_out_delta\": $T_OUT, \"multi_agent.tokens_cached_delta\": $T_CACHED}"
|
|
618
683
|
# Re-render so live token count is visible on the active phase. Callers
|
|
619
684
|
# that want silent accumulation (e.g. tight loops) can suppress with
|
|
@@ -627,6 +692,7 @@ case "$ACTION" in
|
|
|
627
692
|
need_jq
|
|
628
693
|
[ "$#" -ge 3 ] || { echo "meta needs <phase_id> <key> <value>" >&2; exit 64; }
|
|
629
694
|
PID="$1"; KEY="$2"; VALUE="$3"
|
|
695
|
+
acquire_state_lock
|
|
630
696
|
state=$(load_state)
|
|
631
697
|
new=$(echo "$state" | jq --arg id "$PID" --arg k "$KEY" --arg v "$VALUE" '
|
|
632
698
|
.phases |= map(
|
|
@@ -635,6 +701,7 @@ case "$ACTION" in
|
|
|
635
701
|
else . end
|
|
636
702
|
)')
|
|
637
703
|
save_state "$new"
|
|
704
|
+
release_state_lock
|
|
638
705
|
emit_otel_span "phase.meta" "$PID" "$KEY" "{\"multi_agent.meta_key\": \"$KEY\", \"multi_agent.meta_value\": \"$VALUE\"}"
|
|
639
706
|
if [ "${TRACKER_QUIET:-0}" != "1" ]; then
|
|
640
707
|
render
|
|
@@ -645,12 +712,14 @@ case "$ACTION" in
|
|
|
645
712
|
need_jq
|
|
646
713
|
[ "$#" -ge 2 ] || { echo "model needs <phase_id> <model_name>" >&2; exit 64; }
|
|
647
714
|
PID="$1"; MODEL="$2"
|
|
715
|
+
acquire_state_lock
|
|
648
716
|
state=$(load_state)
|
|
649
717
|
new=$(echo "$state" | jq --arg id "$PID" --arg m "$MODEL" '
|
|
650
718
|
.phases |= map(
|
|
651
719
|
if .id == $id then .model = $m else . end
|
|
652
720
|
)')
|
|
653
721
|
save_state "$new"
|
|
722
|
+
release_state_lock
|
|
654
723
|
emit_otel_span "phase.model" "$PID" "" "{\"multi_agent.model\": \"$MODEL\"}"
|
|
655
724
|
if [ "${TRACKER_QUIET:-0}" != "1" ]; then
|
|
656
725
|
render
|
|
@@ -667,12 +736,14 @@ case "$ACTION" in
|
|
|
667
736
|
if [ "${#TEXT}" -gt 60 ]; then
|
|
668
737
|
TEXT="${TEXT:0:59}…"
|
|
669
738
|
fi
|
|
739
|
+
acquire_state_lock
|
|
670
740
|
state=$(load_state)
|
|
671
741
|
new=$(echo "$state" | jq --arg id "$PID" --arg v "$TEXT" '
|
|
672
742
|
.phases |= map(
|
|
673
743
|
if .id == $id then .meta = ((.meta // {}) + {"Now": $v}) else . end
|
|
674
744
|
)')
|
|
675
745
|
save_state "$new"
|
|
746
|
+
release_state_lock
|
|
676
747
|
emit_otel_span "phase.now" "$PID" "$TEXT" "{}"
|
|
677
748
|
;;
|
|
678
749
|
|
|
@@ -5,6 +5,45 @@
|
|
|
5
5
|
|
|
6
6
|
set -uo pipefail
|
|
7
7
|
|
|
8
|
+
# Hook-mode fast exit. The Claude Code PreToolUse matcher is the tool name
|
|
9
|
+
# ("Bash"), so this script now fires for EVERY Bash call and receives JSON on
|
|
10
|
+
# stdin: {"tool_name":"Bash","tool_input":{"command":"..."}}. Extract the
|
|
11
|
+
# command and exit 0 immediately unless it actually invokes `git commit`.
|
|
12
|
+
# Direct invocations (empty/non-JSON stdin, e.g. smoke tests or manual runs)
|
|
13
|
+
# fall through to the full scan. Extraction failure also falls through - the
|
|
14
|
+
# only cost is an unnecessary scan, never a skipped one on a real commit.
|
|
15
|
+
if [ ! -t 0 ]; then
|
|
16
|
+
HOOK_INPUT="$(cat 2>/dev/null || true)"
|
|
17
|
+
if [ -n "$HOOK_INPUT" ]; then
|
|
18
|
+
HOOK_COMMAND=""
|
|
19
|
+
if command -v python3 >/dev/null 2>&1; then
|
|
20
|
+
HOOK_COMMAND="$(printf '%s' "$HOOK_INPUT" | python3 -c '
|
|
21
|
+
import json, sys
|
|
22
|
+
try:
|
|
23
|
+
print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
' 2>/dev/null || true)"
|
|
27
|
+
elif command -v node >/dev/null 2>&1; then
|
|
28
|
+
HOOK_COMMAND="$(printf '%s' "$HOOK_INPUT" | node -e '
|
|
29
|
+
let raw = "";
|
|
30
|
+
process.stdin.on("data", (c) => (raw += c));
|
|
31
|
+
process.stdin.on("end", () => {
|
|
32
|
+
try {
|
|
33
|
+
process.stdout.write(String(JSON.parse(raw)?.tool_input?.command ?? ""));
|
|
34
|
+
} catch { /* fall through to full scan */ }
|
|
35
|
+
});
|
|
36
|
+
' 2>/dev/null || true)"
|
|
37
|
+
fi
|
|
38
|
+
if [ -n "$HOOK_COMMAND" ]; then
|
|
39
|
+
if ! printf '%s\n' "$HOOK_COMMAND" \
|
|
40
|
+
| grep -qE '(^|[^[:alnum:]_./-])git[[:space:]]+([^|&;]*[[:space:]])?commit([^[:alnum:]_-]|$)'; then
|
|
41
|
+
exit 0
|
|
42
|
+
fi
|
|
43
|
+
fi
|
|
44
|
+
fi
|
|
45
|
+
fi
|
|
46
|
+
|
|
8
47
|
if [ -z "$(git diff --cached --name-only 2>/dev/null)" ]; then
|
|
9
48
|
exit 0
|
|
10
49
|
fi
|
|
@@ -85,12 +85,24 @@ add_finding() {
|
|
|
85
85
|
FINDINGS+=("$sev|$file|$line|$pattern|$message")
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
-
# --- Pattern scanning
|
|
88
|
+
# --- Pattern scanning (single-pass tree-wide) ----------------------------
|
|
89
|
+
#
|
|
90
|
+
# Each pattern family runs as ONE grep over the whole file list instead of
|
|
91
|
+
# one grep per file (the per-file design spawned thousands of processes and
|
|
92
|
+
# dominated install time). Findings are tagged with (fileIdx, familyIdx,
|
|
93
|
+
# seq) sort keys and re-ordered afterwards so the output is byte-identical
|
|
94
|
+
# to the historical per-file scan order:
|
|
95
|
+
# per file: critical(1 shell-pipe, 2 base64-pipe, 3 eval-net, 4 bidi)
|
|
96
|
+
# high(5 js-eval / 6 py-exec, 7 credential, 8 pastebin, 9 chmod)
|
|
97
|
+
# medium(10 long-base64) low(11 unknown-endpoint, 12 SKILL.md)
|
|
89
98
|
|
|
90
99
|
# Files to consider: *.md, *.sh, *.py, *.mjs, *.js, *.ts (skill content)
|
|
91
100
|
# Exclude: binary, images, large fixtures, hidden
|
|
92
|
-
|
|
93
|
-
trap 'rm -
|
|
101
|
+
WORK="$(mktemp -d)"
|
|
102
|
+
trap 'rm -rf "$WORK"' EXIT
|
|
103
|
+
SCAN_LIST="$WORK/files"
|
|
104
|
+
RAW="$WORK/raw"
|
|
105
|
+
: > "$RAW"
|
|
94
106
|
|
|
95
107
|
find "$ROOT" -type f \
|
|
96
108
|
\( -name "*.md" -o -name "*.sh" -o -name "*.py" -o -name "*.mjs" -o -name "*.js" -o -name "*.ts" \) \
|
|
@@ -98,156 +110,230 @@ find "$ROOT" -type f \
|
|
|
98
110
|
-not -path "*/.git/*" \
|
|
99
111
|
2>/dev/null > "$SCAN_LIST"
|
|
100
112
|
|
|
101
|
-
|
|
102
|
-
local f="$1"
|
|
103
|
-
# curl|wget piping to shell
|
|
104
|
-
while IFS= read -r hit; do
|
|
105
|
-
[ -z "$hit" ] && continue
|
|
106
|
-
local ln="${hit%%:*}"
|
|
107
|
-
add_finding critical "$f" "$ln" "shell-pipe-exec" "curl/wget piped to shell interpreter"
|
|
108
|
-
done < <(grep -nE '(curl|wget)[^|]*\|[[:space:]]*(sh|bash|zsh|ksh|ash|dash)([[:space:]]|$)' "$f" 2>/dev/null)
|
|
113
|
+
FILE_COUNT=$(wc -l < "$SCAN_LIST" | tr -d '[:space:]')
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
115
|
+
# One grep invocation for a whole pattern family. xargs preserves argument
|
|
116
|
+
# order, so hits come out grouped per file in SCAN_LIST order.
|
|
117
|
+
tree_grep() {
|
|
118
|
+
tr '\n' '\0' < "$SCAN_LIST" | xargs -0 grep -nHE -- "$1" 2>/dev/null
|
|
119
|
+
return 0
|
|
120
|
+
}
|
|
116
121
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
122
|
+
# stdin: "path:line:content" grep hits -> stdout: "fileIdx|path|line|content"
|
|
123
|
+
index_hits() {
|
|
124
|
+
awk -v listfile="$SCAN_LIST" '
|
|
125
|
+
BEGIN {
|
|
126
|
+
i = 0
|
|
127
|
+
while ((getline l < listfile) > 0) { i++; idx[l] = i }
|
|
128
|
+
close(listfile)
|
|
129
|
+
}
|
|
130
|
+
{
|
|
131
|
+
p = index($0, ":"); f = substr($0, 1, p - 1)
|
|
132
|
+
rest = substr($0, p + 1)
|
|
133
|
+
q = index(rest, ":"); ln = substr(rest, 1, q - 1)
|
|
134
|
+
printf "%d|%s|%s|%s\n", (f in idx ? idx[f] : 999999), f, ln, substr(rest, q + 1)
|
|
135
|
+
}'
|
|
136
|
+
}
|
|
123
137
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
138
|
+
# stdin: bare file paths -> stdout: "fileIdx|path"
|
|
139
|
+
index_files() {
|
|
140
|
+
awk -v listfile="$SCAN_LIST" '
|
|
141
|
+
BEGIN {
|
|
142
|
+
i = 0
|
|
143
|
+
while ((getline l < listfile) > 0) { i++; idx[l] = i }
|
|
144
|
+
close(listfile)
|
|
145
|
+
}
|
|
146
|
+
{ printf "%d|%s\n", ($0 in idx ? idx[$0] : 999999), $0 }'
|
|
128
147
|
}
|
|
129
148
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
149
|
+
emit_raw() {
|
|
150
|
+
# $1 fileIdx $2 familyIdx $3 seq $4 sev $5 file $6 line $7 pattern $8 message
|
|
151
|
+
printf '%s|%s|%s|%s|%s|%s|%s|%s\n' "$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >> "$RAW"
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Split an indexed hit "fileIdx|path|line|content" into HIT_IDX/HIT_FILE/
|
|
155
|
+
# HIT_LINE/HIT_CONTENT (content may itself contain pipes - it is the rest).
|
|
156
|
+
parse_hit() {
|
|
157
|
+
HIT_IDX="${1%%|*}"; local rest="${1#*|}"
|
|
158
|
+
HIT_FILE="${rest%%|*}"; rest="${rest#*|}"
|
|
159
|
+
HIT_LINE="${rest%%|*}"
|
|
160
|
+
HIT_CONTENT="${rest#*|}"
|
|
161
|
+
}
|
|
133
162
|
|
|
163
|
+
# --- critical families (rank 0 - always at/above threshold) ------------
|
|
164
|
+
|
|
165
|
+
seq=0
|
|
166
|
+
while IFS= read -r hit; do
|
|
167
|
+
[ -z "$hit" ] && continue
|
|
168
|
+
parse_hit "$hit"
|
|
169
|
+
seq=$((seq+1))
|
|
170
|
+
emit_raw "$HIT_IDX" 1 "$seq" critical "$HIT_FILE" "$HIT_LINE" "shell-pipe-exec" "curl/wget piped to shell interpreter"
|
|
171
|
+
done < <(tree_grep '(curl|wget)[^|]*\|[[:space:]]*(sh|bash|zsh|ksh|ash|dash)([[:space:]]|$)' | index_hits)
|
|
172
|
+
|
|
173
|
+
seq=0
|
|
174
|
+
while IFS= read -r hit; do
|
|
175
|
+
[ -z "$hit" ] && continue
|
|
176
|
+
parse_hit "$hit"
|
|
177
|
+
seq=$((seq+1))
|
|
178
|
+
emit_raw "$HIT_IDX" 2 "$seq" critical "$HIT_FILE" "$HIT_LINE" "base64-pipe-exec" "base64 decoded output piped to shell"
|
|
179
|
+
done < <(tree_grep '(base64|openssl[[:space:]]+base64)[^|]*-d[^|]*\|[[:space:]]*(sh|bash|zsh|eval|sudo)' | index_hits)
|
|
180
|
+
|
|
181
|
+
seq=0
|
|
182
|
+
while IFS= read -r hit; do
|
|
183
|
+
[ -z "$hit" ] && continue
|
|
184
|
+
parse_hit "$hit"
|
|
185
|
+
seq=$((seq+1))
|
|
186
|
+
emit_raw "$HIT_IDX" 3 "$seq" critical "$HIT_FILE" "$HIT_LINE" "eval-of-network" "eval of network-fetched content"
|
|
187
|
+
done < <(tree_grep 'eval[[:space:]]+(\$\([[:space:]]*(curl|wget|fetch)|`[[:space:]]*(curl|wget|fetch))' | index_hits)
|
|
188
|
+
|
|
189
|
+
# Unicode bidi override characters - invisible injection attack
|
|
190
|
+
seq=0
|
|
191
|
+
while IFS= read -r row; do
|
|
192
|
+
[ -z "$row" ] && continue
|
|
193
|
+
seq=$((seq+1))
|
|
194
|
+
emit_raw "${row%%|*}" 4 "$seq" critical "${row#*|}" "0" "unicode-bidi" "bidirectional control chars (U+202D-202E, U+2066-2069) - trojan source risk"
|
|
195
|
+
done < <(tr '\n' '\0' < "$SCAN_LIST" \
|
|
196
|
+
| LC_ALL=C xargs -0 grep -l $'\xe2\x80\xad\|\xe2\x80\xae\|\xe2\x81\xa6\|\xe2\x81\xa7\|\xe2\x81\xa8\|\xe2\x81\xa9' 2>/dev/null \
|
|
197
|
+
| index_files; true)
|
|
198
|
+
|
|
199
|
+
# --- high families (rank 1) ----------------------------------------------
|
|
200
|
+
|
|
201
|
+
if [ "$THRESHOLD_RANK" -ge 1 ]; then
|
|
134
202
|
# JavaScript/TypeScript: eval(, new Function(, Function(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
203
|
+
seq=0
|
|
204
|
+
while IFS= read -r hit; do
|
|
205
|
+
[ -z "$hit" ] && continue
|
|
206
|
+
parse_hit "$hit"
|
|
207
|
+
case "$HIT_FILE" in *.js|*.mjs|*.ts) ;; *) continue ;; esac
|
|
208
|
+
seq=$((seq+1))
|
|
209
|
+
emit_raw "$HIT_IDX" 5 "$seq" high "$HIT_FILE" "$HIT_LINE" "js-dynamic-eval" "JavaScript dynamic code execution"
|
|
210
|
+
done < <(tree_grep '\b(eval|new[[:space:]]+Function|Function)\s*\(' | index_hits)
|
|
142
211
|
|
|
143
212
|
# Python: exec(, eval( on non-literal - exclude re.compile (regex) and subprocess
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
213
|
+
seq=0
|
|
214
|
+
while IFS= read -r hit; do
|
|
215
|
+
[ -z "$hit" ] && continue
|
|
216
|
+
parse_hit "$hit"
|
|
217
|
+
case "$HIT_FILE" in *.py) ;; *) continue ;; esac
|
|
218
|
+
# Skip re.compile (standard regex) and subprocess.* (legitimate process invocation)
|
|
219
|
+
echo "$HIT_CONTENT" | grep -qE '(\bre\.compile|\bre2\.compile|subprocess\.|typing\.)' && continue
|
|
220
|
+
seq=$((seq+1))
|
|
221
|
+
emit_raw "$HIT_IDX" 6 "$seq" high "$HIT_FILE" "$HIT_LINE" "py-dynamic-exec" "Python dynamic code execution"
|
|
222
|
+
done < <(tree_grep '(^|[^a-zA-Z0-9_.])(exec|eval)[[:space:]]*\(' | index_hits)
|
|
154
223
|
|
|
155
224
|
# Hardcoded API keys - AWS, OpenAI, GitHub, generic sk-* with length
|
|
156
225
|
# Skip lines in FORBIDDEN/NEVER/example blocks (false positives from docs)
|
|
226
|
+
seq=0
|
|
157
227
|
while IFS= read -r hit; do
|
|
158
228
|
[ -z "$hit" ] && continue
|
|
159
|
-
|
|
160
|
-
# Check
|
|
161
|
-
|
|
229
|
+
parse_hit "$hit"
|
|
230
|
+
# Check +/-3 lines around match for documentation context markers
|
|
231
|
+
ctx_start=$((HIT_LINE - 3))
|
|
162
232
|
[ "$ctx_start" -lt 1 ] && ctx_start=1
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
ctx=$(sed -n "${ctx_start},${ctx_end}p" "$f" 2>/dev/null)
|
|
233
|
+
ctx_end=$((HIT_LINE + 3))
|
|
234
|
+
ctx=$(sed -n "${ctx_start},${ctx_end}p" "$HIT_FILE" 2>/dev/null)
|
|
166
235
|
if echo "$ctx" | grep -qiE '(FORBIDDEN|NEVER do|don.t do|example:|placeholder|sample credential|DO NOT|✗|XXX|YYY|dummy|fake.?key)'; then
|
|
167
236
|
continue
|
|
168
237
|
fi
|
|
169
|
-
|
|
170
|
-
|
|
238
|
+
seq=$((seq+1))
|
|
239
|
+
emit_raw "$HIT_IDX" 7 "$seq" high "$HIT_FILE" "$HIT_LINE" "hardcoded-credential" "possible hardcoded API credential"
|
|
240
|
+
done < <(tree_grep '(AKIA[0-9A-Z]{16}|sk-(live|test|proj|ant|or)-[A-Za-z0-9_-]{20,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{82}|gho_[A-Za-z0-9]{36}|xox[bp]-[A-Za-z0-9-]{10,})' | index_hits)
|
|
171
241
|
|
|
172
242
|
# Pastebin / URL shortener raw content fetching
|
|
243
|
+
seq=0
|
|
173
244
|
while IFS= read -r hit; do
|
|
174
245
|
[ -z "$hit" ] && continue
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
246
|
+
parse_hit "$hit"
|
|
247
|
+
seq=$((seq+1))
|
|
248
|
+
emit_raw "$HIT_IDX" 8 "$seq" high "$HIT_FILE" "$HIT_LINE" "pastebin-fetch" "fetch from ephemeral/obscured content host"
|
|
249
|
+
done < <(tree_grep 'https?://(pastebin\.com/raw|paste\.ee|ghostbin|hastebin|bit\.ly|tinyurl\.com|goo\.gl|t\.co|is\.gd|ow\.ly|rebrand\.ly|gist\.github\.com/[^/]+/[a-f0-9]+/raw)' | index_hits)
|
|
178
250
|
|
|
179
251
|
# chmod +x followed by execution on same file in same block
|
|
252
|
+
seq=0
|
|
180
253
|
while IFS= read -r hit; do
|
|
181
254
|
[ -z "$hit" ] && continue
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
255
|
+
parse_hit "$hit"
|
|
256
|
+
seq=$((seq+1))
|
|
257
|
+
emit_raw "$HIT_IDX" 9 "$seq" high "$HIT_FILE" "$HIT_LINE" "chmod-then-exec" "script made executable and immediately invoked"
|
|
258
|
+
done < <(tree_grep 'chmod[[:space:]]+\+x[[:space:]]+[^&;]+[[:space:]]*(&&|;)[[:space:]]*\./' | index_hits)
|
|
259
|
+
fi
|
|
186
260
|
|
|
187
|
-
|
|
188
|
-
local f="$1"
|
|
261
|
+
# --- medium families (rank 2) ---------------------------------------------
|
|
189
262
|
|
|
263
|
+
if [ "$THRESHOLD_RANK" -ge 2 ]; then
|
|
190
264
|
# Long base64 blobs - obfuscation indicator.
|
|
191
265
|
# Skip .md files (documentation often shows example b64) - only scan executable content types.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
esac
|
|
202
|
-
}
|
|
266
|
+
seq=0
|
|
267
|
+
while IFS= read -r hit; do
|
|
268
|
+
[ -z "$hit" ] && continue
|
|
269
|
+
parse_hit "$hit"
|
|
270
|
+
case "$HIT_FILE" in *.md) continue ;; esac
|
|
271
|
+
seq=$((seq+1))
|
|
272
|
+
emit_raw "$HIT_IDX" 10 "$seq" medium "$HIT_FILE" "$HIT_LINE" "long-base64" "base64-looking blob >200 chars (possible obfuscation)"
|
|
273
|
+
done < <(tree_grep '[A-Za-z0-9+/]{200,}={0,2}' | index_hits)
|
|
274
|
+
fi
|
|
203
275
|
|
|
204
|
-
|
|
205
|
-
local f="$1"
|
|
276
|
+
# --- low families (rank 3) -------------------------------------------------
|
|
206
277
|
|
|
278
|
+
if [ "$THRESHOLD_RANK" -ge 3 ]; then
|
|
207
279
|
# Unknown network endpoints - LOW severity (informational). URLs in skills
|
|
208
280
|
# are usually doc references, not exfil channels. Only the critical
|
|
209
281
|
# shell-pipe-exec patterns detect active network abuse.
|
|
282
|
+
# Historical behavior: at most the first 20 URL-bearing lines per file.
|
|
283
|
+
url_re='https?://[a-zA-Z0-9._-]+'
|
|
284
|
+
allow_re="^${ALLOW_DOMAINS}\$"
|
|
285
|
+
seq=0
|
|
286
|
+
prev_file=""
|
|
287
|
+
url_lines=0
|
|
210
288
|
while IFS= read -r hit; do
|
|
211
289
|
[ -z "$hit" ] && continue
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
290
|
+
parse_hit "$hit"
|
|
291
|
+
if [ "$HIT_FILE" != "$prev_file" ]; then
|
|
292
|
+
prev_file="$HIT_FILE"
|
|
293
|
+
url_lines=0
|
|
294
|
+
fi
|
|
295
|
+
url_lines=$((url_lines+1))
|
|
296
|
+
[ "$url_lines" -gt 20 ] && continue
|
|
297
|
+
[[ "$HIT_CONTENT" =~ $url_re ]] || continue
|
|
298
|
+
url="${BASH_REMATCH[0]}"
|
|
299
|
+
host="${url#*://}"
|
|
218
300
|
host="${host%%/*}"
|
|
219
|
-
if !
|
|
220
|
-
|
|
301
|
+
if ! [[ "$host" =~ $allow_re ]]; then
|
|
302
|
+
seq=$((seq+1))
|
|
303
|
+
emit_raw "$HIT_IDX" 11 "$seq" low "$HIT_FILE" "$HIT_LINE" "unknown-endpoint" "network endpoint not in allow-list: $host"
|
|
221
304
|
fi
|
|
222
|
-
done < <(
|
|
223
|
-
}
|
|
305
|
+
done < <(tree_grep 'https?://[a-zA-Z0-9._-]+' | index_hits)
|
|
224
306
|
|
|
225
|
-
scan_low() {
|
|
226
|
-
local f="$1"
|
|
227
307
|
# Missing SKILL.md frontmatter (only check files named SKILL.md)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
308
|
+
seq=0
|
|
309
|
+
file_idx=0
|
|
310
|
+
while IFS= read -r file; do
|
|
311
|
+
file_idx=$((file_idx+1))
|
|
312
|
+
[ -z "$file" ] && continue
|
|
313
|
+
case "$file" in
|
|
314
|
+
*/SKILL.md)
|
|
315
|
+
if ! head -1 "$file" | grep -Fxq -- "---"; then
|
|
316
|
+
seq=$((seq+1))
|
|
317
|
+
emit_raw "$file_idx" 12 "$seq" low "$file" "1" "missing-frontmatter" "SKILL.md without YAML frontmatter"
|
|
318
|
+
elif ! grep -qE '^description:' "$file"; then
|
|
319
|
+
seq=$((seq+1))
|
|
320
|
+
emit_raw "$file_idx" 12 "$seq" low "$file" "0" "missing-description" "SKILL.md frontmatter missing 'description'"
|
|
321
|
+
fi
|
|
322
|
+
;;
|
|
323
|
+
esac
|
|
324
|
+
done < "$SCAN_LIST"
|
|
325
|
+
fi
|
|
240
326
|
|
|
241
|
-
|
|
242
|
-
while IFS= read -r
|
|
243
|
-
[ -z "$
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
done < "$
|
|
327
|
+
# Re-order into the historical per-file scan order and load into FINDINGS.
|
|
328
|
+
while IFS= read -r row; do
|
|
329
|
+
[ -z "$row" ] && continue
|
|
330
|
+
rest="${row#*|}"; rest="${rest#*|}"; rest="${rest#*|}" # drop idx|fam|seq|
|
|
331
|
+
sev="${rest%%|*}"; rest="${rest#*|}"
|
|
332
|
+
file="${rest%%|*}"; rest="${rest#*|}"
|
|
333
|
+
line="${rest%%|*}"; rest="${rest#*|}"
|
|
334
|
+
pat="${rest%%|*}"; msg="${rest#*|}"
|
|
335
|
+
add_finding "$sev" "$file" "$line" "$pat" "$msg"
|
|
336
|
+
done < <(sort -t'|' -k1,1n -k2,2n -k3,3n "$RAW")
|
|
251
337
|
|
|
252
338
|
# --- Report -------------------------------------------------------------
|
|
253
339
|
|
|
@@ -261,20 +347,24 @@ if [ "$JSON" -eq 1 ]; then
|
|
|
261
347
|
printf ' "findings": [\n'
|
|
262
348
|
local_i=0
|
|
263
349
|
total=${#FINDINGS[@]}
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
350
|
+
# Guard the expansion: on an empty array "${FINDINGS[@]:-}" yields one empty
|
|
351
|
+
# word, which used to emit a spurious all-empty findings element.
|
|
352
|
+
if [ "$total" -gt 0 ]; then
|
|
353
|
+
for f in "${FINDINGS[@]}"; do
|
|
354
|
+
local_i=$((local_i+1))
|
|
355
|
+
sev="${f%%|*}"; rest="${f#*|}"
|
|
356
|
+
file="${rest%%|*}"; rest="${rest#*|}"
|
|
357
|
+
line="${rest%%|*}"; rest="${rest#*|}"
|
|
358
|
+
pat="${rest%%|*}"; msg="${rest#*|}"
|
|
359
|
+
# json-escape file/msg minimally
|
|
360
|
+
jf=$(printf '%s' "$file" | sed 's/"/\\"/g')
|
|
361
|
+
jm=$(printf '%s' "$msg" | sed 's/"/\\"/g')
|
|
362
|
+
printf ' { "severity": "%s", "file": "%s", "line": "%s", "pattern": "%s", "message": "%s" }' \
|
|
363
|
+
"$sev" "$jf" "$line" "$pat" "$jm"
|
|
364
|
+
[ "$local_i" -lt "$total" ] && printf ','
|
|
365
|
+
printf '\n'
|
|
366
|
+
done
|
|
367
|
+
fi
|
|
278
368
|
printf ' ]\n}\n'
|
|
279
369
|
else
|
|
280
370
|
# Colored text report
|
|
@@ -295,7 +385,7 @@ else
|
|
|
295
385
|
printf ' ✓ clean (0 findings)\n'
|
|
296
386
|
else
|
|
297
387
|
printf ' %sfound %d%s: critical=%d high=%d medium=%d low=%d\n\n' "$C_BLD" "$total" "$C_RST" "$CRIT" "$HIGH" "$MED" "$LOW"
|
|
298
|
-
for f in "${FINDINGS[@]
|
|
388
|
+
for f in "${FINDINGS[@]}"; do
|
|
299
389
|
sev="${f%%|*}"; rest="${f#*|}"
|
|
300
390
|
file="${rest%%|*}"; rest="${rest#*|}"
|
|
301
391
|
line="${rest%%|*}"; rest="${rest#*|}"
|
|
@@ -307,7 +397,7 @@ else
|
|
|
307
397
|
low) ico="· "; col="$C_DIM" ;;
|
|
308
398
|
*) ico="? "; col="" ;;
|
|
309
399
|
esac
|
|
310
|
-
rel="${file
|
|
400
|
+
rel="${file#"$REPO_ROOT"/}"
|
|
311
401
|
printf ' %s%s%s %-8s %s:%s [%s]\n' "$col" "$ico" "$C_RST" "$sev" "$rel" "$line" "$pat"
|
|
312
402
|
printf ' %s%s%s\n' "$C_DIM" "$msg" "$C_RST"
|
|
313
403
|
done
|