Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
125 commits
Select commit Hold shift + click to select a range
d2b7a8f
docs: update README with new information
nadavis Mar 14, 2026
fee4ef7
style: fix formatting in README.md
nadavis Mar 14, 2026
28a5926
fix: resolve merge conflict in README (duplicate CLI Reference sections)
nadavis Mar 14, 2026
d3c99cf
feat: add Claude AI code review on PRs to main
nadavis Mar 15, 2026
7f018aa
fix: add env validation and diff size limit to AI review
nadavis Mar 15, 2026
7fe57ab
style: format ai-review.mjs with prettier
nadavis Mar 15, 2026
a2ea754
Merge branch 'main' into dev
node9ai Mar 15, 2026
bac1a39
feat: context sniper UI, browser gating, Apache-2.0, Claude AI review
nadavis Mar 15, 2026
abe4b3a
fix: harden AI review script per security audit
nadavis Mar 16, 2026
f7ae32d
style: apply prettier formatting
nadavis Mar 16, 2026
d666294
fix: switch to GITHUB_TOKEN, add --ignore-scripts, auto-sync dev from…
nadavis Mar 16, 2026
96f9d9f
fix: pin transitive CI deps via package-lock.json
nadavis Mar 16, 2026
e4d2cdc
chore: sync dev from main [skip ci]
github-actions[bot] Mar 16, 2026
430db8d
feat: Context Sniper UI parity — native popup, browser daemon, cloud
nadavis Mar 16, 2026
748a5bc
feat: shadow mode — passive stderr warning when SaaS allows through
nadavis Mar 16, 2026
260eac1
fix: config validation, audit mode delivery, cloud race, double-brows…
nadavis Mar 16, 2026
9f62709
Merge branch 'main' into dev
node9ai Mar 16, 2026
2230792
fix: invalid config fields now stripped before merge, not silently ap…
nadavis Mar 16, 2026
904c3cb
style: prettier format config-schema.ts
nadavis Mar 16, 2026
553b653
test: address code review — fix timing race, add wildcard ignored-too…
nadavis Mar 16, 2026
97890ce
test: fix double-resolve in runCheckAsync, add malformed payload + ig…
nadavis Mar 16, 2026
65302b8
test: fix malformed payload test to match fail-open design
nadavis Mar 16, 2026
4b5d643
chore: sync dev from main [skip ci]
github-actions[bot] Mar 16, 2026
52762da
fix: two policy bugs + refresh example config for v1 release
nadavis Mar 17, 2026
38dde45
chore: prettier format example config
nadavis Mar 17, 2026
aac0c3c
fix: address all PR review issues — environment merge safety, allow-r…
nadavis Mar 17, 2026
97b961d
fix: block $() substitution, global npm install, and multi-field secr…
nadavis Mar 17, 2026
bc28e8e
Merge branch 'main' into dev
node9ai Mar 17, 2026
32e0dc9
chore: sync dev from main [skip ci]
github-actions[bot] Mar 17, 2026
44b477e
fix: address second code review — dangerousWords regression, semicolo…
nadavis Mar 17, 2026
e160871
Merge branch 'main' into dev
node9ai Mar 17, 2026
353a745
chore: sync dev from main [skip ci]
github-actions[bot] Mar 17, 2026
4bf8d3d
feat: scope node9 undo to current directory by default
nadavis Mar 20, 2026
67e8dde
fix: address code review — image src, jq injection safe payload
nadavis Mar 20, 2026
5d58ed5
Merge remote-tracking branch 'origin/main' into dev
nadavis Mar 20, 2026
0b671fd
chore: sync dev from main [skip ci]
github-actions[bot] Mar 20, 2026
61d37ab
docs: add Hugging Face Space badge and Try it Live section
nadavis Mar 20, 2026
4b4cc9e
Merge branch 'main' into dev
node9ai Mar 20, 2026
2a2c109
chore: sync dev from main [skip ci]
github-actions[bot] Mar 20, 2026
344cca1
feat: add shield templates — one-command security for postgres, githu…
nadavis Mar 20, 2026
1afab89
style: apply prettier formatting to cli.ts
nadavis Mar 20, 2026
5e30a9a
fix: address shield code review — security regex hardening and correc…
nadavis Mar 20, 2026
6080a74
fix: address second shield code review
nadavis Mar 20, 2026
feafc0f
fix: address third shield code review
nadavis Mar 20, 2026
2da4a63
fix: address fourth shield review — regex fix, /etc/ narrowing, tests
nadavis Mar 20, 2026
30dc5a6
fix: address fifth shield review — scoping bug, Array.isArray guards,…
nadavis Mar 20, 2026
896cd22
refactor: load shields dynamically in getConfig() — remove config.jso…
nadavis Mar 21, 2026
729f3f8
fix: remove shield rules that duplicate built-in protections
nadavis Mar 21, 2026
cdb1cfd
docs: restructure README with two-layer protection model
nadavis Mar 21, 2026
da639e1
feat: replace rules system with smartRules + add matchesGlob/notMatch…
nadavis Mar 21, 2026
4950297
style: fix Prettier formatting in README.md
nadavis Mar 21, 2026
41f0632
test: add Layer 1 security invariant tests + restore precedence docs
nadavis Mar 21, 2026
1b14a0f
feat: add DLP content scanner + fix Cursor hook + fix shields warning
nadavis Mar 21, 2026
cf0d7d9
fix: address PR review — rule name assertions, glob tests, README cla…
nadavis Mar 21, 2026
86920d3
style: fix Prettier formatting in README.md
nadavis Mar 21, 2026
298ed68
fix: address second PR review — README clarity, test isolation, Layer…
nadavis Mar 21, 2026
f2ff28c
Merge branch 'main' into dev
node9ai Mar 21, 2026
01790c5
chore: sync dev from main [skip ci]
github-actions[bot] Mar 21, 2026
e6b11d4
feat: add Flight Recorder — real-time activity stream in browser and …
nadavis Mar 21, 2026
1c019e5
fix: resolve race engine clashes between browser and native popup cha…
nadavis Mar 21, 2026
a119f70
fix: address PR review — timer leak, MCP caller gap, and browser UX
nadavis Mar 21, 2026
80dab4c
fix: await socket flush in notifyActivity so all tool calls appear in…
nadavis Mar 21, 2026
7556512
fix: suppress false BLOCK entries and render history in node9 tail
nadavis Mar 21, 2026
638b52d
chore: fix prettier formatting in authorizeHeadless
nadavis Mar 21, 2026
5977e5c
fix: address PR review — socket ordering, byte cap, audit trail, dupl…
nadavis Mar 21, 2026
6243d05
fix: address second PR review — ID mismatch, shields auth, dedup, por…
nadavis Mar 21, 2026
b51483c
Merge branch 'main' into dev
node9ai Mar 21, 2026
3cc0ba1
chore: sync dev from main [skip ci]
github-actions[bot] Mar 22, 2026
ceb2f57
feat: secure defaults, flightRecorder, audit mode, orphaned daemon re…
nadavis Mar 22, 2026
95f09d0
test(core): add coverage for isDaemonRunning ss-fallback paths
nadavis Mar 22, 2026
30a98b8
Merge branch 'main' into dev
node9ai Mar 22, 2026
b100438
fix: close notMatchesGlob open-gate, enforce glob value in schema, ad…
nadavis Mar 22, 2026
423b5fa
chore: sync dev from main [skip ci]
github-actions[bot] Mar 22, 2026
01378b1
docs: add Flight Recorder screenshot to README
nadavis Mar 22, 2026
e011efb
Merge branch 'main' into dev
node9ai Mar 22, 2026
9b26f71
chore: sync dev from main [skip ci]
github-actions[bot] Mar 22, 2026
cb93965
fix(tail): --clear now exits after clearing instead of streaming
nadavis Mar 22, 2026
b63329d
fix(tail): use http module for --clear request instead of fetch
nadavis Mar 22, 2026
f234175
fix(tail): ensureDaemon health-checks daemon even when PID file exists
nadavis Mar 22, 2026
95d7997
fix(tail): distinguish ECONNREFUSED from other errors in --clear, upd…
nadavis Mar 22, 2026
5c13ea4
fix(tail): 2s timeout on --clear request, use 2xx status range check
nadavis Mar 22, 2026
d3222a3
fix(tail): surface HTTP status on non-2xx clear response; document br…
nadavis Mar 22, 2026
971e58c
style: fix Prettier formatting in tail.ts
nadavis Mar 22, 2026
cfe6113
fix(tail): throw errors instead of process.exit in --clear; add tests
nadavis Mar 22, 2026
1c86d2b
fix(lint): prefix unused cb params with _ in tail.test.ts
nadavis Mar 22, 2026
669e9a4
fix(tail): add once() to http mock and use req.once for error listener
nadavis Mar 22, 2026
224a820
fix(tail): robust http mock and fix res listener ordering
nadavis Mar 22, 2026
35186f8
test(tail): document args[1] assumption and add 299 boundary test
nadavis Mar 22, 2026
9420de5
fix(tail): resolve before destroy on timeout; move breaking change to…
nadavis Mar 22, 2026
7e91a11
fix(tail): register error handler before setTimeout; harden tests
nadavis Mar 22, 2026
1e7035a
fix(test): add destroy to mockHttpRequest handler req type
nadavis Mar 22, 2026
3242a1e
fix(tail): warn on corrupt PID file; clarify timeout/destroy comments
nadavis Mar 22, 2026
5f0c89c
test: verify git push popup triggers correctly
nadavis Mar 22, 2026
84278e3
fix(cli): suppress allowed-status stderr write unless NODE9_DEBUG=1
nadavis Mar 22, 2026
791c4a8
feat(cli): add node9 uninstall and node9 removefrom commands
nadavis Mar 22, 2026
ecf002d
fix(setup): don't double-prefix node when installed globally or via n…
nadavis Mar 22, 2026
d436711
fix: address code review findings on uninstall/teardown
nadavis Mar 22, 2026
ca99f40
fix: address code review findings — purge confirmation, teardown resi…
nadavis Mar 22, 2026
ca67646
fix: remove force:true from rmSync, add empty-args MCP unwrap test
nadavis Mar 22, 2026
e8073c0
fix: removefrom error handling, empty-args MCP guard test, clear/SSE …
nadavis Mar 22, 2026
6087153
style: prettier format cli.ts
nadavis Mar 22, 2026
b61bde3
fix: uninstall exit code on partial failure, empty-args MCP warning, …
nadavis Mar 22, 2026
65bd81c
fix: validate removefrom target before logging, add missing edge-case…
nadavis Mar 22, 2026
674f5fc
test: assert stderr silence in production mode, add teardownGemini no…
nadavis Mar 22, 2026
a4a5c13
test: teardownGemini mixed-hooks preservation, removefrom valid-targe…
nadavis Mar 22, 2026
f82d03f
fix: tighten isNode9Hook matching, add teardownGemini legacy test, re…
nadavis Mar 22, 2026
612bdea
fix: use minimal env in removefrom tests to avoid leaking CI secrets
nadavis Mar 22, 2026
4b75114
test: 3xx boundary, malformed JSON teardown, fix comment label
nadavis Mar 22, 2026
0712937
Merge branch 'main' into dev
node9ai Mar 22, 2026
7f48a45
chore: sync dev from main [skip ci]
github-actions[bot] Mar 22, 2026
1d5efb1
docs: add documentation badge and link to README
nadavis Mar 23, 2026
4b2948d
fix: default smart rules miss git -C <dir> subcommand pattern
nadavis Mar 23, 2026
d50c102
style: fix prettier formatting in core.ts
nadavis Mar 23, 2026
74726b8
fix(security): re-anchor git smart rules to prevent bypass via embedd…
nadavis Mar 23, 2026
fa88cfe
Merge branch 'main' into dev
node9ai Mar 23, 2026
ec92ce3
chore: sync dev from main [skip ci]
github-actions[bot] Mar 23, 2026
0a5ff96
feat(security): ReDoS protection, regex cache, sensitive path DLP
nadavis Mar 24, 2026
ba47a5e
feat(undo): shadow repo snapshot engine
nadavis Mar 24, 2026
77b0331
docs: update CHANGELOG and README for shadow repo, ReDoS, and DLP pat…
nadavis Mar 24, 2026
6b5ae32
test(undo): fix PR review issues — realpathSync.native mock, rev-pars…
nadavis Mar 24, 2026
41f688d
test(dlp,undo): address second PR review — scanFilePath coverage, fai…
nadavis Mar 24, 2026
00ebc47
test(core,dlp,undo): address third PR review — ReDoS tests, notMatche…
nadavis Mar 24, 2026
7e3a1f2
test: address fourth PR review — ReDoS coverage, TOCTOU, LRU bound, t…
nadavis Mar 24, 2026
27d7621
fix(dlp): fail-closed on TOCTOU race; test backreference ReDoS and ca…
nadavis Mar 24, 2026
d89cbce
fix: remove existsSync TOCTOU window, relax alternation ReDoS check, …
nadavis Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- **Improved Pending Approval Cards:** Approval cards now show an `⚠️ Action Required` header with a live countdown timer that turns red under 15 seconds. Allow/Deny buttons have clearer labels (`✅ Allow this Action` / `🚫 Block this Action`). The deny button uses a softer outlined style to reduce accidental clicks.
- **DLP Content Scanner:** Node9 now scans every tool call argument for secrets before policy evaluation. Seven built-in patterns cover AWS Access Key IDs, GitHub tokens (`ghp_`, `gho_`, `ghs_`), Slack bot tokens (`xoxb-`), OpenAI API keys, Stripe secret keys, PEM private keys, and Bearer tokens. `block`-severity patterns hard-deny the call immediately; `review`-severity patterns route through the normal race engine. Secrets are redacted to a prefix+suffix sample in all audit logs. Configurable via `policy.dlp.enabled` and `policy.dlp.scanIgnoredTools`.
- **Shield Templates:** `node9 shield enable <service>` installs a curated rule set for a specific infrastructure service. Available shields: `postgres` (blocks `DROP TABLE`, `TRUNCATE`, `DROP COLUMN`; reviews `GRANT`/`REVOKE`), `github` (blocks `gh repo delete`; reviews remote branch deletion), `aws` (blocks S3 bucket deletion, EC2 termination; reviews IAM and RDS changes), `filesystem` (reviews `chmod 777` and writes to `/etc/`). Manage with `node9 shield enable|disable|list|status`.
- **Shadow Git Snapshots (Phase 2):** (Coming Soon) Automatic lightweight git commits before AI edits, allowing `node9 undo`.
- **Shadow Git Snapshots (Phase 2 — Implemented):** Node9 now takes automatic, lightweight git snapshots before every AI file edit using an isolated shadow bare repo at `~/.node9/snapshots/<hash16>/`. The user's `.git` is never touched — snapshots live in a separate hidden repository keyed by a SHA-256 hash of the project path. Run `node9 undo` to revert with a full diff preview; `--steps N` goes back multiple actions. Per-invocation `GIT_INDEX_FILE` prevents concurrent-session corruption. A `project-path.txt` sentinel inside each shadow repo detects hash collisions and directory renames and auto-recovers by reinitializing. `.git` and `.node9` directories are always excluded from snapshots (inception prevention). Performance-tuned with `core.untrackedCache` and `core.fsmonitor`. Periodic background `git gc --auto` keeps shadow repos tidy. The last 10 snapshots are tracked in `~/.node9/snapshots.json`.
- **ReDoS Protection + LRU Regex Cache:** The policy engine now validates all user-supplied regex patterns before compilation. Patterns with nested quantifiers, quantified alternations, or quantified backreferences are rejected as ReDoS vectors. A bounded LRU cache (max 500 entries) stores compiled `RegExp` objects so repeated rule evaluations never recompile the same pattern. The `notMatches` condition is now fail-closed: if the regex is invalid, the condition fails rather than silently passing.
- **Expanded DLP Patterns:** Two new `block`-severity content patterns added to the scanner: GCP service account JSON keys (detected via the `type` field unique to service account files) and NPM registry auth tokens (detected in `.npmrc` format). Total built-in patterns: 9.
- **Sensitive File Path Blocking:** The DLP engine now intercepts tool calls targeting credential files before their content is ever read. Twenty path patterns cover SSH keys, AWS credentials, GCP config, Azure credentials, kubeconfig, dotenv files, PEM/key/p12/pfx certificate files, system auth files, and common credential JSON files. Symlinks are resolved via `fs.realpathSync.native()` before matching to prevent symlink escape attacks where a safe-looking path points to a protected file.
- **`flightRecorder` setting:** New `settings.flightRecorder` flag (default `true`) controls whether the daemon records tool call activity to the flight recorder ring buffer. Can be set to `false` to disable activity recording when the browser dashboard is not in use.

### Changed
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ Node9 doesn't just "cut the wire." When a command is blocked, it injects a **Str

### ⏪ Shadow Git Snapshots (Auto-Undo)

Node9 takes a silent, lightweight Git snapshot before every AI file edit. If the AI hallucinates and breaks your code, run `node9 undo` to instantly revert — with a full diff preview before anything changes.
Node9 takes a silent, lightweight Git snapshot before every AI file edit. Snapshots are stored in an isolated shadow bare repo at `~/.node9/snapshots/` — your project's `.git` is never touched, and no existing git setup is required. If the AI hallucinates and breaks your code, run `node9 undo` to instantly revert — with a full diff preview before anything changes.

```bash
# Undo the last AI action (shows diff + asks confirmation)
Expand All @@ -93,6 +93,8 @@ node9 undo
node9 undo --steps 3
```

The last 10 snapshots are kept globally across all sessions in `~/.node9/snapshots.json`. Older snapshots are dropped as new ones are added.

---

## 🎮 Try it Live
Expand Down
150 changes: 150 additions & 0 deletions src/__tests__/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ import {
evaluateSmartConditions,
shouldSnapshot,
DEFAULT_CONFIG,
validateRegex,
getCompiledRegex,
} from '../core.js';

// Global spies
Expand Down Expand Up @@ -690,6 +692,29 @@ describe('evaluateSmartConditions', () => {
)
).toBe(false);
});

it('notMatches — fail-closed on invalid regex (returns false, not true)', () => {
// A buggy rule with a broken regex must fail-closed: the condition returns
// false (meaning "does not pass"), NOT true. If it returned true, an invalid
// notMatches rule would silently allow every call — a security hole.
expect(
evaluateSmartConditions(
{ sql: 'DROP TABLE users' },
makeRule([{ field: 'sql', op: 'notMatches', value: '[broken(' }])
)
).toBe(false);
});

it('notMatches — absent field (null) still returns true (field not present → condition passes)', () => {
// Original semantics: if the field is absent, notMatches passes (no value to match against).
// This must not regress when regex validation is added.
expect(
evaluateSmartConditions(
{ command: 'ls' }, // no 'sql' field
makeRule([{ field: 'sql', op: 'notMatches', value: '^DROP' }])
)
).toBe(true);
});
});

describe('conditionMode', () => {
Expand Down Expand Up @@ -1232,3 +1257,128 @@ describe('isDaemonRunning', () => {
expect(isDaemonRunning()).toBe(false);
});
});

// ── validateRegex — ReDoS protection ─────────────────────────────────────────

describe('validateRegex', () => {
it('accepts valid simple patterns', () => {
expect(validateRegex('^DROP\\s+TABLE')).toBeNull(); // null = no error
expect(validateRegex('\\bWHERE\\b')).toBeNull();
expect(validateRegex('[A-Z]{3,}')).toBeNull();
});

it('rejects empty pattern', () => {
expect(validateRegex('')).not.toBeNull();
});

it('rejects patterns exceeding max length', () => {
expect(validateRegex('a'.repeat(101))).not.toBeNull();
});

it('rejects nested quantifiers — catastrophic backtracking risk', () => {
expect(validateRegex('(a+)+')).not.toBeNull();
expect(validateRegex('(a*)*')).not.toBeNull();
expect(validateRegex('([a-z]+){2,}')).not.toBeNull();
});

it('rejects quantified alternations where alternatives contain quantifiers (true ReDoS risk)', () => {
// Dangerous: alternatives themselves have quantifiers — can match same string many ways
expect(validateRegex('(a+|b+)*')).not.toBeNull();
expect(validateRegex('(a{1,10}|b{1,10}){1,10}')).not.toBeNull();
expect(validateRegex('(?:a+|b+){1,100}')).not.toBeNull();
expect(validateRegex('(a{2}|b{3})+')).not.toBeNull();
});

it('allows quantified alternations with fixed-length disjoint alternatives (safe)', () => {
// Safe: alternatives are fixed-length and disjoint — no ambiguous matching
expect(validateRegex('(foo|bar)+')).toBeNull();
expect(validateRegex('(a|b|c)*')).toBeNull();
expect(validateRegex('(GET|POST|PUT)+')).toBeNull();
expect(validateRegex('(https?|ftp)://')).toBeNull();
// ? is also safe (bounded zero-or-one)
expect(validateRegex('(?:a|b)*')).toBeNull();
});

it('allows bounded quantifiers with ? (safe — zero-or-one cannot backtrack)', () => {
// ? is safe: it matches at most one time, so no catastrophic backtracking
expect(validateRegex('(ba|z|da|fi|c|k)?sh')).toBeNull();
expect(validateRegex('(\\.\\w+)?')).toBeNull();
});

it('rejects quantified backreferences — catastrophic backtracking risk', () => {
// (\w+)\1+ can catastrophically backtrack on strings like 'aaaaaaaaab'
// The guard checks for \<digit>[*+{] in the pattern
expect(validateRegex('(\\w+)\\1+')).not.toBeNull();
expect(validateRegex('(\\w+)\\1*')).not.toBeNull();
expect(validateRegex('(\\w+)\\1{2,}')).not.toBeNull();
});

it('rejects invalid regex syntax', () => {
expect(validateRegex('[unclosed')).not.toBeNull();
});
});

// ── getCompiledRegex — LRU cache ──────────────────────────────────────────────

describe('getCompiledRegex', () => {
it('returns a compiled RegExp for a valid pattern', () => {
const re = getCompiledRegex('^DROP', 'i');
expect(re).toBeInstanceOf(RegExp);
expect(re!.test('drop table')).toBe(true);
});

it('returns null for an invalid pattern', () => {
expect(getCompiledRegex('[invalid(')).toBeNull();
});

it('returns null for a ReDoS pattern', () => {
expect(getCompiledRegex('(a+)+')).toBeNull();
});

it('returns null for invalid flag characters', () => {
expect(getCompiledRegex('hello', 'z')).toBeNull(); // z is not a valid JS flag
expect(getCompiledRegex('hello', 'ig!')).toBeNull();
});

it('accepts valid flag characters', () => {
expect(getCompiledRegex('hello', 'i')).toBeInstanceOf(RegExp);
expect(getCompiledRegex('hello2', 'gi')).toBeInstanceOf(RegExp);
expect(getCompiledRegex('hello3', 'gims')).toBeInstanceOf(RegExp);
});

it('returns the same RegExp instance for the same pattern (cache hit)', () => {
const re1 = getCompiledRegex('cached-pattern');
const re2 = getCompiledRegex('cached-pattern');
expect(re1).toBe(re2); // same object reference
});

it('treats pattern+flags as a distinct cache key', () => {
const re1 = getCompiledRegex('hello', '');
const re2 = getCompiledRegex('hello', 'i');
expect(re1).not.toBe(re2);
});

it('cache key uses null-byte separator — no collision between pattern and flags', () => {
// Key format: `${pattern}\0${flags}`. Flags are always [gimsuy] so they
// can't contain \0. Verify that a pattern ending in 'i' with no flags
// does NOT collide with the same prefix with flag 'i'.
// pattern='foo\0' flags='' → key 'foo\0\0'
// pattern='foo' flags='' → key 'foo\0' (different length → no collision)
const reSuffix = getCompiledRegex('collision-test-i', '');
const reFlag = getCompiledRegex('collision-test-', 'i');
expect(reSuffix).not.toBe(reFlag); // distinct entries, not a cache collision
// Both should compile successfully
expect(reSuffix).toBeInstanceOf(RegExp);
expect(reFlag).toBeInstanceOf(RegExp);
});

it('handles 520 distinct patterns without error (LRU stays bounded)', () => {
// Adds more entries than REGEX_CACHE_MAX (500) to verify the eviction path
// runs without throwing and all returned values are valid RegExps.
// Note: getCompiledRegex is sync — no async interleaving concerns.
for (let i = 0; i < 520; i++) {
const re = getCompiledRegex(`lru-bound-test-[a-z]{${i + 1}}`);
expect(re).toBeInstanceOf(RegExp);
}
});
});
145 changes: 141 additions & 4 deletions src/__tests__/dlp.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { scanArgs, DLP_PATTERNS } from '../dlp.js';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import { scanArgs, scanFilePath, DLP_PATTERNS } from '../dlp.js';

// NOTE: All fake secret strings are built via concatenation so GitHub's secret
// scanner doesn't flag this test file. The values are obviously fake (sequential
Expand Down Expand Up @@ -181,8 +182,11 @@ describe('scanArgs — performance guards', () => {
// ── All patterns export ───────────────────────────────────────────────────────

describe('DLP_PATTERNS export', () => {
it('exports at least 7 built-in patterns', () => {
expect(DLP_PATTERNS.length).toBeGreaterThanOrEqual(7);
it('exports at least 9 built-in patterns', () => {
// 9 patterns as of current implementation:
// AWS Key ID, GitHub Token, Slack Bot Token, OpenAI Key, Stripe Secret Key,
// Private Key PEM, GCP Service Account, NPM Auth Token, Bearer Token
expect(DLP_PATTERNS.length).toBeGreaterThanOrEqual(9);
});

it('all patterns have name, regex, and severity', () => {
Expand All @@ -193,3 +197,136 @@ describe('DLP_PATTERNS export', () => {
}
});
});

// ── scanFilePath — sensitive file path blocking ───────────────────────────────

// Typed alias to reduce repetition when accessing realpathSync.native
type RealpathWithNative = typeof fs.realpathSync & { native: (p: unknown) => string };

describe('scanFilePath — sensitive path blocking', () => {
// Save the original .native so afterEach can restore it precisely.
// vi.restoreAllMocks() only restores vi.spyOn spies — direct property
// assignments survive it, so we must restore manually to guarantee isolation.
const originalNative = (fs.realpathSync as RealpathWithNative).native;

beforeEach(() => {
vi.spyOn(fs, 'realpathSync').mockImplementation((p) => String(p));
// Mock realpathSync.native — called unconditionally in production (no existsSync pre-check)
(fs.realpathSync as RealpathWithNative).native = vi
.fn()
.mockImplementation((p: unknown) => String(p));
});

afterEach(() => {
vi.restoreAllMocks();
// Explicitly restore .native since restoreAllMocks() doesn't track it
(fs.realpathSync as RealpathWithNative).native = originalNative;
});

it('blocks access to SSH key files', () => {
const match = scanFilePath('/home/user/.ssh/id_rsa', '/');
expect(match).not.toBeNull();
expect(match!.patternName).toBe('Sensitive File Path');
expect(match!.severity).toBe('block');
});

it('blocks access to AWS credentials directory', () => {
const match = scanFilePath('/home/user/.aws/credentials', '/');
expect(match).not.toBeNull();
expect(match!.severity).toBe('block');
});

it('blocks .env files', () => {
expect(scanFilePath('/project/.env', '/')).not.toBeNull();
expect(scanFilePath('/project/.env.local', '/')).not.toBeNull();
expect(scanFilePath('/project/.env.production', '/')).not.toBeNull();
});

it('does NOT block .envoy or similar non-credential files', () => {
expect(scanFilePath('/project/.envoy-config', '/')).toBeNull();
expect(scanFilePath('/project/environment.ts', '/')).toBeNull();
});

it('blocks PEM certificate files', () => {
expect(scanFilePath('/certs/server.pem', '/')).not.toBeNull();
expect(scanFilePath('/keys/private.key', '/')).not.toBeNull();
});

it('blocks /etc/passwd and /etc/shadow', () => {
expect(scanFilePath('/etc/passwd', '/')).not.toBeNull();
expect(scanFilePath('/etc/shadow', '/')).not.toBeNull();
});

it('returns null for ordinary source files', () => {
expect(scanFilePath('src/app.ts', '/project')).toBeNull();
expect(scanFilePath('README.md', '/project')).toBeNull();
expect(scanFilePath('package.json', '/project')).toBeNull();
});

it('returns null for empty or missing path', () => {
expect(scanFilePath('', '/project')).toBeNull();
});

it('calls realpathSync.native unconditionally (no existsSync pre-check)', () => {
// native() is always called — existsSync guard removed to eliminate TOCTOU window
const nativeSpy = vi.mocked((fs.realpathSync as RealpathWithNative).native);
scanFilePath('/project/safe-looking-link.txt', '/project');
expect(nativeSpy).toHaveBeenCalled();
});

it('blocks when a symlink resolves to a sensitive path', () => {
(fs.realpathSync as RealpathWithNative).native = vi
.fn()
.mockReturnValue('/home/user/.ssh/id_rsa');
const match = scanFilePath('/project/totally-safe-link', '/project');
expect(match).not.toBeNull();
expect(match!.severity).toBe('block');
});

it('does NOT block when a symlink resolves to a safe path', () => {
(fs.realpathSync as RealpathWithNative).native = vi.fn().mockReturnValue('/project/src/app.ts');
expect(scanFilePath('/project/link-to-app', '/project')).toBeNull();
});

it('blocks path traversal that resolves outside project root to a sensitive path', () => {
// ../../.ssh/id_rsa from /project/src resolves to /home/user/.ssh/id_rsa
(fs.realpathSync as RealpathWithNative).native = vi
.fn()
.mockReturnValue('/home/user/.ssh/id_rsa');
const match = scanFilePath('../../.ssh/id_rsa', '/project/src');
expect(match).not.toBeNull();
expect(match!.severity).toBe('block');
});

it('treats ENOENT as safe — new file being written is not a symlink', () => {
(fs.realpathSync as RealpathWithNative).native = vi.fn().mockImplementation(() => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
// Non-existent file: safe, cannot be a symlink pointing anywhere
expect(scanFilePath('/project/src/new-file.ts', '/project')).toBeNull();
});

it('is fail-closed when native throws with a non-ENOENT error', () => {
// EACCES, unexpected errors, or TOCTOU remnants → block immediately
(fs.realpathSync as RealpathWithNative).native = vi.fn().mockImplementation(() => {
throw Object.assign(new Error('EACCES'), { code: 'EACCES' });
});
expect(() => scanFilePath('/project/src/app.ts', '/project')).not.toThrow();
const match = scanFilePath('/project/src/app.ts', '/project');
expect(match).not.toBeNull();
expect(match!.severity).toBe('block');
});

it('blocks (fail-closed) on TOCTOU — safe-looking symlink pointing to sensitive file', () => {
// The attack: /project/harmless-config.ts → /home/user/.ssh/id_rsa
// native() throws because file was deleted between check and resolve
(fs.realpathSync as RealpathWithNative).native = vi.fn().mockImplementation(() => {
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
});
// ENOENT on a path that looks safe → treated as safe (not a TOCTOU attack)
// The attack scenario requires the file to EXIST (so attacker can create symlink)
// In that case native() would succeed and return the sensitive resolved path
// This test confirms: if file is deleted mid-race, we don't block unnecessarily
expect(scanFilePath('/project/harmless-config.ts', '/project')).toBeNull();
});
});
Loading
Loading