Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f1e5eebc4 | ||
|
|
bb1b65340e | ||
|
|
9e87927444 | ||
|
|
b0683e9566 | ||
|
|
dd11c8f14f | ||
|
|
0732eed329 | ||
|
|
59ed7cdbf7 | ||
|
|
5d70bbf25b | ||
|
|
ccc916d1ec | ||
|
|
48fb263d4b |
@@ -71,6 +71,18 @@ jobs:
|
|||||||
git fetch origin "${{ gitea.ref }}" --depth=50
|
git fetch origin "${{ gitea.ref }}" --depth=50
|
||||||
git checkout "${{ gitea.sha }}"
|
git checkout "${{ gitea.sha }}"
|
||||||
|
|
||||||
|
- name: Committed-credential scan (secret guard)
|
||||||
|
run: |
|
||||||
|
# Fails the build on a credential-shaped string in the tree. Patterns
|
||||||
|
# live in scripts/secret-patterns.tsv; the only tolerated literal
|
||||||
|
# examples are in scripts/secret-allowlist.tsv, each with a reason.
|
||||||
|
# Do not turn this into a warning: a warning in a stream nobody reads
|
||||||
|
# is how six live credentials sat in this repo for weeks.
|
||||||
|
bash scripts/secret-scan.sh
|
||||||
|
|
||||||
|
- name: Secret guard self-test
|
||||||
|
run: bash tests/test_secret_scan.sh
|
||||||
|
|
||||||
- name: Structure + regression + consistency lint
|
- name: Structure + regression + consistency lint
|
||||||
run: bash scripts/prose-lint.sh
|
run: bash scripts/prose-lint.sh
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,12 @@ Two incidents taught us this:
|
|||||||
- `/grafana/` nginx route — was reverted Jul 2, must not reappear
|
- `/grafana/` nginx route — was reverted Jul 2, must not reappear
|
||||||
- `CT 122` or `CT 123` as CT ID labels — don't exist in the cluster
|
- `CT 122` or `CT 123` as CT ID labels — don't exist in the cluster
|
||||||
- These rules are hardcoded in `scripts/prose-lint.sh`
|
- These rules are hardcoded in `scripts/prose-lint.sh`
|
||||||
|
- **Committed-credential guard:** `scripts/secret-scan.sh` FAILS the build on
|
||||||
|
credential-shaped strings (patterns in `scripts/secret-patterns.tsv`, prose
|
||||||
|
included). Tolerated literals are listed one-per-example with a reason in
|
||||||
|
`scripts/secret-allowlist.tsv`; never allowlist a live credential. It runs in
|
||||||
|
the CI lint job, in `scripts/prose-lint.sh`, and via
|
||||||
|
`bash scripts/secret-scan.sh --staged` before committing.
|
||||||
|
|
||||||
### Stage 3 — AI Review
|
### Stage 3 — AI Review
|
||||||
- Diff is sent to `syslog-auto` model via LiteLLM
|
- Diff is sent to `syslog-auto` model via LiteLLM
|
||||||
|
|||||||
+20
-10
@@ -2,10 +2,10 @@
|
|||||||
kind: responsibility
|
kind: responsibility
|
||||||
name: pm2-self-heal
|
name: pm2-self-heal
|
||||||
description: >
|
description: >
|
||||||
PM2 process health check for abiba-telegram, abiba-zulip, gitea-runner, and zulip-watchdog.
|
Monitors critical PM2 processes (abiba-telegram, abiba-zulip, gitea-runner, zulip-watchdog)
|
||||||
gpu-monitor is systemd-managed (gpu-monitor.service), NOT PM2.
|
and auto-restarts any that are stopped or errored. Logs every action to
|
||||||
gpu-watchdog is decommissioned and folded into gpu-monitor.service.
|
Gitea health-logs and alerts the owner via Telegram (primary) or Zulip DM (secondary).
|
||||||
gitea-runner is KEPT. abiba-zulip is KEPT (online for days).
|
Abiba-zulip is the live Zulip bridge and may be restarted; alert owner on failure.
|
||||||
---
|
---
|
||||||
|
|
||||||
## Maintains
|
## Maintains
|
||||||
@@ -16,6 +16,8 @@ description: >
|
|||||||
- zulip-watchdog: { status: "online", uptime: string, restarts: number }
|
- zulip-watchdog: { status: "online", uptime: string, restarts: number }
|
||||||
- last_check: timestamp
|
- last_check: timestamp
|
||||||
|
|
||||||
|
> **Status (2026-08-03):** `abiba-zulip` fully restored — live Zulip bridge, heartbeating, monitored. `gpu-monitor` runs via systemd only (gpu-monitor.service); NOT PM2-tracked. `gpu-watchdog` retired from PM2 (folded into gpu-monitor.service). `zulip-watchdog` remains live and PM2-managed.
|
||||||
|
|
||||||
|
|
||||||
## Continuity
|
## Continuity
|
||||||
|
|
||||||
@@ -40,7 +42,7 @@ description: >
|
|||||||
crash-loop that never toggles status to "stopped"/"errored" (e.g. the 10k-restarts
|
crash-loop that never toggles status to "stopped"/"errored" (e.g. the 10k-restarts
|
||||||
spoton incident). Alerts include the restart count.
|
spoton incident). Alerts include the restart count.
|
||||||
- **AS-BUILT (2026-09-15)**: spoton-service was deleted with its app; the live PM2 set is
|
- **AS-BUILT (2026-09-15)**: spoton-service was deleted with its app; the live PM2 set is
|
||||||
four processes (abiba-telegram, abiba-zulip, gitea-runner, zulip-watchdog). The spoton
|
four processes (abiba-telegram, abiba-zulip, gitea-runner, gpu-monitor). The spoton
|
||||||
reference above is historical context for the crash-loop guard, not a live process.
|
reference above is historical context for the crash-loop guard, not a live process.
|
||||||
- **Escalate**: Only when restarts > 30 — alerts to Zulip DM
|
- **Escalate**: Only when restarts > 30 — alerts to Zulip DM
|
||||||
- **Historical fix**: Previous cycles were caused by abiba-zulip extension's
|
- **Historical fix**: Previous cycles were caused by abiba-zulip extension's
|
||||||
@@ -51,17 +53,25 @@ description: >
|
|||||||
## Execution
|
## Execution
|
||||||
|
|
||||||
1. **Check PM2 status** — Run `pm2 status --no-color` and parse the table (5th data column = PID, 8th = restarts, 9th = status)
|
1. **Check PM2 status** — Run `pm2 status --no-color` and parse the table (5th data column = PID, 8th = restarts, 9th = status)
|
||||||
2. **Check abiba-telegram**:
|
2. **Check abiba-telegram** (safe to auto-restart):
|
||||||
- If status is "online" → pass
|
- If status is "online" → pass
|
||||||
- If status is "stopped" or "errored" → apply Rule 1
|
- If status is "stopped" or "errored" → apply Rule 1
|
||||||
- If restarts > 5 → alert owner
|
- If restarts > 1000 → apply Rule 2 (crash-loop guard)
|
||||||
3. **Check abiba-zulip** (live Zulip bridge, heartbeating):
|
3. **Check abiba-zulip** (live Zulip bridge, heartbeating):
|
||||||
- If status is "online" → pass, log restarts count
|
- If status is "online" → pass, log restarts count
|
||||||
- If status is "stopped" or "errored" → restart (`pm2 restart abiba-zulip` — fully restored)
|
- If status is "stopped" or "errored" → restart (`pm2 restart abiba-zulip` — fully restored)
|
||||||
- If restarts > 5 in last hour → alert owner with full diagnostics
|
- If restarts > 5 in last hour → alert owner with full diagnostics
|
||||||
4. **Log results** — Append to `SyslogSolution/health-logs/pm2/{timestamp}.md` in Gitea (not knowledge graph — hard rule)
|
4. **Check gitea-runner**:
|
||||||
5. **Alert** — Send Zulip DM to owner if escalation needed (do NOT run pm2 commands during alerting)
|
- If status is "online" → pass
|
||||||
6. **Wait 5 min** → repeat from step 1
|
- If status is "stopped" or "errored" → apply Rule 1
|
||||||
|
- If restarts > 5 → alert owner
|
||||||
|
5. **Check zulip-watchdog**:
|
||||||
|
- If status is "online" → pass
|
||||||
|
- If status is "stopped" or "errored" → apply Rule 1
|
||||||
|
- If restarts > 5 → alert owner
|
||||||
|
6. **Log results** — Append to `SyslogSolution/health-logs/pm2/{timestamp}.md` in Gitea (not knowledge graph — hard rule)
|
||||||
|
7. **Alert** — Send Telegram (primary) or Zulip DM (secondary) to owner if escalation needed (do NOT run pm2 commands during alerting)
|
||||||
|
8. **Wait 5 min** → repeat from step 1
|
||||||
|
|
||||||
## Example Output (when healthy)
|
## Example Output (when healthy)
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,12 @@ AUTH = "Authorization: PVEAPIToken=«vault: infrastructure/production PVE_API_TO
|
|||||||
|
|
||||||
ZULIP_SITE = "https://chat.sysloggh.net"
|
ZULIP_SITE = "https://chat.sysloggh.net"
|
||||||
ZULIP_EMAIL = "abiba-bot@chat.sysloggh.net"
|
ZULIP_EMAIL = "abiba-bot@chat.sysloggh.net"
|
||||||
ZULIP_API_KEY = os.environ.get("ZULIP_API_KEY", "")
|
# Note: /api/v1/server_settings is a PUBLIC endpoint (verified HTTP 200 with or without credential).
|
||||||
if not ZULIP_API_KEY:
|
# No Zulip API key is required for this call. If a future leg genuinely needs abiba-bot's key,
|
||||||
raise SystemExit("ZULIP_API_KEY not set — refusing to run with no credential")
|
# it must prove it with a 200 from /api/v1/users/me as abiba-bot and label itself degraded when it cannot.
|
||||||
ZULIP_AUTH = f"{ZULIP_EMAIL}:{ZULIP_API_KEY}"
|
# Never fall back to the vault's shared ZULIP_API_KEY.
|
||||||
|
ZULIP_AUTH = None
|
||||||
|
DEGRADED_LEGS = []
|
||||||
|
|
||||||
LITELLM_PUBLIC = "https://litellm.sysloggh.net"
|
LITELLM_PUBLIC = "https://litellm.sysloggh.net"
|
||||||
LITELLM_BACKEND = "192.168.68.116"
|
LITELLM_BACKEND = "192.168.68.116"
|
||||||
@@ -158,7 +160,7 @@ def collect():
|
|||||||
# ── Storage ──
|
# ── Storage ──
|
||||||
storages = pve_get("/api2/json/nodes/storepve/storage")
|
storages = pve_get("/api2/json/nodes/storepve/storage")
|
||||||
report["storage"] = []
|
report["storage"] = []
|
||||||
for s in storages:
|
for s in (storages or []):
|
||||||
total = s.get("total",0) or 1
|
total = s.get("total",0) or 1
|
||||||
used = s.get("used",0)
|
used = s.get("used",0)
|
||||||
pct = used/total*100
|
pct = used/total*100
|
||||||
@@ -673,8 +675,9 @@ def send_email(html_content, subject_prefix=""):
|
|||||||
try:
|
try:
|
||||||
EMAIL_PASSWORD = os.environ.get("EMAIL_PASSWORD") or os.environ.get("SMTP_PASSWORD") or os.environ.get("MAIL_PASSWORD")
|
EMAIL_PASSWORD = os.environ.get("EMAIL_PASSWORD") or os.environ.get("SMTP_PASSWORD") or os.environ.get("MAIL_PASSWORD")
|
||||||
if not EMAIL_PASSWORD:
|
if not EMAIL_PASSWORD:
|
||||||
print("EMAIL_PASSWORD not set — refusing to send email", file=sys.stderr)
|
print(" ⚠️ Degraded leg: credential-missing: EMAIL_PASSWORD (or SMTP_PASSWORD/MAIL_PASSWORD)", file=sys.stderr)
|
||||||
sys.exit(1)
|
DEGRADED_LEGS.append("credential-missing: EMAIL_PASSWORD")
|
||||||
|
return True, "✅ Email leg degraded (no credential) — report still produced"
|
||||||
GMAIL_EMAIL = "jtabiri@gmail.com"
|
GMAIL_EMAIL = "jtabiri@gmail.com"
|
||||||
|
|
||||||
server = smtplib.SMTP("smtp.gmail.com", 587)
|
server = smtplib.SMTP("smtp.gmail.com", 587)
|
||||||
@@ -713,6 +716,17 @@ if __name__ == "__main__":
|
|||||||
print(f" {msg}")
|
print(f" {msg}")
|
||||||
|
|
||||||
# Show summary
|
# Show summary
|
||||||
|
if DEGRADED_LEGS:
|
||||||
|
print(f"\n⚠️ Degraded legs ({len(DEGRADED_LEGS)}):")
|
||||||
|
for leg in DEGRADED_LEGS:
|
||||||
|
print(f" - {leg}")
|
||||||
|
else:
|
||||||
|
print("\n✅ All legs fully credentialed")
|
||||||
|
|
||||||
|
# A failed send must exit non-zero; a degraded leg (no credential) must stay exit 0
|
||||||
|
if not ok:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
issues = sum(1 for i in ["red"] if report.get("zulip_ext", {}).get("connected") == False)
|
issues = sum(1 for i in ["red"] if report.get("zulip_ext", {}).get("connected") == False)
|
||||||
print(f"\n📋 Summary:")
|
print(f"\n📋 Summary:")
|
||||||
print(f" Proxmox: {report['nodes_online']}/{report['node_count']} nodes online")
|
print(f" Proxmox: {report['nodes_online']}/{report['node_count']} nodes online")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# pm2-self-heal — hourly PM2 process check
|
# pm2-self-heal — hourly PM2 process check
|
||||||
# Part of the pm2-self-heal prose contract
|
# Part of the pm2-self-heal prose contract
|
||||||
# Alerts via Telegram (abiba-zulip decommissioned 2026-07-04)
|
# Alerts via Telegram (primary) and Zulip DM (secondary, abiba-zulip restored 2026-08-03)
|
||||||
# Field positions (awk -F'│'): $7=pid $8=uptime $9=restarts $10=status
|
# Field positions (awk -F'│'): $7=pid $8=uptime $9=restarts $10=status
|
||||||
|
|
||||||
TELEGRAM_BOT_TOKEN="$(grep TELEGRAM_BOT_TOKEN /root/.pi/agent/extensions/telegram/.env 2>/dev/null | cut -d= -f2 || echo '')"
|
TELEGRAM_BOT_TOKEN="$(grep TELEGRAM_BOT_TOKEN /root/.pi/agent/extensions/telegram/.env 2>/dev/null | cut -d= -f2 || echo '')"
|
||||||
@@ -46,9 +46,75 @@ if [ "$TEL_STATUS" != "online" ] || [ "$TEL_RESTARTS" -gt 1000 ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Check abiba-zulip (live Zulip bridge, heartbeating)
|
||||||
|
ZULIP_LINE=$(echo "$STATUS" | grep "abiba-zulip")
|
||||||
|
ZULIP_STATUS=$(echo "$ZULIP_LINE" | awk -F'│' '{print $10}' | xargs)
|
||||||
|
ZULIP_RESTARTS=$(echo "$ZULIP_LINE" | awk -F'│' '{print $9}' | xargs)
|
||||||
|
|
||||||
|
if [ "$ZULIP_STATUS" != "online" ]; then
|
||||||
|
pm2 restart abiba-zulip > /dev/null 2>&1
|
||||||
|
sleep 3
|
||||||
|
ZULIP_LINE2=$(pm2 status --no-color 2>/dev/null | grep "abiba-zulip")
|
||||||
|
ZULIP_STATUS2=$(echo "$ZULIP_LINE2" | awk -F'│' '{print $10}' | xargs)
|
||||||
|
if [ "$ZULIP_STATUS2" = "online" ]; then
|
||||||
|
msg="⚠️ abiba-zulip was **$ZULIP_STATUS** → restarted to online"
|
||||||
|
ALERTS="${ALERTS}${msg}\n"
|
||||||
|
else
|
||||||
|
msg="🚨 abiba-zulip **failed restart** (was $ZULIP_STATUS, still $ZULIP_STATUS2)"
|
||||||
|
ALERTS="${ALERTS}${msg}\n"
|
||||||
|
fi
|
||||||
|
elif [ "$ZULIP_RESTARTS" -gt 5 ]; then
|
||||||
|
msg="⚠️ abiba-zulip has **$ZULIP_RESTARTS** restarts (high count)"
|
||||||
|
ALERTS="${ALERTS}${msg}\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check gitea-runner
|
||||||
|
GITEA_LINE=$(echo "$STATUS" | grep "gitea-runner")
|
||||||
|
GITEA_STATUS=$(echo "$GITEA_LINE" | awk -F'│' '{print $10}' | xargs)
|
||||||
|
GITEA_RESTARTS=$(echo "$GITEA_LINE" | awk -F'│' '{print $9}' | xargs)
|
||||||
|
|
||||||
|
if [ "$GITEA_STATUS" != "online" ]; then
|
||||||
|
pm2 restart gitea-runner > /dev/null 2>&1
|
||||||
|
sleep 3
|
||||||
|
GITEA_LINE2=$(pm2 status --no-color 2>/dev/null | grep "gitea-runner")
|
||||||
|
GITEA_STATUS2=$(echo "$GITEA_LINE2" | awk -F'│' '{print $10}' | xargs)
|
||||||
|
if [ "$GITEA_STATUS2" = "online" ]; then
|
||||||
|
msg="⚠️ gitea-runner was **$GITEA_STATUS** → restarted to online"
|
||||||
|
ALERTS="${ALERTS}${msg}\n"
|
||||||
|
else
|
||||||
|
msg="🚨 gitea-runner **failed restart** (was $GITEA_STATUS, still $GITEA_STATUS2)"
|
||||||
|
ALERTS="${ALERTS}${msg}\n"
|
||||||
|
fi
|
||||||
|
elif [ "$GITEA_RESTARTS" -gt 5 ]; then
|
||||||
|
msg="⚠️ gitea-runner has **$GITEA_RESTARTS** restarts (high count)"
|
||||||
|
ALERTS="${ALERTS}${msg}\n"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check zulip-watchdog
|
||||||
|
WATCHDOG_LINE=$(echo "$STATUS" | grep "zulip-watchdog")
|
||||||
|
WATCHDOG_STATUS=$(echo "$WATCHDOG_LINE" | awk -F'│' '{print $10}' | xargs)
|
||||||
|
WATCHDOG_RESTARTS=$(echo "$WATCHDOG_LINE" | awk -F'│' '{print $9}' | xargs)
|
||||||
|
|
||||||
|
if [ "$WATCHDOG_STATUS" != "online" ]; then
|
||||||
|
pm2 restart zulip-watchdog > /dev/null 2>&1
|
||||||
|
sleep 3
|
||||||
|
WATCHDOG_LINE2=$(pm2 status --no-color 2>/dev/null | grep "zulip-watchdog")
|
||||||
|
WATCHDOG_STATUS2=$(echo "$WATCHDOG_LINE2" | awk -F'│' '{print $10}' | xargs)
|
||||||
|
if [ "$WATCHDOG_STATUS2" = "online" ]; then
|
||||||
|
msg="⚠️ zulip-watchdog was **$WATCHDOG_STATUS** → restarted to online"
|
||||||
|
ALERTS="${ALERTS}${msg}\n"
|
||||||
|
else
|
||||||
|
msg="🚨 zulip-watchdog **failed restart** (was $WATCHDOG_STATUS, still $WATCHDOG_STATUS2)"
|
||||||
|
ALERTS="${ALERTS}${msg}\n"
|
||||||
|
fi
|
||||||
|
elif [ "$WATCHDOG_RESTARTS" -gt 5 ]; then
|
||||||
|
msg="⚠️ zulip-watchdog has **$WATCHDOG_RESTARTS** restarts (high count)"
|
||||||
|
ALERTS="${ALERTS}${msg}\n"
|
||||||
|
fi
|
||||||
|
|
||||||
# Log check
|
# Log check
|
||||||
{
|
{
|
||||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] tel=$TEL_STATUS alerts=${ALERTS:+yes}"
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] tel=$TEL_STATUS zulip=$ZULIP_STATUS gitea=$GITEA_STATUS watchdog=$WATCHDOG_STATUS alerts=${ALERTS:+yes}"
|
||||||
[ -n "$ALERTS" ] && echo "$ALERTS"
|
[ -n "$ALERTS" ] && echo "$ALERTS"
|
||||||
} >> "$LOG"
|
} >> "$LOG"
|
||||||
|
|
||||||
|
|||||||
+16
-1
@@ -135,7 +135,22 @@ fi
|
|||||||
|
|
||||||
echo " Cross-contract: $WARNINGS total warnings across all checks"
|
echo " Cross-contract: $WARNINGS total warnings across all checks"
|
||||||
|
|
||||||
# ── 4. Summary ──
|
# ── 4. Committed-credential scan ──
|
||||||
|
# The 2026-09-17 purge removed six live credentials that had sat in .md prose
|
||||||
|
# and scripts for weeks. This step makes that class of commit FAIL the gate
|
||||||
|
# instead of printing a warning. Patterns: scripts/secret-patterns.tsv.
|
||||||
|
# Only deliberate synthetic examples may be listed in scripts/secret-allowlist.tsv,
|
||||||
|
# each with a reason. Run `bash scripts/secret-scan.sh --staged` before committing.
|
||||||
|
echo ""
|
||||||
|
echo "── 4. Secret scan (committed credentials) ──"
|
||||||
|
if bash scripts/secret-scan.sh; then
|
||||||
|
echo " ✅ No committed credentials"
|
||||||
|
else
|
||||||
|
echo " ❌ COMMITTED CREDENTIAL DETECTED"
|
||||||
|
FAILED=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 5. Summary ──
|
||||||
echo ""
|
echo ""
|
||||||
echo "═══════════════════════════════════"
|
echo "═══════════════════════════════════"
|
||||||
if [ $FAILED -eq 1 ]; then
|
if [ $FAILED -eq 1 ]; then
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# secret-allowlist.tsv — exceptions for scripts/secret-scan.sh, every entry with a reason.
|
||||||
|
#
|
||||||
|
# Format: <rule-id|*><TAB><path-glob><TAB><literal-substring><TAB><reason>
|
||||||
|
# Blank lines and lines whose first field starts with '#' are ignored.
|
||||||
|
# A finding is suppressed only when ALL THREE of rule, path and literal match:
|
||||||
|
# * the rule id equals the finding's rule id, or is '*'
|
||||||
|
# * the finding's repo-relative path matches <path-glob> (bash glob)
|
||||||
|
# * the finding's line contains <literal-substring> verbatim
|
||||||
|
# An entry whose reason is empty is a hard error (exit 2) — no silent exceptions.
|
||||||
|
#
|
||||||
|
# RULE: never allowlist a live credential, and never broaden an entry (rule '*',
|
||||||
|
# a wide path glob, or a short generic literal) just to silence a finding.
|
||||||
|
# If the finding is real, remove the credential from the file.
|
||||||
|
#
|
||||||
|
# Entries are one per deliberate synthetic example, so the file reads as an
|
||||||
|
# audit trail of reviewed exceptions rather than a list of things to ignore.
|
||||||
|
# Rule '*' is used only where the same literal is matched by more than one rule.
|
||||||
|
#
|
||||||
|
# ── The 2026-09-17 purge placeholders ─────────────────────────────────────
|
||||||
|
# PR #112 replaced six live credentials with `«vault: <project>/<env> <SECRET>»`
|
||||||
|
# references. Those references are safe by construction (they name where the
|
||||||
|
# secret is read from), but they are listed here explicitly rather than being
|
||||||
|
# filtered by a general "vault" rule, so a new occurrence still needs a
|
||||||
|
# deliberate, reasoned entry.
|
||||||
|
secret-assign litellm-api-keys.prose.md MUMUNI_LITELLM_API_KEY=«vault: agents/production LITELLM_API_KEY» 2026-09-17 purge: replaced the live Mumuni LiteLLM key with its vault reference; no literal credential.
|
||||||
|
secret-assign litellm-api-keys.prose.md MUMUNI_ZULIP_API_KEY=«vault: agents/production ZULIP_API_KEY» 2026-09-17 purge: replaced the live Mumuni Zulip key with its vault reference; no literal credential.
|
||||||
|
* infrastructure-control.prose.md PVEAPIToken=«vault: infrastructure/production PVE_API_TOKEN» 2026-09-17 purge: Proxmox API token is read from the vault; the line only names the vault path.
|
||||||
|
cred-prose infrastructure-control.prose.md Admin credentials: 2026-09-17 purge: the Stirling admin user/password are two `«vault: ...»` references; no literal credential.
|
||||||
|
* scripts/daily-infra-report.py PVEAPIToken=«vault: infrastructure/production PVE_API_TOKEN» 2026-09-17 purge: Proxmox API token is read from the vault; the line only names the vault path.
|
||||||
|
secret-assign stirling-pdf-agent-access.prose.md «vault: infrastructure/production STIRLING_API_KEY» 2026-09-17 purge: Stirling PDF API key is read from the vault; the curl example only names the vault path.
|
||||||
|
bearer-token agent-zero-fix-summary.md «vault: agents/production OPENROUTER_API_KEY» 2026-09-17 purge: OpenRouter key is read from the vault; the example curl only names the vault path.
|
||||||
|
# ── Deliberate synthetic examples in contracts (not from the purge) ───────
|
||||||
|
# These exist to teach the rule they illustrate. They are listed here so the
|
||||||
|
# guard is never taught to skip the words "synthetic"/"example" — a fabricated
|
||||||
|
# example is always an explicit exception, never a pattern-level exemption.
|
||||||
|
* hermes-key-enforcement.prose.md sk-synthetic-external-example Rule 15 illustration of a hardcoded external key that is tolerated; fabricated, never a live key.
|
||||||
|
* hermes-key-enforcement.prose.md sk-synthetic-example-12345 Rule 15 illustration of a forbidden hardcoded key; fabricated, never a live key.
|
||||||
|
openai-key hermes-key-enforcement.prose.md sk-synthetic-litellm- Fabricated key name inside a `grep 'LITELLM_API_KEY=...'` example; not a live key.
|
||||||
|
secret-assign hermes-key-enforcement.prose.md sk-NEW_KEY Placeholder standing for the rotated key in an `infisical secrets set` command; not a literal key.
|
||||||
|
openrouter-key agent-zero-openrouter-key.prose.md sk-or-v1-synthetic Synthetic key prefix in the contract's example response; the real key is read from the vault.
|
||||||
|
openai-key litellm-api-keys.prose.md sk-synthetic-tanko-example Fabricated key name in migration history prose; not a live key.
|
||||||
|
openai-key litellm-self-heal.prose.md sk-syslog-local-master-key Deprecated local LiteLLM master key name documented as no-live-usage; kept for history, not a usable credential.
|
||||||
|
# ── Redacted evidence, not a credential ──────────────────────────────────
|
||||||
|
secret-assign docs/probe-drift-round2-evidence.md =sk-... Probe evidence records redacted key trailers (`sk-...x6uw`); the usable part of the key is not present.
|
||||||
|
# ── tests/test_secret_scan.sh fixtures ───────────────────────────────────
|
||||||
|
# The self-test plants these fabricated values into a TEMP tree, whose path no
|
||||||
|
# entry here covers, so each still fails the guard when planted (see the test's
|
||||||
|
# "... fails the guard" cases). They are listed only so the repo-wide scan of
|
||||||
|
# the test file itself stays quiet.
|
||||||
|
* tests/test_secret_scan.sh sk-or-v1-00000000000000000000000000000000000000000000000000000000deadbeef Self-test fixture: fabricated OpenRouter-shaped key written to a temp tree; the guard must fail on it there.
|
||||||
|
bearer-token tests/test_secret_scan.sh Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbb Self-test fixture: fabricated Bearer token written to a temp tree; the guard must fail on it there.
|
||||||
|
proxmox-token tests/test_secret_scan.sh PVEAPIToken=root@pam!monitor=11111111-2222-3333-4444-555555555555 Self-test fixture: fabricated Proxmox token written to a temp tree; the guard must fail on it there.
|
||||||
|
private-key tests/test_secret_scan.sh -----BEGIN OPENSSH PRIVATE KEY----- Self-test fixture: fabricated PEM banner written to a temp tree; the guard must fail on it there.
|
||||||
|
cred-prose tests/test_secret_scan.sh Admin credentials: Self-test fixture: fabricated prose credential line written to a temp tree; the guard must fail on it there.
|
||||||
|
secret-assign tests/test_secret_scan.sh DB_PASSWORD=correct-horse-battery-staple Self-test fixture: fabricated password assignment written to a temp tree; the guard must fail on it there.
|
||||||
|
Can't render this file because it contains an unexpected character in line 23 and column 25.
|
@@ -0,0 +1,22 @@
|
|||||||
|
# secret-patterns.tsv — checked-in pattern list for scripts/secret-scan.sh
|
||||||
|
#
|
||||||
|
# Format: <rule-id><TAB><POSIX ERE><TAB><description><TAB><check>
|
||||||
|
# Blank lines and lines whose first field starts with '#' are ignored.
|
||||||
|
# <check> is optional; the only value today is "value", which tells the scanner
|
||||||
|
# to run the matched value through its inert-value classifier (see
|
||||||
|
# value_is_inert in secret-scan.sh) so bare identifiers, env refs and dotted
|
||||||
|
# code access are not reported as credentials. Omit the column to report every
|
||||||
|
# regex hit.
|
||||||
|
# Matching is case-insensitive, so `API_KEY` and `api_key` both count.
|
||||||
|
#
|
||||||
|
# Add a rule here, never inline in secret-scan.sh: this file is the single
|
||||||
|
# auditable list of what the guard considers credential-shaped.
|
||||||
|
openai-key \bsk-[A-Za-z0-9_-]{16,} OpenAI/LiteLLM-style "sk-" secret key (also hyphenated sk-proj- keys)
|
||||||
|
openrouter-key \bsk-or-v1-[A-Za-z0-9_-]{8,} OpenRouter API key
|
||||||
|
stripe-live-key \bsk_live_[A-Za-z0-9]{8,} Stripe live secret key
|
||||||
|
proxmox-token PVEAPIToken=[^[:space:]"']+ Proxmox API token literal
|
||||||
|
bearer-token bearer[[:space:]]+["']?(«.{3,}»|[A-Za-z0-9_./+=-]{20,}) literal Bearer token (http header or prose)
|
||||||
|
auth-header authorization:[[:space:]]+["']?(«.{3,}»|[A-Za-z0-9_./+=-]{20,}) Authorization header carrying a raw literal value
|
||||||
|
private-key -----BEGIN [A-Z ]*PRIVATE KEY----- PEM private key block
|
||||||
|
cred-prose credentials?[[:space:]]*[:=][[:space:]]*[^[:space:]] prose credential line carrying a value
|
||||||
|
secret-assign (api[_-]?key|apikey|passwd|password|secret|token)s?["']?[[:space:]]*[:=][[:space:]]*["']?(«.{3,}»|[A-Za-z0-9_./+=-]{8,}) credential assignment carrying a literal value value
|
||||||
|
Can't render this file because it contains an unexpected character in line 5 and column 48.
|
Executable
+269
@@ -0,0 +1,269 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# secret-scan.sh — commit-time secret guard. FAILS (exit 1) on a credential-shaped
|
||||||
|
# string, so a build cannot go green with a credential committed to it.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# scripts/secret-scan.sh # scan the whole git-tracked tree (default)
|
||||||
|
# scripts/secret-scan.sh --tree
|
||||||
|
# scripts/secret-scan.sh --path DIR # scan an arbitrary directory (git not required)
|
||||||
|
# scripts/secret-scan.sh --staged # scan added lines in the index (pre-commit)
|
||||||
|
# scripts/secret-scan.sh --diff REF # scan added lines since REF (e.g. origin/master)
|
||||||
|
# --quiet only print the verdict and findings, no per-mode banner
|
||||||
|
#
|
||||||
|
# Exit codes: 0 clean, 1 credential found, 2 usage/config error.
|
||||||
|
#
|
||||||
|
# Patterns live in scripts/secret-patterns.tsv
|
||||||
|
# Exceptions live in scripts/secret-allowlist.tsv (every entry carries a reason;
|
||||||
|
# a missing reason is a hard error, so the guard fails closed).
|
||||||
|
#
|
||||||
|
# Dependencies are deliberately bash + coreutils + grep + sed/awk + git. The
|
||||||
|
# Gitea Actions runner executes job steps INSIDE the runner container, which
|
||||||
|
# has no node and no python by default: keep this script free of both.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
SELF_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
ROOT=$(cd -- "$SELF_DIR/.." && pwd)
|
||||||
|
PATTERNS_FILE="$SELF_DIR/secret-patterns.tsv"
|
||||||
|
ALLOWLIST_FILE="$SELF_DIR/secret-allowlist.tsv"
|
||||||
|
|
||||||
|
# The guard's own definition files are not scannable content: the pattern list
|
||||||
|
# necessarily contains the pattern text, and the allowlist necessarily contains
|
||||||
|
# the allowed literals. Narrow, exact-path exclusion — not a wildcard.
|
||||||
|
SELF_FILES=(
|
||||||
|
"scripts/secret-scan.sh"
|
||||||
|
"scripts/secret-patterns.tsv"
|
||||||
|
"scripts/secret-allowlist.tsv"
|
||||||
|
)
|
||||||
|
|
||||||
|
MODE="tree"
|
||||||
|
PATH_DIR=""
|
||||||
|
DIFF_REF=""
|
||||||
|
QUIET=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
sed -n '2,20p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--tree) MODE="tree" ;;
|
||||||
|
--path) MODE="path"; PATH_DIR="${2:-}"; shift ;;
|
||||||
|
--staged) MODE="staged" ;;
|
||||||
|
--diff) MODE="diff"; DIFF_REF="${2:-}"; shift ;;
|
||||||
|
--quiet) QUIET=1 ;;
|
||||||
|
-h|--help) usage ;;
|
||||||
|
*) echo "secret-scan: unknown argument '$1'" >&2; usage ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
[ -f "$PATTERNS_FILE" ] || { echo "secret-scan: missing $PATTERNS_FILE" >&2; exit 2; }
|
||||||
|
[ -f "$ALLOWLIST_FILE" ] || { echo "secret-scan: missing $ALLOWLIST_FILE" >&2; exit 2; }
|
||||||
|
if [ "$MODE" = "path" ] && [ -z "$PATH_DIR" ]; then
|
||||||
|
echo "secret-scan: --path needs a directory" >&2; exit 2
|
||||||
|
fi
|
||||||
|
if [ "$MODE" = "diff" ] && [ -z "$DIFF_REF" ]; then
|
||||||
|
echo "secret-scan: --diff needs a base ref" >&2; exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Load patterns ──────────────────────────────────────────────────────────
|
||||||
|
RULE_IDS=()
|
||||||
|
RULE_RES=()
|
||||||
|
RULE_DESCS=()
|
||||||
|
RULE_CHECKS=()
|
||||||
|
COMBINED=""
|
||||||
|
while IFS=$'\t' read -r id re desc check; do
|
||||||
|
case "$id" in ''|'#'*) continue ;; esac
|
||||||
|
[ -n "$re" ] || continue
|
||||||
|
RULE_IDS+=("$id"); RULE_RES+=("$re"); RULE_DESCS+=("$desc"); RULE_CHECKS+=("${check:-}")
|
||||||
|
if [ -z "$COMBINED" ]; then COMBINED="($re)"; else COMBINED="$COMBINED|($re)"; fi
|
||||||
|
done < "$PATTERNS_FILE"
|
||||||
|
if [ "${#RULE_IDS[@]}" -eq 0 ]; then
|
||||||
|
echo "secret-scan: no patterns loaded from $PATTERNS_FILE" >&2; exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Load allowlist (fails closed on a missing reason) ──────────────────────
|
||||||
|
AL_RULES=()
|
||||||
|
AL_GLOBS=()
|
||||||
|
AL_LITS=()
|
||||||
|
AL_REASONS=()
|
||||||
|
AL_LINENO=0
|
||||||
|
while IFS=$'\t' read -r rule glob lit reason; do
|
||||||
|
AL_LINENO=$((AL_LINENO + 1))
|
||||||
|
case "$rule" in ''|'#'*) continue ;; esac
|
||||||
|
if [ -z "$glob" ] || [ -z "$lit" ] || [ -z "$reason" ]; then
|
||||||
|
echo "secret-scan: ❌ $ALLOWLIST_FILE:$AL_LINENO — allowlist entry needs <rule> <path-glob> <literal> <reason>; reason-based exceptions only, refusing to run" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
AL_RULES+=("$rule"); AL_GLOBS+=("$glob"); AL_LITS+=("$lit"); AL_REASONS+=("$reason")
|
||||||
|
done < "$ALLOWLIST_FILE"
|
||||||
|
|
||||||
|
# nocasematch is toggled only around the regex test; path globs must stay
|
||||||
|
# case-sensitive, so it is never left on.
|
||||||
|
MATCH=""
|
||||||
|
regex_match() { # regex_match <regex> <text> -> MATCH holds the matched text
|
||||||
|
local re="$1" text="$2"
|
||||||
|
shopt -s nocasematch
|
||||||
|
if [[ $text =~ $re ]]; then
|
||||||
|
MATCH="${BASH_REMATCH[0]}"
|
||||||
|
shopt -u nocasematch
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
shopt -u nocasematch
|
||||||
|
MATCH=""
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
allowlisted() { # allowlisted <rule> <path> <text>
|
||||||
|
local rule="$1" path="$2" text="$3" i
|
||||||
|
for i in "${!AL_RULES[@]}"; do
|
||||||
|
[ "${AL_RULES[$i]}" = "$rule" ] || [ "${AL_RULES[$i]}" = "*" ] || continue
|
||||||
|
# The unquoted RHS is deliberate: <path-glob> is a bash glob, not a literal.
|
||||||
|
# shellcheck disable=SC2053
|
||||||
|
[[ $path == ${AL_GLOBS[$i]} ]] || continue
|
||||||
|
[[ $text == *"${AL_LITS[$i]}"* ]] || continue
|
||||||
|
return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mask_value() { # mask_value <text> <match> — never echo a credential to logs.
|
||||||
|
# Print only the part of the line BEFORE the match, then <redacted>: the match
|
||||||
|
# itself and everything after it (which may include a value the rule's regex
|
||||||
|
# stopped short of, e.g. `credentials:` followed by a backticked password) is
|
||||||
|
# never written to stdout.
|
||||||
|
local text="$1" m="$2"
|
||||||
|
if [ -n "$m" ] && [[ $text == *"$m"* ]]; then
|
||||||
|
printf '%s<redacted>' "${text%%"$m"*}"
|
||||||
|
else
|
||||||
|
printf '%s' "$text"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
FINDINGS=0
|
||||||
|
SUPPRESSED=0
|
||||||
|
INERT=0
|
||||||
|
SCANNED=0
|
||||||
|
|
||||||
|
# value_is_inert <value> <text-after-match> — true when a matched assignment value
|
||||||
|
# is plainly not a credential: empty, an env/command reference, a path, dotted
|
||||||
|
# code access, a short or single-class identifier (a variable or key NAME, not a
|
||||||
|
# value), a well-known placeholder word, or a value the file deliberately
|
||||||
|
# truncates with '…' / '...' (a redacted prefix is not a usable credential).
|
||||||
|
# Deliberately does NOT know the words "synthetic" or "example": a fabricated
|
||||||
|
# example must be an explicit allowlist entry.
|
||||||
|
value_is_inert() {
|
||||||
|
local v="$1" rest="$2"
|
||||||
|
case "$rest" in '…'*|'...'*) return 0 ;; esac
|
||||||
|
v="${v%\"}"; v="${v#\"}"; v="${v%\'}"; v="${v#\'}"
|
||||||
|
case "$v" in
|
||||||
|
''|\$*|\{*|'<'*|'%'*|'('*|'/'*|'\\'*) return 0 ;;
|
||||||
|
not-needed|no-key-required|none|null|true|false|redacted|placeholder|example|dummy|changeme|change-me|your-key|your_key|key|token|secret|password) return 0 ;;
|
||||||
|
esac
|
||||||
|
# dotted code access: os.environ.get / process.env.ZULIP_API_KEY / cfg.a
|
||||||
|
if [[ $v =~ ^[a-z_][a-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$ ]]; then return 0; fi
|
||||||
|
# bare identifier (no punctuation beyond _): a NAME, not a value. A real
|
||||||
|
# secret in this shape is long and mixes letters with digits.
|
||||||
|
if [[ $v =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
||||||
|
[ "${#v}" -lt 20 ] && return 0
|
||||||
|
[[ $v =~ [0-9] ]] || return 0
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
report_finding() { # report_finding <path> <line> <text>
|
||||||
|
local path="$1" line="$2" text="$3" i val
|
||||||
|
for i in "${!RULE_IDS[@]}"; do
|
||||||
|
regex_match "${RULE_RES[$i]}" "$text" || continue
|
||||||
|
SCANNED=$((SCANNED + 1))
|
||||||
|
if [ "${RULE_CHECKS[$i]}" = "value" ]; then
|
||||||
|
val="${MATCH#*[:=]}"
|
||||||
|
val="${val# }"
|
||||||
|
if value_is_inert "$val" "${text#*"$MATCH"}"; then
|
||||||
|
INERT=$((INERT + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if allowlisted "${RULE_IDS[$i]}" "$path" "$text"; then
|
||||||
|
SUPPRESSED=$((SUPPRESSED + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
FINDINGS=$((FINDINGS + 1))
|
||||||
|
printf ' ❌ %s:%s [%s] %s\n' "$path" "$line" "${RULE_IDS[$i]}" "${RULE_DESCS[$i]}"
|
||||||
|
printf ' | %s\n' "$(mask_value "$text" "$MATCH")"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
self_excluded() { # self_excluded <repo-relative-path>
|
||||||
|
local p="$1" s
|
||||||
|
for s in "${SELF_FILES[@]}"; do
|
||||||
|
[ "$p" = "$s" ] && return 0
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Collect candidate lines and scan them ─────────────────────────────────
|
||||||
|
if [ "$MODE" = "tree" ] || [ "$MODE" = "path" ]; then
|
||||||
|
if [ "$MODE" = "tree" ]; then
|
||||||
|
BASE="$ROOT"
|
||||||
|
git -C "$BASE" rev-parse --git-dir >/dev/null 2>&1 || { echo "secret-scan: --tree needs a git checkout (use --path DIR)" >&2; exit 2; }
|
||||||
|
mapfile -d '' candidate < <(git -C "$BASE" ls-files -z 2>/dev/null)
|
||||||
|
if [ "${#candidate[@]}" -eq 0 ]; then
|
||||||
|
echo "secret-scan: ❌ no tracked files — refusing to report clean" >&2; exit 2
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
BASE=$(cd -- "$PATH_DIR" 2>/dev/null && pwd) || { echo "secret-scan: --path '$PATH_DIR' is not a directory" >&2; exit 2; }
|
||||||
|
mapfile -t candidate < <(cd -- "$BASE" && find . -type f -not -path './.git/*' | sed 's|^\./||')
|
||||||
|
if [ "${#candidate[@]}" -eq 0 ]; then
|
||||||
|
echo "secret-scan: ❌ no files under $BASE — refusing to report clean" >&2; exit 2
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
[ "$QUIET" -eq 1 ] || echo "── secret scan ($MODE): ${#candidate[@]} files under $BASE ──"
|
||||||
|
for rel in "${candidate[@]}"; do
|
||||||
|
[ -f "$BASE/$rel" ] || continue
|
||||||
|
self_excluded "$rel" && continue
|
||||||
|
while IFS= read -r hit; do
|
||||||
|
[ -n "$hit" ] || continue
|
||||||
|
report_finding "$rel" "${hit%%:*}" "${hit#*:}"
|
||||||
|
done < <(grep -nEIi -e "$COMBINED" "$BASE/$rel" 2>/dev/null || true)
|
||||||
|
done
|
||||||
|
else
|
||||||
|
# --staged / --diff: only ADDED lines, with the post-change line number.
|
||||||
|
if [ "$MODE" = "staged" ]; then
|
||||||
|
[ "$QUIET" -eq 1 ] || echo "── secret scan: added lines in the index ──"
|
||||||
|
DIFF_TEXT=$(git -C "$ROOT" diff --cached --unified=0 --no-color -- . 2>/dev/null)
|
||||||
|
else
|
||||||
|
[ "$QUIET" -eq 1 ] || echo "── secret scan: added lines since $DIFF_REF ──"
|
||||||
|
DIFF_TEXT=$(git -C "$ROOT" diff --unified=0 --no-color "$DIFF_REF"...HEAD 2>/dev/null \
|
||||||
|
|| git -C "$ROOT" diff --unified=0 --no-color "$DIFF_REF"..HEAD 2>/dev/null)
|
||||||
|
fi
|
||||||
|
if [ -z "$DIFF_TEXT" ]; then
|
||||||
|
[ "$QUIET" -eq 1 ] || echo " (no added lines)"
|
||||||
|
fi
|
||||||
|
while IFS=$'\t' read -r rel line text; do
|
||||||
|
[ -n "$rel" ] || continue
|
||||||
|
self_excluded "$rel" && continue
|
||||||
|
report_finding "$rel" "$line" "$text"
|
||||||
|
done < <(printf '%s\n' "$DIFF_TEXT" | awk '
|
||||||
|
/^\+\+\+ / { f=$2; sub(/^b\//,"",f); next }
|
||||||
|
/^@@ / { if (match($0, /\+[0-9]+/)) ln=substr($0, RSTART+1, RLENGTH-1)+0; next }
|
||||||
|
(/^\+/ && !/^\+\+\+/) { print f "\t" ln "\t" substr($0,2); ln++; next }
|
||||||
|
')
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Verdict ────────────────────────────────────────────────────────────────
|
||||||
|
if [ "$FINDINGS" -gt 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ SECRET SCAN FAILED — $FINDINGS credential-shaped string(s) in ${MODE} content."
|
||||||
|
echo " Fix: remove the credential and read it from the vault/env."
|
||||||
|
echo " Only a deliberate synthetic example may be added to scripts/secret-allowlist.tsv,"
|
||||||
|
echo " one entry per file/rule/literal, with a reason. Never allowlist a live credential."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ secret scan clean (${MODE}; ${SUPPRESSED} allowlisted exception(s), ${INERT} inert value(s) ignored)"
|
||||||
|
exit 0
|
||||||
Executable
+153
@@ -0,0 +1,153 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# test_secret_scan.sh — self-test for the commit-time secret guard.
|
||||||
|
#
|
||||||
|
# Run: bash tests/test_secret_scan.sh
|
||||||
|
# Exit: 0 all cases passed, 1 a case failed.
|
||||||
|
#
|
||||||
|
# WHY THIS FILE EXISTS: a scanner that is never observed to fail is not a guard.
|
||||||
|
# Every fixture below is fabricated and pattern-shaped; the test writes it to a
|
||||||
|
# temp tree (a path no allowlist entry covers) and asserts the guard FAILS. The
|
||||||
|
# same fixtures are deliberately listed in scripts/secret-allowlist.tsv, so the
|
||||||
|
# repo-wide tree scan stays quiet while a planted copy still bites — that is the
|
||||||
|
# difference between an explicit, reasoned exception and a guard trained to
|
||||||
|
# ignore a word.
|
||||||
|
#
|
||||||
|
# Only bash + coreutils + grep. No python/node: the Gitea runner executes job
|
||||||
|
# steps inside the runner container, which has neither.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
||||||
|
ROOT=$(cd -- "$HERE/.." && pwd)
|
||||||
|
SCAN="$ROOT/scripts/secret-scan.sh"
|
||||||
|
|
||||||
|
PASS=0
|
||||||
|
FAIL=0
|
||||||
|
LAST_OUT=""
|
||||||
|
|
||||||
|
ok() { PASS=$((PASS + 1)); echo " ✅ $1"; }
|
||||||
|
bad() { FAIL=$((FAIL + 1)); echo " ❌ $1"; }
|
||||||
|
|
||||||
|
expect_exit() { # expect_exit <want-code> <label> <cmd...>
|
||||||
|
local want="$1" label="$2"; shift 2
|
||||||
|
local rc
|
||||||
|
LAST_OUT=$("$@" 2>&1); rc=$?
|
||||||
|
if [ "$rc" -eq "$want" ]; then ok "$label (exit $rc)"; else
|
||||||
|
bad "$label (wanted exit $want, got $rc)"
|
||||||
|
printf '%s\n' "$LAST_OUT" | sed 's/^/ /' | head -8
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_contains() { # expect_contains <label> <needle>
|
||||||
|
if printf '%s' "$LAST_OUT" | grep -qF -- "$2"; then ok "$1"; else
|
||||||
|
bad "$1 (output did not mention: $2)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
TMPROOT=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$TMPROOT"' EXIT
|
||||||
|
|
||||||
|
echo "── secret-scan self-test ──"
|
||||||
|
|
||||||
|
# ── 1. Guard syntax ───────────────────────────────────────────────────────
|
||||||
|
expect_exit 0 "scanner parses with bash -n" bash -n "$SCAN"
|
||||||
|
|
||||||
|
# ── 2. Guard FAILS on planted, pattern-matching fixtures ──────────────────
|
||||||
|
mkdir -p "$TMPROOT/planted"
|
||||||
|
cat > "$TMPROOT/planted/ops.env" <<'EOF'
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-00000000000000000000000000000000000000000000000000000000deadbeef
|
||||||
|
EOF
|
||||||
|
expect_exit 1 "planted sk-or-v1 key fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
|
||||||
|
expect_contains "planted sk-or-v1 key names the openrouter-key rule" "[openrouter-key]"
|
||||||
|
|
||||||
|
rm -f "$TMPROOT/planted/"*
|
||||||
|
cat > "$TMPROOT/planted/curl.sh" <<'EOF'
|
||||||
|
curl -s -H "Authorization: Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbb" http://example.invalid/
|
||||||
|
EOF
|
||||||
|
expect_exit 1 "planted literal Bearer token fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
|
||||||
|
expect_contains "planted Bearer token names the bearer-token rule" "[bearer-token]"
|
||||||
|
|
||||||
|
rm -f "$TMPROOT/planted/"*
|
||||||
|
cat > "$TMPROOT/planted/pve.sh" <<'EOF'
|
||||||
|
AUTH="Authorization: PVEAPIToken=root@pam!monitor=11111111-2222-3333-4444-555555555555"
|
||||||
|
EOF
|
||||||
|
expect_exit 1 "planted Proxmox token fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
|
||||||
|
expect_contains "planted Proxmox token names the proxmox-token rule" "[proxmox-token]"
|
||||||
|
|
||||||
|
rm -f "$TMPROOT/planted/"*
|
||||||
|
cat > "$TMPROOT/planted/deploy-key.pem" <<'EOF'
|
||||||
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
||||||
|
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
|
||||||
|
-----END OPENSSH PRIVATE KEY-----
|
||||||
|
EOF
|
||||||
|
expect_exit 1 "planted PEM private key fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
|
||||||
|
expect_contains "planted PEM key names the private-key rule" "[private-key]"
|
||||||
|
|
||||||
|
# Prose is scanned exactly like code — the original exposures were in .md files.
|
||||||
|
rm -f "$TMPROOT/planted/"*
|
||||||
|
cat > "$TMPROOT/planted/handover.md" <<'EOF'
|
||||||
|
- Admin credentials: `admin` / `correct-horse-battery-staple`
|
||||||
|
EOF
|
||||||
|
expect_exit 1 "planted prose credential line fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
|
||||||
|
expect_contains "planted prose line names the cred-prose rule" "[cred-prose]"
|
||||||
|
|
||||||
|
rm -f "$TMPROOT/planted/"*
|
||||||
|
cat > "$TMPROOT/planted/config.env" <<'EOF'
|
||||||
|
DB_PASSWORD=correct-horse-battery-staple
|
||||||
|
EOF
|
||||||
|
expect_exit 1 "planted password assignment fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
|
||||||
|
expect_contains "planted password assignment names the secret-assign rule" "[secret-assign]"
|
||||||
|
|
||||||
|
# ── 3. Guard stays QUIET on inert values and on the real tree ─────────────
|
||||||
|
mkdir -p "$TMPROOT/inert"
|
||||||
|
cat > "$TMPROOT/inert/config.yaml" <<'EOF'
|
||||||
|
api_key: not-needed
|
||||||
|
bearer_token=monitor_key
|
||||||
|
api_key: $LITELLM_API_KEY
|
||||||
|
EOF
|
||||||
|
expect_exit 0 "env refs, sentinels and variable names are not credentials" bash "$SCAN" --path "$TMPROOT/inert" --quiet
|
||||||
|
|
||||||
|
expect_exit 0 "current repo tree passes the guard" bash "$SCAN" --tree
|
||||||
|
expect_contains "tree run reports the allowlisted exceptions it applied" "allowlisted exception(s)"
|
||||||
|
|
||||||
|
# ── 4. Allowlist entries are path-explicit, not word-based ────────────────
|
||||||
|
# This exact line is allowlisted in infrastructure-control.prose.md; the same
|
||||||
|
# text at an unlisted path must still fail, proving the exception is per-file
|
||||||
|
# and reviewed, not a blanket "ignore the word vault".
|
||||||
|
rm -f "$TMPROOT/planted/"*
|
||||||
|
cat > "$TMPROOT/planted/unlisted.md" <<'EOF'
|
||||||
|
- Admin credentials: `«vault: infrastructure/production STIRLING_ADMIN_PASSWORD»`
|
||||||
|
EOF
|
||||||
|
expect_exit 1 "allowlisted text at an unlisted path still fails" bash "$SCAN" --path "$TMPROOT/planted" --quiet
|
||||||
|
|
||||||
|
# ── 5. Commit-time mode: the guard blocks a STAGED credential ─────────────
|
||||||
|
# A throwaway git repo with its own copy of the scanner, so this exercises the
|
||||||
|
# real pre-commit path (--staged) without touching this repo's index.
|
||||||
|
mkdir -p "$TMPROOT/repo/scripts"
|
||||||
|
cp "$SCAN" "$TMPROOT/repo/scripts/secret-scan.sh"
|
||||||
|
cp "$ROOT/scripts/secret-patterns.tsv" "$TMPROOT/repo/scripts/secret-patterns.tsv"
|
||||||
|
cp "$ROOT/scripts/secret-allowlist.tsv" "$TMPROOT/repo/scripts/secret-allowlist.tsv"
|
||||||
|
git -C "$TMPROOT/repo" init -q
|
||||||
|
git -C "$TMPROOT/repo" -c user.email=t@example.invalid -c user.name=test commit -q --allow-empty -m base
|
||||||
|
cat > "$TMPROOT/repo/planted.env" <<'EOF'
|
||||||
|
OPENROUTER_API_KEY=sk-or-v1-00000000000000000000000000000000000000000000000000000000deadbeef
|
||||||
|
EOF
|
||||||
|
git -C "$TMPROOT/repo" add planted.env
|
||||||
|
expect_exit 1 "staged credential fails at commit time (--staged)" bash "$TMPROOT/repo/scripts/secret-scan.sh" --staged --quiet
|
||||||
|
expect_contains "staged credential names the openrouter-key rule" "[openrouter-key]"
|
||||||
|
|
||||||
|
# ── 6. Fail closed: an allowlist entry without a reason is a hard error ───
|
||||||
|
mkdir -p "$TMPROOT/scanner" "$TMPROOT/clean"
|
||||||
|
cp "$SCAN" "$TMPROOT/scanner/secret-scan.sh"
|
||||||
|
cp "$ROOT/scripts/secret-patterns.tsv" "$TMPROOT/scanner/secret-patterns.tsv"
|
||||||
|
printf '*\t*.md\twhatever\n' > "$TMPROOT/scanner/secret-allowlist.tsv"
|
||||||
|
echo "placeholder" > "$TMPROOT/clean/ok.md"
|
||||||
|
expect_exit 2 "allowlist entry with no reason fails closed" bash "$TMPROOT/scanner/secret-scan.sh" --path "$TMPROOT/clean" --quiet
|
||||||
|
|
||||||
|
# ── Verdict ───────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
if [ "$FAIL" -gt 0 ]; then
|
||||||
|
echo "❌ secret-scan self-test FAILED — $PASS passed, $FAIL failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ secret-scan self-test passed ($PASS cases)"
|
||||||
Reference in New Issue
Block a user