Skip to content
Open
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
48 changes: 48 additions & 0 deletions .github/workflows/secret-scan.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
name: Secret scan

# BLOCKING secret scanning with Gitleaks. A project that builds trust tooling
# must not rely on humans noticing an accidental key commit; this makes it a
# failed CI run instead.
#
# - `pull_request`: scans the changes being proposed.
# - `push` / weekly cron: scans the FULL history — catches secrets that landed
# on main before this gate existed.
#
# If something trips here: rotate the secret immediately (it is already burned
# — scanning does not un-leak it), then purge it from history.
#
# To customize rules or add allowlists (e.g. obvious test fixtures), add a
# `.gitleaks.toml` at the repo root — Gitleaks picks it up automatically.

on:
pull_request:
push:
branches: [main]
schedule:
# Weekly full-history catch-up scan.
- cron: "0 4 * * 1"

permissions:
contents: read

jobs:
gitleaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# Full history so push/cron runs cover pre-gate history, not just HEAD.
fetch-depth: 0

- name: Gitleaks
# Pinned to the exact commit of gitleaks-action v2.3.9 — same pinning
# policy as NVIDIA SkillSpector in skillspector.yml. Bump deliberately.
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Signing keyring hygiene
# BLOCKING: both rings must parse, fingerprint-match and have exactly
# one active key. An empty ring made "verified by SuperAgentSkill"
# forgeable (any public key passed verification); see docs/SIGNING-KEYS.md.
run: node --experimental-strip-types scripts/check-signing-keyrings.ts
11 changes: 9 additions & 2 deletions .github/workflows/skillspector.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,13 @@ permissions:

# Pin SkillSpector to a known-good ref so a surprise upstream change can't break
# or silently weaken the gate. Bump deliberately.
#
# Pinned to the exact commit of the NVIDIA/SkillSpector v2.13.2 release
# (fetched by SHA — a tag can be deleted/recreated, a SHA cannot).
# To bump: pick the new release, git ls-remote the tag, replace the SHA AND the
# version note below in the same commit.
env:
SKILLSPECTOR_REF: main
SKILLSPECTOR_REF: abd82935b67f765a9541013c0866c6349251dcdf # v2.13.2

jobs:
scan:
Expand All @@ -46,7 +51,9 @@ jobs:
- name: Install SkillSpector
run: |
python3 --version
git clone --depth 1 --branch "$SKILLSPECTOR_REF" https://github.com/NVIDIA/skillspector.git "$RUNNER_TEMP/skillspector"
git init "$RUNNER_TEMP/skillspector"
git -C "$RUNNER_TEMP/skillspector" fetch --depth 1 https://github.com/NVIDIA/skillspector.git "$SKILLSPECTOR_REF"
git -C "$RUNNER_TEMP/skillspector" checkout FETCH_HEAD
pipx install uv || python3 -m pip install --user uv
uv tool install --python 3.12 "$RUNNER_TEMP/skillspector"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
Expand Down
53 changes: 53 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Gitleaks configuration — extends the default ruleset with audited false
# positives. Every entry below was hand-verified on the first secret scan
# (2026-10-31: 60 findings, 0 real secrets). Add entries sparingly and document
# WHY each value is safe to expose — an allowlist is an audit log, not a bypass.
[extend]
useDefault = true

# NOTE: gitleaks matches `regexes` against the captured secret value itself,
# while `paths` matches the file path. `condition = "AND"` requires BOTH to
# hit, so these allowlists stay scoped to their exact context.

[[allowlists]]
description = "Supabase anon/publishable JWTs — public by design, also shipped inside every client bundle. Never extend this to service_role keys."
regexes = [
'''^eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJpc3MiOiJzdXBhYmFzZSIsInJlZi''',
]

[[allowlists]]
description = "SHA-256 file-content hashes recorded in model profiles (not credentials)."
paths = [
'''(^|/)model-profile\.json$''',
'''(^|/)(playbook-)?model-profiles\.json$''',
]
regexes = [
'''^[0-9a-f]{64}$''',
]
condition = "AND"

[[allowlists]]
description = "Public Apify ACTOR identifiers inside .claude signal scripts (identifiers, not credentials)."
paths = [
'''\.claude/skills/(community|event)-signals/scripts/(community_signals|event_signals)\.py$''',
]
regexes = [
'''^(oAuCIx3ItNrs2okjQ|qaDijpjO2HlVfLvoE)$''',
]
condition = "AND"

[[allowlists]]
description = "Migration deleting an mcp token: the value is the sha256 digest of a revoked token, retained for audit — not a usable secret."
paths = [
'''supabase/migrations/20260730210507_728c169f-9e8a-44cf-85bf-327f62f843ba\.sql$''',
]
regexes = [
'''^71fea397e09bdef521aa3976cfc79c1d93c506a1a023abcba90d6988745b3322$''',
]
condition = "AND"

[[allowlists]]
description = "Adversarial fixtures explicitly named synthetic_* — used to teach redaction, not real secrets."
regexes = [
'''^synthetic_[a-z_]{0,20}private_key_\d+\.?$''',
]
48 changes: 46 additions & 2 deletions cli/super-agent.js
Original file line number Diff line number Diff line change
Expand Up @@ -27686,6 +27686,7 @@ function date4(params) {
}

// src/lib/security/prompt-injection-guard.ts
var INVISIBLE_CHARS = /[\u200b-\u200f\u202a-\u202e\u2060-\u206f\u{E0000}-\u{E007F}]/gu;
var RULES = [
// Instruction override
{
Expand Down Expand Up @@ -27795,7 +27796,50 @@ var RULES = [
label: "zero-width characters",
category: "encoding_evasion",
severity: "medium",
re: /[\u200b-‏‪-‮⁠-]/g,
re: INVISIBLE_CHARS,
},
// ---- Golden patterns imported from NVIDIA SkillSpector (Apache-2.0), ported
// ---- as blocking detections so the audit gate and these tests stay in sync:
// P2 — instructions hidden in the Unicode TAG block (U+E0000..U+E007F):
// Invisible "character tags" carrying a whole second instruction set.
{
label: "hidden unicode tag characters (SkillSpector P2)",
category: "encoding_evasion",
severity: "high",
re: /[\u{E0000}-\u{E007F}]+/gu,
},
// MP1/MP2 — memory poisoning: instructions that persist attacker content in
// agent memory. The exfil variant (persist a credential-looking payload) is
// critical; generic "remember this forever" seeding is medium.
{
label: "memory poisoning \u2014 persist instructions in agent memory (SkillSpector MP1-MP3)",
category: "memory_poisoning",
severity: "high",
re: /(\b(add|store|persist|save)\s+(this|that|it)\s+(to|in)\s+(your\s+)?(memory|context))|(\bpersist\s+[^.\n]{0,60}\b(across|between)\s+sessions?)|(\bremember\s+this\s+[^.\n]{0,80}\b(forever|every\s+session|all\s+future))/gi,
},
{
label: "memory poisoning \u2014 seed a credential into memory (SkillSpector MP1)",
category: "memory_poisoning",
severity: "critical",
re: /\bremember\s+that\s+[^.\n]{0,80}\b(api[_\s-]?key|password|token|secret|credential|card\s+number|private\s+key)\b/gi,
},
// TP3 — parameter description injection: instructions telling a Trojan skill
// to smuggle directives inside tool parameter descriptions.
{
label: "tool parameter description injection (SkillSpector TP3)",
category: "tool_injection",
severity: "medium",
re: /\b(parameters?|descriptions?)\s+should\s+(always|secretly|silently|invisibly)\s+[^.\n]{0,60}(instruct|tell|ask|invoke|trigger|exfiltrate)/gi,
},
// AR1-AR3 — anti-refusal hardening: attack skills disable the model's ability
// to refuse, which is the same move as disabling a safety guardrail. Scoped
// to UNCONDITIONAL refusal wipes: "never refuse outright — offer
// alternatives" is legitimate guidance, not an override.
{
label: "anti-refusal override \u2014 forbid the agent from refusing (SkillSpector AR1-AR3)",
category: "policy_bypass",
severity: "high",
re: /\b(never|do\s+not|don'?t)\s+(refuse|decline|say\s+no|deny)\b[^\n]{0,60}\b(no\s+matter|regardless|even\s+if|any\s+content|any\s+request|any\s+user)\b|\bignore\s+((your|any|all)\s+)?(refusals?|safety\s+(training|checks?))\b|\brefusals?\s+are\s+(disabled|forbidden|not\s+allowed|off)\b/gi,
},
];
var SEVERITY_RANK = {
Expand Down Expand Up @@ -27837,7 +27881,7 @@ function inspectContent(input2, opts = {}) {
if (!rule.re.global) break;
}
}
const stripped = input2.replace(/[\u200b-‏‪-‮⁠-]/g, "");
const stripped = input2.replace(INVISIBLE_CHARS, "");
const neutralized = stripped
.replace(/<\|im_start\|>/g, "<\\|im_start\\|>")
.replace(/<\|im_end\|>/g, "<\\|im_end\\|>")
Expand Down
77 changes: 77 additions & 0 deletions docs/SIGNING-KEYS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Signing keys — runbook

Two independent Ed25519 keyrings anchor every "verifiable" claim this project
ships. Both follow the `sak-signing-keyring/v1` spec (see
`src/lib/trust/keyring.ts`): at most **one active key per ring**, retired keys
keep verifying historical artifacts, revoked keys verify nothing.

| Ring | File | Signs | Key material |
| ---------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| Site/trust/plugin ring | `security/signing-keys.json` | Trust attestations, benchmark signatures, agent-plugin packages (zip + signature sidecars) | GitHub secrets `SIGNING_PRIVATE_KEY` / `SIGNING_PUBLIC_KEY` + local `.env` |
| Content release ring | `security/content-signing-keys.json` | Registry release bundles (`content-v*` tags) | GitHub secrets `CONTENT_SIGNING_PRIVATE_KEY` / `CONTENT_SIGNING_PUBLIC_KEY` |

**Important:** since the site ring is registered (key id `25de98852a1efe47`,
registered 2026-10-31), signing and verification are **fail-closed**: an env
key that is not the active ring key produces NO signature (the download degrades
to an explicit `unsigned_reason`) and forged signatures verify as `false`.
The same applies to an empty/anchorless ring — that was the pre-fix behavior
and it is not coming back.

## Daily use (verification)

```bash
# Offline verification of a signed package (no network, no hosted service):
npm run trust:verify -- --attestation pkg.trust.json --pkg pkg.yaml --pubkey pub.pem

# Verify a release bundle against its ring:
node --experimental-strip-types scripts/verify-release-bundle.ts \
--dir dist/release --keyring security/content-signing-keys.json
```

## Rotation procedure (a key is suspect, or a scheduled rotation)

1. **Generate a fresh pair** (never reuse disks/machines for old+new):

```bash
openssl genpkey -algorithm ed25519 -out new.private.pem # PRIVATE — never commit
openssl pkey -in new.private.pem -pubout -out new.public.pem
chmod 600 new.private.pem
```

2. **Register the public half** (retires the previous active key):

```bash
node --experimental-strip-types scripts/manage-signing-keys.ts \
rotate security/signing-keys.json new.public.pem
git add security/signing-keys.json && git commit -m "rotate site signing key"
```

3. **Update the consumers of the PRIVATE half**, in this order to avoid a
signing outage: GitHub secrets (`SIGNING_PRIVATE_KEY`, `SIGNING_PUBLIC_KEY`),
then the local `.env`, then any KMS/secret-manager copies. Old artifacts
signed with the retired key keep verifying (rotation never breaks history).
4. **Revoke** only if the old key is actively compromised (not merely retired):

```bash
node --experimental-strip-types scripts/manage-signing-keys.ts \
revoke security/signing-keys.json <16-char-key-id> "compromised, see SAS-YYYY-NNNN"
```

Deployments that verify offline must receive the updated `security/*.json`
(served publicly at `/api/public/signing-keys.json`) or, for the content ring,
the `SIGNING_KEYS.json` shipped in every release zip.

## What the public ring endpoint guarantees

`/api/public/signing-keys.json` serves the site ring verbatim. Consumers pin
against the ACTIVE key listed there; the CI guard (`secret-scan` workflow →
`keyring hygiene` step) fails the build if the ring is unparsable, has zero or
more than one active key, or has a fingerprint mismatch.

## Storage rules

- **Never** commit a private PEM. The private half lives in your key manager,
the GitHub secret store, and (optionally) a local chmod-600 file.
- The public half is public by construction — commit it, ship it, badge it.
- `security/signing-keys.json` changes are auditable commits; never edit the
file by hand — always through `scripts/manage-signing-keys.ts`.
9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"check:skills-mirror": "node --experimental-strip-types scripts/build-skills-sh-mirror.ts --check",
"build:agent-plugin": "node --experimental-strip-types scripts/build-agent-plugin.ts",
"check:agent-plugin": "node --experimental-strip-types scripts/build-agent-plugin.ts --check",
"check:rug-pull": "node --experimental-strip-types scripts/check-rug-pull.ts",
"audit:skills": "node --experimental-strip-types scripts/audit-skills.ts",
"scan:skillspector": "node --experimental-strip-types scripts/scan-skillspector.ts",
"sync:content": "node --experimental-strip-types scripts/sync-content-to-registry.ts",
Expand All @@ -36,10 +37,10 @@
"trust:verify": "node --experimental-strip-types scripts/verify-trust-attestation.ts",
"package:verify": "node --experimental-strip-types scripts/verify-package-signature.ts",
"test": "npm run test:plain && npm run test:ts",
"test:security-headers": "node --experimental-strip-types --test tests/response-security.test.ts",
"test:plain": "node --experimental-strip-types --test tests/adversarial-harness.test.ts tests/trust.test.ts tests/release-signing.test.ts tests/cli-install.test.ts tests/trust-attestation.test.ts",
"test:ts": "node --experimental-strip-types --test tests/site-stats.test.ts tests/certify.test.ts tests/prompt-injection-guard.test.ts tests/audit-skills.test.ts tests/runtime.test.ts tests/integrations.test.ts tests/growth-revenue-split.test.ts tests/trust-badge.test.ts tests/bounties.test.ts tests/trust-scoring.test.ts tests/adversarial-holdout.test.ts tests/adversarial-judge.test.ts tests/paired-benchmark.test.ts tests/format-rubric.test.ts tests/lang-detect.test.ts tests/university.test.ts tests/package-signature.test.ts tests/pricing-copy.test.ts",
"test:all": "node --experimental-strip-types --test tests/*.test.ts",
"test:security-headers": "node --experimental-strip-types --test --test-force-exit tests/response-security.test.ts",
"test:plain": "node --experimental-strip-types --test --test-force-exit tests/adversarial-harness.test.ts tests/trust.test.ts tests/release-signing.test.ts tests/cli-install.test.ts tests/trust-attestation.test.ts",
"test:ts": "node --experimental-strip-types --test --test-force-exit tests/site-stats.test.ts tests/certify.test.ts tests/prompt-injection-guard.test.ts tests/audit-skills.test.ts tests/runtime.test.ts tests/integrations.test.ts tests/growth-revenue-split.test.ts tests/trust-badge.test.ts tests/bounties.test.ts tests/trust-scoring.test.ts tests/adversarial-holdout.test.ts tests/adversarial-judge.test.ts tests/paired-benchmark.test.ts tests/format-rubric.test.ts tests/lang-detect.test.ts tests/university.test.ts tests/package-signature.test.ts tests/pricing-copy.test.ts tests/trust.test.ts tests/mcp-server.test.ts tests/evidence-stamping.test.ts tests/rug-pull.test.ts",
"test:all": "node --experimental-strip-types --test --test-force-exit tests/*.test.ts",
"lint:strict": "eslint .",
"check:navigation": "node scripts/check-navigation.mjs",
"check:project": "node scripts/check-project.mjs",
Expand Down
Loading
Loading