diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3c4f0b99..9c57512ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,11 +8,12 @@ on: jobs: test: - name: Test on Node.js ${{ matrix.node-version }} - runs-on: ubuntu-latest + name: Test on ${{ matrix.os }} Node.js ${{ matrix.node-version }} + runs-on: ${{ matrix.os }} strategy: matrix: + os: [ubuntu-latest, windows-latest] node-version: [20.x, 22.x] steps: @@ -44,12 +45,32 @@ jobs: - name: Run type check run: npm run typecheck + - name: Generate and verify SBOM + run: | + npm run sbom:generate + npm run sbom:verify + - name: Run tests with coverage run: npm run coverage - name: Build run: npm run build + - name: Assert keychain mode storage contract + run: npm run ops:keychain-assert + + - name: Seed enterprise health fixture + run: | + node scripts/seed-health-fixture.js + + - name: Enterprise health check + env: + CODEX_MULTI_AUTH_DIR: ${{ github.workspace }}/.tmp/health-fixture + run: npm run ops:health-check -- --require-files + + - name: Performance budget check + run: npm run perf:budget-check + lint: name: Lint diff --git a/.github/workflows/recovery-drill.yml b/.github/workflows/recovery-drill.yml new file mode 100644 index 000000000..e74ec2905 --- /dev/null +++ b/.github/workflows/recovery-drill.yml @@ -0,0 +1,69 @@ +name: Recovery Drill + +on: + schedule: + - cron: "30 3 1 * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + recovery-drill: + name: Monthly Storage Recovery Drill + runs-on: ubuntu-latest + timeout-minutes: 30 + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build + run: npm run build + + - name: Run recovery drill tests + run: | + mkdir -p .tmp + npm run ops:recovery-drill -- --reporter=default --reporter=json --outputFile=.tmp/recovery-drill-vitest.json + + - name: Run health check snapshot + run: node scripts/enterprise-health-check.js > .tmp/recovery-drill-health.json 2>&1 + + - name: Upload recovery drill artifacts + if: always() + uses: actions/upload-artifact@v4 + with: + name: recovery-drill-artifacts + path: | + .tmp/recovery-drill-vitest.json + .tmp/recovery-drill-health.json + + - name: Notify recovery drill failure + if: failure() + env: + RECOVERY_DRILL_WEBHOOK_URL: ${{ secrets.RECOVERY_DRILL_WEBHOOK_URL }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + message="Recovery drill failed. Run: ${RUN_URL}. Artifacts: .tmp/recovery-drill-vitest.json and .tmp/recovery-drill-health.json." + if [[ -n "${RECOVERY_DRILL_WEBHOOK_URL:-}" ]]; then + payload=$(jq -n --arg msg "${message}" '{"text": $msg}') + curl --fail --silent --show-error \ + --max-time 30 \ + -X POST \ + -H "Content-Type: application/json" \ + --data "${payload}" \ + "${RECOVERY_DRILL_WEBHOOK_URL}" + else + echo "::warning::${message} Configure secrets.RECOVERY_DRILL_WEBHOOK_URL for push notifications." + fi diff --git a/.github/workflows/release-provenance.yml b/.github/workflows/release-provenance.yml new file mode 100644 index 000000000..abcbed9da --- /dev/null +++ b/.github/workflows/release-provenance.yml @@ -0,0 +1,59 @@ +name: Release Publish (Provenance) + +on: + workflow_dispatch: + release: + types: [published] + +permissions: + contents: read + id-token: write + +jobs: + publish: + name: Publish with npm provenance + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + registry-url: https://registry.npmjs.org + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Validate quality gates + env: + CODEX_MULTI_AUTH_DIR: ${{ github.workspace }}/.tmp/health-fixture + run: | + mkdir -p "${GITHUB_WORKSPACE}/.tmp/health-fixture/logs" + printf '{"version":3,"accounts":[],"activeIndex":0}\n' > "${GITHUB_WORKSPACE}/.tmp/health-fixture/openai-codex-accounts.json" + printf '{"version":1,"pluginConfig":{},"dashboardDisplaySettings":{}}\n' > "${GITHUB_WORKSPACE}/.tmp/health-fixture/settings.json" + printf '{"timestamp":"%s","action":"request.start","outcome":"success"}\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "${GITHUB_WORKSPACE}/.tmp/health-fixture/logs/audit.log" + npm run audit:ci + npm run ops:health-check -- --require-files + npm run perf:budget-check + npm run lint + npm run typecheck + npm run build + npm test + npm run ops:keychain-assert + npm run sbom:generate + npm run sbom:verify + node scripts/compliance-evidence-bundle.js --profile=quick --out-dir=.tmp/compliance-evidence-release + + - name: Upload release evidence bundle + uses: actions/upload-artifact@v4 + with: + name: release-evidence-bundle + path: .tmp/compliance-evidence-release + + - name: Publish package with provenance + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/retention-maintenance.yml b/.github/workflows/retention-maintenance.yml new file mode 100644 index 000000000..aefa0ad94 --- /dev/null +++ b/.github/workflows/retention-maintenance.yml @@ -0,0 +1,48 @@ +name: Retention Maintenance + +on: + schedule: + - cron: "15 2 * * 0" + workflow_dispatch: + +permissions: + contents: read + +jobs: + retention: + name: Weekly Retention Cleanup Drill + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set retention root + run: echo "CODEX_MULTI_AUTH_DIR=${{ runner.temp }}/codex-retention-root" >> "$GITHUB_ENV" + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Prepare retention fixture + run: | + node -e "const fs=require('fs'); const path=require('path'); const root=process.env.CODEX_MULTI_AUTH_DIR; const logs=path.join(root,'logs','codex-plugin'); const cache=path.join(root,'cache'); const recovery=path.join(root,'recovery'); fs.mkdirSync(logs,{recursive:true}); fs.mkdirSync(cache,{recursive:true}); fs.mkdirSync(recovery,{recursive:true}); const oldFile=path.join(logs,'old-audit.log'); const newFile=path.join(cache,'fresh-cache.json'); fs.writeFileSync(oldFile,'old'); fs.writeFileSync(newFile,'new'); const oldTime=new Date(Date.now()-120*24*60*60*1000); fs.utimesSync(oldFile,oldTime,oldTime);" + + - name: Run retention cleanup + run: | + mkdir -p .tmp + node scripts/retention-cleanup.js --days=90 > .tmp/retention-report.json + + - name: Verify retention fixture cleanup + run: | + node -e "const fs=require('fs'); const path=require('path'); const root=process.env.CODEX_MULTI_AUTH_DIR; const oldFile=path.join(root,'logs','codex-plugin','old-audit.log'); const newFile=path.join(root,'cache','fresh-cache.json'); if(fs.existsSync(oldFile)){console.error('expected old file to be deleted'); process.exit(1);} if(!fs.existsSync(newFile)){console.error('expected fresh file to remain'); process.exit(1);} console.log('retention verification passed');" + + - name: Upload retention report + uses: actions/upload-artifact@v4 + with: + name: retention-maintenance-report + path: .tmp/retention-report.json diff --git a/.github/workflows/sbom-attestation.yml b/.github/workflows/sbom-attestation.yml new file mode 100644 index 000000000..e0db3e129 --- /dev/null +++ b/.github/workflows/sbom-attestation.yml @@ -0,0 +1,48 @@ +name: SBOM and Dependency Attestation + +on: + pull_request: + branches: [main] + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + id-token: write + attestations: write + +jobs: + sbom: + name: Generate SBOM + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Generate SBOM + run: npm run sbom:generate + + - name: Verify SBOM + run: npm run sbom:verify + + - name: Upload SBOM artifact + uses: actions/upload-artifact@v4 + with: + name: sbom-cyclonedx + path: .tmp/sbom.cdx.json + + - name: Attest SBOM provenance + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/attest-build-provenance@v2 + with: + subject-path: .tmp/sbom.cdx.json diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 000000000..208413a5f --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,35 @@ +name: Secret Scan + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + pull-requests: write + +jobs: + gitleaks: + name: Gitleaks + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + fetch-depth: 0 + + - name: Run gitleaks + uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITLEAKS_VERSION: "8.25.0" + GITLEAKS_CONFIG: .gitleaks.toml + + - name: Verify secret-scan policy regression + run: bash test/security/secret-scan-regression.test.sh + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EXPECTED_GITLEAKS_VERSION: v8.25.0 + GITLEAKS_CONFIG: .gitleaks.toml diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..be20b3bb8 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,19 @@ +title = "codex-multi-auth gitleaks config" + +[extend] +useDefault = true + +[[allowlists]] +description = "Allowlisted fixture/docs synthetic credentials only" +condition = "AND" +paths = [ + '''^test[\\/]security[\\/]fixtures[\\/]''', + '''^docs[\\/]releases[\\/]''', + '''^docs[\\/]development[\\/]DEEP_AUDIT_2026-03-01\.md$''' +] +regexes = [ + '''fake_refresh_token_[0-9]+''', + '''secret-(access|refresh)-token''', + '''top secret prompt''', + '''sk-test-[A-Za-z0-9]{16,}''' +] diff --git a/README.md b/README.md index e254c6a65..01970d079 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,7 @@ Selected runtime/environment overrides: | `CODEX_TUI_V2=0/1` | Disable/enable TUI v2 | | `CODEX_TUI_COLOR_PROFILE=truecolor|ansi256|ansi16` | TUI color profile | | `CODEX_TUI_GLYPHS=ascii|unicode|auto` | TUI glyph style | +| `CODEX_SECRET_STORAGE_MODE` | Token-at-rest backend selection: `keychain`, `plaintext`, or `auto` (`keychain` default; set explicit `keychain` in enterprise deployments) | | `CODEX_AUTH_FETCH_TIMEOUT_MS=` | Request timeout override | | `CODEX_AUTH_STREAM_STALL_TIMEOUT_MS=` | Stream stall timeout override | diff --git a/SECURITY.md b/SECURITY.md index 7d7068856..4fff02e27 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -84,6 +84,10 @@ Before release and after dependency changes: ```bash npm run audit:ci +npm run ops:health-check +npm run perf:budget-check +npm run sbom:generate +npm run sbom:verify npm run lint npm run typecheck npm test diff --git a/config/performance-budgets.json b/config/performance-budgets.json new file mode 100644 index 000000000..c6a81a6ce --- /dev/null +++ b/config/performance-budgets.json @@ -0,0 +1,9 @@ +{ + "filterInput_small": 2.0, + "filterInput_large": 10.0, + "cleanupToolDefinitions_medium": 10.0, + "cleanupToolDefinitions_large": 20.0, + "accountHybridSelection_200": 30.0, + "resolveRequestAccountId_1000": 3.0, + "normalizeAccountStorage_240": 20.0 +} diff --git a/config/performance-budgets.schema.json b/config/performance-budgets.schema.json new file mode 100644 index 000000000..c2e13b822 --- /dev/null +++ b/config/performance-budgets.schema.json @@ -0,0 +1,53 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Runtime Performance Budgets", + "description": "Performance budget thresholds for runtime path benchmarks. All values are milliseconds.", + "type": "object", + "additionalProperties": false, + "required": [ + "filterInput_small", + "filterInput_large", + "cleanupToolDefinitions_medium", + "cleanupToolDefinitions_large", + "accountHybridSelection_200", + "resolveRequestAccountId_1000", + "normalizeAccountStorage_240" + ], + "properties": { + "filterInput_small": { + "type": "number", + "minimum": 0, + "description": "Maximum average runtime in milliseconds for small filterInput benchmark." + }, + "filterInput_large": { + "type": "number", + "minimum": 0, + "description": "Maximum average runtime in milliseconds for large filterInput benchmark." + }, + "cleanupToolDefinitions_medium": { + "type": "number", + "minimum": 0, + "description": "Maximum average runtime in milliseconds for medium cleanupToolDefinitions benchmark." + }, + "cleanupToolDefinitions_large": { + "type": "number", + "minimum": 0, + "description": "Maximum average runtime in milliseconds for large cleanupToolDefinitions benchmark." + }, + "accountHybridSelection_200": { + "type": "number", + "minimum": 0, + "description": "Maximum average runtime in milliseconds for accountHybridSelection benchmark with 200 accounts." + }, + "resolveRequestAccountId_1000": { + "type": "number", + "minimum": 0, + "description": "Maximum average runtime in milliseconds for resolveRequestAccountId benchmark with 1000 accounts." + }, + "normalizeAccountStorage_240": { + "type": "number", + "minimum": 0, + "description": "Maximum average runtime in milliseconds for normalizeAccountStorage benchmark with 240 accounts." + } + } +} diff --git a/config/slo-policy.json b/config/slo-policy.json new file mode 100644 index 000000000..adaba5367 --- /dev/null +++ b/config/slo-policy.json @@ -0,0 +1,8 @@ +{ + "windowDays": 30, + "objectives": { + "requestSuccessRatePercent": 99.5, + "healthCheckPassRequired": true, + "staleWalFindingsMax": 0 + } +} diff --git a/docs/README.md b/docs/README.md index 2accdd99f..ddc4f0f65 100644 --- a/docs/README.md +++ b/docs/README.md @@ -59,6 +59,11 @@ Canonical documentation map for `codex-multi-auth`. | [development/REPOSITORY_SCOPE.md](development/REPOSITORY_SCOPE.md) | Ownership map by repository path | | [development/TESTING.md](development/TESTING.md) | Validation gates and test matrix | | [development/TUI_PARITY_CHECKLIST.md](development/TUI_PARITY_CHECKLIST.md) | Dashboard UX parity checklist | +| [operations/incident-response.md](operations/incident-response.md) | Incident triage, containment, and recovery | +| [operations/incident-drill-template.md](operations/incident-drill-template.md) | Monthly tabletop incident drill worksheet | +| [operations/release-runbook.md](operations/release-runbook.md) | Release governance, provenance, and rollback | +| [operations/slo-error-budget.md](operations/slo-error-budget.md) | Reliability objectives and budget policy | +| [operations/audit-forwarding.md](operations/audit-forwarding.md) | SIEM forwarding controls for audit events | | [benchmarks/code-edit-format-benchmark.md](benchmarks/code-edit-format-benchmark.md) | Benchmark methodology and outputs | --- diff --git a/docs/configuration.md b/docs/configuration.md index 172296c74..4d2cf8bdd 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -66,6 +66,7 @@ These are safe for most operators and frequently used in day-to-day workflows. | `CODEX_TUI_V2=0/1` | Disable or enable TUI v2 | | `CODEX_TUI_COLOR_PROFILE=truecolor|ansi256|ansi16` | Color profile selection | | `CODEX_TUI_GLYPHS=ascii|unicode|auto` | Glyph mode selection | +| `CODEX_SECRET_STORAGE_MODE` | Secret-at-rest backend mode: `keychain`, `plaintext`, or `auto` (`keychain` default; enterprise profile should pin `keychain`) | | `CODEX_AUTH_FETCH_TIMEOUT_MS=` | HTTP request timeout override | | `CODEX_AUTH_STREAM_STALL_TIMEOUT_MS=` | Stream stall timeout override | diff --git a/docs/operations/audit-forwarding.md b/docs/operations/audit-forwarding.md new file mode 100644 index 000000000..1a78e235b --- /dev/null +++ b/docs/operations/audit-forwarding.md @@ -0,0 +1,79 @@ +# Audit Forwarding + +Forward local audit logs to a central SIEM endpoint. + +--- + +## Purpose + +- Export append-only audit events from local log files. +- Maintain checkpointed delivery (`audit-forwarder-checkpoint.json`) to avoid duplicate sends. +- Support dry-run validation before production rollout. + +--- + +## Required Configuration + +- `CODEX_SIEM_ENDPOINT` (HTTPS ingestion endpoint) +- `CODEX_SIEM_API_KEY` (bearer token; required when the SIEM endpoint enforces authentication) +- `CODEX_MULTI_AUTH_DIR` (optional runtime root override) + +--- + +## Commands + +Dry run: + +```bash +npm run ops:audit-forwarder -- --dry-run +``` + +Send batch: + +```bash +npm run ops:audit-forwarder -- --batch-size=500 +``` + +Explicit endpoint: + +```bash +node scripts/audit-log-forwarder.js --endpoint=https://siem.example.com/ingest --batch-size=500 +``` + +--- + +## Delivery Contract + +Payload fields: + +- `source` +- `generatedAt` +- `count` +- `checksum` (SHA-256 over event payload) +- `entries` (JSON audit entries) + +Checkpoint fields: + +- `file` +- `line` +- `updatedAt` + +### Failure & Retry Behavior + +- Export delivery retries on HTTP `429` or `5xx`, plus timeout/network failures. +- Retry count and timeout are configurable: + - `CODEX_AUDIT_FORWARDER_MAX_ATTEMPTS` (default `3`) + - `CODEX_AUDIT_FORWARDER_TIMEOUT_MS` (default `15000`) +- Backoff is exponential with jitter (`250ms * 2^attempt + random(0..99ms)`). +- Non-retryable responses and terminal retry failures stop the run and return non-zero. +- Checkpoints are written only after a successful send batch. Failed sends keep the prior checkpoint (`file`, `line`, `updatedAt`) so operators can re-run safely. + +--- + +## Alerting Recommendations + +Configure SIEM alerts for: + +1. `request.failure` spikes above baseline. +2. auth failures crossing incident threshold. +3. stale WAL detection events from scheduled health checks. diff --git a/docs/operations/incident-drill-template.md b/docs/operations/incident-drill-template.md new file mode 100644 index 000000000..827f9c891 --- /dev/null +++ b/docs/operations/incident-drill-template.md @@ -0,0 +1,81 @@ +# Incident Drill Template + +Use this template for monthly incident-response tabletop drills. + +--- + +## Drill Metadata + +- Drill date (UTC): +- Facilitator: +- Participants: +- Scenario ID: +- Related runbook version: + +--- + +## Scenario Setup + +1. Trigger condition: +2. Initial symptoms: +3. Assumed blast radius: +4. Detection source: + +--- + +## Timeline (UTC) + +| Timestamp | Event | Owner | +| --- | --- | --- | +| | | | +| | | | +| | | | + +--- + +## Required Command Evidence + +```bash +npm run ops:health-check +codex auth report --live --json +codex auth doctor --json +``` + +```powershell +npm run ops:health-check +codex auth report --live --json +codex auth doctor --json +``` + +Attach: + +- command outputs +- branch and commit SHA +- incident severity classification + +--- + +## Decision Log + +| Decision | Reason | Approver | +| --- | --- | --- | +| | | | +| | | | + +--- + +## Exit Criteria Review + +- [ ] health check returned `status: "pass"` (verify JSON `status` field) +- [ ] no unresolved `SEV-1` conditions +- [ ] rollback decision documented (if applicable) +- [ ] prevention tasks created with owners and due dates + +--- + +## Follow-ups + +| Action | Owner | Due date | +| --- | --- | --- | +| | | | +| | | | diff --git a/docs/operations/incident-response.md b/docs/operations/incident-response.md new file mode 100644 index 000000000..0d731103f --- /dev/null +++ b/docs/operations/incident-response.md @@ -0,0 +1,86 @@ +# Incident Response Runbook + +Operational incident workflow for `codex-multi-auth` deployments in enterprise environments. + +--- + +## Severity Model + +| Severity | Definition | Initial response | +| --- | --- | --- | +| `SEV-1` | Auth/token failures causing broad outage or data exposure risk | Acknowledge within 15 minutes | +| `SEV-2` | Partial degradation (intermittent auth, persistent retries, stale WAL) | Acknowledge within 30 minutes | +| `SEV-3` | Non-critical defects with workaround available | Acknowledge within 1 business day | + +--- + +## Detection Commands + +```bash +npm run ops:health-check +codex auth report --live --json +codex auth doctor --json +``` + +Required evidence: + +- health-check JSON output +- `codex auth report --live --json` +- `codex auth doctor --json` +- current commit SHA and branch + +--- + +## First 30 Minutes + +1. Run `npm run ops:health-check` and capture output. +2. If status is `fail`, block release or rollback active release candidate. +3. If stale WAL is reported, run `codex auth doctor --fix --dry-run` first, then `codex auth doctor --fix`. +4. If auth failures persist, rotate account via `codex auth switch ` and re-run `codex auth check`. +5. If all accounts are exhausted/disabled, escalate immediately to `SEV-1`, stop automated retries, and switch to fallback credentials via incident commander approval. +6. Record timeline with absolute UTC timestamps. + +Windows operator note: + +- Default path is `%USERPROFILE%\\.codex\\multi-auth`; if `CODEX_HOME` is set, use `%CODEX_HOME%\\multi-auth`. +- When deleting WAL artifacts manually, close shells/editors first to avoid `EPERM`/`EBUSY` locks. + +--- + +## Containment and Recovery + +1. Disable debug body logging unless actively diagnosing: + - ensure `CODEX_PLUGIN_LOG_BODIES` is unset +2. Run containment commands serially (do not run concurrently): + - `npm run ops:retention-cleanup` +3. Re-run verification pack: + - `npm run ops:health-check` + - `npm run audit:ci` + - `npm run test -- test/storage.test.ts test/fetch-helpers.test.ts` + +Recovery exit criteria: + +- `ops:health-check` status is `pass` +- no unresolved `SEV-1` findings +- CI checks green on remediation branch + +--- + +## Post-Incident + +1. Publish root-cause analysis with: + - trigger + - blast radius + - remediation commit SHA + - prevention tasks with owners and due dates +2. Add/adjust regression tests in `test/` for the failure mode. +3. Update this runbook if manual steps were required. + +--- + +## Drill Cadence + +- Run a tabletop drill monthly. +- Run `npm run ops:recovery-drill` as the drill execution command and archive outputs. +- Use [incident-drill-template.md](incident-drill-template.md) for drill evidence. +- Track unresolved drill actions as release blockers when severity is `SEV-1` equivalent. diff --git a/docs/operations/release-runbook.md b/docs/operations/release-runbook.md new file mode 100644 index 000000000..7562620c1 --- /dev/null +++ b/docs/operations/release-runbook.md @@ -0,0 +1,82 @@ +# Release and Rollback Runbook + +Release governance for `codex-multi-auth` with provenance and rollback controls. + +--- + +## Preconditions + +1. Branch is up to date with `main`. +2. Required checks pass: + - `npm run lint` + - `npm run typecheck` + - `npm test` + - `npm run build` + - `npm run audit:ci` + - `npm run perf:budget-check` +3. `secret-scan` workflow is green. + +--- + +## Release Procedure + +1. Create release tag from validated commit. +2. Publish GitHub release. +3. Trigger workflow: + - `.github/workflows/release-provenance.yml` + - `.github/workflows/sbom-attestation.yml` +4. Validate published package integrity: + - `npm view codex-multi-auth version` + - verify provenance is attached to the publish event. +5. Capture compliance evidence bundle: + - `node scripts/compliance-evidence-bundle.js --profile=release --out-dir=.tmp/compliance-evidence-release` + +Required release record: + +- release tag +- commit SHA +- workflow run URL +- test evidence timestamp +- SBOM artifact reference +- compliance evidence bundle path + +--- + +## Rollback Procedure + +Use rollback when `SEV-1` or unmitigated `SEV-2` occurs after release. + +1. Stop further publishing. +2. Re-point consumers to previous known-good tag. +3. Open hotfix branch from previous stable SHA. +4. Re-run mandatory checks and republish fixed patch. + +Rollback verification: + +```bash +npm run ops:health-check +npm run audit:ci +npm run test -- test/storage.test.ts test/codex-manager-cli.test.ts +``` + +Rollback is complete only when: + +- verification commands pass +- issue reproduction no longer occurs +- release notes include rollback details + +--- + +## Retention and Cleanup + +Run scheduled cleanup at least weekly: + +```bash +npm run ops:retention-cleanup +``` + +Default retention is 90 days. Override for emergency cleanup: + +```bash +npm run ops:retention-cleanup -- --days=30 +``` diff --git a/docs/operations/slo-error-budget.md b/docs/operations/slo-error-budget.md new file mode 100644 index 000000000..411a3e252 --- /dev/null +++ b/docs/operations/slo-error-budget.md @@ -0,0 +1,58 @@ +# SLO and Error Budget Policy + +Reliability policy for enterprise operation of `codex-multi-auth`. + +--- + +## Measurement Window + +- Rolling window: 30 days +- Data source: + - audit logs (`request.success`, `request.failure`) + - `ops:health-check` findings +- Policy file: `config/slo-policy.json` + +--- + +## SLO Objectives + +| Objective | Target | +| --- | --- | +| Request success rate | `>= 99.5%` | +| Health-check status | `pass` | +| Stale WAL findings | `0` | + +--- + +## Error Budget + +- Request error budget: `0.5%` per 30-day window. +- Budget burn: + - `100 - requestSuccessRatePercent` +- Trigger thresholds: + - `>= 50%` burn: freeze non-critical feature work for reliability review. + - `>= 100%` burn: incident review required before next release. + +--- + +## Reporting + +Generate report: + +```bash +npm run ops:slo-report +``` + +Enforce gate (non-zero exit on violations): + +```bash +node scripts/slo-budget-report.js --enforce --output=.tmp/slo-report.json +``` + +--- + +## Governance + +1. Review SLO report weekly. +2. Review error budget during release readiness. +3. If budget is exhausted, require remediation plan and owner sign-off. diff --git a/docs/privacy.md b/docs/privacy.md index 4fa153420..2100ca9c4 100644 --- a/docs/privacy.md +++ b/docs/privacy.md @@ -48,6 +48,21 @@ Current external destinations: Raw body logs may contain sensitive payload text. Treat logs as sensitive data and rotate/delete as needed. +Retention control: + +```bash +npm run ops:retention-cleanup +npm run ops:retention-cleanup -- --days=30 +``` + +Default retention window is 90 days. + +Audit forwarding (for central SIEM ingestion): + +```bash +npm run ops:audit-forwarder -- --dry-run +``` + --- ## Data Cleanup diff --git a/docs/reference/settings.md b/docs/reference/settings.md index 1466374b9..ed5ef12db 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -126,9 +126,14 @@ Common operator overrides: - `CODEX_TUI_V2` - `CODEX_TUI_COLOR_PROFILE` - `CODEX_TUI_GLYPHS` +- `CODEX_SECRET_STORAGE_MODE` - `CODEX_AUTH_FETCH_TIMEOUT_MS` - `CODEX_AUTH_STREAM_STALL_TIMEOUT_MS` +Enterprise recommendation: + +- pin `CODEX_SECRET_STORAGE_MODE=keychain` for production. + --- ## Advanced and Internal Overrides @@ -175,4 +180,4 @@ codex auth forecast --live - [commands.md](commands.md) - [storage-paths.md](storage-paths.md) -- [../configuration.md](../configuration.md) \ No newline at end of file +- [../configuration.md](../configuration.md) diff --git a/docs/reference/storage-paths.md b/docs/reference/storage-paths.md index bae76b844..5157163ff 100644 --- a/docs/reference/storage-paths.md +++ b/docs/reference/storage-paths.md @@ -31,6 +31,11 @@ Override root: | Codex CLI accounts | `~/.codex/accounts.json` | | Codex CLI auth | `~/.codex/auth.json` | +Security note: + +- Current secure format (`version: 4`) stores keychain references (`refreshTokenRef`, `accessTokenRef`) instead of raw token values in account storage files. +- Set `CODEX_SECRET_STORAGE_MODE=plaintext` only for controlled migration/testing environments. + Ownership note: - `~/.codex/multi-auth/*` is managed by this project. diff --git a/docs/upgrade.md b/docs/upgrade.md index e34ecb2d4..295084df8 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -16,36 +16,36 @@ Migrate legacy installs to the canonical `codex-multi-auth` workflow on the `0.x 1. Install official Codex CLI: -```bash -npm i -g @openai/codex -``` + ```bash + npm i -g @openai/codex + ``` -1. Remove legacy scoped package if present: +2. Remove legacy scoped package if present: -```bash -npm uninstall -g @ndycode/codex-multi-auth -``` + ```bash + npm uninstall -g @ndycode/codex-multi-auth + ``` -1. Install canonical package: +3. Install canonical package: -```bash -npm i -g codex-multi-auth -``` + ```bash + npm i -g codex-multi-auth + ``` -1. Verify routing and status: +4. Verify routing and status: -```bash -codex --version -codex auth status -``` + ```bash + codex --version + codex auth status + ``` -1. Rebuild account health baseline: +5. Rebuild account health baseline: -```bash -codex auth login -codex auth check -codex auth forecast --live --model gpt-5-codex -``` + ```bash + codex auth login + codex auth check + codex auth forecast --live --model gpt-5-codex + ``` --- @@ -62,6 +62,46 @@ After source selection, environment variables still override individual setting For day-to-day operator use, prefer stable overrides documented in [configuration.md](configuration.md). For maintainer/debug flows, see advanced/internal controls in [development/CONFIG_FIELDS.md](development/CONFIG_FIELDS.md). +### Secret Storage Mode Migration (plaintext -> keychain) + +Use this flow when migrating existing deployments that were running with plaintext token storage. + +1. Back up runtime state before changing secret storage mode: + + ```bash + cp -r ~/.codex/multi-auth ~/.codex/multi-auth.backup + ``` + +2. Validate keychain backend availability: + + ```bash + npm run ops:keychain-assert + ``` + + Run this from the project repository root where `package.json` defines enterprise ops scripts, or run your CI/job wrapper that exposes these scripts. + +3. Set `CODEX_SECRET_STORAGE_MODE=keychain` in your runtime environment (or use `auto` only after the keychain validation above passes). + +4. Trigger a controlled account rewrite so token refs are persisted in v4 format: + + ```bash + codex auth check + codex auth report --live + ``` + +5. Verify health and storage state: + + ```bash + npm run ops:health-check -- --require-files + ``` + + Run this from the same repository checkout (or your standard CI/job wrapper). + +Windows migration note: + +- Close editors/shells that may hold handles on `%CODEX_HOME%\\multi-auth` before migration writes. +- If you hit transient `EBUSY`/`EPERM` during migration, retry after closing locking processes; storage/settings writes use exponential backoff, but persistent locks still require operator action. + --- ## Legacy Compatibility diff --git a/lib/audit.ts b/lib/audit.ts index 937640228..82c9d8efe 100644 --- a/lib/audit.ts +++ b/lib/audit.ts @@ -1,4 +1,13 @@ -import { writeFileSync, mkdirSync, existsSync, statSync, renameSync, readdirSync, unlinkSync } from "node:fs"; +import { + chmodSync, + writeFileSync, + mkdirSync, + existsSync, + statSync, + renameSync, + readdirSync, + unlinkSync, +} from "node:fs"; import { join } from "node:path"; import { getCorrelationId, maskEmail } from "./logger.js"; import { getCodexLogDir } from "./runtime-paths.js"; @@ -21,6 +30,7 @@ export enum AuditAction { REQUEST_FAILURE = "request.failure", CIRCUIT_OPEN = "circuit.open", CIRCUIT_CLOSE = "circuit.close", + COMMAND_RUN = "command.run", } export enum AuditOutcome { @@ -44,19 +54,26 @@ export interface AuditConfig { logDir: string; maxFileSizeBytes: number; maxFiles: number; + retentionDays: number; } +const DEFAULT_AUDIT_RETENTION_DAYS = 90; +const RETRYABLE_AUDIT_FS_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]); +const PURGE_INTERVAL_MS = 60 * 60 * 1000; const DEFAULT_CONFIG: AuditConfig = { enabled: true, logDir: getCodexLogDir(), maxFileSizeBytes: 10 * 1024 * 1024, maxFiles: 5, + retentionDays: DEFAULT_AUDIT_RETENTION_DAYS, }; let auditConfig: AuditConfig = { ...DEFAULT_CONFIG }; +let lastPurgeAttemptMs = 0; export function configureAudit(config: Partial): void { auditConfig = { ...auditConfig, ...config }; + lastPurgeAttemptMs = 0; } export function getAuditConfig(): AuditConfig { @@ -93,6 +110,57 @@ function rotateLogsIfNeeded(): void { } } +function isRetryableAuditFsError(error: unknown): boolean { + const maybeCode = (error as NodeJS.ErrnoException).code; + return typeof maybeCode === "string" && RETRYABLE_AUDIT_FS_CODES.has(maybeCode); +} + +function withRetryableAuditFsOperation(operation: () => T): T { + let lastError: unknown; + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + return operation(); + } catch (error) { + lastError = error; + if (!isRetryableAuditFsError(error) || attempt === 4) { + throw error; + } + } + } + throw lastError; +} + +function purgeExpiredLogs(): void { + const nowMs = Date.now(); + if (nowMs - lastPurgeAttemptMs < PURGE_INTERVAL_MS) { + return; + } + const retentionDays = + Number.isFinite(auditConfig.retentionDays) && auditConfig.retentionDays >= 1 + ? Math.floor(auditConfig.retentionDays) + : DEFAULT_AUDIT_RETENTION_DAYS; + const cutoffMs = nowMs - retentionDays * 24 * 60 * 60 * 1000; + let files: string[] = []; + try { + files = withRetryableAuditFsOperation(() => readdirSync(auditConfig.logDir)); + } catch { + return; + } + lastPurgeAttemptMs = nowMs; + for (const file of files) { + if (!file.startsWith("audit") || !file.endsWith(".log")) continue; + const target = join(auditConfig.logDir, file); + try { + const stats = withRetryableAuditFsOperation(() => statSync(target)); + if (stats.mtimeMs < cutoffMs) { + withRetryableAuditFsOperation(() => unlinkSync(target)); + } + } catch { + // Best-effort purge. + } + } +} + function sanitizeActor(actor: string): string { if (actor.includes("@")) { return maskEmail(actor); @@ -131,6 +199,7 @@ export function auditLog( try { ensureLogDir(); rotateLogsIfNeeded(); + purgeExpiredLogs(); const entry: AuditEntry = { timestamp: new Date().toISOString(), @@ -144,8 +213,17 @@ export function auditLog( const logPath = getLogFilePath(); const line = JSON.stringify(entry) + "\n"; - - writeFileSync(logPath, line, { flag: "a" }); + + withRetryableAuditFsOperation(() => + writeFileSync(logPath, line, { encoding: "utf8", flag: "a", mode: 0o600 }), + ); + if (process.platform !== "win32") { + try { + withRetryableAuditFsOperation(() => chmodSync(logPath, 0o600)); + } catch { + // Best-effort hardening. + } + } } catch { // Audit logging should never break the application } diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 794eb7c65..31f0c16ff 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -49,6 +49,7 @@ import { type QuotaCacheData, type QuotaCacheEntry, } from "./quota-cache.js"; +import { maskEmail } from "./logger.js"; import { getStoragePath, loadFlaggedAccounts, @@ -67,6 +68,7 @@ import { loadCodexCliState, } from "./codex-cli/state.js"; import { setCodexCliActiveSelection } from "./codex-cli/writer.js"; +import { auditLog, AuditAction, AuditOutcome } from "./audit.js"; import { ANSI } from "./ui/ansi.js"; import { UI_COPY } from "./ui/copy.js"; import { paintUiText, quotaToneFromLeftPercent } from "./ui/format.js"; @@ -4039,6 +4041,63 @@ export async function autoSyncActiveAccountToCodex(): Promise { }); } +function auditActionForCommand(command: string): AuditAction { + switch (command) { + case "login": + return AuditAction.AUTH_LOGIN; + case "switch": + return AuditAction.ACCOUNT_SWITCH; + case "check": + return AuditAction.REQUEST_START; + case "verify-flagged": + return AuditAction.ACCOUNT_REFRESH; + case "forecast": + case "report": + case "fix": + case "doctor": + case "list": + case "status": + return AuditAction.COMMAND_RUN; + default: + return AuditAction.COMMAND_RUN; + } +} + +function sanitizeAuditError(error: unknown): string { + const raw = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + const masked = raw + .replace(/\bsk-[A-Za-z0-9_-]{12,}\b/g, "***REDACTED***") + .replace(/\b(?:refresh|access)_token_[A-Za-z0-9_-]{8,}\b/gi, "***REDACTED***") + .replace(/\bsecret-(?:access|refresh)-token\b/gi, "***REDACTED***") + .replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, (match) => maskEmail(match)); + return masked.slice(0, 200); +} + +async function runWithAudit( + command: string, + runner: () => Promise, +): Promise { + const action = auditActionForCommand(command); + const resource = `codex auth ${command}`; + try { + const code = await runner(); + auditLog( + action, + "cli-user", + resource, + code === 0 ? AuditOutcome.SUCCESS : AuditOutcome.FAILURE, + { command, exitCode: code }, + ); + return code; + } catch (error) { + auditLog(action, "cli-user", resource, AuditOutcome.FAILURE, { + command, + error: sanitizeAuditError(error), + }); + throw error; + } +} + export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { const startupDisplaySettings = await loadDashboardDisplaySettings(); applyUiThemeFromDashboardSettings(startupDisplaySettings); @@ -4065,36 +4124,40 @@ export async function runCodexMultiAuthCli(rawArgs: string[]): Promise { return 0; } if (command === "login") { - return runAuthLogin(); + return runWithAudit(command, () => runAuthLogin()); } if (command === "list" || command === "status") { - await showAccountStatus(); - return 0; + return runWithAudit(command, async () => { + await showAccountStatus(); + return 0; + }); } if (command === "switch") { - return runSwitch(rest); + return runWithAudit(command, () => runSwitch(rest)); } if (command === "check") { - await runHealthCheck({ liveProbe: true }); - return 0; + return runWithAudit(command, async () => { + await runHealthCheck({ liveProbe: true }); + return 0; + }); } if (command === "features") { return runFeaturesReport(); } if (command === "verify-flagged") { - return runVerifyFlagged(rest); + return runWithAudit(command, () => runVerifyFlagged(rest)); } if (command === "forecast") { - return runForecast(rest); + return runWithAudit(command, () => runForecast(rest)); } if (command === "report") { - return runReport(rest); + return runWithAudit(command, () => runReport(rest)); } if (command === "fix") { - return runFix(rest); + return runWithAudit(command, () => runFix(rest)); } if (command === "doctor") { - return runDoctor(rest); + return runWithAudit(command, () => runDoctor(rest)); } console.error(`Unknown command: ${command}`); diff --git a/lib/config.ts b/lib/config.ts index f9e7ecf85..3410d87e4 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -34,6 +34,8 @@ const UNSUPPORTED_CODEX_POLICIES = new Set(["strict", "fallback"]); const emittedConfigWarnings = new Set(); const configSaveQueues = new Map>(); const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]); +const SECURE_DIR_MODE = 0o700; +const SECURE_FILE_MODE = 0o600; export type UnsupportedCodexPolicy = "strict" | "fallback"; @@ -282,8 +284,11 @@ async function writeJsonFileAtomicWithRetry( payload: Record, ): Promise { const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; - await fs.mkdir(dirname(filePath), { recursive: true }); - await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + await fs.mkdir(dirname(filePath), { recursive: true, mode: SECURE_DIR_MODE }); + await fs.writeFile(tempPath, `${JSON.stringify(payload, null, 2)}\n`, { + encoding: "utf8", + mode: SECURE_FILE_MODE, + }); let renamed = false; try { for (let attempt = 0; attempt < 5; attempt += 1) { diff --git a/lib/keytar.d.ts b/lib/keytar.d.ts new file mode 100644 index 000000000..da75e886d --- /dev/null +++ b/lib/keytar.d.ts @@ -0,0 +1,5 @@ +declare module "keytar" { + export function setPassword(service: string, account: string, password: string): Promise; + export function getPassword(service: string, account: string): Promise; + export function deletePassword(service: string, account: string): Promise; +} diff --git a/lib/schemas.ts b/lib/schemas.ts index 55028b6ed..e8b0824ae 100644 --- a/lib/schemas.ts +++ b/lib/schemas.ts @@ -137,6 +137,40 @@ export const AccountStorageV3Schema = z.object({ export type AccountStorageV3FromSchema = z.infer; +/** + * Account metadata V4 - keychain-backed secret reference format. + */ +export const AccountMetadataV4Schema = z.object({ + accountId: z.string().optional(), + accountIdSource: AccountIdSourceSchema.optional(), + accountLabel: z.string().optional(), + email: z.string().optional(), + refreshTokenRef: z.string().min(1), + accessTokenRef: z.string().min(1).optional(), + expiresAt: z.number().optional(), + enabled: z.boolean().optional(), + addedAt: z.number(), + lastUsed: z.number(), + lastSwitchReason: SwitchReasonSchema.optional(), + rateLimitResetTimes: RateLimitStateV3Schema.optional(), + coolingDownUntil: z.number().optional(), + cooldownReason: CooldownReasonSchema.optional(), +}); + +export type AccountMetadataV4FromSchema = z.infer; + +/** + * Account storage V4 - current secure storage format with keychain refs. + */ +export const AccountStorageV4Schema = z.object({ + version: z.literal(4), + accounts: z.array(AccountMetadataV4Schema), + activeIndex: z.number().min(0), + activeIndexByFamily: ActiveIndexByFamilySchema.optional(), +}); + +export type AccountStorageV4FromSchema = z.infer; + /** * Legacy V1 account metadata for migration support. */ @@ -171,11 +205,12 @@ export const AccountStorageV1Schema = z.object({ export type AccountStorageV1FromSchema = z.infer; /** - * Union of V1 and V3 storage formats for migration detection. + * Union of V1/V3/V4 storage formats for migration detection. */ export const AnyAccountStorageSchema = z.discriminatedUnion("version", [ AccountStorageV1Schema, AccountStorageV3Schema, + AccountStorageV4Schema, ]); export type AnyAccountStorageFromSchema = z.infer; diff --git a/lib/secrets/token-store.ts b/lib/secrets/token-store.ts new file mode 100644 index 000000000..9cf77761e --- /dev/null +++ b/lib/secrets/token-store.ts @@ -0,0 +1,219 @@ +import { createHash } from "node:crypto"; +import { createLogger } from "../logger.js"; +import { sleep } from "../utils.js"; + +type SecretStorageMode = "keychain" | "plaintext" | "auto"; +type EffectiveSecretStorageMode = "keychain" | "plaintext"; + +type KeytarModule = { + setPassword(service: string, account: string, password: string): Promise; + getPassword(service: string, account: string): Promise; + deletePassword(service: string, account: string): Promise; +}; + +interface DeleteAccountSecretsOptions { + force?: boolean; +} + +export interface AccountSecretRefs { + refreshTokenRef: string; + accessTokenRef?: string; +} + +export interface AccountSecrets { + refreshToken: string; + accessToken?: string; +} + +export interface AccountSecretRefInput { + accountId?: string; + email?: string; + addedAt?: number; + refreshToken: string; +} + +const log = createLogger("token-store"); +const SECRET_SERVICE = "codex-multi-auth"; +const SECRET_DELETE_RETRY_CODES = new Set(["EBUSY", "EPERM", "EAGAIN"]); +const SECRET_DELETE_RETRY_ATTEMPTS = 4; +let keytarLoader: Promise | null = null; + +function parseSecretStorageMode(value: string | undefined): SecretStorageMode { + const normalized = (value ?? "").trim().toLowerCase(); + if (normalized === "plaintext") return "plaintext"; + if (normalized === "auto") return "auto"; + return "keychain"; +} + +async function loadKeytar(): Promise { + if (!keytarLoader) { + keytarLoader = (async () => { + try { + const imported = (await import("keytar")) as unknown as { default?: unknown }; + const mod = (imported.default ?? imported) as KeytarModule; + if ( + typeof mod.setPassword !== "function" || + typeof mod.getPassword !== "function" || + typeof mod.deletePassword !== "function" + ) { + return null; + } + return mod; + } catch { + return null; + } + })(); + } + return keytarLoader; +} + +function isRetryableDeleteError(error: unknown): boolean { + const maybe = error as { code?: string; status?: number; message?: string }; + if (typeof maybe.code === "string" && SECRET_DELETE_RETRY_CODES.has(maybe.code)) { + return true; + } + if (typeof maybe.status === "number" && maybe.status === 429) { + return true; + } + const message = typeof maybe.message === "string" ? maybe.message.toLowerCase() : ""; + return message.includes("429") || message.includes("rate limit"); +} + +async function deleteSecretRefWithRetry(keytar: KeytarModule, ref: string): Promise { + for (let attempt = 0; attempt < SECRET_DELETE_RETRY_ATTEMPTS; attempt += 1) { + try { + await keytar.deletePassword(SECRET_SERVICE, ref); + return; + } catch (error) { + if (!isRetryableDeleteError(error) || attempt === SECRET_DELETE_RETRY_ATTEMPTS - 1) { + throw error; + } + await sleep(25 * 2 ** attempt); + } + } +} + +export async function getEffectiveSecretStorageMode(): Promise { + const configured = parseSecretStorageMode(process.env.CODEX_SECRET_STORAGE_MODE); + if (configured === "plaintext") return "plaintext"; + if (configured === "keychain") return "keychain"; + const keytar = await loadKeytar(); + return keytar ? "keychain" : "plaintext"; +} + +async function getKeytarOrThrow(): Promise { + const keytar = await loadKeytar(); + if (keytar) return keytar; + throw new Error( + "Keychain secret storage is required but keytar is unavailable. Install optional dependency 'keytar' or set CODEX_SECRET_STORAGE_MODE=plaintext.", + ); +} + +export async function ensureSecretStorageBackendAvailable(): Promise { + const mode = await getEffectiveSecretStorageMode(); + if (mode === "plaintext") return; + await getKeytarOrThrow(); +} + +export function deriveAccountSecretRef(input: AccountSecretRefInput): string { + const normalizedEmail = typeof input.email === "string" ? input.email.trim().toLowerCase() : ""; + const normalizedAccountId = typeof input.accountId === "string" ? input.accountId.trim() : ""; + const stableSeed = `${normalizedAccountId}|${normalizedEmail}|${input.addedAt ?? 0}`; + const hasStableIdentity = normalizedAccountId.length > 0 || normalizedEmail.length > 0; + const fallbackSeed = createHash("sha256") + .update(input.refreshToken) + .digest("hex") + .slice(0, 16); + const seed = hasStableIdentity ? stableSeed : fallbackSeed; + return createHash("sha256").update(seed).digest("hex").slice(0, 24); +} + +export async function persistAccountSecrets( + baseRef: string, + secrets: AccountSecrets, +): Promise { + const mode = await getEffectiveSecretStorageMode(); + if (mode === "plaintext") return null; + + const keytar = await getKeytarOrThrow(); + const refreshTokenRef = `${baseRef}:refresh`; + await keytar.setPassword(SECRET_SERVICE, refreshTokenRef, secrets.refreshToken); + + let accessTokenRef: string | undefined; + if (typeof secrets.accessToken === "string" && secrets.accessToken.trim().length > 0) { + accessTokenRef = `${baseRef}:access`; + try { + await keytar.setPassword(SECRET_SERVICE, accessTokenRef, secrets.accessToken); + } catch (error) { + try { + await deleteSecretRefWithRetry(keytar, refreshTokenRef); + } catch (cleanupError) { + log.warn("Failed to rollback refresh secret after access secret write failure", { + refreshTokenRef, + error: String(cleanupError), + }); + } + throw error; + } + } + + return { + refreshTokenRef, + accessTokenRef, + }; +} + +export async function loadAccountSecrets( + refs: AccountSecretRefs, +): Promise { + const mode = await getEffectiveSecretStorageMode(); + if (mode === "plaintext") return null; + + const keytar = await getKeytarOrThrow(); + const refreshToken = await keytar.getPassword(SECRET_SERVICE, refs.refreshTokenRef); + if (!refreshToken) { + log.warn("Missing refresh token in keychain", { ref: refs.refreshTokenRef }); + return null; + } + let accessToken: string | undefined; + if (refs.accessTokenRef) { + accessToken = (await keytar.getPassword(SECRET_SERVICE, refs.accessTokenRef)) ?? undefined; + } + return { refreshToken, accessToken }; +} + +export async function deleteAccountSecrets( + refs: AccountSecretRefs, + options: DeleteAccountSecretsOptions = {}, +): Promise { + let keytar: KeytarModule | null = null; + if (options.force) { + keytar = await loadKeytar(); + } else { + const mode = await getEffectiveSecretStorageMode(); + if (mode === "plaintext") return; + keytar = await getKeytarOrThrow(); + } + if (!keytar) return; + + const deleteErrors: unknown[] = []; + try { + await deleteSecretRefWithRetry(keytar, refs.refreshTokenRef); + } catch (error) { + deleteErrors.push(error); + } + if (refs.accessTokenRef) { + try { + await deleteSecretRefWithRetry(keytar, refs.accessTokenRef); + } catch (error) { + deleteErrors.push(error); + } + } + if (deleteErrors.length > 0) { + throw deleteErrors[0]; + } +} + +export function resetSecretStoreCacheForTests(): void { + keytarLoader = null; +} diff --git a/lib/storage.ts b/lib/storage.ts index 3453a426a..1a7742a3e 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -4,7 +4,11 @@ import { createHash } from "node:crypto"; import { ACCOUNT_LIMITS } from "./constants.js"; import { createLogger } from "./logger.js"; import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js"; -import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js"; +import { + AnyAccountStorageSchema, + AccountStorageV4Schema, + getValidationErrors, +} from "./schemas.js"; import { getConfigDir, getProjectConfigDir, @@ -15,15 +19,35 @@ import { } from "./storage/paths.js"; import { migrateV1ToV3, + migrateV3ToV4, type CooldownReason, type RateLimitStateV3, type AccountMetadataV1, type AccountStorageV1, type AccountMetadataV3, type AccountStorageV3, + type AccountMetadataV4, + type AccountStorageV4, } from "./storage/migrations.js"; - -export type { CooldownReason, RateLimitStateV3, AccountMetadataV1, AccountStorageV1, AccountMetadataV3, AccountStorageV3 }; +import { + deleteAccountSecrets, + deriveAccountSecretRef, + ensureSecretStorageBackendAvailable, + getEffectiveSecretStorageMode, + loadAccountSecrets, + persistAccountSecrets, +} from "./secrets/token-store.js"; + +export type { + CooldownReason, + RateLimitStateV3, + AccountMetadataV1, + AccountStorageV1, + AccountMetadataV3, + AccountStorageV3, + AccountMetadataV4, + AccountStorageV4, +}; const log = createLogger("storage"); const ACCOUNTS_FILE_NAME = "openai-codex-accounts.json"; @@ -104,7 +128,7 @@ function withStorageLock(fn: () => Promise): Promise { return previousMutex.then(fn).finally(() => releaseLock()); } -type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; +type AnyAccountStorage = AccountStorageV1 | AccountStorageV3 | AccountStorageV4; type AccountLike = { accountId?: string; @@ -849,14 +873,87 @@ export async function loadAccounts(): Promise { return loadAccountsInternal(saveAccounts); } -function parseAndNormalizeStorage(data: unknown): { +async function hydrateV4Storage(data: AccountStorageV4): Promise { + const mode = await getEffectiveSecretStorageMode(); + if (mode === "plaintext") { + log.warn("Cannot load v4 keychain-backed account storage in plaintext mode"); + return null; + } + + const hydratedAccounts: AccountMetadataV3[] = []; + let skippedBeforeActiveIndex = 0; + const skippedIndices: number[] = []; + for (let index = 0; index < data.accounts.length; index += 1) { + const rawAccount = data.accounts[index]; + if (!rawAccount) continue; + const secrets = await loadAccountSecrets({ + refreshTokenRef: rawAccount.refreshTokenRef, + accessTokenRef: rawAccount.accessTokenRef, + }); + if (!secrets || !secrets.refreshToken) { + if (index < data.activeIndex) { + skippedBeforeActiveIndex += 1; + } + skippedIndices.push(index); + log.warn("Skipping v4 account with missing keychain secret", { + accountId: rawAccount.accountId, + }); + continue; + } + const { + refreshTokenRef, + accessTokenRef, + ...rest + } = rawAccount; + void refreshTokenRef; + void accessTokenRef; + hydratedAccounts.push({ + ...rest, + refreshToken: secrets.refreshToken, + accessToken: secrets.accessToken, + }); + } + const adjustedActiveIndex = Math.max(0, data.activeIndex - skippedBeforeActiveIndex); + const remapFamilyIndex = (rawIndex: number): number => { + const droppedBefore = skippedIndices.filter((droppedIndex) => droppedIndex < rawIndex).length; + const remapped = Math.max(0, rawIndex - droppedBefore); + if (hydratedAccounts.length <= 0) return 0; + return Math.min(remapped, hydratedAccounts.length - 1); + }; + const adjustedActiveIndexByFamily: Partial> = {}; + if (data.activeIndexByFamily) { + for (const family of MODEL_FAMILIES) { + const raw = data.activeIndexByFamily[family]; + if (typeof raw === "number" && Number.isFinite(raw)) { + adjustedActiveIndexByFamily[family] = remapFamilyIndex(raw); + } + } + } + + return normalizeAccountStorage({ + version: 3, + accounts: hydratedAccounts, + activeIndex: adjustedActiveIndex, + activeIndexByFamily: adjustedActiveIndexByFamily, + }); +} + +async function parseAndNormalizeStorage(data: unknown): Promise<{ normalized: AccountStorageV3 | null; storedVersion: unknown; schemaErrors: string[]; -} { +}> { const schemaErrors = getValidationErrors(AnyAccountStorageSchema, data); - const normalized = normalizeAccountStorage(data); const storedVersion = isRecord(data) ? (data as { version?: unknown }).version : undefined; + if (storedVersion === 4 && isRecord(data)) { + const parsedV4 = AccountStorageV4Schema.safeParse(data); + if (!parsedV4.success) { + return { normalized: null, storedVersion, schemaErrors }; + } + const normalized = await hydrateV4Storage(parsedV4.data); + return { normalized, storedVersion, schemaErrors }; + } + const normalized = normalizeAccountStorage(data); return { normalized, storedVersion, schemaErrors }; } @@ -867,7 +964,7 @@ async function loadAccountsFromPath(path: string): Promise<{ }> { const content = await fs.readFile(path, "utf-8"); const data = JSON.parse(content) as unknown; - return parseAndNormalizeStorage(data); + return await parseAndNormalizeStorage(data); } async function loadAccountsFromJournal(path: string): Promise { @@ -885,7 +982,7 @@ async function loadAccountsFromJournal(path: string): Promise 0) { log.warn("Account storage schema validation warnings", { errors: schemaErrors.slice(0, 5) }); } - if (normalized && storedVersion !== normalized.version) { + if (normalized && storedVersion !== normalized.version && storedVersion !== 4) { log.info("Migrating account storage to v3", { from: storedVersion, to: normalized.version }); if (persistMigration) { try { @@ -1028,14 +1125,74 @@ async function loadAccountsInternal( } } +type PersistedSecretRef = { refreshTokenRef: string; accessTokenRef?: string }; + +type SerializedStoragePayload = { + content: string; + persistedSecretRefs: PersistedSecretRef[]; +}; + +async function serializeStorageForPersist(storage: AccountStorageV3): Promise { + const mode = await getEffectiveSecretStorageMode(); + if (mode === "plaintext") { + return { + content: JSON.stringify(storage, null, 2), + persistedSecretRefs: [], + }; + } + + await ensureSecretStorageBackendAvailable(); + const refsByIndex: Array = []; + const persistedSecretRefs: PersistedSecretRef[] = []; + try { + for (let index = 0; index < storage.accounts.length; index += 1) { + const account = storage.accounts[index]; + if (!account) continue; + const baseRef = `acct-${deriveAccountSecretRef({ + accountId: account.accountId, + email: account.email, + addedAt: account.addedAt, + refreshToken: account.refreshToken, + })}`; + const refs = await persistAccountSecrets(baseRef, { + refreshToken: account.refreshToken, + accessToken: account.accessToken, + }); + if (!refs) { + throw new Error("Keychain mode selected but no secret refs were returned"); + } + persistedSecretRefs.push(refs); + refsByIndex[index] = refs; + } + + const storageV4 = migrateV3ToV4(storage, (_account, index) => { + const refs = refsByIndex[index]; + if (!refs) { + throw new Error(`Missing keychain refs for account index ${index}`); + } + return refs; + }); + return { + content: JSON.stringify(storageV4, null, 2), + persistedSecretRefs, + }; + } catch (error) { + await Promise.allSettled( + persistedSecretRefs.map((refs) => deleteAccountSecrets(refs, { force: true })), + ); + throw error; + } +} + async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { const path = getStoragePath(); const uniqueSuffix = `${Date.now()}.${Math.random().toString(36).slice(2, 8)}`; const tempPath = `${path}.${uniqueSuffix}.tmp`; const walPath = getAccountsWalPath(path); + let persistedSecretRefs: PersistedSecretRef[] = []; try { - await fs.mkdir(dirname(path), { recursive: true }); + await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); await ensureGitignore(path); if (looksLikeSyntheticFixtureStorage(storage)) { @@ -1069,7 +1226,9 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { } } - const content = JSON.stringify(storage, null, 2); + const serialized = await serializeStorageForPersist(storage); + const content = serialized.content; + persistedSecretRefs = serialized.persistedSecretRefs; const journalEntry: AccountsJournalEntry = { version: 1, createdAt: Date.now(), @@ -1095,6 +1254,7 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { try { await fs.rename(tempPath, path); lastAccountsSaveTimestamp = Date.now(); + persistedSecretRefs = []; try { await fs.unlink(walPath); } catch { @@ -1118,6 +1278,11 @@ async function saveAccountsUnlocked(storage: AccountStorageV3): Promise { } catch { // Ignore cleanup failure. } + if (persistedSecretRefs.length > 0) { + await Promise.allSettled( + persistedSecretRefs.map((refs) => deleteAccountSecrets(refs, { force: true })), + ); + } const err = error as NodeJS.ErrnoException; const code = err?.code || "UNKNOWN"; @@ -1165,6 +1330,75 @@ export async function saveAccounts(storage: AccountStorageV3): Promise { }); } +function collectSecretRefsFromV4Payload(payload: unknown): PersistedSecretRef[] { + if (!isRecord(payload) || payload.version !== 4 || !Array.isArray(payload.accounts)) { + return []; + } + const refs: PersistedSecretRef[] = []; + for (const rawAccount of payload.accounts) { + if (!isRecord(rawAccount)) continue; + const refreshTokenRef = + typeof rawAccount.refreshTokenRef === "string" + ? rawAccount.refreshTokenRef.trim() + : ""; + if (!refreshTokenRef) continue; + const accessTokenRef = + typeof rawAccount.accessTokenRef === "string" + ? rawAccount.accessTokenRef.trim() + : undefined; + refs.push({ refreshTokenRef, accessTokenRef }); + } + return refs; +} + +function collectPersistedSecretRefs(payload: unknown): PersistedSecretRef[] { + const directRefs = collectSecretRefsFromV4Payload(payload); + if (directRefs.length > 0) { + return directRefs; + } + if ( + !isRecord(payload) || + payload.version !== 1 || + typeof payload.content !== "string" || + payload.content.trim().length === 0 + ) { + return []; + } + try { + const journalPayload = JSON.parse(payload.content) as unknown; + return collectSecretRefsFromV4Payload(journalPayload); + } catch { + return []; + } +} + +async function clearPersistedAccountSecrets(path: string): Promise { + try { + const raw = await fs.readFile(path, "utf-8"); + const parsed = JSON.parse(raw) as unknown; + const refs = collectPersistedSecretRefs(parsed); + for (const ref of refs) { + try { + await deleteAccountSecrets(ref, { force: true }); + } catch (error) { + log.warn("Failed to clear keychain secret reference", { + path, + refreshTokenRef: ref.refreshTokenRef, + error: String(error), + }); + } + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to clear persisted account secrets", { + path, + error: String(error), + }); + } + } +} + /** * Deletes the account storage file from disk. * Silently ignores if file doesn't exist. @@ -1189,6 +1423,11 @@ export async function clearAccounts(): Promise { }; try { + await clearPersistedAccountSecrets(path); + await clearPersistedAccountSecrets(walPath); + for (const backupPath of backupPaths) { + await clearPersistedAccountSecrets(backupPath); + } await Promise.all([clearPath(path), clearPath(walPath), ...backupPaths.map(clearPath)]); } catch { // Individual path cleanup is already best-effort with per-artifact logging. diff --git a/lib/storage/migrations.ts b/lib/storage/migrations.ts index 2339d1d1f..f3f8cada7 100644 --- a/lib/storage/migrations.ts +++ b/lib/storage/migrations.ts @@ -63,6 +63,30 @@ export interface AccountStorageV3 { activeIndexByFamily?: Partial>; } +export interface AccountMetadataV4 { + accountId?: string; + accountIdSource?: AccountIdSource; + accountLabel?: string; + email?: string; + refreshTokenRef: string; + accessTokenRef?: string; + expiresAt?: number; + enabled?: boolean; + addedAt: number; + lastUsed: number; + lastSwitchReason?: "rate-limit" | "initial" | "rotation"; + rateLimitResetTimes?: RateLimitStateV3; + coolingDownUntil?: number; + cooldownReason?: CooldownReason; +} + +export interface AccountStorageV4 { + version: 4; + accounts: AccountMetadataV4[]; + activeIndex: number; + activeIndexByFamily?: Partial>; +} + function nowMs(): number { return Date.now(); } @@ -101,3 +125,36 @@ export function migrateV1ToV3(v1: AccountStorageV1): AccountStorageV3 { ) as Partial>, }; } + +export function migrateV3ToV4( + v3: AccountStorageV3, + resolveRefs: (account: AccountMetadataV3, index: number) => { + refreshTokenRef: string; + accessTokenRef?: string; + }, +): AccountStorageV4 { + return { + version: 4, + activeIndex: v3.activeIndex, + activeIndexByFamily: v3.activeIndexByFamily, + accounts: v3.accounts.map((account, index) => { + const refs = resolveRefs(account, index); + return { + accountId: account.accountId, + accountIdSource: account.accountIdSource, + accountLabel: account.accountLabel, + email: account.email, + refreshTokenRef: refs.refreshTokenRef, + accessTokenRef: refs.accessTokenRef, + expiresAt: account.expiresAt, + enabled: account.enabled, + addedAt: account.addedAt, + lastUsed: account.lastUsed, + lastSwitchReason: account.lastSwitchReason, + rateLimitResetTimes: account.rateLimitResetTimes, + coolingDownUntil: account.coolingDownUntil, + cooldownReason: account.cooldownReason, + }; + }), + }; +} diff --git a/lib/unified-settings.ts b/lib/unified-settings.ts index ff63d8942..0684e59c2 100644 --- a/lib/unified-settings.ts +++ b/lib/unified-settings.ts @@ -1,4 +1,5 @@ import { + chmodSync, existsSync, mkdirSync, renameSync, @@ -17,6 +18,8 @@ export const UNIFIED_SETTINGS_VERSION = 1 as const; const UNIFIED_SETTINGS_PATH = join(getCodexMultiAuthDir(), "settings.json"); const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]); +const SECURE_DIR_MODE = 0o700; +const SECURE_FILE_MODE = 0o600; let settingsWriteQueue: Promise = Promise.resolve(); function isRetryableFsError(error: unknown): boolean { @@ -121,16 +124,31 @@ function normalizeForWrite(record: JsonRecord): JsonRecord { * @param record - The settings object to persist; it will be normalized to include the unified settings version. */ function writeSettingsRecordSync(record: JsonRecord): void { - mkdirSync(getCodexMultiAuthDir(), { recursive: true }); + const settingsDir = getCodexMultiAuthDir(); + mkdirSync(settingsDir, { recursive: true, mode: SECURE_DIR_MODE }); + if (process.platform !== "win32") { + try { + chmodSync(settingsDir, SECURE_DIR_MODE); + } catch { + // Best-effort hardening. + } + } const payload = normalizeForWrite(record); const data = `${JSON.stringify(payload, null, 2)}\n`; const tempPath = `${UNIFIED_SETTINGS_PATH}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, data, "utf8"); + writeFileSync(tempPath, data, { encoding: "utf8", mode: SECURE_FILE_MODE }); let moved = false; try { for (let attempt = 0; attempt < 5; attempt += 1) { try { renameSync(tempPath, UNIFIED_SETTINGS_PATH); + if (process.platform !== "win32") { + try { + chmodSync(UNIFIED_SETTINGS_PATH, SECURE_FILE_MODE); + } catch { + // Best-effort hardening. + } + } moved = true; return; } catch (error) { @@ -172,16 +190,31 @@ function writeSettingsRecordSync(record: JsonRecord): void { * @param record - The settings object to persist; it will be normalized (version set) */ async function writeSettingsRecordAsync(record: JsonRecord): Promise { - await fs.mkdir(getCodexMultiAuthDir(), { recursive: true }); + const settingsDir = getCodexMultiAuthDir(); + await fs.mkdir(settingsDir, { recursive: true, mode: SECURE_DIR_MODE }); + if (process.platform !== "win32") { + try { + await fs.chmod(settingsDir, SECURE_DIR_MODE); + } catch { + // Best-effort hardening. + } + } const payload = normalizeForWrite(record); const data = `${JSON.stringify(payload, null, 2)}\n`; const tempPath = `${UNIFIED_SETTINGS_PATH}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; - await fs.writeFile(tempPath, data, "utf8"); + await fs.writeFile(tempPath, data, { encoding: "utf8", mode: SECURE_FILE_MODE }); let moved = false; try { for (let attempt = 0; attempt < 5; attempt += 1) { try { await fs.rename(tempPath, UNIFIED_SETTINGS_PATH); + if (process.platform !== "win32") { + try { + await fs.chmod(UNIFIED_SETTINGS_PATH, SECURE_FILE_MODE); + } catch { + // Best-effort hardening. + } + } moved = true; return; } catch (error) { diff --git a/package-lock.json b/package-lock.json index 93ee2ca5e..78d174c25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,9 @@ "engines": { "node": ">=18.0.0" }, + "optionalDependencies": { + "keytar": "^7.9.0" + }, "peerDependencies": { "typescript": "^5" } @@ -1785,6 +1788,39 @@ "node": "20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/brace-expansion": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", @@ -1811,6 +1847,31 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1821,6 +1882,13 @@ "node": ">=18" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC", + "optional": true + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1904,6 +1972,32 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1911,6 +2005,16 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -1918,6 +2022,16 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -2198,6 +2312,16 @@ "dev": true, "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2323,6 +2447,13 @@ "dev": true, "license": "ISC" }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT", + "optional": true + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2351,6 +2482,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT", + "optional": true + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2406,6 +2544,27 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2426,6 +2585,20 @@ "node": ">=0.8.19" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC", + "optional": true + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -2558,6 +2731,18 @@ "dev": true, "license": "MIT" }, + "node_modules/keytar": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/keytar/-/keytar-7.9.0.tgz", + "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -2726,6 +2911,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -2742,6 +2940,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT", + "optional": true + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -2791,6 +3006,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2798,6 +3020,26 @@ "dev": true, "license": "MIT" }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "license": "MIT", + "optional": true + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2809,6 +3051,16 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -2964,6 +3216,34 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2974,6 +3254,17 @@ "node": ">= 0.8.0" } }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3001,6 +3292,37 @@ ], "license": "MIT" }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -3070,11 +3392,32 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -3126,6 +3469,53 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3195,6 +3585,16 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -3238,6 +3638,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3251,6 +3661,36 @@ "node": ">=8" } }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3362,6 +3802,19 @@ "typescript": ">=4.8.4" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3419,6 +3872,13 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT", + "optional": true + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -3708,6 +4168,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC", + "optional": true + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/package.json b/package.json index 6f848975f..b25e486b9 100644 --- a/package.json +++ b/package.json @@ -59,8 +59,18 @@ "bench:edit-formats:render": "node scripts/benchmark-render-dashboard.mjs", "bench:runtime-path": "npm run build && node scripts/benchmark-runtime-path.mjs", "bench:runtime-path:quick": "node scripts/benchmark-runtime-path.mjs", + "perf:budget-check": "node scripts/performance-budget-check.js", + "sbom:generate": "node scripts/generate-sbom.js", + "sbom:verify": "node scripts/verify-sbom.js .tmp/sbom.cdx.json", "test:coverage": "vitest run --coverage", "coverage": "npm run build && vitest run --coverage", + "ops:health-check": "node scripts/enterprise-health-check.js", + "ops:retention-cleanup": "node scripts/retention-cleanup.js", + "ops:audit-forwarder": "node scripts/audit-log-forwarder.js", + "ops:slo-report": "node scripts/slo-budget-report.js --output=.tmp/slo-report.json", + "ops:compliance-evidence": "node scripts/compliance-evidence-bundle.js --profile=release", + "ops:recovery-drill": "npm run test -- test/storage-recovery-paths.test.ts test/storage.test.ts", + "ops:keychain-assert": "node scripts/keychain-assert.js", "audit:prod": "npm audit --omit=dev --audit-level=high", "audit:all": "npm audit --audit-level=high", "audit:dev:allowlist": "node scripts/audit-dev-allowlist.js", @@ -121,6 +131,9 @@ "hono": "4.12.3", "zod": "^4.3.6" }, + "optionalDependencies": { + "keytar": "^7.9.0" + }, "overrides": { "hono": "4.12.3", "minimatch": "10.2.4", diff --git a/scripts/audit-log-forwarder.js b/scripts/audit-log-forwarder.js new file mode 100644 index 000000000..6a7294ef0 --- /dev/null +++ b/scripts/audit-log-forwarder.js @@ -0,0 +1,463 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import process from "node:process"; + +const DEFAULT_BATCH_SIZE = 500; +const SEND_TIMEOUT_MS = Number.parseInt(process.env.CODEX_AUDIT_FORWARDER_TIMEOUT_MS ?? "15000", 10); +const SEND_MAX_ATTEMPTS = Number.parseInt(process.env.CODEX_AUDIT_FORWARDER_MAX_ATTEMPTS ?? "3", 10); +const CHECKPOINT_LOCK_MAX_ATTEMPTS = parsePositiveInt(process.env.CODEX_AUDIT_FORWARDER_LOCK_MAX_ATTEMPTS, 40); +const CHECKPOINT_LOCK_STALE_MS = parsePositiveInt(process.env.CODEX_AUDIT_FORWARDER_STALE_LOCK_MS, 5 * 60 * 1000); +const CHECKPOINT_LOCK_MAX_WAIT_MS = parsePositiveInt(process.env.CODEX_AUDIT_FORWARDER_MAX_WAIT_MS, 60 * 1000); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function parseArgValue(name) { + const prefix = `${name}=`; + const hit = process.argv.slice(2).find((arg) => arg.startsWith(prefix)); + return hit ? hit.slice(prefix.length) : undefined; +} + +function hasFlag(name) { + return process.argv.slice(2).includes(name); +} + +function parseBatchSize(value) { + if (!value) return DEFAULT_BATCH_SIZE; + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_BATCH_SIZE; + return parsed; +} + +function countNonEmptyLines(content) { + return content.split(/\r?\n/).reduce((count, line) => (line.trim().length > 0 ? count + 1 : count), 0); +} + +function getNewestRotatedAuditFile(files) { + return files + .map((file) => { + const match = /^audit\.(\d+)\.log$/i.exec(file); + if (!match) return null; + return { + file, + rotation: Number.parseInt(match[1], 10), + }; + }) + .filter((entry) => entry !== null) + .sort((a, b) => a.rotation - b.rotation)[0]?.file ?? null; +} + +function resolveRoot() { + const override = (process.env.CODEX_MULTI_AUTH_DIR ?? "").trim(); + if (override.length > 0) return override; + return join(homedir(), ".codex", "multi-auth"); +} + +async function loadCheckpoint(path) { + if (!existsSync(path)) { + return { file: null, line: 0 }; + } + try { + const raw = await readFile(path, "utf8"); + const parsed = JSON.parse(raw); + if ( + parsed && + (typeof parsed.file === "string" || parsed.file === null) && + typeof parsed.line === "number" && + parsed.line >= 0 + ) { + return { + file: parsed.file, + line: parsed.line, + }; + } + } catch { + // Ignore malformed checkpoint and re-seed from zero. + } + return { file: null, line: 0 }; +} + +async function discoverAuditFiles(logDir) { + let entries; + try { + entries = await readdir(logDir, { withFileTypes: true }); + } catch (error) { + const code = error?.code; + if (code === "ENOENT" || code === "ENOTDIR") { + return []; + } + throw error; + } + const files = []; + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.startsWith("audit") || !entry.name.endsWith(".log")) continue; + try { + await stat(join(logDir, entry.name)); + } catch (error) { + const code = error?.code; + if (code === "ENOENT" || code === "ENOTDIR") { + continue; + } + throw error; + } + files.push(entry.name); + } + files.sort((a, b) => { + const leftRotation = /^audit\.(\d+)\.log$/i.exec(a); + const rightRotation = /^audit\.(\d+)\.log$/i.exec(b); + const leftActive = a.toLowerCase() === "audit.log"; + const rightActive = b.toLowerCase() === "audit.log"; + if (leftActive && rightActive) return 0; + if (leftActive) return 1; + if (rightActive) return -1; + if (leftRotation && rightRotation) { + // Process older rotated files first (audit.3.log before audit.1.log). + return Number.parseInt(rightRotation[1], 10) - Number.parseInt(leftRotation[1], 10); + } + return a.localeCompare(b, undefined, { sensitivity: "base" }); + }); + return files; +} + +function resolveCheckpointFile(files, checkpointFile) { + if (!checkpointFile) return null; + const newestRotated = getNewestRotatedAuditFile(files); + if (files.includes(checkpointFile)) { + return checkpointFile; + } + if (checkpointFile === "audit.log") { + return newestRotated; + } + const rotatedMatch = /^audit\.(\d+)\.log$/i.exec(checkpointFile); + if (rotatedMatch) { + const nextRotation = `audit.${Number.parseInt(rotatedMatch[1], 10) + 1}.log`; + if (files.includes(nextRotation)) return nextRotation; + } + return null; +} + +async function collectBatch(logDir, files, checkpoint, batchSize) { + let checkpointFile = resolveCheckpointFile(files, checkpoint.file); + if (checkpoint.file === "audit.log" && checkpointFile === "audit.log") { + const newestRotated = getNewestRotatedAuditFile(files); + if (newestRotated) { + const activePath = join(logDir, "audit.log"); + try { + const activeRaw = await readFile(activePath, "utf8"); + const activeLineCount = countNonEmptyLines(activeRaw); + if (checkpoint.line > activeLineCount) { + checkpointFile = newestRotated; + } + } catch { + // Best effort: fall back to active audit.log checkpoint. + } + } + } + const checkpointFileIndex = checkpointFile ? files.indexOf(checkpointFile) : -1; + const entries = []; + for (let fileIndex = 0; fileIndex < files.length; fileIndex += 1) { + const file = files[fileIndex]; + if (checkpointFileIndex >= 0 && fileIndex < checkpointFileIndex) { + continue; + } + + const fullPath = join(logDir, file); + let lineNumber = 0; + const raw = await readFile(fullPath, "utf8"); + const lines = raw.split(/\r?\n/); + for (const line of lines) { + if (!line.trim()) continue; + lineNumber += 1; + + if (checkpointFile === file && lineNumber <= checkpoint.line) { + continue; + } + try { + entries.push({ + file, + line: lineNumber, + entry: JSON.parse(line), + }); + } catch { + entries.push({ + file, + line: lineNumber, + entry: { + parseError: true, + raw: line, + }, + }); + } + if (entries.length >= batchSize) { + return entries; + } + } + } + return entries; +} + +async function sendBatch({ endpoint, apiKey, payload }) { + const headers = { + "content-type": "application/json", + }; + if (apiKey) { + headers.authorization = `Bearer ${apiKey}`; + } + const maxAttempts = Number.isFinite(SEND_MAX_ATTEMPTS) && SEND_MAX_ATTEMPTS > 0 ? SEND_MAX_ATTEMPTS : 3; + const timeoutMs = Number.isFinite(SEND_TIMEOUT_MS) && SEND_TIMEOUT_MS > 0 ? SEND_TIMEOUT_MS : 15_000; + + for (let attempt = 0; attempt < maxAttempts; attempt += 1) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }); + if (response.ok) { + return; + } + const body = await response.text(); + const retryableStatus = response.status === 429 || response.status >= 500; + if (!retryableStatus || attempt === maxAttempts - 1) { + throw new Error(`SIEM endpoint ${response.status}: ${body.slice(0, 500)}`); + } + } catch (error) { + const retryableNetworkError = + error instanceof Error && + (error.name === "AbortError" || + /timeout|network|fetch/i.test(error.message)); + if (!retryableNetworkError || attempt === maxAttempts - 1) { + throw error; + } + } finally { + clearTimeout(timeout); + } + const backoffMs = 250 * 2 ** attempt + Math.floor(Math.random() * 100); + await sleep(backoffMs); + } +} + +function isProcessAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error?.code === "EPERM"; + } +} + +async function clearStaleCheckpointLock(lockPath) { + let details; + try { + details = await stat(lockPath); + } catch (error) { + if (error?.code === "ENOENT") return false; + return false; + } + if (Date.now() - details.mtimeMs < CHECKPOINT_LOCK_STALE_MS) { + return false; + } + + let ownerPid = null; + try { + const raw = (await readFile(lockPath, "utf8")).trim(); + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0) { + ownerPid = parsed; + } + } catch { + // Ignore parse/read failures and treat as stale candidate. + } + + if (ownerPid !== null && isProcessAlive(ownerPid)) { + return false; + } + try { + await unlink(lockPath); + return true; + } catch (error) { + if (error?.code === "ENOENT") return true; + return false; + } +} + +function buildCheckpointLockTimeoutError(lockPath, elapsedMs, waitMs) { + const effectiveElapsed = Math.max(0, Math.floor(elapsedMs + waitMs)); + return new Error(`Timed out acquiring checkpoint lock after ${effectiveElapsed}ms: ${lockPath}`); +} + +async function withCheckpointLock(checkpointPath, action) { + const lockPath = `${checkpointPath}.lock`; + const startedAt = Date.now(); + for (let attempt = 0; attempt < CHECKPOINT_LOCK_MAX_ATTEMPTS; attempt += 1) { + try { + const handle = await open(lockPath, "wx", 0o600); + try { + await handle.writeFile(`${process.pid}\n`, "utf8"); + } finally { + await handle.close(); + } + try { + return await action(); + } finally { + await unlink(lockPath).catch(() => {}); + } + } catch (error) { + const code = error?.code; + const contention = code === "EEXIST" || code === "EPERM"; + if (!contention) { + throw error; + } + if (await clearStaleCheckpointLock(lockPath)) { + continue; + } + const backoffMs = 25 * 2 ** Math.min(attempt, 6); + const elapsedMs = Date.now() - startedAt; + if ( + attempt === CHECKPOINT_LOCK_MAX_ATTEMPTS - 1 || + elapsedMs >= CHECKPOINT_LOCK_MAX_WAIT_MS || + elapsedMs + backoffMs > CHECKPOINT_LOCK_MAX_WAIT_MS + ) { + throw buildCheckpointLockTimeoutError(lockPath, elapsedMs, backoffMs); + } + await sleep(backoffMs); + } + } + throw buildCheckpointLockTimeoutError(lockPath, Date.now() - startedAt, 0); +} + +async function writeCheckpointAtomic(checkpointPath, checkpoint) { + const tmpPath = `${checkpointPath}.${process.pid}.${Date.now()}.tmp`; + await withCheckpointLock(checkpointPath, async () => { + try { + await writeFile(tmpPath, `${JSON.stringify(checkpoint, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + }); + await rename(tmpPath, checkpointPath); + } finally { + await unlink(tmpPath).catch((error) => { + if (error?.code !== "ENOENT") { + throw error; + } + }); + } + }); +} + +async function main() { + const dryRun = hasFlag("--dry-run"); + const endpoint = parseArgValue("--endpoint") ?? process.env.CODEX_SIEM_ENDPOINT; + const apiKey = parseArgValue("--api-key") ?? process.env.CODEX_SIEM_API_KEY; + const batchSize = parseBatchSize(parseArgValue("--batch-size")); + const root = resolveRoot(); + const logDir = resolve(parseArgValue("--log-dir") ?? join(root, "logs")); + const checkpointPath = resolve(parseArgValue("--checkpoint") ?? join(root, "audit-forwarder-checkpoint.json")); + + await mkdir(dirname(checkpointPath), { recursive: true }); + const checkpoint = await loadCheckpoint(checkpointPath); + const files = await discoverAuditFiles(logDir); + const batch = await collectBatch(logDir, files, checkpoint, batchSize); + + if (batch.length === 0) { + console.log( + JSON.stringify( + { + command: "audit-log-forwarder", + status: "noop", + reason: "no-new-audit-events", + logDir, + checkpoint, + }, + null, + 2, + ), + ); + return; + } + + const data = batch.map((item) => item.entry); + const checksum = createHash("sha256").update(JSON.stringify(data)).digest("hex"); + const last = batch[batch.length - 1]; + const payload = { + source: "codex-multi-auth", + generatedAt: new Date().toISOString(), + count: data.length, + checksum, + entries: data, + }; + + if (!dryRun) { + if (!endpoint) { + throw new Error("Missing --endpoint (or CODEX_SIEM_ENDPOINT) for audit export."); + } + await sendBatch({ endpoint, apiKey, payload }); + } + + const checkpointNext = { + file: last?.file ?? checkpoint.file, + line: last?.line ?? checkpoint.line, + updatedAt: new Date().toISOString(), + }; + if (!dryRun) { + await writeCheckpointAtomic(checkpointPath, checkpointNext); + } + + const newestMtime = (() => { + const newest = files[files.length - 1]; + return newest ? join(logDir, newest) : null; + })(); + let newestLogMtimeMs = null; + if (newestMtime) { + try { + const metadata = await stat(newestMtime); + newestLogMtimeMs = metadata.mtimeMs; + } catch (error) { + const code = error?.code; + if (code !== "ENOENT" && code !== "ENOTDIR") { + throw error; + } + } + } + + console.log( + JSON.stringify( + { + command: "audit-log-forwarder", + status: dryRun ? "dry-run" : "sent", + dryRun, + endpoint: endpoint ?? null, + logDir, + checkpointPath, + sent: data.length, + checksum, + checkpoint: checkpointNext, + newestLogMtimeMs, + }, + null, + 2, + ), + ); +} + +main().catch((error) => { + console.error(`audit-log-forwarder failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/benchmark-runtime-path.mjs b/scripts/benchmark-runtime-path.mjs index 2fc857ec0..24ace4063 100644 --- a/scripts/benchmark-runtime-path.mjs +++ b/scripts/benchmark-runtime-path.mjs @@ -7,6 +7,8 @@ import { dirname, resolve } from "node:path"; import { filterInput } from "../dist/lib/request/request-transformer.js"; import { cleanupToolDefinitions } from "../dist/lib/request/helpers/tool-utils.js"; import { AccountManager } from "../dist/lib/accounts.js"; +import { resolveRequestAccountId } from "../dist/lib/auth/token-utils.js"; +import { normalizeAccountStorage } from "../dist/lib/storage.js"; function argValue(args, name) { const prefix = `${name}=`; @@ -104,6 +106,33 @@ function buildManager(accountCount) { }); } +function buildStoragePayload(accountCount) { + const now = Date.now(); + const accounts = []; + for (let i = 0; i < accountCount; i += 1) { + accounts.push({ + accountId: `acct_${i % 40}`, + email: `user${i % 35}@example.com`, + refreshToken: `refresh_${i}`, + accessToken: `access_${i}`, + expiresAt: now + 3_600_000, + enabled: true, + addedAt: now - i * 1_000, + lastUsed: now - i * 100, + lastSwitchReason: "rotation", + }); + } + return { + version: 3, + accounts, + activeIndex: Math.floor(accountCount / 3), + activeIndexByFamily: { + codex: 0, + default: Math.floor(accountCount / 4), + }, + }; +} + function run() { const args = process.argv.slice(2); const iterations = parsePositiveInt(argValue(args, "--iterations"), 30); @@ -113,6 +142,7 @@ function run() { const inputLarge = buildInputItems(2000); const toolsMedium = buildTools(40, 12); const toolsLarge = buildTools(140, 25); + const storageLarge = buildStoragePayload(240); const results = [ benchmarkCase("filterInput_small", iterations, () => { @@ -137,6 +167,17 @@ function run() { manager.getCurrentOrNextForFamilyHybrid("codex", "gpt-5-codex", { pidOffsetEnabled: false }); } }), + benchmarkCase("resolveRequestAccountId_1000", iterations, () => { + for (let i = 0; i < 1_000; i += 1) { + resolveRequestAccountId("org_123", "org", `token_${i}`); + resolveRequestAccountId(undefined, "token", `token_${i}`); + resolveRequestAccountId("acct_manual", "manual", `token_${i}`); + } + }), + benchmarkCase("normalizeAccountStorage_240", iterations, () => { + const out = normalizeAccountStorage(storageLarge); + if (!out || out.version !== 3) throw new Error("normalizeAccountStorage_240 failed"); + }), ]; const payload = { diff --git a/scripts/compliance-evidence-bundle.js b/scripts/compliance-evidence-bundle.js new file mode 100644 index 000000000..f12ea183d --- /dev/null +++ b/scripts/compliance-evidence-bundle.js @@ -0,0 +1,184 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve, join } from "node:path"; +import process from "node:process"; + +const PROFILES = { + quick: [ + { id: "typecheck", args: ["run", "typecheck"] }, + { id: "lint", args: ["run", "lint"] }, + { id: "build", args: ["run", "build"] }, + { id: "health-check", args: ["run", "ops:health-check"] }, + { id: "perf-budget", args: ["run", "perf:budget-check"] }, + ], + release: [ + { id: "typecheck", args: ["run", "typecheck"] }, + { id: "lint", args: ["run", "lint"] }, + { id: "build", args: ["run", "build"] }, + { id: "test", args: ["test"] }, + { id: "audit-ci", args: ["run", "audit:ci"] }, + { id: "health-check", args: ["run", "ops:health-check"] }, + { id: "perf-budget", args: ["run", "perf:budget-check"] }, + { id: "sbom-generate", args: ["run", "sbom:generate"] }, + { id: "sbom-verify", args: ["run", "sbom:verify"] }, + ], +}; +const MAX_BUFFER_BYTES = 20 * 1024 * 1024; + +function parseArgValue(name) { + const prefix = `${name}=`; + const hit = process.argv.slice(2).find((arg) => arg.startsWith(prefix)); + return hit ? hit.slice(prefix.length) : undefined; +} + +function hasFlag(name) { + return process.argv.slice(2).includes(name); +} + +function safeExec(command, args, cwd) { + try { + return execFileSync(command, args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + return (error?.stdout ?? error?.stderr ?? "").toString(); + } +} + +function runNpm(args, options) { + if (process.platform === "win32") { + const escaped = args + .map((arg) => (/\s/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg)) + .join(" "); + return execFileSync("cmd.exe", ["/d", "/s", "/c", `npm ${escaped}`], { + ...options, + maxBuffer: MAX_BUFFER_BYTES, + }); + } + return execFileSync("npm", args, { + ...options, + maxBuffer: MAX_BUFFER_BYTES, + }); +} + +function runCheck(entry, cwd, dryRun) { + const startedAt = new Date().toISOString(); + const startedMs = Date.now(); + if (dryRun) { + return { + id: entry.id, + command: `npm ${entry.args.join(" ")}`, + startedAt, + durationMs: 0, + status: "skipped", + exitCode: 0, + output: "dry-run", + }; + } + try { + const output = runNpm(entry.args, { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return { + id: entry.id, + command: `npm ${entry.args.join(" ")}`, + startedAt, + durationMs: Date.now() - startedMs, + status: "pass", + exitCode: 0, + output, + }; + } catch (error) { + const stdout = error?.stdout ? String(error.stdout) : ""; + const stderr = error?.stderr ? String(error.stderr) : ""; + const message = error instanceof Error ? error.message : String(error); + return { + id: entry.id, + command: `npm ${entry.args.join(" ")}`, + startedAt, + durationMs: Date.now() - startedMs, + status: "fail", + exitCode: typeof error?.status === "number" ? error.status : 1, + output: `${stdout}${stderr}${stdout || stderr ? "" : message}`, + }; + } +} + +function markdownSummary(payload) { + const lines = [ + "# Compliance Evidence Bundle", + "", + `Generated at: ${payload.generatedAt}`, + `Profile: ${payload.profile}`, + `Branch: ${payload.git.branch}`, + `Commit: ${payload.git.commit}`, + "", + "| Check | Status | Exit | Duration (ms) |", + "| --- | --- | ---: | ---: |", + ]; + for (const result of payload.results) { + lines.push(`| ${result.id} | ${result.status} | ${result.exitCode} | ${result.durationMs} |`); + } + lines.push("", `Overall: **${payload.status.toUpperCase()}**`); + return `${lines.join("\n")}\n`; +} + +async function main() { + const cwd = process.cwd(); + const profile = parseArgValue("--profile") ?? "quick"; + const dryRun = hasFlag("--dry-run"); + if (!Object.hasOwn(PROFILES, profile)) { + throw new Error(`Unknown profile: ${profile}. Expected one of: ${Object.keys(PROFILES).join(", ")}`); + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); + const outDir = resolve(parseArgValue("--out-dir") ?? join(cwd, ".tmp", "compliance-evidence", timestamp)); + await mkdir(outDir, { recursive: true }); + + const git = { + branch: safeExec("git", ["branch", "--show-current"], cwd).trim(), + commit: safeExec("git", ["rev-parse", "HEAD"], cwd).trim(), + }; + + const checks = PROFILES[profile]; + const results = checks.map((entry) => runCheck(entry, cwd, dryRun)); + for (let index = 0; index < results.length; index += 1) { + const result = results[index]; + const logName = `${String(index + 1).padStart(2, "0")}-${result.id}.log`; + await writeFile(join(outDir, logName), result.output, "utf8"); + } + + const payload = { + command: "compliance-evidence-bundle", + generatedAt: new Date().toISOString(), + profile, + dryRun, + outputDir: outDir, + git, + results: results.map(({ output, ...rest }) => rest), + status: results.every((entry) => entry.status === "pass" || entry.status === "skipped") + ? "pass" + : "fail", + }; + + await writeFile(join(outDir, "manifest.json"), `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + await writeFile(join(outDir, "summary.md"), markdownSummary(payload), "utf8"); + + console.log(JSON.stringify(payload, null, 2)); + if (payload.status === "fail") { + process.exit(1); + } +} + +main().catch((error) => { + console.error( + `compliance-evidence-bundle failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); +}); diff --git a/scripts/enterprise-health-check.js b/scripts/enterprise-health-check.js new file mode 100644 index 000000000..9c9724d86 --- /dev/null +++ b/scripts/enterprise-health-check.js @@ -0,0 +1,296 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync } from "node:fs"; +import { readdir, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, win32 } from "node:path"; + +const WAL_STALE_MS = 24 * 60 * 60 * 1000; +const MAX_AUDIT_STALENESS_MS = 7 * 24 * 60 * 60 * 1000; + +function parseArgValue(flagName) { + for (const arg of process.argv.slice(2)) { + if (arg.startsWith(`${flagName}=`)) { + return arg.slice(flagName.length + 1).trim(); + } + } + return ""; +} + +function hasFlag(flag) { + return process.argv.slice(2).includes(flag); +} + +function firstNonEmpty(values) { + for (const value of values) { + const trimmed = (value ?? "").trim(); + if (trimmed.length > 0) { + return trimmed; + } + } + return null; +} + +function getResolvedUserHomeDir() { + if (process.platform === "win32") { + const homeDrive = (process.env.HOMEDRIVE ?? "").trim(); + const homePath = (process.env.HOMEPATH ?? "").trim(); + const drivePathHome = + homeDrive.length > 0 && homePath.length > 0 + ? win32.resolve(`${homeDrive}\\`, homePath) + : undefined; + return ( + firstNonEmpty([ + process.env.USERPROFILE, + process.env.HOME, + drivePathHome, + homedir(), + ]) ?? homedir() + ); + } + return firstNonEmpty([process.env.HOME, homedir()]) ?? homedir(); +} + +function deduplicatePaths(paths) { + const seen = new Set(); + const unique = []; + for (const path of paths) { + const trimmed = (path ?? "").trim(); + if (trimmed.length === 0) continue; + const key = process.platform === "win32" ? trimmed.toLowerCase() : trimmed; + if (seen.has(key)) continue; + seen.add(key); + unique.push(trimmed); + } + return unique; +} + +function getCodexHomeDir() { + const fromEnv = (process.env.CODEX_HOME ?? "").trim(); + return fromEnv.length > 0 ? fromEnv : join(getResolvedUserHomeDir(), ".codex"); +} + +function hasStorageSignals(dir) { + const signals = [ + "openai-codex-accounts.json", + "codex-accounts.json", + "settings.json", + "config.json", + "dashboard-settings.json", + ]; + for (const signal of signals) { + if (existsSync(join(dir, signal))) { + return true; + } + } + return existsSync(join(dir, "projects")); +} + +function hasAccountsStorage(dir) { + const accountFiles = ["openai-codex-accounts.json", "codex-accounts.json"]; + for (const fileName of accountFiles) { + if (existsSync(join(dir, fileName)) || existsSync(join(dir, `${fileName}.wal`))) { + return true; + } + } + try { + const entries = readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + for (const fileName of accountFiles) { + if (!entry.name.startsWith(`${fileName}.`)) continue; + if (entry.name.endsWith(".tmp")) continue; + if (entry.name.includes(".rotate.")) continue; + return true; + } + } + } catch { + // Ignore unreadable directories and fall back to known filename probes. + } + return false; +} + +function getFallbackCodexHomeDirs() { + const userHome = getResolvedUserHomeDir(); + return deduplicatePaths([ + getCodexHomeDir(), + join(userHome, "DevTools", "config", "codex"), + join(userHome, ".codex"), + ]); +} + +function resolveRoot() { + const overrideArg = parseArgValue("--root"); + if (overrideArg.length > 0) return overrideArg; + + const overrideEnv = (process.env.CODEX_MULTI_AUTH_DIR ?? "").trim(); + if (overrideEnv.length > 0) return overrideEnv; + + const primary = join(getCodexHomeDir(), "multi-auth"); + const fallbackCandidates = deduplicatePaths([ + ...getFallbackCodexHomeDirs().map((dir) => join(dir, "multi-auth")), + join(getResolvedUserHomeDir(), ".codex"), + ]); + const orderedCandidates = deduplicatePaths([primary, ...fallbackCandidates]); + + for (const candidate of orderedCandidates) { + if (hasAccountsStorage(candidate)) { + return candidate; + } + } + if (hasStorageSignals(primary)) { + return primary; + } + for (const candidate of fallbackCandidates) { + if (candidate === primary) continue; + if (hasStorageSignals(candidate)) { + return candidate; + } + } + return primary; +} + +function getAuditDir(root) { + // Mirrors getCodexLogDir() from lib/runtime-paths.ts by deriving logs from the resolved multi-auth root. + return join(root, "logs"); +} + +async function newestMtimeMs(dir) { + if (!existsSync(dir)) return null; + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch (error) { + const code = error?.code; + if (code === "ENOENT" || code === "ENOTDIR") { + return null; + } + throw error; + } + let newest = null; + for (const entry of entries) { + if (!entry.isFile()) continue; + const fullPath = join(dir, entry.name); + try { + const details = await stat(fullPath); + if (newest === null || details.mtimeMs > newest) { + newest = details.mtimeMs; + } + } catch { + // Ignore transient stat failures. + } + } + return newest; +} + +async function checkSecureMode(path, findings) { + if (process.platform === "win32") return; + try { + const details = await stat(path); + const perms = details.mode & 0o777; + if (perms !== 0o600) { + findings.push({ + severity: "high", + code: "insecure-file-permissions", + path, + message: `expected 0600 permissions, found ${perms.toString(8)}`, + }); + } + } catch (error) { + if (error?.code === "ENOENT") return; + findings.push({ + severity: "medium", + code: "stat-failed", + path, + message: error instanceof Error ? error.message : String(error), + }); + } +} + +async function run() { + const now = Date.now(); + const requireFiles = hasFlag("--require-files"); + const root = resolveRoot(); + const findings = []; + const checks = []; + + const storagePath = join(root, "openai-codex-accounts.json"); + const settingsPath = join(root, "settings.json"); + const walPath = `${storagePath}.wal`; + const auditDir = getAuditDir(root); + + try { + const walStats = await stat(walPath); + const walAgeMs = now - walStats.mtimeMs; + checks.push({ name: "wal-age-ms", value: walAgeMs }); + if (walAgeMs > WAL_STALE_MS) { + findings.push({ + severity: "high", + code: "stale-wal", + path: walPath, + message: `WAL file older than ${WAL_STALE_MS}ms`, + }); + } + } catch { + // WAL does not exist or is transiently unavailable. + } + + await checkSecureMode(storagePath, findings); + await checkSecureMode(settingsPath, findings); + + const newestAuditMs = await newestMtimeMs(auditDir); + checks.push({ name: "newest-audit-mtime-ms", value: newestAuditMs }); + if (newestAuditMs !== null && now - newestAuditMs > MAX_AUDIT_STALENESS_MS) { + findings.push({ + severity: "medium", + code: "stale-audit-log", + path: auditDir, + message: `no audit activity in ${MAX_AUDIT_STALENESS_MS}ms`, + }); + } + + if (requireFiles) { + const requiredArtifacts = [ + { path: storagePath, code: "missing-storage-file" }, + { path: settingsPath, code: "missing-settings-file" }, + { path: auditDir, code: "missing-audit-dir" }, + ]; + for (const artifact of requiredArtifacts) { + if (!existsSync(artifact.path)) { + findings.push({ + severity: "high", + code: artifact.code, + path: artifact.path, + message: "required artifact missing for enterprise health validation", + }); + } + } + if (newestAuditMs === null) { + findings.push({ + severity: "high", + code: "missing-audit-events", + path: auditDir, + message: "required audit log entries missing for enterprise health validation", + }); + } + } + + const highFindings = findings.filter((entry) => entry.severity === "high"); + const payload = { + command: "enterprise-health-check", + root, + auditDir, + status: highFindings.length === 0 ? "pass" : "fail", + checks, + findings, + }; + console.log(JSON.stringify(payload, null, 2)); + if (highFindings.length > 0) { + process.exit(1); + } +} + +run().catch((error) => { + console.error(`enterprise-health-check failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/generate-sbom.js b/scripts/generate-sbom.js new file mode 100644 index 000000000..2f0f75ffc --- /dev/null +++ b/scripts/generate-sbom.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { mkdir, writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import process from "node:process"; + +const MAX_BUFFER_BYTES = 20 * 1024 * 1024; + +async function main() { + const npmExecPath = process.env.npm_execpath; + const outPath = resolve(".tmp/sbom.cdx.json"); + await mkdir(resolve(".tmp"), { recursive: true }); + const sbomArgs = ["sbom", "--omit=dev", "--sbom-format=cyclonedx", "--json"]; + const sbom = npmExecPath + ? execFileSync(process.execPath, [npmExecPath, ...sbomArgs], { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: MAX_BUFFER_BYTES, + }) + : execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", sbomArgs, { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: MAX_BUFFER_BYTES, + }); + await writeFile(outPath, `${sbom.trim()}\n`, "utf8"); + console.log( + JSON.stringify( + { + command: "generate-sbom", + outputPath: outPath, + status: "pass", + }, + null, + 2, + ), + ); +} + +main().catch((error) => { + console.error(`generate-sbom failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/keychain-assert.js b/scripts/keychain-assert.js new file mode 100644 index 000000000..0d80612fd --- /dev/null +++ b/scripts/keychain-assert.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import process from "node:process"; + +function main() { + const args = [ + "run", + "test", + "--", + "test/storage-v4-keychain.test.ts", + "test/token-store.test.ts", + ]; + const env = { + ...process.env, + CODEX_SECRET_STORAGE_MODE: "keychain", + }; + if (process.env.npm_execpath) { + execFileSync(process.execPath, [process.env.npm_execpath, ...args], { + cwd: process.cwd(), + stdio: "inherit", + env, + }); + return; + } + const npmBin = process.platform === "win32" ? "npm.cmd" : "npm"; + execFileSync(npmBin, args, { + cwd: process.cwd(), + stdio: "inherit", + env, + }); +} + +try { + main(); +} catch (error) { + process.exit(typeof error?.status === "number" ? error.status : 1); +} diff --git a/scripts/performance-budget-check.js b/scripts/performance-budget-check.js new file mode 100644 index 000000000..4eae967d7 --- /dev/null +++ b/scripts/performance-budget-check.js @@ -0,0 +1,105 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; + +// Every threshold in config/performance-budgets.json is interpreted in milliseconds. +const projectRoot = process.cwd(); +const outputPath = resolve(projectRoot, ".tmp", "runtime-budget-report.json"); +const budgetPath = resolve(projectRoot, "config", "performance-budgets.json"); + +function runBenchmark() { + if (!existsSync(resolve(projectRoot, ".tmp"))) { + mkdirSync(resolve(projectRoot, ".tmp"), { recursive: true }); + } + execFileSync( + process.execPath, + ["scripts/benchmark-runtime-path.mjs", "--iterations=10", `--output=${outputPath}`], + { + cwd: projectRoot, + stdio: "pipe", + encoding: "utf8", + }, + ); +} + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJsonFile(path, label) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`invalid ${label} json at ${path}: ${message}`); + } +} + +function main() { + runBenchmark(); + if (!existsSync(budgetPath)) { + throw new Error(`budget file not found: ${budgetPath}`); + } + if (!existsSync(outputPath)) { + throw new Error(`benchmark report not found: ${outputPath}`); + } + const budgetsRaw = parseJsonFile(budgetPath, "budget"); + if (!isRecord(budgetsRaw)) { + throw new Error(`invalid budget json at ${budgetPath}: root must be an object`); + } + const reportRaw = parseJsonFile(outputPath, "benchmark report"); + const results = isRecord(reportRaw) && Array.isArray(reportRaw.results) ? reportRaw.results : []; + const violations = []; + const seen = new Set(); + + for (const result of results) { + if (!isRecord(result) || typeof result.name !== "string") { + continue; + } + seen.add(result.name); + const budget = budgetsRaw[result.name]; + if (typeof budget !== "number") continue; + if (typeof result.avgMs !== "number") continue; + if (result.avgMs > budget) { + violations.push({ + name: result.name, + avgMs: result.avgMs, + budgetMs: budget, + }); + } + } + for (const [name, budgetMs] of Object.entries(budgetsRaw)) { + if (typeof budgetMs !== "number") continue; + if (!seen.has(name)) { + violations.push({ + name, + avgMs: null, + budgetMs, + reason: "missing benchmark metric", + }); + } + } + + const payload = { + command: "performance-budget-check", + generatedAt: new Date().toISOString(), + reportPath: outputPath, + violations, + status: violations.length === 0 ? "pass" : "fail", + }; + console.log(JSON.stringify(payload, null, 2)); + if (violations.length > 0) { + process.exit(1); + } +} + +try { + main(); +} catch (error) { + console.error( + `performance-budget-check failed: ${error instanceof Error ? error.message : String(error)}`, + ); + process.exit(1); +} diff --git a/scripts/retention-cleanup.js b/scripts/retention-cleanup.js new file mode 100644 index 000000000..01f05cef3 --- /dev/null +++ b/scripts/retention-cleanup.js @@ -0,0 +1,127 @@ +#!/usr/bin/env node + +import { existsSync } from "node:fs"; +import { readdir, rm, stat } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); +const DEFAULT_RETENTION_DAYS = 90; + +function parseRetentionDays(raw) { + if (!raw) return DEFAULT_RETENTION_DAYS; + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_RETENTION_DAYS; + return parsed; +} + +function parseArgDays(args) { + for (const arg of args) { + if (arg.startsWith("--days=")) { + return parseRetentionDays(arg.slice("--days=".length)); + } + } + return parseRetentionDays(process.env.CODEX_RETENTION_DAYS); +} + +function resolveRuntimeRoot() { + const override = (process.env.CODEX_MULTI_AUTH_DIR ?? "").trim(); + if (override.length > 0) return override; + return join(homedir(), ".codex", "multi-auth"); +} + +async function removeWithRetry(targetPath, options) { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await rm(targetPath, options); + return; + } catch (error) { + const code = error?.code; + if (code === "ENOENT") return; + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +async function collectExpiredFiles(rootPath, cutoffMs, output) { + if (!existsSync(rootPath)) return; + let entries; + try { + entries = await readdir(rootPath, { withFileTypes: true }); + } catch (error) { + const code = error?.code; + if (code === "ENOENT" || code === "ENOTDIR" || code === "EPERM") { + return; + } + throw error; + } + for (const entry of entries) { + const fullPath = join(rootPath, entry.name); + if (entry.isDirectory()) { + await collectExpiredFiles(fullPath, cutoffMs, output); + continue; + } + if (!entry.isFile()) continue; + try { + const metadata = await stat(fullPath); + if (metadata.mtimeMs < cutoffMs) { + output.push(fullPath); + } + } catch { + // Ignore transient stat failures. + } + } +} + +async function run() { + const retentionDays = parseArgDays(process.argv.slice(2)); + const root = resolveRuntimeRoot(); + const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000; + const targets = [ + join(root, "logs"), + join(root, "cache"), + join(root, "recovery"), + ]; + + const expired = []; + for (const target of targets) { + await collectExpiredFiles(target, cutoffMs, expired); + } + + let deletedFiles = 0; + const failed = []; + for (const targetPath of expired) { + try { + await removeWithRetry(targetPath, { force: true }); + deletedFiles += 1; + } catch (error) { + failed.push({ + path: targetPath, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const payload = { + command: "retention-cleanup", + root, + retentionDays, + cutoffIso: new Date(cutoffMs).toISOString(), + deletedFiles, + failedFiles: failed.length, + failures: failed, + status: failed.length === 0 ? "pass" : "partial", + }; + console.log(JSON.stringify(payload, null, 2)); + if (failed.length > 0) { + process.exit(1); + } +} + +run().catch((error) => { + console.error(`retention-cleanup failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); +}); diff --git a/scripts/secret-scan-regression.sh b/scripts/secret-scan-regression.sh new file mode 100644 index 000000000..2d04aa770 --- /dev/null +++ b/scripts/secret-scan-regression.sh @@ -0,0 +1,153 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(git rev-parse --show-toplevel)" +CONFIG_PATH="${GITLEAKS_CONFIG:-.gitleaks.toml}" +EXPECTED_GITLEAKS_VERSION="${EXPECTED_GITLEAKS_VERSION:-v8.25.0}" +if [[ "${CONFIG_PATH}" != /* ]]; then + CONFIG_PATH="${ROOT_DIR}/${CONFIG_PATH}" +fi + +if [[ ! -f "${CONFIG_PATH}" ]]; then + echo "secret-scan-regression: missing gitleaks config at ${CONFIG_PATH}" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d)" +cleanup() { + rm -rf "${TMP_DIR}" +} +trap cleanup EXIT + +FAIL_CASE_DIR="${TMP_DIR}/fail-case" +PASS_CASE_DIR="${TMP_DIR}/pass-case" +mkdir -p "${FAIL_CASE_DIR}/src" "${FAIL_CASE_DIR}/test/security/fixtures" "${PASS_CASE_DIR}/test/security/fixtures" + +cat > "${FAIL_CASE_DIR}/src/leak.txt" <<'EOF' +OPENAI_API_KEY=sk-test-placeholder-leak-12345678901234567890 +EOF +cat > "${FAIL_CASE_DIR}/test/security/fixtures/fixture.txt" <<'EOF' +OPENAI_API_KEY=sk-test-allowlist-should-exclude-1234567890 +EOF +cat > "${FAIL_CASE_DIR}/test/security/fixtures/real-secret.txt" <<'EOF' +OPENAI_API_KEY=sk-test-placeholder-in-fixture-12345678901234567890 +EOF +cat > "${PASS_CASE_DIR}/test/security/fixtures/fixture.txt" <<'EOF' +fake_refresh_token_67890 +EOF + +node -e ' +const fs = require("node:fs"); +const configPath = process.argv[1]; +const config = fs.readFileSync(configPath, "utf8"); +if (!config.includes("^test[\\\\/]security[\\\\/]fixtures[\\\\/]")) { + throw new Error("expected fixture allowlist path for test/security/fixtures"); +} +const windowsFixturePath = "test\\\\security\\\\fixtures\\\\fixture.txt"; +const fixturePattern = /^test[\\/]security[\\/]fixtures[\\/]/i; +if (!fixturePattern.test(windowsFixturePath)) { + throw new Error("windows fixture path regex parity check failed"); +} +' "${CONFIG_PATH}" + +FAIL_REPORT="${TMP_DIR}/fail-report.json" +PASS_REPORT="${TMP_DIR}/pass-report.json" + +run_gitleaks_detect() { + local source_dir="$1" + local report_path="$2" + + if command -v gitleaks >/dev/null 2>&1; then + # Native binary path should match the docker fallback major/minor behavior. + echo "secret-scan-regression: native gitleaks expected compatibility with ${EXPECTED_GITLEAKS_VERSION}" >/dev/null + gitleaks detect \ + --source "${source_dir}" \ + --config "${CONFIG_PATH}" \ + --report-format json \ + --report-path "${report_path}" \ + --no-git + return + fi + + if ! command -v docker >/dev/null 2>&1; then + node -e ' +const fs = require("node:fs"); +const path = require("node:path"); +const [sourceDir, reportPath] = process.argv.slice(1); +const findings = []; +const allowlistedFixture = /test[\\/]+security[\\/]+fixtures[\\/]+fixture\.txt$/i; +const secretPattern = /OPENAI_API_KEY=sk-[A-Za-z0-9-]{10,}/; +function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (!entry.isFile()) continue; + const rel = path.relative(sourceDir, full).replace(/\\/g, "/"); + const content = fs.readFileSync(full, "utf8"); + if (!secretPattern.test(content)) continue; + if (allowlistedFixture.test(rel)) continue; + findings.push({ File: rel }); + } +} +walk(sourceDir); +fs.writeFileSync(reportPath, JSON.stringify(findings, null, 2), "utf8"); +process.exit(findings.length > 0 ? 1 : 0); +' "${source_dir}" "${report_path}" + return + fi + + docker run --rm \ + -v "${source_dir}:/scan" \ + -v "${CONFIG_PATH}:/config/.gitleaks.toml:ro" \ + -v "${TMP_DIR}:/out" \ + "zricethezav/gitleaks:${EXPECTED_GITLEAKS_VERSION}" \ + detect \ + --source /scan \ + --config /config/.gitleaks.toml \ + --report-format json \ + --report-path "/out/$(basename "${report_path}")" \ + --no-git +} + +set +e +run_gitleaks_detect "${FAIL_CASE_DIR}" "${FAIL_REPORT}" >/dev/null 2>&1 +FAIL_STATUS=$? +set -e + +if [[ "${FAIL_STATUS}" -eq 0 ]]; then + echo "secret-scan-regression: expected fail-case scan to fail, but it passed" >&2 + exit 1 +fi + +node -e ' +const fs = require("node:fs"); +const [reportPath] = process.argv.slice(1); +const findings = JSON.parse(fs.readFileSync(reportPath, "utf8")); +if (!Array.isArray(findings) || findings.length === 0) { + throw new Error("expected non-empty findings for fail-case scan"); +} +if (!findings.some((f) => typeof f?.File === "string" && f.File.includes("src/leak.txt"))) { + throw new Error("expected finding for src/leak.txt"); +} +if (!findings.some((f) => typeof f?.File === "string" && f.File.includes("test/security/fixtures/real-secret.txt"))) { + throw new Error("expected finding for non-allowlisted secret in fixture path"); +} +if (findings.some((f) => typeof f?.File === "string" && f.File.includes("test/security/fixtures/fixture.txt"))) { + throw new Error("allowlisted fixture unexpectedly reported"); +} +' "${FAIL_REPORT}" + +set +e +run_gitleaks_detect "${PASS_CASE_DIR}" "${PASS_REPORT}" >/dev/null 2>&1 +PASS_STATUS=$? +set -e +if [[ "${PASS_STATUS}" -ne 0 ]]; then + echo "secret-scan-regression: expected pass-case scan to succeed, but it failed (status=${PASS_STATUS})" >&2 + exit 1 +fi + +echo "secret-scan-regression: passed" diff --git a/scripts/seed-health-fixture.js b/scripts/seed-health-fixture.js new file mode 100644 index 000000000..1785f4e18 --- /dev/null +++ b/scripts/seed-health-fixture.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node + +import { chmodSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const workspace = process.env.GITHUB_WORKSPACE ?? process.cwd(); +const root = join(workspace, ".tmp", "health-fixture"); +const logsDir = join(root, "logs"); + +mkdirSync(logsDir, { recursive: true, mode: 0o700 }); +const accountsPath = join(root, "openai-codex-accounts.json"); +writeFileSync( + accountsPath, + `${JSON.stringify( + { + version: 4, + accounts: [ + { + refreshTokenRef: "fixture-account:refresh", + accessTokenRef: "fixture-account:access", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + activeIndexByFamily: { + codex: 0, + legacy: 0, + gpt5: 0, + o3: 0, + o4mini: 0, + oss: 0, + }, + }, + null, + 2, + )}\n`, + { encoding: "utf8", mode: 0o600 }, +); +try { + chmodSync(accountsPath, 0o600); +} catch { + // Best-effort hardening for non-posix environments. +} +const settingsPath = join(root, "settings.json"); +writeFileSync( + settingsPath, + `${JSON.stringify({ version: 1, pluginConfig: {}, dashboardDisplaySettings: {} }, null, 2)}\n`, + { encoding: "utf8", mode: 0o600 }, +); +try { + chmodSync(settingsPath, 0o600); +} catch { + // Best-effort hardening for non-posix environments. +} +const auditPath = join(logsDir, "audit.log"); +writeFileSync( + auditPath, + `${JSON.stringify({ timestamp: new Date().toISOString(), action: "request.start", outcome: "success" })}\n`, + { encoding: "utf8", mode: 0o600 }, +); +try { + chmodSync(auditPath, 0o600); +} catch { + // Best-effort hardening for non-posix environments. +} diff --git a/scripts/slo-budget-report.js b/scripts/slo-budget-report.js new file mode 100644 index 000000000..9960fbb38 --- /dev/null +++ b/scripts/slo-budget-report.js @@ -0,0 +1,234 @@ +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { readFile, readdir, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { pathToFileURL } from "node:url"; + +const DEFAULT_HEALTH_CHECK_TIMEOUT_MS = 15_000; +const DEFAULT_HEALTH_CHECK_SCRIPT = "scripts/enterprise-health-check.js"; +const HEALTH_CHECK_MAX_BUFFER_BYTES = 2 * 1024 * 1024; + +function parseArgValue(name) { + const prefix = `${name}=`; + const hit = process.argv.slice(2).find((arg) => arg.startsWith(prefix)); + return hit ? hit.slice(prefix.length) : undefined; +} + +function hasFlag(name) { + return process.argv.slice(2).includes(name); +} + +function resolveRoot() { + const override = (process.env.CODEX_MULTI_AUTH_DIR ?? "").trim(); + if (override.length > 0) return override; + return join(homedir(), ".codex", "multi-auth"); +} + +async function loadAuditEntries(logDir, cutoffMs) { + if (!existsSync(logDir)) return []; + let entries; + try { + entries = await readdir(logDir, { withFileTypes: true }); + } catch (error) { + const code = error?.code; + if (code === "ENOENT" || code === "ENOTDIR") { + return []; + } + throw error; + } + const files = entries + .filter((entry) => entry.isFile() && entry.name.startsWith("audit") && entry.name.endsWith(".log")) + .map((entry) => entry.name) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })); + + const output = []; + for (const file of files) { + const fullPath = join(logDir, file); + let raw; + try { + raw = await readFile(fullPath, "utf8"); + } catch (error) { + const code = error?.code; + if (code === "ENOENT" || code === "ENOTDIR") { + continue; + } + throw error; + } + for (const line of raw.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + if (!parsed || typeof parsed !== "object") continue; + const timestamp = Date.parse(parsed.timestamp); + if (Number.isFinite(timestamp) && timestamp >= cutoffMs) { + output.push(parsed); + } + } catch { + // Ignore malformed audit lines. + } + } + } + return output; +} + +function parsePositiveInt(value, fallback) { + const parsed = Number.parseInt(String(value ?? ""), 10); + if (!Number.isFinite(parsed) || parsed <= 0) return fallback; + return parsed; +} + +function resolveHealthCheckScriptPath() { + const override = parseArgValue("--health-script") ?? process.env.CODEX_SLO_HEALTH_CHECK_SCRIPT; + return override && override.trim().length > 0 ? override.trim() : DEFAULT_HEALTH_CHECK_SCRIPT; +} + +export function runHealthCheck() { + const timeoutMs = parsePositiveInt(process.env.CODEX_HEALTH_CHECK_TIMEOUT_MS, DEFAULT_HEALTH_CHECK_TIMEOUT_MS); + const healthCheckScript = resolveHealthCheckScriptPath(); + try { + const nodeCmd = process.execPath; + const raw = execFileSync(nodeCmd, [healthCheckScript], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + cwd: process.cwd(), + timeout: timeoutMs, + killSignal: "SIGTERM", + maxBuffer: HEALTH_CHECK_MAX_BUFFER_BYTES, + }); + return JSON.parse(raw); + } catch (error) { + const out = `${error?.stdout ?? ""}${error?.stderr ?? ""}`.trim(); + const fallbackMessage = error instanceof Error ? error.message : String(error); + return { + status: "fail", + checks: [], + findings: [ + { + code: "health-check-exec-failed", + message: (out.length > 0 ? out : fallbackMessage).slice(0, 500), + }, + ], + }; + } +} + +function pct(value) { + if (!Number.isFinite(value)) return null; + return Number(value.toFixed(3)); +} + +async function main() { + const policyPath = resolve(parseArgValue("--policy") ?? "config/slo-policy.json"); + const outputPath = parseArgValue("--output"); + const enforce = hasFlag("--enforce"); + const root = resolveRoot(); + const logDir = resolve(parseArgValue("--log-dir") ?? join(root, "logs")); + const policy = JSON.parse(await readFile(policyPath, "utf8")); + const windowDays = typeof policy.windowDays === "number" ? policy.windowDays : 30; + const objectives = policy.objectives ?? {}; + const cutoffMs = Date.now() - windowDays * 24 * 60 * 60 * 1000; + const entries = await loadAuditEntries(logDir, cutoffMs); + const health = runHealthCheck(); + + let requestSuccess = 0; + let requestFailure = 0; + for (const entry of entries) { + if (entry.action === "request.success") requestSuccess += 1; + if (entry.action === "request.failure") requestFailure += 1; + } + const requestTotal = requestSuccess + requestFailure; + const requestSuccessRate = requestTotal > 0 ? (requestSuccess * 100) / requestTotal : null; + + const staleWalFindings = Array.isArray(health.findings) + ? health.findings.filter( + (finding) => + finding && + (finding.code === "stale-wal" || finding.code === "stale-audit-log"), + ).length + : 0; + const healthCheckPass = health.status === "pass"; + + const evaluations = [ + { + id: "request-success-rate", + target: objectives.requestSuccessRatePercent ?? null, + actual: requestSuccessRate, + status: + requestSuccessRate === null || typeof objectives.requestSuccessRatePercent !== "number" + ? "insufficient_data" + : requestSuccessRate >= objectives.requestSuccessRatePercent + ? "pass" + : "fail", + }, + { + id: "health-check-pass", + target: objectives.healthCheckPassRequired === true ? true : null, + actual: healthCheckPass, + status: + objectives.healthCheckPassRequired === true + ? healthCheckPass + ? "pass" + : "fail" + : "insufficient_data", + }, + { + id: "stale-wal-findings", + target: typeof objectives.staleWalFindingsMax === "number" ? objectives.staleWalFindingsMax : null, + actual: staleWalFindings, + status: + typeof objectives.staleWalFindingsMax !== "number" + ? "insufficient_data" + : staleWalFindings <= objectives.staleWalFindingsMax + ? "pass" + : "fail", + }, + ]; + + const hardFailures = evaluations.filter((item) => item.status === "fail"); + const payload = { + command: "slo-budget-report", + generatedAt: new Date().toISOString(), + windowDays, + root, + logDir, + entriesConsidered: entries.length, + requests: { + success: requestSuccess, + failure: requestFailure, + total: requestTotal, + successRatePercent: pct(requestSuccessRate), + errorBudgetConsumedPercent: + typeof objectives.requestSuccessRatePercent === "number" && requestSuccessRate !== null + ? pct(100 - requestSuccessRate) + : null, + errorBudgetAllowedPercent: + typeof objectives.requestSuccessRatePercent === "number" + ? pct(100 - objectives.requestSuccessRatePercent) + : null, + }, + health: { + status: health.status, + staleWalFindings, + }, + evaluations, + status: hardFailures.length === 0 ? "pass" : "fail", + }; + + if (outputPath) { + await writeFile(resolve(outputPath), `${JSON.stringify(payload, null, 2)}\n`, "utf8"); + } + console.log(JSON.stringify(payload, null, 2)); + if (enforce && payload.status === "fail") { + process.exit(1); + } +} + +const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ""; +if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) { + main().catch((error) => { + console.error(`slo-budget-report failed: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +} diff --git a/scripts/verify-sbom.js b/scripts/verify-sbom.js new file mode 100644 index 000000000..ede0b1287 --- /dev/null +++ b/scripts/verify-sbom.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import process from "node:process"; + +function fail(message) { + console.error(`verify-sbom failed: ${message}`); + process.exit(1); +} + +function main() { + const inputPath = resolve(process.argv[2] ?? ".tmp/sbom.cdx.json"); + let parsed; + try { + parsed = JSON.parse(readFileSync(inputPath, "utf8")); + } catch (error) { + fail(`unable to parse JSON from ${inputPath}: ${error instanceof Error ? error.message : String(error)}`); + } + + if (!parsed || typeof parsed !== "object") { + fail("SBOM root must be a JSON object"); + } + if (parsed.bomFormat !== "CycloneDX") { + fail(`expected bomFormat CycloneDX, got ${String(parsed.bomFormat)}`); + } + if (typeof parsed.specVersion !== "string" || parsed.specVersion.trim().length === 0) { + fail("specVersion is missing"); + } + if (!Array.isArray(parsed.components) || parsed.components.length === 0) { + fail("components array is missing or empty"); + } + + const metadata = parsed.metadata && typeof parsed.metadata === "object" ? parsed.metadata : {}; + const component = metadata.component && typeof metadata.component === "object" ? metadata.component : {}; + const payload = { + command: "verify-sbom", + inputPath, + bomFormat: parsed.bomFormat, + specVersion: parsed.specVersion, + componentCount: parsed.components.length, + rootComponentName: typeof component.name === "string" ? component.name : null, + rootComponentVersion: typeof component.version === "string" ? component.version : null, + status: "pass", + }; + console.log(JSON.stringify(payload, null, 2)); +} + +main(); diff --git a/test/audit-log-forwarder.test.ts b/test/audit-log-forwarder.test.ts new file mode 100644 index 000000000..8141a1592 --- /dev/null +++ b/test/audit-log-forwarder.test.ts @@ -0,0 +1,379 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, utimesSync } from "node:fs"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { createServer, type Server } from "node:http"; +import { spawn } from "node:child_process"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; + +const scriptPath = path.resolve(process.cwd(), "scripts", "audit-log-forwarder.js"); + +function runForwarder( + args: string[], + env: NodeJS.ProcessEnv = {}, + timeoutMs = 10_000, +): Promise<{ status: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [scriptPath, ...args], { + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + let timedOut = false; + let settled = false; + const timeout = setTimeout(() => { + timedOut = true; + stderr += `${stderr ? "\n" : ""}runForwarder timed out after ${timeoutMs}ms`; + child.kill(); + }, timeoutMs); + const finish = (status: number | null): void => { + if (settled) return; + settled = true; + clearTimeout(timeout); + resolve({ status, stdout, stderr }); + }; + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.on("error", (error) => { + if (timedOut) { + finish(null); + return; + } + if (settled) return; + settled = true; + clearTimeout(timeout); + reject(error); + }); + child.on("close", (status) => { + finish(timedOut ? null : status); + }); + }); +} + +function parseJsonStdout(output: string): Record { + return JSON.parse(output) as Record; +} + +async function withServer( + handler: Parameters[0], + run: (url: string) => Promise, +): Promise { + const server = createServer(handler); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") { + await new Promise((resolve) => server.close(() => resolve())); + throw new Error("failed to resolve server address"); + } + const endpoint = `http://127.0.0.1:${address.port}/ingest`; + try { + await run(endpoint); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } +} + +describe("audit-log-forwarder script", () => { + const fixtures: string[] = []; + + afterEach(async () => { + while (fixtures.length > 0) { + const fixture = fixtures.pop(); + if (!fixture) continue; + await removeWithRetry(fixture); + } + }); + + it("retries transient 429 responses and writes checkpoint", async () => { + const root = mkdtempSync(path.join(tmpdir(), "audit-forwarder-retry-")); + fixtures.push(root); + const logDir = path.join(root, "logs"); + const checkpointPath = path.join(root, "checkpoint.json"); + await fs.mkdir(logDir, { recursive: true }); + await fs.writeFile( + path.join(logDir, "audit.log"), + '{"timestamp":"2026-03-01T00:00:00Z","action":"request.start"}\n{"timestamp":"2026-03-01T00:01:00Z","action":"request.success"}\n', + "utf8", + ); + + let requestCount = 0; + await withServer(async (_req, res) => { + requestCount += 1; + if (requestCount === 1) { + res.statusCode = 429; + res.end("rate limited"); + return; + } + res.statusCode = 200; + res.end("ok"); + }, async (endpoint) => { + const result = await runForwarder( + [ + `--endpoint=${endpoint}`, + `--log-dir=${logDir}`, + `--checkpoint=${checkpointPath}`, + "--batch-size=25", + ], + { + CODEX_AUDIT_FORWARDER_MAX_ATTEMPTS: "3", + CODEX_AUDIT_FORWARDER_TIMEOUT_MS: "500", + }, + ); + + expect(result.status).toBe(0); + const payload = parseJsonStdout(result.stdout); + expect(payload.status).toBe("sent"); + expect(payload.sent).toBe(2); + expect(requestCount).toBe(2); + }); + + const checkpoint = JSON.parse(await fs.readFile(checkpointPath, "utf8")) as { + file?: string; + line?: number; + }; + expect(checkpoint.file).toBe("audit.log"); + expect(checkpoint.line).toBe(2); + }); + + it("replays rotated tail when checkpoint line exceeds new active log length", async () => { + const root = mkdtempSync(path.join(tmpdir(), "audit-forwarder-rotation-")); + fixtures.push(root); + const logDir = path.join(root, "logs"); + const checkpointPath = path.join(root, "checkpoint.json"); + await fs.mkdir(logDir, { recursive: true }); + await fs.writeFile( + path.join(logDir, "audit.1.log"), + '{"timestamp":"2026-03-01T00:00:00Z","id":"old-1"}\n{"timestamp":"2026-03-01T00:01:00Z","id":"old-2"}\n{"timestamp":"2026-03-01T00:02:00Z","id":"old-3"}\n', + "utf8", + ); + await fs.writeFile( + path.join(logDir, "audit.log"), + '{"timestamp":"2026-03-01T00:03:00Z","id":"new-1"}\n', + "utf8", + ); + await fs.writeFile( + checkpointPath, + JSON.stringify({ file: "audit.log", line: 2, updatedAt: "2026-03-01T00:02:00Z" }), + "utf8", + ); + + const result = await runForwarder([ + "--dry-run", + `--log-dir=${logDir}`, + `--checkpoint=${checkpointPath}`, + "--batch-size=25", + ]); + expect(result.status).toBe(0); + const payload = parseJsonStdout(result.stdout); + expect(payload.status).toBe("dry-run"); + expect(payload.sent).toBe(2); + }); + + it("times out hanging endpoint requests and exits non-zero", async () => { + const root = mkdtempSync(path.join(tmpdir(), "audit-forwarder-timeout-")); + fixtures.push(root); + const logDir = path.join(root, "logs"); + const checkpointPath = path.join(root, "checkpoint.json"); + await fs.mkdir(logDir, { recursive: true }); + await fs.writeFile( + path.join(logDir, "audit.log"), + '{"timestamp":"2026-03-01T00:00:00Z","action":"request.start"}\n', + "utf8", + ); + + await withServer((_req, _res) => { + // Intentionally never respond. + }, async (endpoint) => { + const result = await runForwarder( + [ + `--endpoint=${endpoint}`, + `--log-dir=${logDir}`, + `--checkpoint=${checkpointPath}`, + ], + { + CODEX_AUDIT_FORWARDER_MAX_ATTEMPTS: "2", + CODEX_AUDIT_FORWARDER_TIMEOUT_MS: "50", + }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("audit-log-forwarder failed"); + }); + + await expect(fs.stat(checkpointPath)).rejects.toThrow(); + }); + + it("waits for checkpoint lock release and then completes", async () => { + const root = mkdtempSync(path.join(tmpdir(), "audit-forwarder-lock-")); + fixtures.push(root); + const logDir = path.join(root, "logs"); + const checkpointPath = path.join(root, "checkpoint.json"); + const checkpointLockPath = `${checkpointPath}.lock`; + await fs.mkdir(logDir, { recursive: true }); + await fs.writeFile( + path.join(logDir, "audit.log"), + '{"timestamp":"2026-03-01T00:00:00Z","action":"request.start"}\n', + "utf8", + ); + await fs.writeFile(checkpointLockPath, "locked", "utf8"); + + await withServer((_req, res) => { + res.statusCode = 200; + res.end("ok"); + }, async (endpoint) => { + const releaseTimer = setTimeout(async () => { + await fs.unlink(checkpointLockPath).catch(() => {}); + }, 50); + try { + const result = await runForwarder( + [ + `--endpoint=${endpoint}`, + `--log-dir=${logDir}`, + `--checkpoint=${checkpointPath}`, + ], + { + CODEX_AUDIT_FORWARDER_MAX_ATTEMPTS: "2", + CODEX_AUDIT_FORWARDER_TIMEOUT_MS: "2000", + CODEX_AUDIT_FORWARDER_MAX_WAIT_MS: "2000", + CODEX_AUDIT_FORWARDER_LOCK_MAX_ATTEMPTS: "200", + }, + ); + expect(result.status).toBe(0); + } finally { + clearTimeout(releaseTimer); + } + }); + + const checkpoint = JSON.parse(await fs.readFile(checkpointPath, "utf8")) as { + file?: string; + line?: number; + }; + expect(checkpoint.file).toBe("audit.log"); + expect(checkpoint.line).toBe(1); + }); + + it("clears stale checkpoint locks and proceeds", async () => { + const root = mkdtempSync(path.join(tmpdir(), "audit-forwarder-stale-lock-")); + fixtures.push(root); + const logDir = path.join(root, "logs"); + const checkpointPath = path.join(root, "checkpoint.json"); + const checkpointLockPath = `${checkpointPath}.lock`; + await fs.mkdir(logDir, { recursive: true }); + await fs.writeFile( + path.join(logDir, "audit.log"), + '{"timestamp":"2026-03-01T00:00:00Z","action":"request.start"}\n', + "utf8", + ); + await fs.writeFile(checkpointLockPath, "999999\n", "utf8"); + const staleDate = new Date(Date.now() - 60 * 1000); + utimesSync(checkpointLockPath, staleDate, staleDate); + + await withServer((_req, res) => { + res.statusCode = 200; + res.end("ok"); + }, async (endpoint) => { + const result = await runForwarder( + [ + `--endpoint=${endpoint}`, + `--log-dir=${logDir}`, + `--checkpoint=${checkpointPath}`, + ], + { + CODEX_AUDIT_FORWARDER_STALE_LOCK_MS: "50", + CODEX_AUDIT_FORWARDER_MAX_WAIT_MS: "500", + }, + ); + expect(result.status).toBe(0); + const payload = parseJsonStdout(result.stdout); + expect(payload.status).toBe("sent"); + expect(payload.sent).toBe(1); + }); + + const checkpoint = JSON.parse(await fs.readFile(checkpointPath, "utf8")) as { + file?: string; + line?: number; + }; + expect(checkpoint.file).toBe("audit.log"); + expect(checkpoint.line).toBe(1); + }); + + it("fails with a clear timeout when checkpoint lock contention persists", async () => { + const root = mkdtempSync(path.join(tmpdir(), "audit-forwarder-lock-timeout-")); + fixtures.push(root); + const logDir = path.join(root, "logs"); + const checkpointPath = path.join(root, "checkpoint.json"); + const checkpointLockPath = `${checkpointPath}.lock`; + await fs.mkdir(logDir, { recursive: true }); + await fs.writeFile( + path.join(logDir, "audit.log"), + '{"timestamp":"2026-03-01T00:00:00Z","action":"request.start"}\n', + "utf8", + ); + await fs.writeFile(checkpointLockPath, `${process.pid}\n`, "utf8"); + + await withServer((_req, res) => { + res.statusCode = 200; + res.end("ok"); + }, async (endpoint) => { + const result = await runForwarder( + [ + `--endpoint=${endpoint}`, + `--log-dir=${logDir}`, + `--checkpoint=${checkpointPath}`, + ], + { + CODEX_AUDIT_FORWARDER_MAX_WAIT_MS: "80", + CODEX_AUDIT_FORWARDER_LOCK_MAX_ATTEMPTS: "10", + }, + ); + expect(result.status).toBe(1); + expect(result.stderr).toContain("Timed out acquiring checkpoint lock"); + }); + }); + + it("continues when newest log disappears before final mtime stat", async () => { + const root = mkdtempSync(path.join(tmpdir(), "audit-forwarder-newest-race-")); + fixtures.push(root); + const logDir = path.join(root, "logs"); + const checkpointPath = path.join(root, "checkpoint.json"); + const newestLogPath = path.join(logDir, "audit.log"); + await fs.mkdir(logDir, { recursive: true }); + await fs.writeFile( + newestLogPath, + '{"timestamp":"2026-03-01T00:00:00Z","action":"request.start"}\n', + "utf8", + ); + + await withServer(async (_req, res) => { + await fs.unlink(newestLogPath).catch(() => {}); + res.statusCode = 200; + res.end("ok"); + }, async (endpoint) => { + const result = await runForwarder([ + `--endpoint=${endpoint}`, + `--log-dir=${logDir}`, + `--checkpoint=${checkpointPath}`, + ]); + expect(result.status).toBe(0); + const payload = parseJsonStdout(result.stdout); + expect(payload.status).toBe("sent"); + expect(payload.newestLogMtimeMs).toBeNull(); + }); + + const checkpoint = JSON.parse(await fs.readFile(checkpointPath, "utf8")) as { + file?: string; + line?: number; + }; + expect(checkpoint.file).toBe("audit.log"); + expect(checkpoint.line).toBe(1); + }); +}); diff --git a/test/audit-retry.test.ts b/test/audit-retry.test.ts new file mode 100644 index 000000000..70e8e62f9 --- /dev/null +++ b/test/audit-retry.test.ts @@ -0,0 +1,80 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function createBusyError(): NodeJS.ErrnoException { + const error = new Error("resource busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + return error; +} + +describe("audit purge retry handling", () => { + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + vi.doUnmock("node:fs"); + vi.doUnmock("../lib/runtime-paths.js"); + vi.restoreAllMocks(); + }); + + it("retries stale audit log deletion on EBUSY and eventually purges", async () => { + const atomicsWaitSpy = vi.spyOn(Atomics, "wait"); + const writeFileSync = vi.fn(); + const mkdirSync = vi.fn(); + const existsSync = vi.fn((target: string) => !target.endsWith("audit.log")); + const statSync = vi.fn(() => ({ + mtimeMs: 0, + size: 0, + })); + const renameSync = vi.fn(); + const readdirSync = vi.fn(() => ["audit.1.log"]); + const chmodSync = vi.fn(); + + let unlinkAttempts = 0; + const unlinkSync = vi.fn(() => { + unlinkAttempts += 1; + if (unlinkAttempts < 3) { + throw createBusyError(); + } + }); + + vi.doMock("node:fs", () => ({ + writeFileSync, + mkdirSync, + existsSync, + statSync, + renameSync, + readdirSync, + unlinkSync, + chmodSync, + })); + vi.doMock("../lib/runtime-paths.js", () => ({ + getCodexLogDir: () => "/tmp/codex-logs", + })); + + const audit = await import("../lib/audit.js"); + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(3_000_000_000); + + try { + audit.configureAudit({ + enabled: true, + logDir: "/tmp/codex-logs", + retentionDays: 1, + maxFileSizeBytes: 1024, + maxFiles: 3, + }); + audit.auditLog( + audit.AuditAction.REQUEST_START, + "actor@example.com", + "resource", + audit.AuditOutcome.SUCCESS, + ); + } finally { + nowSpy.mockRestore(); + } + + expect(unlinkSync).toHaveBeenCalledTimes(3); + expect(writeFileSync).toHaveBeenCalledTimes(1); + expect(atomicsWaitSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/test/audit.test.ts b/test/audit.test.ts index b04038179..5b3594373 100644 --- a/test/audit.test.ts +++ b/test/audit.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { join } from "node:path"; -import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, rmSync, existsSync, readFileSync, statSync, writeFileSync, utimesSync } from "node:fs"; import { tmpdir } from "node:os"; import { AuditAction, @@ -207,6 +207,24 @@ describe("Audit logging", () => { expect(lines.length).toBe(2); }); + + it("keeps secure 0600 mode on existing audit logs (posix)", () => { + if (process.platform === "win32") { + auditLog(AuditAction.REQUEST_START, "actor", "resource", AuditOutcome.SUCCESS); + const content = readFileSync(getAuditLogPath(), "utf8"); + expect(content).toContain("\"action\":\"request.start\""); + return; + } + const logPath = getAuditLogPath(); + writeFileSync(logPath, "existing\n", "utf8"); + chmodSync(logPath, 0o644); + + auditLog(AuditAction.REQUEST_START, "actor", "resource", AuditOutcome.SUCCESS); + + const stats = statSync(logPath); + expect(stats.mode & 0o777).toBe(0o600); + }); + }); describe("log rotation", () => { @@ -230,6 +248,55 @@ describe("Audit logging", () => { const files = listAuditLogFiles(); expect(files.length).toBeLessThanOrEqual(3); }); + + it("purges stale rotated logs during write cycle", () => { + configureAudit({ retentionDays: 1 }); + const fixedNowMs = 2_000_000_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(fixedNowMs); + const staleLogPath = join(testLogDir, "audit.1.log"); + writeFileSync(staleLogPath, "old\n", "utf8"); + const staleMs = Date.now() - 3 * 24 * 60 * 60 * 1000; + const staleDate = new Date(staleMs); + utimesSync(staleLogPath, staleDate, staleDate); + + try { + auditLog(AuditAction.REQUEST_START, "actor", "resource", AuditOutcome.SUCCESS); + } finally { + nowSpy.mockRestore(); + } + + expect(existsSync(staleLogPath)).toBe(false); + }); + + it("does not throttle purge when a prior directory read fails", () => { + const fixedNowMs = 2_000_000_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(fixedNowMs); + try { + const blockedLogDir = join(testLogDir, "blocked-log-dir"); + writeFileSync(blockedLogDir, "not-a-directory", "utf8"); + configureAudit({ + enabled: true, + logDir: blockedLogDir, + maxFileSizeBytes: 1024, + maxFiles: 3, + retentionDays: 1, + }); + + auditLog(AuditAction.REQUEST_START, "actor", "resource", AuditOutcome.SUCCESS); + + rmSync(blockedLogDir); + mkdirSync(blockedLogDir, { recursive: true }); + const staleLogPath = join(blockedLogDir, "audit.1.log"); + writeFileSync(staleLogPath, "stale\n", "utf8"); + const staleDate = new Date(fixedNowMs - 3 * 24 * 60 * 60 * 1000); + utimesSync(staleLogPath, staleDate, staleDate); + + auditLog(AuditAction.REQUEST_SUCCESS, "actor", "resource", AuditOutcome.SUCCESS); + expect(existsSync(staleLogPath)).toBe(false); + } finally { + nowSpy.mockRestore(); + } + }); }); describe("listAuditLogFiles", () => { @@ -254,6 +321,7 @@ describe("Audit logging", () => { expect(AuditAction.CONFIG_LOAD).toBe("config.load"); expect(AuditAction.REQUEST_START).toBe("request.start"); expect(AuditAction.CIRCUIT_OPEN).toBe("circuit.open"); + expect(AuditAction.COMMAND_RUN).toBe("command.run"); }); }); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 27261cd27..be7bffe1c 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -18,6 +18,7 @@ const saveQuotaCacheMock = vi.fn(); const loadPluginConfigMock = vi.fn(); const savePluginConfigMock = vi.fn(); const selectMock = vi.fn(); +const auditLogMock = vi.fn(); vi.mock("../lib/logger.js", () => ({ createLogger: vi.fn(() => ({ @@ -27,8 +28,17 @@ vi.mock("../lib/logger.js", () => ({ error: vi.fn(), })), logWarn: vi.fn(), + maskEmail: vi.fn((email: string) => email.replace(/^(.).+(@.*)$/, "$1***$2")), })); +vi.mock("../lib/audit.js", async () => { + const actual = await vi.importActual("../lib/audit.js"); + return { + ...(actual as Record), + auditLog: auditLogMock, + }; +}); + vi.mock("../lib/auth/auth.js", () => ({ createAuthorizationFlow: vi.fn(), exchangeAuthorizationCode: vi.fn(), @@ -198,6 +208,7 @@ describe("codex manager cli commands", () => { loadPluginConfigMock.mockReset(); savePluginConfigMock.mockReset(); selectMock.mockReset(); + auditLogMock.mockReset(); fetchCodexQuotaSnapshotMock.mockResolvedValue({ status: 200, model: "gpt-5-codex", @@ -1970,6 +1981,122 @@ describe("codex manager cli commands", () => { expect(saveAccountsMock.mock.calls[0]?.[0]?.accounts?.[0]?.enabled).toBe(false); }); + it("maps audited commands to expected actions and outcomes", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const { AuditAction, AuditOutcome } = await import("../lib/audit.js"); + const { createAuthorizationFlow } = await import("../lib/auth/auth.js"); + + vi.mocked(createAuthorizationFlow).mockRejectedValueOnce(new Error("mock login failure")); + await expect(runCodexMultiAuthCli(["auth", "login"])).rejects.toThrow("mock login failure"); + expect(auditLogMock).toHaveBeenCalledWith( + AuditAction.AUTH_LOGIN, + "cli-user", + "codex auth login", + AuditOutcome.FAILURE, + expect.objectContaining({ command: "login", error: expect.any(String) }), + ); + + auditLogMock.mockClear(); + const switchCode = await runCodexMultiAuthCli(["auth", "switch"]); + expect(switchCode).toBe(1); + expect(auditLogMock).toHaveBeenCalledWith( + AuditAction.ACCOUNT_SWITCH, + "cli-user", + "codex auth switch", + AuditOutcome.FAILURE, + expect.objectContaining({ command: "switch", exitCode: 1 }), + ); + + auditLogMock.mockClear(); + loadAccountsMock.mockResolvedValueOnce(null); + const checkCode = await runCodexMultiAuthCli(["auth", "check"]); + expect(checkCode).toBe(0); + expect(auditLogMock).toHaveBeenCalledWith( + AuditAction.REQUEST_START, + "cli-user", + "codex auth check", + AuditOutcome.SUCCESS, + expect.objectContaining({ command: "check", exitCode: 0 }), + ); + + auditLogMock.mockClear(); + loadFlaggedAccountsMock.mockResolvedValueOnce({ version: 1, accounts: [] }); + const verifyCode = await runCodexMultiAuthCli(["auth", "verify-flagged", "--json"]); + expect(verifyCode).toBe(0); + expect(auditLogMock).toHaveBeenCalledWith( + AuditAction.ACCOUNT_REFRESH, + "cli-user", + "codex auth verify-flagged", + AuditOutcome.SUCCESS, + expect.objectContaining({ command: "verify-flagged", exitCode: 0 }), + ); + + auditLogMock.mockClear(); + loadAccountsMock.mockResolvedValueOnce(null); + const forecastCode = await runCodexMultiAuthCli(["auth", "forecast", "--json"]); + expect(forecastCode).toBe(0); + expect(auditLogMock).toHaveBeenCalledWith( + AuditAction.COMMAND_RUN, + "cli-user", + "codex auth forecast", + AuditOutcome.SUCCESS, + expect.objectContaining({ command: "forecast", exitCode: 0 }), + ); + + auditLogMock.mockClear(); + loadAccountsMock.mockResolvedValueOnce(null); + const statusCode = await runCodexMultiAuthCli(["auth", "status"]); + expect(statusCode).toBe(0); + expect(auditLogMock).toHaveBeenCalledWith( + AuditAction.COMMAND_RUN, + "cli-user", + "codex auth status", + AuditOutcome.SUCCESS, + expect.objectContaining({ command: "status", exitCode: 0 }), + ); + }); + + it("sanitizes audited thrown errors with token and email redaction", async () => { + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const { AuditAction, AuditOutcome } = await import("../lib/audit.js"); + const secretError = new Error( + [ + "EBUSY while refreshing account", + "HTTP 429 from upstream", + "user@example.com", + "sk-test-secret-abcdefghijklmnopqrstuvwxyz", + "refresh_token_sensitive-abcdef12", + "access_token_sensitive-qwerty12", + "secret-access-token", + "x".repeat(260), + ].join(" | "), + ); + loadAccountsMock.mockRejectedValueOnce(secretError); + + await expect(runCodexMultiAuthCli(["auth", "status"])).rejects.toThrow(secretError); + expect(auditLogMock).toHaveBeenCalledTimes(1); + + const call = auditLogMock.mock.calls[0]; + expect(call?.[0]).toBe(AuditAction.COMMAND_RUN); + expect(call?.[3]).toBe(AuditOutcome.FAILURE); + const metadata = call?.[4] as Record | undefined; + const sanitizedError = typeof metadata?.error === "string" ? metadata.error : ""; + expect(sanitizedError.length).toBeLessThanOrEqual(200); + expect(sanitizedError).toContain("EBUSY"); + expect(sanitizedError).toContain("429"); + expect(sanitizedError).not.toContain("sk-test-secret-abcdefghijklmnopqrstuvwxyz"); + expect(sanitizedError).not.toContain("refresh_token_sensitive-abcdef12"); + expect(sanitizedError).not.toContain("access_token_sensitive-qwerty12"); + expect(sanitizedError).not.toContain("secret-access-token"); + expect(sanitizedError).not.toContain("user@example.com"); + expect(sanitizedError).toContain("***REDACTED***"); + }); + it("keeps settings unchanged in non-interactive mode and returns to menu", async () => { const now = Date.now(); loadAccountsMock.mockResolvedValue({ diff --git a/test/compliance-evidence-bundle.test.ts b/test/compliance-evidence-bundle.test.ts new file mode 100644 index 000000000..78d43b26f --- /dev/null +++ b/test/compliance-evidence-bundle.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync, writeFileSync, chmodSync, mkdirSync } from "node:fs"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; + +const scriptPath = path.resolve(process.cwd(), "scripts", "compliance-evidence-bundle.js"); + +async function removeWithRetry(targetPath: string): Promise { + const retryableCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return; + if (!code || !retryableCodes.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +function createFakeNpmBin(root: string): string { + const binDir = path.join(root, "bin"); + mkdirSync(binDir, { recursive: true }); + const fakeNpmPath = path.join(binDir, "fake-npm.js"); + const npmShellPath = path.join(binDir, "npm"); + const npmCmdPath = path.join(binDir, "npm.cmd"); + const fakeNpmSource = ` +const fs = require("node:fs"); +const markerPath = process.env.FAKE_NPM_MARKER_PATH; +if (markerPath) { + fs.writeFileSync(markerPath, process.env.FAKE_NPM_WRAPPER ?? "unknown", "utf8"); +} +const args = process.argv.slice(2); +if (args[0] === "sbom") { + process.stdout.write(JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.5", metadata: "x".repeat(1_500_000) })); + process.exit(0); +} +const chunk = "verbose-output-" + "x".repeat(300) + "\\n"; +let output = ""; +for (let index = 0; index < 5000; index += 1) { + output += chunk; +} +process.stdout.write(output); +process.exit(0); +`.trimStart(); + const nodeExecPosix = process.execPath.replace(/\\/g, "/").replace(/"/g, '\\"'); + const nodeExecWindows = process.execPath.replace(/"/g, '""'); + const npmShellSource = `#!/usr/bin/env sh\nFAKE_NPM_WRAPPER=sh \"${nodeExecPosix}\" \"${fakeNpmPath.replace(/\\/g, "/")}\" \"$@\"\n`; + const npmCmdSource = `@echo off\r\nset FAKE_NPM_WRAPPER=cmd\r\n\"${nodeExecWindows}\" \"%~dp0\\fake-npm.js\" %*\r\n`; + writeFileSync(fakeNpmPath, fakeNpmSource, "utf8"); + writeFileSync(npmShellPath, npmShellSource, "utf8"); + writeFileSync(npmCmdPath, npmCmdSource, "utf8"); + if (process.platform !== "win32") { + chmodSync(npmShellPath, 0o755); + chmodSync(fakeNpmPath, 0o755); + } + return binDir; +} + +describe("compliance-evidence-bundle script", () => { + const fixtures: string[] = []; + + afterEach(async () => { + while (fixtures.length > 0) { + const fixture = fixtures.pop(); + if (!fixture) continue; + await removeWithRetry(fixture); + } + }); + + it("handles verbose npm output without maxBuffer overflow", async () => { + const root = mkdtempSync(path.join(tmpdir(), "compliance-bundle-")); + fixtures.push(root); + const outDir = path.join(root, "evidence"); + const binDir = createFakeNpmBin(root); + + const result = spawnSync( + process.execPath, + [scriptPath, "--profile=quick", `--out-dir=${outDir}`], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + }, + }, + ); + + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout) as { + status?: string; + }; + expect(payload.status).toBe("pass"); + + const manifest = JSON.parse(await fs.readFile(path.join(outDir, "manifest.json"), "utf8")) as { + status?: string; + results?: Array<{ id?: string }>; + }; + expect(manifest.status).toBe("pass"); + expect(Array.isArray(manifest.results)).toBe(true); + expect(manifest.results?.length).toBeGreaterThan(0); + + const firstLogStat = await fs.stat(path.join(outDir, "01-typecheck.log")); + expect(firstLogStat.size).toBeGreaterThan(1_000_000); + }); + + it.skipIf(process.platform !== "win32")("uses npm.cmd wrapper on Windows for verbose runs", async () => { + const root = mkdtempSync(path.join(tmpdir(), "compliance-bundle-win32-")); + fixtures.push(root); + const outDir = path.join(root, "evidence"); + const binDir = createFakeNpmBin(root); + const markerPath = path.join(root, "wrapper-marker.txt"); + + const result = spawnSync( + process.execPath, + [scriptPath, "--profile=quick", `--out-dir=${outDir}`], + { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ""}`, + FAKE_NPM_MARKER_PATH: markerPath, + }, + }, + ); + + expect(result.status).toBe(0); + const marker = await fs.readFile(markerPath, "utf8"); + expect(marker.trim()).toBe("cmd"); + }); +}); diff --git a/test/config-save.test.ts b/test/config-save.test.ts index 2064faebd..c8da887b9 100644 --- a/test/config-save.test.ts +++ b/test/config-save.test.ts @@ -24,6 +24,12 @@ async function removeWithRetry( } } +async function expectSecureFileMode(path: string): Promise { + if (process.platform === "win32") return; + const stats = await fs.stat(path); + expect(stats.mode & 0o777).toBe(0o600); +} + describe("plugin config save paths", () => { let tempDir = ""; const envKeys = [ @@ -91,6 +97,7 @@ describe("plugin config save paths", () => { expect(parsed.unsupportedCodexFallbackChain).toEqual({ "gpt-5": ["gpt-4o"], }); + await expectSecureFileMode(configPath); }); it("recovers from malformed env-path JSON before saving", async () => { diff --git a/test/enterprise-health-check.test.ts b/test/enterprise-health-check.test.ts new file mode 100644 index 000000000..160fb46d3 --- /dev/null +++ b/test/enterprise-health-check.test.ts @@ -0,0 +1,162 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; + +const scriptPath = path.resolve(process.cwd(), "scripts", "enterprise-health-check.js"); + +function runHealthCheck(args: string[], env: NodeJS.ProcessEnv = {}) { + return spawnSync(process.execPath, [scriptPath, ...args], { + encoding: "utf8", + env: { ...process.env, ...env }, + }); +} + +function parseJsonStdout(output: string): Record { + return JSON.parse(output) as Record; +} + +function pathsEqual(left: string, right: string): boolean { + const normalizedLeft = process.platform === "win32" ? left.replaceAll("/", "\\").toLowerCase() : left; + const normalizedRight = process.platform === "win32" ? right.replaceAll("/", "\\").toLowerCase() : right; + return normalizedLeft === normalizedRight; +} + +describe("enterprise-health-check script", () => { + const fixtures: string[] = []; + + afterEach(async () => { + while (fixtures.length > 0) { + const fixture = fixtures.pop(); + if (!fixture) continue; + await removeWithRetry(fixture); + } + }); + + it("fails require-files mode when runtime artifacts are missing", () => { + const root = mkdtempSync(path.join(tmpdir(), "health-check-missing-")); + fixtures.push(root); + + const result = runHealthCheck(["--require-files", `--root=${root}`]); + expect(result.status).toBe(1); + expect(result.stdout).not.toBe(""); + + const payload = parseJsonStdout(result.stdout); + expect(payload.status).toBe("fail"); + const findings = Array.isArray(payload.findings) ? payload.findings : []; + expect(findings.some((finding) => (finding as { code?: string }).code === "missing-storage-file")).toBe( + true, + ); + expect(findings.some((finding) => (finding as { code?: string }).code === "missing-audit-events")).toBe( + true, + ); + }); + + it("resolves fallback multi-auth root for audit checks when account storage exists there", async () => { + const homeRoot = mkdtempSync(path.join(tmpdir(), "health-check-home-")); + fixtures.push(homeRoot); + + const primaryRoot = path.join(homeRoot, ".codex", "multi-auth"); + const fallbackRoot = path.join(homeRoot, "DevTools", "config", "codex", "multi-auth"); + await fs.mkdir(path.join(primaryRoot), { recursive: true }); + await fs.mkdir(path.join(fallbackRoot, "logs"), { recursive: true }); + await fs.writeFile( + path.join(fallbackRoot, "openai-codex-accounts.json"), + '{"version":3,"accounts":[],"activeIndex":0}\n', + "utf8", + ); + await fs.writeFile( + path.join(fallbackRoot, "settings.json"), + '{"version":1,"pluginConfig":{},"dashboardDisplaySettings":{}}\n', + "utf8", + ); + await fs.writeFile(path.join(fallbackRoot, "logs", "audit.log"), '{"timestamp":"2026-03-01T00:00:00Z"}\n'); + + const result = runHealthCheck([], { + HOME: homeRoot, + USERPROFILE: homeRoot, + CODEX_HOME: "", + CODEX_MULTI_AUTH_DIR: "", + }); + expect(result.status).toBe(0); + + const payload = parseJsonStdout(result.stdout); + const payloadRoot = String(payload.root ?? ""); + if (process.platform === "win32") { + expect(payloadRoot.toLowerCase()).toBe(fallbackRoot.toLowerCase()); + } else { + expect(payloadRoot).toBe(fallbackRoot); + } + expect(pathsEqual(String(payload.auditDir), path.join(fallbackRoot, "logs"))).toBe(true); + expect(payload.status).toBe("pass"); + }); + + it("evaluates stale audit checks from fallback audit directory", async () => { + const homeRoot = mkdtempSync(path.join(tmpdir(), "health-check-stale-fallback-")); + fixtures.push(homeRoot); + + const fallbackRoot = path.join(homeRoot, "DevTools", "config", "codex", "multi-auth"); + const fallbackAuditDir = path.join(fallbackRoot, "logs"); + await fs.mkdir(fallbackAuditDir, { recursive: true }); + await fs.writeFile( + path.join(fallbackRoot, "openai-codex-accounts.json"), + '{"version":3,"accounts":[],"activeIndex":0}\n', + "utf8", + ); + await fs.writeFile( + path.join(fallbackRoot, "settings.json"), + '{"version":1,"pluginConfig":{},"dashboardDisplaySettings":{}}\n', + "utf8", + ); + + const staleAuditPath = path.join(fallbackAuditDir, "audit.log"); + await fs.writeFile(staleAuditPath, '{"timestamp":"2025-01-01T00:00:00Z"}\n', "utf8"); + const staleMtimeMs = Date.now() - 9 * 24 * 60 * 60 * 1000; + const staleDate = new Date(staleMtimeMs); + await fs.utimes(staleAuditPath, staleDate, staleDate); + + const result = runHealthCheck([], { + HOME: homeRoot, + USERPROFILE: homeRoot, + CODEX_HOME: "", + CODEX_MULTI_AUTH_DIR: "", + }); + + expect(result.status).toBe(0); + const payload = parseJsonStdout(result.stdout); + expect(pathsEqual(String(payload.auditDir), fallbackAuditDir)).toBe(true); + + const findings = Array.isArray(payload.findings) ? payload.findings : []; + const staleAuditFinding = findings.find((entry) => (entry as { code?: string }).code === "stale-audit-log") as + | { path?: string } + | undefined; + expect(staleAuditFinding).toBeDefined(); + expect(pathsEqual(String(staleAuditFinding?.path ?? ""), fallbackAuditDir)).toBe(true); + }); + + it("treats audit directory churn (ENOTDIR) as no-audit-data instead of throwing", async () => { + const root = mkdtempSync(path.join(tmpdir(), "health-check-churn-")); + fixtures.push(root); + const auditPath = path.join(root, "logs"); + await fs.writeFile(auditPath, "not-a-directory", "utf8"); + + const result = runHealthCheck([], { + CODEX_MULTI_AUTH_DIR: root, + }); + + expect(result.status).toBe(0); + const payload = parseJsonStdout(result.stdout); + expect(pathsEqual(String(payload.auditDir), auditPath)).toBe(true); + const checks = Array.isArray(payload.checks) + ? payload.checks + : []; + const newestAuditCheck = checks.find((entry) => (entry as { name?: string }).name === "newest-audit-mtime-ms") as + | { value?: unknown } + | undefined; + expect(newestAuditCheck?.value ?? null).toBeNull(); + }); +}); diff --git a/test/generate-sbom.test.ts b/test/generate-sbom.test.ts new file mode 100644 index 000000000..fffd877ef --- /dev/null +++ b/test/generate-sbom.test.ts @@ -0,0 +1,85 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { chmodSync, mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; + +const scriptPath = path.resolve(process.cwd(), "scripts", "generate-sbom.js"); + +async function removeWithRetry(targetPath: string): Promise { + const retryableCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return; + if (!code || !retryableCodes.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +function createFakeNpmScript(root: string): string { + const binDir = path.join(root, "bin"); + mkdirSync(binDir, { recursive: true }); + const fakeNpmPath = path.join(binDir, "fake-npm.js"); + const fakeNpmSource = ` +const args = process.argv.slice(2); +if (args[0] === "sbom") { + process.stdout.write(JSON.stringify({ bomFormat: "CycloneDX", specVersion: "1.5", metadata: "x".repeat(1_500_000), components: [] })); + process.exit(0); +} +process.stdout.write("ok\\n"); +`.trimStart(); + writeFileSync(fakeNpmPath, fakeNpmSource, "utf8"); + if (process.platform !== "win32") { + chmodSync(fakeNpmPath, 0o755); + } + return fakeNpmPath; +} + +describe("generate-sbom script", () => { + const fixtures: string[] = []; + + afterEach(async () => { + while (fixtures.length > 0) { + const fixture = fixtures.pop(); + if (!fixture) continue; + await removeWithRetry(fixture); + } + }); + + it("writes large sbom output without hitting child-process maxBuffer", async () => { + const root = mkdtempSync(path.join(tmpdir(), "generate-sbom-")); + fixtures.push(root); + const fakeNpmPath = createFakeNpmScript(root); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + npm_execpath: fakeNpmPath, + NPM_EXECPATH: fakeNpmPath, + }, + }); + + expect( + result.status, + `stderr: ${result.stderr}\nstdout: ${String(result.stdout).slice(0, 200)}`, + ).toBe(0); + const payload = JSON.parse(result.stdout) as { status?: string; outputPath?: string }; + expect(payload.status).toBe("pass"); + + const sbomPath = path.join(root, ".tmp", "sbom.cdx.json"); + const raw = await fs.readFile(sbomPath, "utf8"); + expect(raw.length).toBeGreaterThan(1_000_000); + expect(() => JSON.parse(raw)).not.toThrow(); + }); +}); diff --git a/test/helpers/remove-with-retry.ts b/test/helpers/remove-with-retry.ts new file mode 100644 index 000000000..a787da98e --- /dev/null +++ b/test/helpers/remove-with-retry.ts @@ -0,0 +1,18 @@ +import { promises as fs } from "node:fs"; + +export async function removeWithRetry(targetPath: string): Promise { + const retryableCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, { recursive: true, force: true }); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return; + if (!code || !retryableCodes.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} diff --git a/test/retention-cleanup.test.ts b/test/retention-cleanup.test.ts new file mode 100644 index 000000000..d50a1f9b6 --- /dev/null +++ b/test/retention-cleanup.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { chmodSync, mkdtempSync, utimesSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { promises as fs } from "node:fs"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; + +const scriptPath = path.resolve(process.cwd(), "scripts", "retention-cleanup.js"); + +function runRetentionCleanup(root: string, extraArgs: string[] = []) { + return spawnSync(process.execPath, [scriptPath, ...extraArgs], { + encoding: "utf8", + env: { + ...process.env, + CODEX_MULTI_AUTH_DIR: root, + }, + }); +} + +function parseJsonStdout(output: string): Record { + return JSON.parse(output) as Record; +} + +describe("retention-cleanup script", () => { + const fixtures: string[] = []; + + afterEach(async () => { + while (fixtures.length > 0) { + const fixture = fixtures.pop(); + if (!fixture) continue; + await removeWithRetry(fixture); + } + }); + + it("handles target directory churn (ENOTDIR) gracefully", async () => { + const root = mkdtempSync(path.join(tmpdir(), "retention-churn-")); + fixtures.push(root); + await fs.writeFile(path.join(root, "logs"), "not-a-dir", "utf8"); + + const result = runRetentionCleanup(root, ["--days=1"]); + expect(result.status).toBe(0); + const payload = parseJsonStdout(result.stdout); + expect(payload.status).toBe("pass"); + expect(payload.failedFiles).toBe(0); + }); + + it.skipIf(process.platform === "win32")("exits non-zero when deletions fail", async () => { + const root = mkdtempSync(path.join(tmpdir(), "retention-fail-")); + fixtures.push(root); + const logsDir = path.join(root, "logs"); + await fs.mkdir(logsDir, { recursive: true }); + const stalePath = path.join(logsDir, "stale.log"); + await fs.writeFile(stalePath, "stale\n", "utf8"); + const staleDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + utimesSync(stalePath, staleDate, staleDate); + + chmodSync(logsDir, 0o500); + let result: SpawnSyncReturns; + try { + result = runRetentionCleanup(root, ["--days=1"]); + } finally { + chmodSync(logsDir, 0o700); + } + + expect(result.status).toBe(1); + const payload = parseJsonStdout(result.stdout); + expect(payload.status).toBe("partial"); + expect((payload.failedFiles as number) > 0).toBe(true); + }); +}); diff --git a/test/schemas.test.ts b/test/schemas.test.ts index 16cd2f97d..deaebb5cd 100644 --- a/test/schemas.test.ts +++ b/test/schemas.test.ts @@ -3,6 +3,7 @@ import { PluginConfigSchema, AccountMetadataV3Schema, AccountStorageV3Schema, + AccountStorageV4Schema, AccountStorageV1Schema, AnyAccountStorageSchema, TokenSuccessSchema, @@ -237,6 +238,48 @@ describe("AccountStorageV3Schema", () => { }); }); +describe("AccountStorageV4Schema", () => { + const validStorage = { + version: 4, + accounts: [ + { refreshTokenRef: "acct-1:refresh", accessTokenRef: "acct-1:access", addedAt: Date.now(), lastUsed: Date.now() }, + ], + activeIndex: 0, + }; + + it("accepts valid V4 storage", () => { + const result = AccountStorageV4Schema.safeParse(validStorage); + expect(result.success).toBe(true); + }); + + it("rejects missing refreshTokenRef", () => { + const result = AccountStorageV4Schema.safeParse({ + ...validStorage, + accounts: [{ addedAt: Date.now(), lastUsed: Date.now() }], + }); + expect(result.success).toBe(false); + }); + + it("rejects empty accessTokenRef when provided", () => { + const result = AccountStorageV4Schema.safeParse({ + ...validStorage, + accounts: [{ refreshTokenRef: "acct-1:refresh", accessTokenRef: "", addedAt: Date.now(), lastUsed: Date.now() }], + }); + expect(result.success).toBe(false); + }); + + it("accepts V4 storage with activeIndexByFamily", () => { + const result = AccountStorageV4Schema.safeParse({ + ...validStorage, + activeIndexByFamily: { + codex: 0, + legacy: 0, + }, + }); + expect(result.success).toBe(true); + }); +}); + describe("AccountStorageV1Schema", () => { const validV1 = { version: 1, @@ -285,6 +328,18 @@ describe("AnyAccountStorageSchema (discriminated union)", () => { } }); + it("accepts V4 storage", () => { + const result = AnyAccountStorageSchema.safeParse({ + version: 4, + accounts: [{ refreshTokenRef: "acct-1:refresh", addedAt: 1, lastUsed: 1 }], + activeIndex: 0, + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.version).toBe(4); + } + }); + it("rejects unknown version", () => { const result = AnyAccountStorageSchema.safeParse({ version: 5, @@ -428,6 +483,16 @@ describe("safeParsePluginConfig", () => { }); describe("safeParseAccountStorage", () => { + it("returns parsed V4 storage", () => { + const result = safeParseAccountStorage({ + version: 4, + accounts: [{ refreshTokenRef: "acct-1:refresh", addedAt: 1, lastUsed: 1 }], + activeIndex: 0, + }); + expect(result).not.toBeNull(); + expect(result?.version).toBe(4); + }); + it("returns parsed V1 storage", () => { const result = safeParseAccountStorage({ version: 1, diff --git a/test/security/secret-scan-regression.test.ts b/test/security/secret-scan-regression.test.ts new file mode 100644 index 000000000..2382471fe --- /dev/null +++ b/test/security/secret-scan-regression.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { mkdtempSync } from "node:fs"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { removeWithRetry } from "../helpers/remove-with-retry.js"; + +const secretPattern = /OPENAI_API_KEY=sk-[A-Za-z0-9-]{10,}/; +const allowlistedFixture = /test[\\/]+security[\\/]+fixtures[\\/]+fixture\.txt$/i; + +async function collectSyntheticFindings(root: string): Promise> { + const findings: Array<{ File: string }> = []; + const stack = [root]; + while (stack.length > 0) { + const current = stack.pop(); + if (!current) continue; + const entries = await fs.readdir(current, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(current, entry.name); + if (entry.isDirectory()) { + stack.push(fullPath); + continue; + } + if (!entry.isFile()) continue; + const rel = path.relative(root, fullPath).replace(/\\/g, "/"); + const content = await fs.readFile(fullPath, "utf8"); + if (!secretPattern.test(content)) continue; + if (allowlistedFixture.test(rel)) continue; + findings.push({ File: rel }); + } + } + return findings; +} + +describe("secret scan regression harness", () => { + const fixtures: string[] = []; + + afterEach(async () => { + while (fixtures.length > 0) { + const fixture = fixtures.pop(); + if (!fixture) continue; + await removeWithRetry(fixture); + } + }); + + it("fixture allowlist regex handles windows paths", () => { + expect(/^test[\\/]security[\\/]fixtures[\\/]/i.test("test\\security\\fixtures\\fixture.txt")).toBe( + true, + ); + expect("test\\security\\fixtures\\fixture.txt".replace(/\\/g, "/")).toBe( + "test/security/fixtures/fixture.txt", + ); + }); + + it("keeps fixture allowlist behavior and flags only non-allowlisted secrets", async () => { + const repoRoot = process.cwd(); + const gitleaksConfig = await fs.readFile(path.join(repoRoot, ".gitleaks.toml"), "utf8"); + expect(gitleaksConfig).toContain("^test[\\\\/]security[\\\\/]fixtures[\\\\/]"); + + const root = mkdtempSync(path.join(tmpdir(), "secret-scan-regression-")); + fixtures.push(root); + const failCase = path.join(root, "fail-case"); + const passCase = path.join(root, "pass-case"); + await fs.mkdir(path.join(failCase, "src"), { recursive: true }); + await fs.mkdir(path.join(failCase, "test", "security", "fixtures"), { recursive: true }); + await fs.mkdir(path.join(passCase, "test", "security", "fixtures"), { recursive: true }); + + await fs.writeFile( + path.join(failCase, "src", "leak.txt"), + "OPENAI_API_KEY=sk-test-placeholder-leak-12345678901234567890\n", + "utf8", + ); + await fs.writeFile( + path.join(failCase, "test", "security", "fixtures", "fixture.txt"), + "OPENAI_API_KEY=sk-test-allowlist-should-exclude-1234567890\n", + "utf8", + ); + await fs.writeFile( + path.join(failCase, "test", "security", "fixtures", "real-secret.txt"), + "OPENAI_API_KEY=sk-test-placeholder-in-fixture-12345678901234567890\n", + "utf8", + ); + await fs.writeFile(path.join(passCase, "test", "security", "fixtures", "fixture.txt"), "fake_refresh_token_67890\n", "utf8"); + + const failFindings = await collectSyntheticFindings(failCase); + expect(failFindings.some((finding) => finding.File.includes("src/leak.txt"))).toBe(true); + expect(failFindings.some((finding) => finding.File.includes("test/security/fixtures/real-secret.txt"))).toBe(true); + expect(failFindings.some((finding) => finding.File.includes("test/security/fixtures/fixture.txt"))).toBe(false); + + const passFindings = await collectSyntheticFindings(passCase); + expect(passFindings).toEqual([]); + }); +}); diff --git a/test/slo-budget-report.test.ts b/test/slo-budget-report.test.ts new file mode 100644 index 000000000..2250d53e7 --- /dev/null +++ b/test/slo-budget-report.test.ts @@ -0,0 +1,161 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, utimesSync } from "node:fs"; +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { spawnSync } from "node:child_process"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; + +const scriptPath = path.resolve(process.cwd(), "scripts", "slo-budget-report.js"); + +describe("slo-budget-report script", () => { + const fixtures: string[] = []; + + afterEach(async () => { + while (fixtures.length > 0) { + const fixture = fixtures.pop(); + if (!fixture) continue; + await removeWithRetry(fixture); + } + vi.doUnmock("node:child_process"); + vi.restoreAllMocks(); + vi.resetModules(); + delete process.env.CODEX_HEALTH_CHECK_TIMEOUT_MS; + delete process.env.CODEX_SLO_HEALTH_CHECK_SCRIPT; + }); + + it("counts stale-audit-log findings in staleWalFindings evaluation", async () => { + const root = mkdtempSync(path.join(tmpdir(), "slo-budget-")); + fixtures.push(root); + const logDir = path.join(root, "logs"); + const policyPath = path.join(root, "policy.json"); + await fs.mkdir(logDir, { recursive: true }); + const staleAuditPath = path.join(logDir, "audit.log"); + await fs.writeFile(staleAuditPath, '{"timestamp":"2025-01-01T00:00:00Z","action":"request.start"}\n', "utf8"); + const staleDate = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + utimesSync(staleAuditPath, staleDate, staleDate); + await fs.writeFile( + policyPath, + JSON.stringify({ + windowDays: 30, + objectives: { + staleWalFindingsMax: 0, + }, + }), + "utf8", + ); + + const result = spawnSync(process.execPath, [scriptPath, `--policy=${policyPath}`], { + encoding: "utf8", + env: { + ...process.env, + CODEX_MULTI_AUTH_DIR: root, + }, + }); + + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout) as { + health?: { staleWalFindings?: number }; + evaluations?: Array<{ id?: string; status?: string }>; + }; + expect(payload.health?.staleWalFindings).toBe(1); + const staleEval = payload.evaluations?.find((entry) => entry.id === "stale-wal-findings"); + expect(staleEval?.status).toBe("fail"); + }); + + it("runHealthCheck surfaces timeout failures and applies timeout/kill options", async () => { + const execError = new Error("spawn ETIMEDOUT: health check timed out"); + Object.assign(execError, { stdout: "", stderr: "" }); + const execFileSync = vi.fn(() => { + throw execError; + }); + vi.doMock("node:child_process", () => ({ execFileSync })); + process.env.CODEX_HEALTH_CHECK_TIMEOUT_MS = "4321"; + + const module = await import("../scripts/slo-budget-report.js"); + const payload = module.runHealthCheck() as { + status?: string; + findings?: Array<{ code?: string; message?: string }>; + }; + + expect(execFileSync).toHaveBeenCalledTimes(1); + const call = execFileSync.mock.calls[0]; + expect(call[0]).toBe(process.execPath); + expect(call[1]).toEqual(["scripts/enterprise-health-check.js"]); + expect(call[2]).toMatchObject({ + timeout: 4321, + killSignal: "SIGTERM", + }); + expect(payload.status).toBe("fail"); + expect(payload.findings?.[0]?.code).toBe("health-check-exec-failed"); + expect(payload.findings?.[0]?.message).toContain("ETIMEDOUT"); + }); + + it("runHealthCheck falls back to spawn error text when stdout/stderr are empty", async () => { + const execError = new Error("spawn ENOENT: missing health check script"); + Object.assign(execError, { stdout: "", stderr: "" }); + const execFileSync = vi.fn(() => { + throw execError; + }); + vi.doMock("node:child_process", () => ({ execFileSync })); + + const module = await import("../scripts/slo-budget-report.js"); + const payload = module.runHealthCheck() as { + status?: string; + findings?: Array<{ message?: string }>; + }; + + expect(payload.status).toBe("fail"); + expect(payload.findings?.[0]?.message).toContain("ENOENT"); + }); + + it("runHealthCheck honors health script overrides (Windows-compatible path)", async () => { + const execFileSync = vi.fn(() => JSON.stringify({ status: "pass", checks: [], findings: [] })); + vi.doMock("node:child_process", () => ({ execFileSync })); + process.env.CODEX_SLO_HEALTH_CHECK_SCRIPT = "scripts\\enterprise-health-check.js"; + + const module = await import("../scripts/slo-budget-report.js"); + const payload = module.runHealthCheck() as { status?: string }; + + expect(execFileSync).toHaveBeenCalledTimes(1); + expect(execFileSync.mock.calls[0][1]).toEqual(["scripts\\enterprise-health-check.js"]); + expect(payload.status).toBe("pass"); + }); + + it("treats ENOTDIR log path churn as no audit entries instead of crashing", async () => { + const root = mkdtempSync(path.join(tmpdir(), "slo-budget-enotdir-")); + fixtures.push(root); + const logDirFile = path.join(root, "logs"); + const policyPath = path.join(root, "policy.json"); + await fs.writeFile(logDirFile, "not-a-directory\n", "utf8"); + await fs.writeFile( + policyPath, + JSON.stringify({ + windowDays: 7, + objectives: { + requestSuccessRatePercent: 99.9, + }, + }), + "utf8", + ); + + const result = spawnSync(process.execPath, [scriptPath, `--policy=${policyPath}`], { + encoding: "utf8", + env: { + ...process.env, + CODEX_MULTI_AUTH_DIR: root, + }, + }); + + expect(result.status).toBe(0); + const payload = JSON.parse(result.stdout) as { + command?: string; + entriesConsidered?: number; + status?: string; + }; + expect(payload.command).toBe("slo-budget-report"); + expect(payload.entriesConsidered).toBe(0); + expect(payload.status).toBe("pass"); + }); +}); diff --git a/test/storage-v4-keychain.test.ts b/test/storage-v4-keychain.test.ts new file mode 100644 index 000000000..df89dbc86 --- /dev/null +++ b/test/storage-v4-keychain.test.ts @@ -0,0 +1,327 @@ +import { promises as fs } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + for (let attempt = 0; attempt < 6; attempt += 1) { + try { + await fs.rm(targetPath, options); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") return; + if (!code || !RETRYABLE_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +type KeytarMockState = { + secrets: Map; +}; + +function installKeytarMock(): KeytarMockState { + const secrets = new Map(); + const keytarModule = { + setPassword: async (_service: string, account: string, password: string) => { + secrets.set(account, password); + }, + getPassword: async (_service: string, account: string) => secrets.get(account) ?? null, + deletePassword: async (_service: string, account: string) => secrets.delete(account), + }; + vi.doMock("keytar", () => ({ + ...keytarModule, + default: keytarModule, + })); + return { secrets }; +} + +describe("storage v4 keychain persistence", () => { + let tempDir = ""; + const originalDir = process.env.CODEX_MULTI_AUTH_DIR; + const originalMode = process.env.CODEX_SECRET_STORAGE_MODE; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(join(tmpdir(), "codex-storage-v4-")); + process.env.CODEX_MULTI_AUTH_DIR = tempDir; + process.env.CODEX_SECRET_STORAGE_MODE = "keychain"; + vi.resetModules(); + }); + + afterEach(async () => { + vi.doUnmock("keytar"); + vi.restoreAllMocks(); + if (originalDir === undefined) { + delete process.env.CODEX_MULTI_AUTH_DIR; + } else { + process.env.CODEX_MULTI_AUTH_DIR = originalDir; + } + if (originalMode === undefined) { + delete process.env.CODEX_SECRET_STORAGE_MODE; + } else { + process.env.CODEX_SECRET_STORAGE_MODE = originalMode; + } + if (tempDir) { + await removeWithRetry(tempDir, { recursive: true, force: true }); + } + }); + + it("writes refs to disk and resolves tokens from keychain", async () => { + installKeytarMock(); + const { saveAccounts, loadAccounts, getStoragePath } = await import("../lib/storage.js"); + + await saveAccounts({ + version: 3, + accounts: [ + { + accountId: "acct_1", + email: "user@example.com", + refreshToken: "refresh-token-1", + accessToken: "access-token-1", + addedAt: 1, + lastUsed: 2, + enabled: true, + }, + ], + activeIndex: 0, + }); + + const filePath = getStoragePath(); + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as { + version: number; + accounts: Array>; + }; + expect(parsed.version).toBe(4); + expect(parsed.accounts[0]?.refreshToken).toBeUndefined(); + expect(parsed.accounts[0]?.refreshTokenRef).toBeTypeOf("string"); + + const loaded = await loadAccounts(); + expect(loaded?.accounts[0]?.refreshToken).toBe("refresh-token-1"); + expect(loaded?.accounts[0]?.accessToken).toBe("access-token-1"); + }); + + it("serializes concurrent saveAccounts calls without torn storage JSON", async () => { + installKeytarMock(); + const { saveAccounts, loadAccounts, getStoragePath } = await import("../lib/storage.js"); + + await Promise.all([ + saveAccounts({ + version: 3, + accounts: [ + { + accountId: "acct_a", + email: "a@example.com", + refreshToken: "refresh-token-a", + accessToken: "access-token-a", + addedAt: 11, + lastUsed: 12, + enabled: true, + }, + ], + activeIndex: 0, + }), + saveAccounts({ + version: 3, + accounts: [ + { + accountId: "acct_b", + email: "b@example.com", + refreshToken: "refresh-token-b", + accessToken: "access-token-b", + addedAt: 21, + lastUsed: 22, + enabled: true, + }, + ], + activeIndex: 0, + }), + ]); + + const filePath = getStoragePath(); + const persistedRaw = await fs.readFile(filePath, "utf8"); + const persisted = JSON.parse(persistedRaw) as { + version: number; + accounts: Array>; + }; + expect(persisted.version).toBe(4); + expect(Array.isArray(persisted.accounts)).toBe(true); + expect(persisted.accounts[0]?.refreshToken).toBeUndefined(); + expect(typeof persisted.accounts[0]?.refreshTokenRef).toBe("string"); + + const loaded = await loadAccounts(); + const loadedTokenPair = `${loaded?.accounts[0]?.refreshToken}|${loaded?.accounts[0]?.accessToken}`; + expect( + new Set(["refresh-token-a|access-token-a", "refresh-token-b|access-token-b"]).has(loadedTokenPair), + ).toBe(true); + }); + + it("retries save rename on windows-style EPERM and persists successfully", async () => { + installKeytarMock(); + const { saveAccounts, loadAccounts } = await import("../lib/storage.js"); + const originalRename = fs.rename.bind(fs); + const renameSpy = vi.spyOn(fs, "rename"); + renameSpy.mockImplementationOnce(async () => { + const error = new Error("locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + }); + renameSpy.mockImplementation(originalRename); + let renameCallCount = 0; + + try { + await saveAccounts({ + version: 3, + accounts: [ + { + accountId: "acct_retry", + email: "retry@example.com", + refreshToken: "refresh-token-retry", + accessToken: "access-token-retry", + addedAt: 30, + lastUsed: 31, + enabled: true, + }, + ], + activeIndex: 0, + }); + } finally { + renameCallCount = renameSpy.mock.calls.length; + renameSpy.mockRestore(); + } + + expect(renameCallCount).toBeGreaterThanOrEqual(2); + const loaded = await loadAccounts(); + expect(loaded?.accounts[0]?.refreshToken).toBe("refresh-token-retry"); + }); + + it("rolls back keychain refs when save fails after refs are written", async () => { + const { secrets } = installKeytarMock(); + const { saveAccounts } = await import("../lib/storage.js"); + const renameSpy = vi.spyOn(fs, "rename"); + renameSpy.mockImplementation(async () => { + const error = new Error("rename denied") as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + }); + + try { + await expect( + saveAccounts({ + version: 3, + accounts: [ + { + accountId: "acct_fail", + email: "fail@example.com", + refreshToken: "refresh-token-fail", + accessToken: "access-token-fail", + addedAt: 40, + lastUsed: 41, + enabled: true, + }, + ], + activeIndex: 0, + }), + ).rejects.toThrow(); + } finally { + renameSpy.mockRestore(); + } + + expect([...secrets.keys()]).toEqual([]); + }); + + it("adjusts activeIndex when v4 hydration skips accounts with missing keychain secrets", async () => { + const { secrets } = installKeytarMock(); + const { getStoragePath, loadAccounts } = await import("../lib/storage.js"); + const storagePath = getStoragePath(); + secrets.set("acct-2:refresh", "refresh-token-2"); + secrets.set("acct-2:access", "access-token-2"); + await fs.writeFile( + storagePath, + JSON.stringify( + { + version: 4, + accounts: [ + { + accountId: "acct_1", + email: "first@example.com", + refreshTokenRef: "acct-1:refresh", + accessTokenRef: "acct-1:access", + addedAt: 1, + lastUsed: 1, + enabled: true, + }, + { + accountId: "acct_2", + email: "second@example.com", + refreshTokenRef: "acct-2:refresh", + accessTokenRef: "acct-2:access", + addedAt: 2, + lastUsed: 2, + enabled: true, + }, + ], + activeIndex: 1, + activeIndexByFamily: { + codex: 1, + }, + }, + null, + 2, + ), + "utf8", + ); + + const loaded = await loadAccounts(); + expect(loaded?.accounts).toHaveLength(1); + expect(loaded?.accounts[0]?.accountId).toBe("acct_2"); + expect(loaded?.activeIndex).toBe(0); + expect(loaded?.activeIndexByFamily?.codex).toBe(0); + }); + + it("clears keychain refs from WAL payload even when runtime mode flips to plaintext", async () => { + const { secrets } = installKeytarMock(); + const { clearAccounts, getStoragePath } = await import("../lib/storage.js"); + const storagePath = getStoragePath(); + const walPath = `${storagePath}.wal`; + secrets.set("acct-wal:refresh", "refresh-token-wal"); + secrets.set("acct-wal:access", "access-token-wal"); + await fs.mkdir(tempDir, { recursive: true }); + await fs.writeFile( + walPath, + JSON.stringify({ + version: 1, + createdAt: Date.now(), + path: storagePath, + checksum: "checksum", + content: JSON.stringify({ + version: 4, + accounts: [ + { + refreshTokenRef: "acct-wal:refresh", + accessTokenRef: "acct-wal:access", + addedAt: 1, + lastUsed: 1, + }, + ], + activeIndex: 0, + }), + }), + "utf8", + ); + + process.env.CODEX_SECRET_STORAGE_MODE = "plaintext"; + await clearAccounts(); + expect(secrets.has("acct-wal:refresh")).toBe(false); + expect(secrets.has("acct-wal:access")).toBe(false); + await expect(fs.stat(walPath)).rejects.toThrow(); + }); +}); diff --git a/test/token-store.test.ts b/test/token-store.test.ts new file mode 100644 index 000000000..d686abdac --- /dev/null +++ b/test/token-store.test.ts @@ -0,0 +1,303 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type Deferred = { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function mockKeytar(module: { + setPassword: (service: string, account: string, password: string) => Promise; + getPassword: (service: string, account: string) => Promise; + deletePassword: (service: string, account: string) => Promise; +}): void { + vi.doMock("keytar", () => ({ + ...module, + default: module, + })); +} + +describe("token store", () => { + const originalMode = process.env.CODEX_SECRET_STORAGE_MODE; + + beforeEach(() => { + vi.resetModules(); + }); + + afterEach(() => { + if (originalMode === undefined) { + delete process.env.CODEX_SECRET_STORAGE_MODE; + } else { + process.env.CODEX_SECRET_STORAGE_MODE = originalMode; + } + vi.doUnmock("keytar"); + vi.restoreAllMocks(); + }); + + it("returns plaintext mode when explicitly configured", async () => { + process.env.CODEX_SECRET_STORAGE_MODE = "plaintext"; + const tokenStore = await import("../lib/secrets/token-store.js"); + expect(await tokenStore.getEffectiveSecretStorageMode()).toBe("plaintext"); + expect( + await tokenStore.persistAccountSecrets("acct-1", { + refreshToken: "refresh-token", + accessToken: "access-token", + }), + ).toBeNull(); + }); + + it("supports CommonJS default export interop for keytar", async () => { + const secrets = new Map(); + mockKeytar({ + setPassword: async (_service: string, account: string, password: string) => { + secrets.set(account, password); + }, + getPassword: async (_service: string, account: string) => secrets.get(account) ?? null, + deletePassword: async (_service: string, account: string) => secrets.delete(account), + }); + + process.env.CODEX_SECRET_STORAGE_MODE = "keychain"; + const tokenStore = await import("../lib/secrets/token-store.js"); + await tokenStore.ensureSecretStorageBackendAvailable(); + + const refs = await tokenStore.persistAccountSecrets("acct-cjs", { + refreshToken: "refresh-cjs", + accessToken: "access-cjs", + }); + expect(refs).toEqual({ + refreshTokenRef: "acct-cjs:refresh", + accessTokenRef: "acct-cjs:access", + }); + expect( + await tokenStore.loadAccountSecrets({ + refreshTokenRef: "acct-cjs:refresh", + accessTokenRef: "acct-cjs:access", + }), + ).toEqual({ + refreshToken: "refresh-cjs", + accessToken: "access-cjs", + }); + }); + + it("stores and loads secrets through keytar in keychain mode", async () => { + const secrets = new Map(); + mockKeytar({ + setPassword: async (_service: string, account: string, password: string) => { + secrets.set(account, password); + }, + getPassword: async (_service: string, account: string) => secrets.get(account) ?? null, + deletePassword: async (_service: string, account: string) => secrets.delete(account), + }); + + process.env.CODEX_SECRET_STORAGE_MODE = "keychain"; + const tokenStore = await import("../lib/secrets/token-store.js"); + + await tokenStore.ensureSecretStorageBackendAvailable(); + const refs = await tokenStore.persistAccountSecrets("acct-1", { + refreshToken: "refresh-token", + accessToken: "access-token", + }); + expect(refs).toEqual({ + refreshTokenRef: "acct-1:refresh", + accessTokenRef: "acct-1:access", + }); + + const loaded = await tokenStore.loadAccountSecrets({ + refreshTokenRef: "acct-1:refresh", + accessTokenRef: "acct-1:access", + }); + expect(loaded).toEqual({ + refreshToken: "refresh-token", + accessToken: "access-token", + }); + }); + + it("handles concurrent keychain writes for the same account ref without torn secrets", async () => { + const secrets = new Map(); + const refreshGate = createDeferred(); + const firstRefreshEntered = createDeferred(); + let refreshCallCount = 0; + + mockKeytar({ + setPassword: async (_service: string, account: string, password: string) => { + if (account === "acct-1:refresh") { + refreshCallCount += 1; + if (refreshCallCount === 1) { + firstRefreshEntered.resolve(); + await refreshGate.promise; + } + } + secrets.set(account, password); + }, + getPassword: async (_service: string, account: string) => secrets.get(account) ?? null, + deletePassword: async (_service: string, account: string) => secrets.delete(account), + }); + + process.env.CODEX_SECRET_STORAGE_MODE = "keychain"; + const tokenStore = await import("../lib/secrets/token-store.js"); + await tokenStore.ensureSecretStorageBackendAvailable(); + + const firstWrite = tokenStore.persistAccountSecrets("acct-1", { + refreshToken: "refresh-token-a", + accessToken: "access-token-a", + }); + const secondWrite = tokenStore.persistAccountSecrets("acct-1", { + refreshToken: "refresh-token-b", + accessToken: "access-token-b", + }); + await firstRefreshEntered.promise; + refreshGate.resolve(); + + const [firstRefs, secondRefs] = await Promise.all([firstWrite, secondWrite]); + expect(firstRefs).toEqual({ + refreshTokenRef: "acct-1:refresh", + accessTokenRef: "acct-1:access", + }); + expect(secondRefs).toEqual(firstRefs); + + const loaded = await tokenStore.loadAccountSecrets({ + refreshTokenRef: "acct-1:refresh", + accessTokenRef: "acct-1:access", + }); + expect(loaded).toBeDefined(); + const signature = `${loaded?.refreshToken}|${loaded?.accessToken}`; + expect(new Set(["refresh-token-a|access-token-a", "refresh-token-b|access-token-b"]).has(signature)).toBe( + true, + ); + }); + + it("rolls back refresh ref when access ref persistence fails", async () => { + const secrets = new Map(); + const deletedRefs: string[] = []; + mockKeytar({ + setPassword: async (_service: string, account: string, password: string) => { + if (account === "acct-rollback:access") { + const error = new Error("access write failed") as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + } + secrets.set(account, password); + }, + getPassword: async (_service: string, account: string) => secrets.get(account) ?? null, + deletePassword: async (_service: string, account: string) => { + deletedRefs.push(account); + secrets.delete(account); + return true; + }, + }); + + process.env.CODEX_SECRET_STORAGE_MODE = "keychain"; + const tokenStore = await import("../lib/secrets/token-store.js"); + await tokenStore.ensureSecretStorageBackendAvailable(); + + await expect( + tokenStore.persistAccountSecrets("acct-rollback", { + refreshToken: "refresh-token", + accessToken: "access-token", + }), + ).rejects.toThrow("access write failed"); + expect(deletedRefs).toContain("acct-rollback:refresh"); + expect(secrets.has("acct-rollback:refresh")).toBe(false); + }); + + it("deleteAccountSecrets cleans up both refresh and access refs", async () => { + const secrets = new Map(); + const deletedRefs: string[] = []; + mockKeytar({ + setPassword: async (_service: string, account: string, password: string) => { + secrets.set(account, password); + }, + getPassword: async (_service: string, account: string) => secrets.get(account) ?? null, + deletePassword: async (_service: string, account: string) => { + deletedRefs.push(account); + secrets.delete(account); + return true; + }, + }); + + process.env.CODEX_SECRET_STORAGE_MODE = "keychain"; + const tokenStore = await import("../lib/secrets/token-store.js"); + await tokenStore.ensureSecretStorageBackendAvailable(); + await tokenStore.persistAccountSecrets("acct-delete", { + refreshToken: "refresh-token", + accessToken: "access-token", + }); + + await tokenStore.deleteAccountSecrets({ + refreshTokenRef: "acct-delete:refresh", + accessTokenRef: "acct-delete:access", + }); + expect(deletedRefs).toEqual(expect.arrayContaining(["acct-delete:refresh", "acct-delete:access"])); + expect(secrets.has("acct-delete:refresh")).toBe(false); + expect(secrets.has("acct-delete:access")).toBe(false); + }); + + it("attempts access-token cleanup even when refresh-token cleanup fails", async () => { + const deletedRefs: string[] = []; + mockKeytar({ + setPassword: async () => {}, + getPassword: async () => null, + deletePassword: async (_service: string, account: string) => { + deletedRefs.push(account); + if (account === "acct-partial:refresh") { + const error = new Error("refresh delete failed") as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + } + return true; + }, + }); + + process.env.CODEX_SECRET_STORAGE_MODE = "keychain"; + const tokenStore = await import("../lib/secrets/token-store.js"); + await tokenStore.ensureSecretStorageBackendAvailable(); + + await expect( + tokenStore.deleteAccountSecrets({ + refreshTokenRef: "acct-partial:refresh", + accessTokenRef: "acct-partial:access", + }), + ).rejects.toThrow("refresh delete failed"); + expect(deletedRefs).toEqual(["acct-partial:refresh", "acct-partial:access"]); + }); + + it("derives stable secret refs from account identity", async () => { + process.env.CODEX_SECRET_STORAGE_MODE = "plaintext"; + const tokenStore = await import("../lib/secrets/token-store.js"); + const first = tokenStore.deriveAccountSecretRef({ + accountId: "acct_123", + email: "USER@example.com", + addedAt: 100, + refreshToken: "rt_1", + }); + const second = tokenStore.deriveAccountSecretRef({ + accountId: "acct_123", + email: "user@example.com", + addedAt: 100, + refreshToken: "rt_2", + }); + expect(first).toBe(second); + }); + + it("falls back to token-derived refs when account identity is missing", async () => { + process.env.CODEX_SECRET_STORAGE_MODE = "plaintext"; + const tokenStore = await import("../lib/secrets/token-store.js"); + const first = tokenStore.deriveAccountSecretRef({ + refreshToken: "rt_identity_missing_a", + }); + const second = tokenStore.deriveAccountSecretRef({ + refreshToken: "rt_identity_missing_b", + }); + expect(first).not.toBe(second); + }); +}); diff --git a/test/unified-settings.test.ts b/test/unified-settings.test.ts index 6eff59e61..da419039f 100644 --- a/test/unified-settings.test.ts +++ b/test/unified-settings.test.ts @@ -1,8 +1,32 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "node:fs"; -import { join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { tmpdir } from "node:os"; +async function expectSecureFileMode(path: string): Promise { + if (process.platform === "win32") { + await expect(fs.readFile(path, "utf8")).resolves.toContain("\"version\": 1"); + const entries = await fs.readdir(dirname(path)); + const leakedTemps = entries.filter( + (entry) => entry.startsWith(`${basename(path)}.`) && entry.endsWith(".tmp"), + ); + expect(leakedTemps).toEqual([]); + return; + } + const stats = await fs.stat(path); + expect(stats.mode & 0o777).toBe(0o600); +} + +async function expectSecureDirectoryMode(path: string): Promise { + if (process.platform === "win32") { + await expect(fs.access(path)).resolves.toBeUndefined(); + return; + } + const stats = await fs.stat(path); + expect(stats.isDirectory()).toBe(true); + expect(stats.mode & 0o777).toBe(0o700); +} + describe("unified settings", () => { let tempDir: string; let originalDir: string | undefined; @@ -51,6 +75,8 @@ describe("unified settings", () => { expect(fileContent).toContain("\"version\": 1"); expect(fileContent).toContain("\"pluginConfig\""); expect(fileContent).toContain("\"dashboardDisplaySettings\""); + await expectSecureFileMode(getUnifiedSettingsPath()); + await expectSecureDirectoryMode(dirname(getUnifiedSettingsPath())); }); it("returns null sections for invalid JSON", async () => { @@ -82,6 +108,22 @@ describe("unified settings", () => { expect(loadUnifiedPluginConfigSync()).toEqual({ codexMode: true, retries: 4 }); const fileContent = await fs.readFile(getUnifiedSettingsPath(), "utf8"); expect(fileContent).toContain("\"version\": 1"); + await expectSecureFileMode(getUnifiedSettingsPath()); + await expectSecureDirectoryMode(dirname(getUnifiedSettingsPath())); + }); + + it("preserves secure file mode on repeated sync writes to the same settings file", async () => { + const { + saveUnifiedPluginConfigSync, + loadUnifiedPluginConfigSync, + getUnifiedSettingsPath, + } = await import("../lib/unified-settings.js"); + + saveUnifiedPluginConfigSync({ codexMode: true, retries: 1 }); + saveUnifiedPluginConfigSync({ codexMode: false, retries: 2 }); + + expect(loadUnifiedPluginConfigSync()).toEqual({ codexMode: false, retries: 2 }); + await expectSecureFileMode(getUnifiedSettingsPath()); }); it("returns null for missing pluginConfig section", async () => { diff --git a/vitest.config.ts b/vitest.config.ts index 929cd21d8..849676f04 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -12,6 +12,10 @@ if (forcePlainTestOutput) { process.env.FORCE_COLOR = '0'; } +if (!process.env.CODEX_SECRET_STORAGE_MODE) { + process.env.CODEX_SECRET_STORAGE_MODE = 'plaintext'; +} + export default defineConfig({ test: { globals: true,