SmartTest

Security review (1.0.0)

The 1.0.0 release gate "Security review: file writes, process execution, Studio" (PRD §30). It records what was reviewed, the threat model, every finding with its fix or the reason it is accepted, and what was not reviewed. Each fix has a regression test, named in the finding.

Scope #

Threat model #

SmartTest runs inside a developer's repository and in CI on pull requests, including pull requests from forks. Two kinds of attacker are considered.

  1. Repository content. Anything in the checkout can be attacker-controlled: the configuration file, sources, test names, branch names, file paths, Git output, runner output, .smarttest/ state files and coverage JSON.
  2. Local and browser attackers, for Studio. Other users and processes on the machine, and web pages open in the developer's browser.

Running a repository's tests runs its code. So does loading a smarttest.config.ts. SmartTest does not try to sandbox that code (see AR-1). The review asks a narrower question: can repository content make SmartTest do something the developer or CI did not ask for? That covers:

Findings #

IDSeverityAreaTitleStatus
SR-01HighFile writesCommitted symbolic links redirect store, log and config-loader writesFixed
SR-02HighFile writesConfigured paths (storeDir, reports.outputDir, generator and runner paths) escape the projectFixed
SR-03MediumFile writesinit --rollback journal and hook installation write outside the projectFixed
SR-04MediumFile writesNative snapshot ids and hashes from file content become file pathsFixed
SR-05MediumProcess execOption injection into svn, hg, git and Playwright argumentsFixed
SR-06HighSelectionA committed store (result cache, index, traces) can reduce the selectionFixed
SR-07LowOutputTerminal escape sequences from repository textFixed
SR-08LowHooksTemporary checkout: node_modules mirror through a linked project directory; Git variablesFixed
SR-09LowProcess exec--open on Windows ran the report path through cmd.exeFixed
SR-10LowVS CodeNo single trust gate before starting the CLI; executablePath not a restricted settingFixed
SR-11LowReportsMarkdown injection in the CI step summaryFixed
SR-12LowStudioSpreadsheet formula injection in the Tests CSV exportFixed
SR-13LowStudioA malformed Host port skipped the port checkFixed
SR-14LowStudioOption-like revisions and traversing snapshot ids as change sourcesFixed
SR-15LowStudioReport download followed links; served reports shared Studio's originFixed
SR-16HighSelectionPersisted state (index and delta, graph, discovery, result cache, traces) could be forgedFixed
SR-17MediumPluginsPath plugins could be loaded from outside the projectFixed
SR-18Low--onlysrc/../../x passed the outside-the-project checkFixed
SR-19LowStudioDOT export: a line break in the change description left its comment lineFixed
SR-20LowStudioA malformed coverage-lines.json entry broke the source viewFixed
SR-21LowCoverageBrowser coverage reported absolute paths when the root sat below a linkFixed

SR-01 to SR-15 come from the first pass, on the tree before the persisted graph, plugins, the impact map and line coverage landed. SR-16 to SR-21 come from the second pass over the merged main.

What happened. Store writes used join(storeDir, …) with mkdir -p, rename, appendFile or writeFile. Each of these follows links in the directory components, and appendFile and writeFile also follow a link at the file itself. A pull request could commit links that redirected these writes:

Any command triggered these writes, including analysis-only commands such as impact and scan, which never run tests.

Fix.

Tests:

SR-02: configured paths escape the project (high) #

What happened. The configuration is repository content, even when it is JSON. Several of its paths were joined to the root unchecked:

Fix.

Tests:

SR-06: a committed store can reduce the selection (high) #

What happened. Three files in the store decide what runs:

A fork's pull request could commit all three. Content hashes are public, so a crafted cache could claim passes for the changed tests, or a crafted index could drop an import edge. Either way CI turns green without running the affected tests. This breaks the rule that SmartTest never reports a test as passed unless it ran (§32.2).

Fix. SmartTest.open runs git ls-files -- <store>/. When any file under the store is tracked, it refuses with ST_STORE_TRACKED (configuration, exit 3) and reads no state. The PRD already requires the store to be excluded from version control (§8.3), and init ignores it.

Test: integration-tests/security.test.ts, "refuses a store that is committed to the repository". It also opens the same project successfully after git rm --cached.

Remaining risk: see AR-4 and AR-5.

SR-03: rollback journal and hook installation (medium) #

What happened.

Fix.

Test: packages/smart-init/src/security.test.ts.

SR-04: native snapshot ids and hashes (medium) #

What happened. Native snapshots are used without a VCS. list() returned the id written inside each snapshot file, and prune and markVerified then built join(dir, `${id}.json`) from it. A crafted id such as ../../../package made prune delete, or markVerified rewrite, any .json file. Blob paths were also built from the stored hash.

Fix.

Test: packages/native-snapshots/src/index.test.ts, "never takes a file path from a crafted snapshot's id or hash".

SR-05: option injection into VCS and runner arguments (medium) #

What happened.

Fix.

Tests:

Checked and already safe:

SR-07: terminal escape sequences (low) #

What happened. Test names, file paths, branch names, error messages and configuration keys were printed raw. An escape sequence in them could do three things:

Fix. terminalSafe is applied to every CLI output line, error, warning and verbose log line. It keeps tab, newline and the SGR colour codes SmartTest itself uses, and removes every other escape and C0/C1 control character. --json output was already safe because JSON escapes control characters.

Tests:

SR-08: hook temporary checkout (low) #

What happened. The node_modules mirror wrote into join(checkout, projectDir, "node_modules"), including a .modules.yaml file. If the content under test committed that project directory as a link, the mirror landed outside the temporary checkout. Separately, Git variables that locate a repository (GIT_COMMON_DIR, GIT_ALTERNATE_OBJECT_DIRECTORIES, GIT_NAMESPACE) were not removed for the checkout.

Fix.

Test: packages/core/src/hook.test.ts, "temporary checkout isolation".

SR-09: opening a report on Windows (low) #

What happened. --open ran cmd /c start "" <path>. cmd.exe interprets &, | and ^ in the path (for example a project in R&D) as commands.

Fix. On Windows the path is now passed to explorer.exe as a single argument.

Test: packages/cli/src/main.test.ts, "opening a report".

SR-10: VS Code Restricted Mode (low) #

What happened. In Restricted Mode, every feature already checked trust before it acted. But no single gate guarded starting the CLI, and smarttest.executablePath was not declared a restricted setting. A workspace's .vscode/settings.json is repository content.

Fix.

Test: extensions/vscode/test/trust.test.ts.

SR-11: markdown injection in the CI summary (low) #

What happened. summary.md is appended to GITHUB_STEP_SUMMARY and posted to pull requests. It wrapped file names in single backticks and printed selection reasons unescaped. A file or symbol name could therefore close the code span, add links or tracking images, or split the failing-files table.

Fix.

Test: packages/report-engine/src/index.test.ts, "keeps repository names from injecting markdown into the CI summary".

SR-12 to SR-15: Studio (low) #

Studio was already strong. It binds to loopback only and uses a 32-byte token, compared in constant time, sent only in a header, and kept in the URL fragment, which is removed after load and never sent to the server. It also has:

Four gaps were found and fixed:

SR-16: persisted state could be forged (high) #

What happened. SR-06 refuses a store committed to Git, but six files decide what runs:

These files also arrive without being committed: from a CI cache, a copied directory, or a Subversion, Mercurial or no-VCS project (AR-5). Every check on them was against public data:

Any of these turns a CI run green without running the affected tests.

Fix. These six files are now authenticated (packages/shared/src/state.ts):

Tests:

Checked and already safe:

SR-17: plugins outside the project (medium) #

What happened. plugins entries that start with . or are absolute were resolved with resolve(root, spec). So ../shared/evil.mjs, /tmp/x.mjs or a link inside the project that leads out was imported and run.

Fix.

Test: packages/core/src/plugins.test.ts.

Loading on CI forks: see AR-2.

SR-18: --only traversal (low) #

What happened. normalizeScopePath checked for ../ before normalising, so src/../../x passed. The value is the user's own command-line flag and only filters the change set, so this was a correctness gap rather than an escalation.

Fix. The path is normalised first.

Test: packages/core/src/scope.test.ts.

Hook scope (hook.runners, hook.exclude), reviewed with no finding:

SR-19 and SR-20: impact map export and line coverage in Studio (low) #

What was checked.

SR-19. The DOT export's first line is a // comment that holds the change description, and the description can contain repository text: refs and --only paths. A line break in it ended the comment and added DOT statements. Graphviz attributes such as image= read local files when the graph is rendered. Line breaks and control characters are now folded into the comment. Test: packages/studio/src/repository-data.test.ts.

SR-20. runs/coverage-lines.json comes from runner output. A malformed entry (no arrays) or a key such as constructor threw in sourceCoverageOf and broke the source view. Entries are now read as own keys only, with their shape checked. Test: packages/studio/src/repository-data.test.ts.

Checked and already safe: coverage is shown only for files the source endpoint has already validated, and only when the file's SHA-256 matches the measured one.

SR-21: browser coverage URL mapping (low) #

What was checked. The @tkmremi/smarttest/playwright fixture writes dumps under a hashed name into the directory SmartTest gives it. ingestBrowserV8 maps http(s), /@fs/ and file: URLs to candidate files. It reads a file only when its real path is inside the project's real path, and uses it only when the browser's source is byte for byte that file. So encoded ../, /@fs/ outside the project and file: URLs outside it are never read, which was already tested.

SR-21. When the project root was reached through a link, the real file path was made relative to the unresolved root. Coverage then fell back to the absolute path, which leaked it into the report and Studio. Paths are now relative to the root's real path, and dropped if that fails.

Test: packages/coverage-engine/src/lines.test.ts, "reports project-relative paths when the root is reached through a link". It also covers /@fs/ and file: URLs outside the project.

Accepted risks #

Not reviewed #

Edit this page on GitHub