Skip to content

fix: security and connection-lifecycle hardening - #11

Merged
deniscuciuc merged 6 commits into
mainfrom
fix/security-hardening
Sep 10, 2026
Merged

deniscuciuc merged 6 commits into
mainfrom
fix/security-hardening

Conversation

@deniscuciuc

@deniscuciuc deniscuciuc commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner

Audit pass on the PostgreSQL analyzer ahead of treating it as production-grade.

Security

  • SQL identifiers reached SQL text unquoted or half-quoted. vacuumTable built "${schema}"."${table}" without doubling embedded quotes, and generate-drop-sql emitted DROP INDEX ${schema}.${index}; into a blob the README tells you to paste into psql. Everything now goes through quoteIdentifier.

    Verified against a real Postgres with a table named evil" ; CREATE TABLE pwned_marker(x int); --: the old code failed the vacuum outright, and its generated drop SQL errors when pasted. The new code vacuums it correctly and emits SQL that drops exactly the intended index. (Postgres itself blocks the DDL-injection payload on the VACUUM path, since a multi-statement query runs in an implicit transaction and VACUUM refuses to — so this is a correctness fix that also closes the injection class, not a live RCE.)

  • --ssl disabled certificate verification. It passed rejectUnauthorized: false, so it encrypted without authenticating the server — full MITM exposure for the remote-production case analyzerrc.example.json advertises, with no way to supply a CA. It now verifies by default, with --ssl-ca <path> and an explicit --ssl-no-verify opt-out.

  • Destructive commands ran unconfirmed from the CLI. -c run-vacuum immediately vacuumed every bloated table and the pg_stat_statements commands ran DDL, while the interactive menus prompted first. They now require --yes, and --dry-run reports what they would do.

Robustness

  • pg.Pool emits error on idle-client failure; with no listener Node treated it as an unhandled error event and killed the process. Near-certain in --watch mode, which holds the pool open for hours.
  • Unknown commands fell through to default: buildFullReport(...), so -c helth silently ran the most expensive path. Now rejected — and validated before the connection opens, so a typo fails in 0.3s instead of after a connection timeout.
  • The watch loop replaced the default SIGINT handler with a flag, so Ctrl+C during a query did nothing and a second Ctrl+C did nothing either — the CLI looked hung. A second signal now exits, SIGTERM is handled so docker stop reaches connection cleanup, and the pending countdown timer is cleared.
  • process.exit(1) in the top-level catch could truncate buffered stdout, which matters when --json is piped to a file. Uses process.exitCode.

Verification

pnpm lint, pnpm build, and the --help smoke test pass. All behaviour above was exercised end to end against a postgres:17-alpine container, including the hostile-identifier case.

Tests, the programmatic API, and the CI/tooling changes follow in separate PRs.


Update: this branch now also contains

A real programmatic API. The README documented new DatabaseAnalyzer({...}) with .analyze()/.close(), a symbol that existed nowhere. The package exported nothing at all — index.ts ended in a bare main(), so require()ing the package connected to the database and ran an analysis as an import side effect, and the generated .d.ts was empty. The CLI moved to src/cli/main.ts and src/index.ts is now a side-effect-free library entry exporting PostgresAnalyzer plus the analyzers, collectors, reporters and report types.

A test suite (40 tests), and the tooling settings that let the bugs through. noUncheckedIndexedAccess was off, which is why argv[++index] looked like a string when it is string | undefined — the root cause of two argument-parsing bugs the new tests found:

  • --watch -1 was mistaken for a flag and silently replaced by the default interval.
  • Numeric flags had no validation, so --port abc produced NaN and a driver error far from the mistake.
  • A flag at the end of argv stored undefined rather than failing.
  • An unknown flag was ignored, so a typo like --jsno silently produced human output instead of JSON.

Biome's noUnusedVariables/noUnusedImports were warnings, so biome check exited 0 on dead code; both are errors now, and the lint scope covers tests/ and scripts/, which were unchecked.

CI and release. CI only ran on main and develop, so a feature branch was checked by nothing until it became a PR. It now runs on every branch, cancels superseded runs, adds Node 24, and runs pnpm test. publish.yml had no id-token: write — so releases carried no npm provenance — and nothing verified the tag matched package.json. Both fixed, plus an npm environment gate on the irreversible publish.

Adds the missing SECURITY.md and CODE_OF_CONDUCT.md, and bumps to 1.1.0 with a changelog entry.

Verification

pnpm lint, pnpm build, pnpm test and the --help smoke test pass on Node 20, 22 and 24. The CLI and the new API were both exercised end to end against a real container.

Needs you

Enable Dependency graph in repository settings if you want a dependency-review job on PRs — I removed the one I had added, because without that setting it fails with "not supported on this repository". Publishing 1.1.0 to npm is yours; the publish workflow now waits on the npm environment.

Schema, table and index names reached SQL text unquoted or with quotes that
were never doubled. VACUUM of a table whose name contains a double quote
failed outright, and generate-drop-sql emitted SQL that errors when pasted
into psql. Route every identifier through quoteIdentifier.

--ssl passed rejectUnauthorized: false, so it encrypted without
authenticating the server. It now verifies by default, with --ssl-ca and an
explicit --ssl-no-verify opt-out.

run-vacuum and the pg_stat_statements DDL commands ran immediately from the
CLI while the interactive menus prompted first. They now require --yes, and
--dry-run reports what they would do.

Also: add a pool error listener so an idle-client failure no longer kills
watch mode, reject unknown commands instead of silently running a full
analysis, handle SIGTERM and a second SIGINT in the watch loop, clear its
pending countdown timer, and set process.exitCode rather than calling
process.exit, which could truncate piped --json output.
Move the command and destructive-command checks out of executeCommand into
src/cli/validate.ts, called from main before the pool is created, so an
invalid command fails immediately instead of after a connection attempt.
The README documented `new DatabaseAnalyzer({...})` with
`.analyze()/.printSummary()/.generateReport()/.close()`. No such symbol
existed anywhere in the codebase, and the package exported nothing at all:
index.ts ended in a bare main(), so requiring the package connected to
Postgres and ran an analysis as a side effect of the import, while the
generated .d.ts was empty.

The CLI moves to src/cli/main.ts (the bin target) and src/index.ts becomes a
side-effect-free library entry exporting a real PostgresAnalyzer plus the
analyzers, collectors, reporters and report types. Adds an `exports` map and
`sideEffects: false`.

PostgresAnalyzer keeps `vacuum()` separate from `analyze()`, since it changes
server state, and can wrap a pool the caller already has, in which case
`close()` leaves it alone. Its SSL options mirror the CLI's: verification on by
default, with `sslCa` and an explicit `sslRejectUnauthorized: false` opt-out.

Also corrects the architecture tree, which still listed `src/interactive.ts`
and omitted `src/cli/`, `src/config/`, `src/watch/` and `src/utils/`.
…rough

The repo had no tests at all — no tests/, no runner, no test script, and no CI
step. tsconfig even excluded **/*.test.ts for tests that did not exist.

Adds 40 tests covering the CLI parser, the config loader and profiles, SQL
identifier quoting, the health score, the report writers' content rather than
just their file extension, and the programmatic API. Includes a regression test
that a hostile table name cannot reach the generated HTML unescaped.

Bugs the tests found:

- `--watch -1` was mistaken for a flag and silently replaced by the default
  interval instead of being rejected. The parser now distinguishes a negative
  number from a flag.
- Numeric flags ran through Number.parseInt with no check, so `--port abc`
  produced NaN and a driver error far from the actual mistake. Each flag now
  reports its own name.
- A flag at the end of argv stored undefined rather than failing.
- An unknown flag was ignored, so a typo like `--jsno` silently produced
  human-readable output instead of JSON.

Tooling: enable noUncheckedIndexedAccess — the missing flag that made
`argv[++index]` look like a `string` when it is `string | undefined`, which is
the root cause of the last two — plus noImplicitOverride,
noFallthroughCasesInSwitch, noUnusedLocals and noUnusedParameters, and fix what
they found. Biome's noUnusedVariables and noUnusedImports were warnings, so
`biome check` exited 0 on dead code; both are now errors, and the lint scope
covers tests/ and scripts/, which were unchecked.
CI only ran on pushes to main and develop, so a feature branch was checked by
nothing until it became a PR. It now runs on every branch, cancels superseded
runs, tests on Node 24 as well as 20 and 22, and runs `pnpm test` — which the
mongo and postgres workflows never did because there were no tests.

publish.yml had no `id-token: write`, so releases carried no npm provenance,
and nothing verified that the tag matched package.json — a mismatched tag would
have published the wrong version. It now checks that first, lints, builds and
tests before publishing, publishes with `--provenance`, and reads the release
notes from CHANGELOG.md with awk taking the version from the environment rather
than interpolating it into the script. The job is gated on an `npm`
environment so an irreversible publish can require approval.

Adds the missing SECURITY.md — with the operational notes this tool needs,
since it takes database credentials and can change server state — plus
CODE_OF_CONDUCT.md and CODEOWNERS. Bumps the version to 1.1.0 with a changelog
entry.
It needs the repository's Dependency graph enabled, which is a settings change
rather than something the workflow can do, so the job failed with "Dependency
review is not supported on this repository". Removed so CI is green; worth
adding back once Dependency graph and Dependabot security updates are switched
on in repository settings.
@deniscuciuc
deniscuciuc merged commit 51f2e05 into main Sep 10, 2026
6 checks passed
@deniscuciuc
deniscuciuc deleted the fix/security-hardening branch September 10, 2026 00:56
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