-
Notifications
You must be signed in to change notification settings - Fork 0
185 lines (167 loc) · 10.7 KB
/
Copy pathci.yml
File metadata and controls
185 lines (167 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
name: ci
# Base CI for the catalog repo (story 055.W3.1) + D24's full invariant suite (story 055.W3.3):
# - D20(4) capability analysis landed in story 055.W4.2 (see its step below).
# - D20(1) BLOCKING secret scanning landed in story 055.W4.1: the gate itself lives inside
# publisher/publish.mjs (lib/secret-scanner.mjs + the vendored gitleaks corpus in
# lib/secret-rules.mjs), with a per-CLASS negative fixture through the real CLI in
# test/publish-cli.test.mjs. The steps this workflow adds for it are the two things a unit test
# cannot show: that the scanner BINARY runs end-to-end over a real artifact and REFUSES one with a
# planted credential, and that the plugin channel stays decoupled from the binary channel.
# What the scanner cannot see is documented in docs/SECRET-SCANNING.md and printed on every run.
# - D24's three no-going-back invariants (immutable id via lineage_id, burned-name ledger,
# license-in-package-root) + the publish-time half of D21 (tier vocabulary from the plugin's own
# manifest) — ADDED this story. See docs/INVARIANTS.md for the full design reasoning.
# - fix-cycle-2 (QG finding F9): check (a) was reinforced with lineage_id, a stable per-plugin
# identity that survives a version bump — digest lineage alone let the realistic rename (rename +
# version bump) pass verde. Nothing new is wired into this workflow by that change: the existing
# steps below already cover it, because the new rules live inside the same shared modules the
# unit tests, validate-index, ledger-append-only and ledger-consistency steps already exercise.
# What this workflow checks: the automated unit test suite (test/*.test.mjs, node:test — includes
# every negative fixture proving each of the 4 checks actually rejects the case it's meant to
# reject, AC5), the index files are structurally valid against the documented schema, the ledger's
# entire git history is append-only (VC-1), the production index is ledger-consistent, no committed
# file contains an obvious secret shape or a machine-specific absolute path, and the production
# index still ships with zero real entries (until this repo's dispatch explicitly authorizes one).
on:
push:
branches: [main]
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
# fetch-depth: 0 — REQUIRED for the ledger append-only check (scripts/check-ledger-append-
# only.mjs) to see the file's ENTIRE commit history, not just the tip. A shallow clone would
# silently see 1 commit and report a false OK — exactly the "gate that passes verde without
# pegging what it should" trap this story exists to avoid.
- uses: actions/checkout@v4
with:
fetch-depth: 0
# fix-cycle-1 (QG round 1, F5): standard CI hardening — the checkout token has no reason
# to persist in the job's local git config once checkout is done (this job never pushes).
persist-credentials: false
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Unit tests (node:test — lib/, publish.mjs + retire.mjs CLIs, render-catalog.mjs, validate-index.mjs, ledger checks)
run: |
node --test test/*.test.mjs
- name: Validate index files against the schema (structural)
run: |
node scripts/validate-index.mjs index/index.json fixtures/index.json
- name: Ledger is append-only across its ENTIRE git history (VC-1, D24 a/b)
run: |
node scripts/check-ledger-append-only.mjs ledger/plugin-ids.json
- name: Production index is consistent with the ledger (D24 a/b, structural re-check independent of publish-time)
run: |
node scripts/check-ledger-consistency.mjs index/index.json ledger/plugin-ids.json
- name: Production index ships with zero real entries (VC-5 / this story's boundary)
run: |
count=$(node -e "console.log(JSON.parse(require('fs').readFileSync('index/index.json','utf8')).entries.length)")
echo "index/index.json entries: $count"
if [ "$count" != "0" ]; then
echo "REFUSED: index/index.json must stay empty until 055.W3.3's CI invariants land — see README.md 'Why the production index is empty'."
exit 1
fi
# story 055.W4.2 (D17 + D20(4)) — capability analysis. Deliberately ONE additive step: the
# analyzer's own unit tests (test/capability-analyzer.test.mjs) already run in the
# "Unit tests" step above via the test/*.test.mjs glob, and the publish-time gates (AC1
# blocking, AC3 blocking, AC6 warn-and-display) run inside publisher/publish.mjs and are
# covered by test/publish-cli.test.mjs against the real CLI. What is left for CI to prove is
# that the analyzer BINARY actually runs end-to-end over a real artifact, which is what a
# unit test cannot show.
- name: Capability analysis runs over a real artifact (D20(4) — derived, never self-declared)
run: |
set -e
tmp="$(mktemp -d)"
mkdir -p "$tmp/pkg/skills/demo"
printf 'MIT\n' > "$tmp/pkg/LICENSE"
printf -- '---\nname: demo\ndescription: demo\nallowed-tools: Read, Bash\n---\n\nRun `bash scripts/run-thing.sh` first.\n' \
> "$tmp/pkg/skills/demo/SKILL.md"
tar -czf "$tmp/demo.tar.gz" -C "$tmp/pkg" .
node scripts/analyze-capabilities.mjs --artifact "$tmp/demo.tar.gz" --require-allowed-tools
echo "OK — analyzer ran end-to-end over a real artifact"
# story 055.W4.1 (D20(1)) — the BLOCKING scanner, exercised end-to-end over real artifacts.
# THREE artifacts on purpose: a clean one that must PASS, a planted one that must be REFUSED,
# and (fix-cycle-1, F2) an UNSCANNABLE one that must also be REFUSED. A step that only ever
# runs the clean case is the exact failure this story names — "a scanner that passes verde
# against a clean package proves nothing". The planted credential is assembled here from
# fragments so this workflow file never contains a literal credential shape (which would also
# trip the base guard further down this job).
- name: Secret scanning refuses a planted credential, refuses an unscannable member, passes a clean package (D20(1) — BLOCKING)
run: |
set -e
tmp="$(mktemp -d)"
# 1. clean package — must exit 0
mkdir -p "$tmp/clean/skills/demo"
printf 'MIT\n' > "$tmp/clean/LICENSE"
printf -- '---\nname: demo\ndescription: demo\nallowed-tools: Read\n---\n\nNothing secret here.\n' \
> "$tmp/clean/skills/demo/SKILL.md"
tar -czf "$tmp/clean.tar.gz" -C "$tmp/clean" .
node scripts/scan-secrets.mjs --artifact "$tmp/clean.tar.gz"
echo "OK — clean package passes"
# 2. same package + one planted credential — must exit non-zero
cp -R "$tmp/clean" "$tmp/dirty"
mkdir -p "$tmp/dirty/config"
printf 'GH_TOKEN=%s%s\n' 'ghp_' 'aB3dEf7hIjKlM9oPqRsTuVwXyZ0123456789' > "$tmp/dirty/config/ci.env"
tar -czf "$tmp/dirty.tar.gz" -C "$tmp/dirty" .
if node scripts/scan-secrets.mjs --artifact "$tmp/dirty.tar.gz"; then
echo "REFUSED: the scanner accepted an artifact with a planted credential — the gate is decorative"
exit 1
fi
echo "OK — planted credential is REFUSED end-to-end"
# 3. fix-cycle-1 (F2): the SAME credential behind a one-byte NUL prefix. Before the
# fail-closed decision this exited 0 — the member was classified binary, skipped, and
# the artifact published. Unscannable is now treated as not publishable.
cp -R "$tmp/clean" "$tmp/unscannable"
mkdir -p "$tmp/unscannable/config"
printf '\000' > "$tmp/unscannable/config/creds.env"
printf 'AWS_ACCESS_KEY_ID=%s%s\n' 'AKIA' 'QRS7TUVWX234YZ56' >> "$tmp/unscannable/config/creds.env"
tar -czf "$tmp/unscannable.tar.gz" -C "$tmp/unscannable" .
if node scripts/scan-secrets.mjs --artifact "$tmp/unscannable.tar.gz"; then
echo "REFUSED: an unscannable member passed — one NUL byte defeats the gate again"
exit 1
fi
echo "OK — unscannable member is REFUSED end-to-end (fail-closed)"
# 4. fix-cycle-2 (F10): a SHADOWED duplicate member — the same path twice in one tar
# stream, credential first, clean second. Extraction keeps only the clean one, so a
# filesystem-based inventory saw nothing; the credential nevertheless shipped and was
# recoverable with `tar -xOzf`. The scan now enumerates the archive's MEMBER TABLE.
cp -R "$tmp/clean" "$tmp/shadow"
mkdir -p "$tmp/shadow/config"
printf 'AWS_ACCESS_KEY_ID=%s%s\n' 'AKIA' 'QRS7TUVWX234YZ56' > "$tmp/shadow/config/app.env"
( cd "$tmp/shadow" && tar -cf "$tmp/shadow.tar" . )
mkdir -p "$tmp/shadow2/config"
printf 'APP_ENV=production\n' > "$tmp/shadow2/config/app.env"
( cd "$tmp/shadow2" && tar -rf "$tmp/shadow.tar" ./config/app.env )
gzip -c "$tmp/shadow.tar" > "$tmp/shadow.tar.gz"
# the fixture is only meaningful if the credential really is in the published bytes
tar -xOzf "$tmp/shadow.tar.gz" ./config/app.env | grep -q 'AKIA' \
|| { echo "fixture broken: the shadowed member does not carry the credential"; exit 1; }
if node scripts/scan-secrets.mjs --artifact "$tmp/shadow.tar.gz"; then
echo "REFUSED: a shadowed duplicate member passed — the credential ships silently"
exit 1
fi
echo "OK — shadowed duplicate member is REFUSED end-to-end (F10)"
# story 055.W4.1 (AC5) — the plugin channel must never read binary-channel state.
- name: Plugin channel stays decoupled from the binary channel (D19 / AC5)
run: |
node scripts/check-channel-separation.mjs
- name: No obvious secret shapes committed (base guard — NOT the D20(1) blocking scanner)
run: |
set -e
if grep -RInE '(-----BEGIN [A-Z ]*PRIVATE KEY-----|AKIA[0-9A-Z]{16}|CLOUDFLARE_API_TOKEN\s*=\s*[A-Za-z0-9_-]{20,})' \
--exclude-dir=.git .; then
echo "REFUSED: possible secret shape found in a committed file"
exit 1
fi
echo "OK — no obvious secret shape found"
- name: No machine-specific absolute paths committed (portable-paths rule)
run: |
set -e
if grep -RInE '/Users/[A-Za-z0-9_.-]+|/home/[A-Za-z0-9_.-]+|C:\\\\Users\\\\' \
--exclude-dir=.git .; then
echo "REFUSED: machine-specific absolute path found in a committed file"
exit 1
fi
echo "OK — no machine-specific absolute path found"