Skip to content

fix(security): scan shipped files for deployment resource identifiers, and remove them - #339

Merged
andrei-hasna merged 2 commits into
mainfrom
fix/38d15243-guard-identifier-class
Aug 2, 2026
Merged

fix(security): scan shipped files for deployment resource identifiers, and remove them#339
andrei-hasna merged 2 commits into
mainfrom
fix/38d15243-guard-identifier-class

Conversation

@andrei-hasna

@andrei-hasna andrei-hasna commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Closes half of todos 38d15243. Scope note first, because this task has two halves and this PR is only one of them.

  • This PR (half A): the CI guard is blind to an entire class, and the identifiers are consequently in main.
  • NOT this PR (half B): what to do about the two already-published versions. No unpublish, no deprecate, no publish, no version bump here. That decision is being taken separately.

No production identifier appears anywhere in this PR — not in the diff's added lines, the commit message, the tests, or this description. Everything below is counts, paths and class names.

The defect

scripts/check-package-secrets.ts selected files by basename:

const name = basename(path);
return isNpmrcName(name) || name === "bunfig.toml" || name === ".bunfig.toml" || LOCKFILE_NAMES.has(name);

That was the entire gate. .md, .ts, .conf, Makefile and .env.example were never opened, so this class was invisible by construction, not by oversight — no rule for it could have fired regardless of how it was written. A census of the whole CI surface (1 workflow, 2 scripts) found 0 files carrying any identifier rule; the positive control (secret) found 2.

What the class is, and where the boundary is

A production resource name grants nothing on its own, so it is not a secret in the credential sense. It is unearned reconnaissance: it says which resources exist and what they are called — and once the naming convention ships next to a worked example, what every sibling resource is called too. That is why the template is treated as in scope, and it is the larger half of this particular leak.

Two rules:

  1. deployment-resource-name — a name built from the house <workload>-<env>-<component> convention, and an infrastructure resource-kind word on the same line. Two independent signals.
  2. cloud-resource-arn / cloud-resource-id / managed-database-endpoint / object-storage-endpoint — provider-assigned identifiers. Unambiguous formats, so no second signal needed.

The boundary was set by measurement, not assertion, because this repo has already been bitten by a pattern that fired on the corrected state and taught people to dismiss the gate:

variant result on the whole tree
hyphenated name with any env-looking segment (incl. dev/test/stage/qa) 67 matches / 30 files — a connector directory named trigger-dev-api-platform, a third-party model id carrying -preview-, API-key fixtures
drop dev/test/stage/qa 29 / 12
+ require an infra resource-kind word on the same line 19 / 5 — exactly the real exposure

The conjunction is the whole idea. A third-party id containing an environment-looking segment survives because nothing on its line claims it names a deployed resource. Suppressed set: 2 occurrences in 2 files, correctly not ours.

The member of the class this pattern still cannot express

A deployment resource identifier that is neither convention-shaped nor provider-assigned, and whose line carries no resource-kind word. Concretely: a bare hostname or a hand-chosen bucket name in a YAML list item, or an identifier split across lines so that the name and the kind word never share one. The rule is line-scoped and requires either a recognisable shape or nearby context; an arbitrary string on a context-free line has neither and passes. I could not close that without either a value allow-list (over-fitted to today's tree) or a whole-file context window (which reintroduces the false-positive rate the table above shows).

Related, and named rather than hidden: <name>-<env>-<component> embedded inside a longer hyphenated run is not matched, because the pattern requires a clean left boundary. That is deliberate — it is what suppresses the third-party model ids — but it is a real gap, not a free win.

Evidence

Guard before the removal — exit 1, and the guard never prints what it matched:

EXITCODE=1
16 package-manager / deployment-identifier finding(s) detected.
connectors/zendesk/.env.example:8,9,10   deployment-resource-name
connectors/zendesk/CLAUDE.md:43,44,45    deployment-resource-name
connectors/zendesk/Makefile:15           deployment-resource-name
connectors/zendesk/README.md:11,12,13,278,279,280
connectors/zendesk/SCAFFOLD.md:43,44,45
Secret values and resource identifiers are never printed by this guard.

16 findings on 16 lines = 19 occurrences (SCAFFOLD carries template + example on one line each).

Guard after the removal:

EXITCODE=0
Package-manager and deployment-identifier guard clean (22745 tracked + packed file(s) scanned, 2 symlink(s) skipped).

22,745 files scanned, against 1,220 before.

Independent confirmation, because the guard and the removal share assumptions. Literals recovered from the pre-change blobs in origin/main, then searched for across the working tree:

distinct literal identifiers recovered from origin/main: 2
POSITIVE CONTROL - occurrences in origin/main README.md: 6
files searched: 22746
OCCURRENCES REMAINING IN WORKING TREE: 0

The control fires, so the zero is a measurement rather than a broken search.

Mutation testing — a passing test proves nothing until it can fail:

MUT 1  restore the ORIGINAL narrow gate (the actual bug)  -> EXITCODE=1, 13 pass / 3 fail
MUT 2  drop the INFRA_KIND conjunction                    -> EXITCODE=1, 15 pass / 1 fail
MUT 3  put dev|test|stage|qa back in the env set          -> EXITCODE=1, 15 pass / 1 fail
RESTORED                                                  -> EXITCODE=0, 16 pass / 0 fail

MUT 1 is the one that matters: the tests bind the file-selection gate, not just the regex. The original defect was the gate, so a pure-regex test would have passed while .md and Makefile stayed unopened.

Suite and typecheck: 3812 pass / 190 fail, against a 3796 / 190 baseline measured on the same commit with the change stashed. Failing sets compared by name: 0 new, 0 fixed — all 190 are pre-existing and dependency-resolution related. bun run typecheck exits 0.

The removal

file occurrences what changed
connectors/zendesk/README.md 6 infrastructure table row and deployment bullets replaced with a statement that names are not published
connectors/zendesk/SCAFFOLD.md 6 the three deployment rows dropped from the naming-convention table; framing says why
connectors/zendesk/CLAUDE.md 3 three bullets collapsed to one
connectors/zendesk/.env.example 3 variable names kept (they are the config contract), values emptied
connectors/zendesk/Makefile 1 committed default removed; require-ec2-host preflight added

The Makefile preflight exists because an empty host does not fail cleanly — rsync ... user@:/path is read as a local copy. Exercised both ways: unset exits 1 with a named message, supplied exits 0.

Out of scope, reported not fixed

  1. connectors/sedo/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc is a committed dangling symlink (mode 120000) pointing at an absolute path on a developer's macOS machine. It does not resolve on Linux and crashed the widened guard on first run. I handled it in the guard (skipped, counted, reported) rather than fixing the symlink. 2 tracked symlinks repo-wide, 1 dangling. It also leaks a local directory layout into a public package.
  2. Widening the existing TOKEN_PATTERNS to all files is a real improvement that I deliberately did not make here. connectors/anthropic/.env.example:4 would trip the Anthropic-key rule on day one. I checked its shape without printing it — a 47-char token whose 40-char body is a single repeated character, i.e. a placeholder, not a live credential. Widening needs placeholder suppression first (the script already has isSafeReference for that idea). Separate change, separate review.

Cost

The guard now reads ~22.7k files instead of ~1.2k, so it takes roughly 1m45s. It runs in prepublishOnly and CI, not per-commit. Files over 2 MiB and known-binary extensions are skipped.

Refs: todos 38d15243


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…, and remove them

The package-manager secret guard opened only .npmrc, bunfig and lockfile
basenames, so .md, .ts, .conf, Makefile and .env.example were never read.
Production deployment resource identifiers were therefore invisible to CI by
construction rather than by oversight, and 19 occurrences across 5 shipped files
in one connector reached main and the published tarball.

Guard: adds a deployment-resource-identifier class in two parts.

  - A name built from the house <workload>-<env>-<component> convention, but
    only when an infrastructure resource-kind word appears on the same line.
    Two independent signals are required because the name shape alone is
    lexical: a repo-wide trial of the shape by itself returned 67 matches
    across 30 files, on a connector directory named trigger-dev-api-platform,
    a third-party model id carrying a -preview- segment, and API-key fixtures.
    dev/test/stage/qa are deliberately excluded from the environment set for
    the same reason. A guard that fires on correct code gets ignored, and the
    habit carries to the hit that is real.

  - Provider-assigned identifiers (ARNs, resource ids, managed-database and
    object-storage endpoint hostnames), which are unambiguous and need no
    second signal.

Templates such as `<prefix>-<env>-<component>-{name}` are in scope: publishing
the pattern beside a worked example makes every sibling resource's name
derivable, which is a wider disclosure than any single name.

File selection becomes an extension deny-list over the existing tracked-plus-
packed union rather than a basename allow-list, since an allow-list is how a
whole class goes unscanned. Tracked symlinks and listed-but-absent paths are
skipped, counted, and reported separately, so the guard cannot quietly read
fewer files than it claims. Findings still carry only path, line, rule and
detail: the guard never emits what it matched, and a test asserts it.

Removal: the three resource names are taken out of the connector's README,
CLAUDE.md, SCAFFOLD.md and .env.example, and the naming-convention table drops
its deployment rows. The Makefile's deployment host loses its committed default
and gains a preflight so an unset host fails with a clear message instead of
silently degrading into a local rsync.

Guard and removal land together: the guard alone would leave main red.

Evidence: guard exits 1 with 16 findings on the tree before the removal and 0
after, scanning 22,745 files against 1,220 previously. An independent search for
the literals recovered from the pre-change blobs finds 0 remaining across 22,746
files, with a positive control confirming that search finds 6 in the old README.
Full suite 3812 pass / 190 fail against a 3796 / 190 baseline on the same
commit: identical failure sets, 0 new, all pre-existing and dependency-related.
Typecheck clean. The new tests were mutation-tested three ways, including
restoring the original narrow gate, and each mutation fails them.

Refs: todos 38d15243

Agent: Silvanus
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] NO_GO — #339 @ 8c2f2c6 — lens: correctness+security+gates, reviewer unresolved-account001 (1 of 1)

Reviewed the complete origin/main...HEAD diff for all seven changed files and surrounding code in package.json, .github/workflows/ci.yml, src/mcp/mcp.test.ts, the full scanner implementation, and its full regression suite.

Commands and measured results:

  • git log --oneline origin/main..HEAD — exit 0; one commit (8c2f2c6b).
  • git diff origin/main...HEAD --stat — exit 0; 7 files, 426 insertions, 40 deletions.
  • bun install — exit 0 (setup only; not reported as a test gate).
  • bun run typecheck — exit 0; TypeScript emits no pass/fail count.
  • bun run test immediately after the prescribed setup — exit 1; 3812 pass, 190 fail, 4002 total. The failures are the known fresh-checkout condition where generated bin/* entrypoints do not exist until the repository's CI build step.
  • bun run check:package-secrets — exit 0; 22,745 files scanned, 2 symlinks skipped.
  • bun run build as the repository's actual CI precondition — exit 0.
  • bun run test after build — exit 1; 4001 pass, 1 fail, 4002 total. The remaining failure was server entry (connectors-serve) > starts with --port= syntax, which timed out on a localhost connection. The remote build CI check at this exact head is green (gh pr checks exit 0), so I treat these pre-existing/local ordering and port-test failures as non-blocking for this diff, but record them rather than calling the local test gate green.

Blocking P0/P1 findings:

  1. P1 — the scanner does not recognize the documented <workload>-<env>-<component> shape when the component is one segment. scripts/check-package-secrets.ts requires one post-environment segment and then (?:-...)+, so EC2 Instance: qzsyn-prod-zzkind returns no finding while EC2 Instance: qzsyn-prod-zzkind-wwexample returns deployment-resource-name (synthetic positive/negative control, command exit 0). This is a currently reachable prepublish bypass: a tracked or packed text file can name a normal convention-shaped production resource on an infrastructure line, scanPaths() reads it, the rule returns no finding, and the guard permits publishing it. That directly contradicts the PR description and implementation comment defining the protected class as <workload>-<env>-<component>. Minimal remedy: allow zero additional component segments after the first and add an end-to-end regression for the single-component shape.

Non-blocking follow-ups:

  • The declared test script is not self-contained on a fresh checkout and the post-build server port test is locally flaky. These are pre-existing and outside this PR's changed surface; remote CI is green at the reviewed SHA.
  • No other concrete, reachable P0/P1 correctness or security defect survived review.

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[FIX] #339 @ 7122206 — unresolved-account001

Fixed the named P1 scanner bypass from the review at 8c2f2c6b:

  • changed the post-environment component tail from one-or-more additional segments to zero-or-more, so the documented <workload>-<env>-<component> shape now matches a single component as well as a hyphenated component;
  • added a synthetic regression proving qzsyn-prod-zzkind on an EC2 resource line produces deployment-resource-name;
  • kept the remediation scoped to the named defect and its direct regressions.

Verification before push:

  • focused bun run test scripts/check-package-secrets.test.ts — exit 0, 17 pass / 0 fail;
  • bun run check:package-secrets — exit 0, 22,914 files scanned;
  • bun run typecheck — exit 0;
  • full bun run test — exit 0, 4003 pass / 0 fail;
  • staged diff scan and pre-push commit scan — exit 0, no leaks found.

Commit: 7122206c6a56f7a6f42b55287e79092d4d11ae13 (Agent: unresolved-account001).

@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #339 @ 7122206 — lens: correctness+security+gates, reviewer unresolved-account001 (1 of 1)

Focused re-review only of the named blocking defect, its fix, and direct regressions. I read commit 7122206c in full: the deployment-resource regex now accepts the documented single-segment component shape, and the new synthetic regression binds that behavior without introducing a real identifier.

Commands and measured results:

  • bun install — exit 0 (setup only).
  • focused bun run test scripts/check-package-secrets.test.ts — exit 0, 17 pass / 0 fail.
  • bun run check:package-secrets — exit 0, 22,914 tracked + packed files scanned, 2 symlinks skipped.
  • bun run typecheck — exit 0; TypeScript emits no pass/fail count.
  • bun run build — exit 0; required generated entrypoints produced.
  • full bun run test — exit 0, 4003 pass / 0 fail, 35,245 expectations across 454 files.
  • staged diff check, staged secret scan, and pre-push commit scan — exit 0; no leaks found.
  • exact-head GitHub Actions CI/build — pass in 2m15s.

Blocking P0/P1 findings: none. The original P1 is fixed and its synthetic control now fires on qzsyn-prod-zzkind as intended.

Non-blocking follow-ups:

  • The repository's declared test script still depends on generated bin/* output and therefore fails on a fresh checkout unless the documented CI build step runs first. This is pre-existing and the actual CI order is correct.
  • One local post-build run saw the existing starts with --port= syntax server test time out; the subsequent full run passed 4003 / 0 and exact-head remote CI passed. No changed code touches that server path.

@andrei-hasna
andrei-hasna merged commit 134cf24 into main Aug 2, 2026
2 checks passed
@andrei-hasna
andrei-hasna deleted the fix/38d15243-guard-identifier-class branch August 2, 2026 17:54
@andrei-hasna

Copy link
Copy Markdown
Contributor Author

[REVIEW] GO — #339 @ 8c2f2c6 — lens: guard-efficacy-vs-circularity + scrub-completeness, reviewer conn339-reviewer (1 of 1)

Reviewed in a throwaway clone at 8c2f2c6b77005b958d47fdfa4a3d06ce18687397. Nothing pushed, nothing merged, branch untouched (git status --short empty at the end, HEAD unchanged). No identifier value appears below — every claim is counts, paths, rule names, or synthetic placeholders.

Verdict

GO. The gating criterion — the source is scrubbed — is met and independently verified across the whole tree, and the guard widening is demonstrably not cosmetic. Two P1 follow-ups below concern stated coverage versus actual coverage; neither is a live exposure and neither reaches this PR's acceptance.

The circularity attack — the guard was tested against a tree it also cleaned

The strongest available control is a real one rather than a planted one: point the new guard at the old tree. git worktree at origin/main (e0021b19cbba), new script copied in, main content otherwise unmodified:

EXITCODE=1
16 package-manager / deployment-identifier finding(s) detected.
connectors/zendesk/.env.example:8,9,10   deployment-resource-name
connectors/zendesk/CLAUDE.md:43,44,45    deployment-resource-name
connectors/zendesk/Makefile:15           deployment-resource-name
connectors/zendesk/README.md:11,12,13,278,279,280
connectors/zendesk/SCAFFOLD.md:43,44,45
Secret values and resource identifiers are never printed by this guard.

Same guard, this branch, unmodified:

EXITCODE=0
Package-manager and deployment-identifier guard clean (22745 tracked + packed file(s) scanned, 2 symlink(s) skipped).

Fires on the real exposure, silent on the cleaned tree. The widening is real. I also confirmed main's own guard contains neither scanDeploymentIdentifiers nor isCandidateTextFile (grep count 0), so the green guard step on main today is vacuous by construction and is not evidence about this change in either direction.

The tests bind the gate, not merely the regex. I re-ran all three mutations rather than trusting the table:

MUT 1  narrow gate restored   -> EXITCODE=1, 13 pass / 3 fail
MUT 2  INFRA_KIND dropped     -> EXITCODE=1, 15 pass / 1 fail
MUT 3  dev|test|stage|qa back -> EXITCODE=1, 15 pass / 1 fail
RESTORED                      -> EXITCODE=0, 16 pass / 0 fail

MUT 1's three failures are exactly the file selection — the gate is what was actually broken cases. That was the claim that mattered and it holds.

File selection is a deny-list, not a larger allow-list. filesToScan() is git ls-files unioned with npm pack --dry-run, filtered by an extension deny-list. The coverage accounting balances exactly: 22748 tracked = 22745 scanned + 2 symlinks + 1 binary-by-extension. 0 oversized, 0 null-byte.

Scrub completeness — repo-wide, not directory-local

A population check rather than a spot check. Literals recovered from main by pattern, then counted across every tracked file on this branch; values hashed to opaque handles and never printed:

STEP1 distinct convention-shaped literals recovered from MAIN: 3
   handle=80151128  segments=4  caught_by_shipped_rule=YES
   handle=3603c619  segments=4  caught_by_shipped_rule=YES
   handle=093d932d  segments=4  caught_by_shipped_rule=YES
STEP2 POSITIVE CONTROL - occurrences in MAIN tree: 19
STEP2 files scanned in PR tree: 22745
STEP2 OCCURRENCES REMAINING IN PR TREE: 0
STEP2 files still carrying one: (none)

The control fires at 19, so the zero is a measurement rather than a broken search. The scrub is repo-complete: the guard run over all of main reported findings in connectors/zendesk/ only, so no other connector carries the class. My recovery found 3 distinct literals against the body's stated 2 — different method (whole tree vs the five changed blobs), same 19 occurrences, same 0 remaining. Not a defect; the "2" is method-dependent.

P1 — non-blocking here, but fix before this guard is relied on elsewhere

P1-a — the canonical three-segment house convention does not fire. DEPLOYMENT_RESOURCE_NAME ends [...]+(?:-[...]+)+; that trailing + demands two or more segments after the env token, i.e. four segments minimum. The house convention is three. Measured on synthetic placeholders:

SILENT | <word>-prod-<word>            + kind word   (3 segments — the convention)
SILENT | <word>-prod-<word>            + 'bucket'
SILENT | <word>-staging-<word>         + 'endpoint'
FIRES  | <word>-prod-<word>-<word>     + kind word   (4 segments)

Both the PR body and the shipped source comment describe the rule as matching "a name built from the house <workload>-<env>-<component> convention". That three-segment form is precisely what does not match. There are currently 0 occurrences of the three-segment form anywhere in the tree, so this is a recurrence gap and not a live exposure — but it is the most likely shape of the next recurrence, and the inaccuracy is durable because it ships in the source comment. Widening the quantifier needs the same repo-wide false-positive measurement the current boundary received, so it is follow-up work rather than a safe one-liner.

P1-b — an all-placeholder template does not fire, and the test asserting otherwise is fitted to its fixture.

SILENT | {workload}-prod-{component}-{role}   + kind word
SILENT | <workload>-prod-<component>-<role>   + kind word
FIRES  | <concrete>-prod-{component}-{role}   + kind word

The leading segment is [a-z][a-z0-9]*, so braces and angle brackets are permitted only after the env token and a convention written the natural way (all placeholders) passes. The test flags the naming TEMPLATE, not only concrete names uses SENTINEL_TEMPLATE = "qzsyn-prod-zzkind-{name}", which retains a concrete leading segment — it passes without establishing the claim in its own name. This matters more than usual because the body argues the template is "the larger half of this particular leak".

Non-blocking

  • INFRA_KIND's lookarounds exclude [A-Za-z0-9_] but not -, so a name whose own component segment is a kind word supplies both signals by itself. Measured: a four-segment name with no other kind word on the line still fires. That makes the rule more sensitive rather than less, but the "two independent signals" framing is weaker than described.
  • Uppercase and snake_case variants are silent (no i flag on the name pattern). Defensible for AWS resource names; the same house standard uses snake_case for database names.
  • One tracked file is dropped by extension with no corresponding line in the guard's output, while symlinks and absent paths each get one. Cosmetic at n=1, but it is the same "reads fewer files than you think" shape the script's own comment warns against.
  • TOKEN_PATTERNS still apply only to package-manager files — disclosed in the body as deliberate, and I agree it is a separate change.

Functional impact of the removals — no regression found

  • .env.example: the three variables were already commented out in main; only values were emptied. The key-set diff between main and this branch is empty in both directions. Nothing in the repo reads EC2_INSTANCE / RDS_DATABASE / S3_BUCKET outside .env.example and the guard's own test.
  • Makefile: require-ec2-host covers every executing target — deploydeploy-ec2deploy-sync, plus logs and ssh. The only unguarded use sits inside an echo in systemd-service, which prints an instruction rather than running anything.
  • connectors/zendesk/.github/workflows/deploy.yml takes the host from ${{ secrets.EC2_HOST }}, so dropping the committed Makefile default does not break it.

Git history

The PR makes no claim to have removed anything from history — it explicitly recovers literals "from the pre-change blobs in origin/main", which concedes they persist. Verified: main's tree still carries 19 occurrences. Accurate as written.

What I did NOT check

  • Half B (unpublish / deprecate / the two already-published versions) — out of scope by the PR's own framing and untouched here.
  • The published npm tarballs. Everything above concerns the git tree; I did not download either shipped version to confirm what is in the registry artifacts.
  • The full suite and typecheck. I ran only scripts/check-package-secrets.test.ts (16/16) plus the guard end-to-end; the body's 3812/190-against-3796/190 baseline and bun run typecheck are unverified by me.
  • Whether untracked-but-packed build output carries the class — my population scan used git ls-files, whereas the guard additionally unions npm pack. The guard's own clean run covers that superset; my independent scan does not.
  • The dangling symlink in connectors/sedo/ beyond confirming it is skipped and counted.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant