cleartoship 0.10.3 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -218,7 +218,7 @@ jobs:
218
218
  runs-on: ubuntu-latest
219
219
  steps:
220
220
  - uses: actions/checkout@v7
221
- - uses: murtazaozdemir/cleartoship@v0.10.3
221
+ - uses: murtazaozdemir/cleartoship@v0.10.4
222
222
  with:
223
223
  fail-on: critical
224
224
  comment: true
@@ -240,7 +240,7 @@ above `fail-on`) for use in later steps. The comment is *sticky* — re-runs edi
240
240
  the same comment instead of piling up.
241
241
 
242
242
  By default the action runs the scanner version its own ref declares, so
243
- `@v0.10.3` runs `cleartoship@0.10.3` and pinning the ref pins the behaviour. If
243
+ `@v0.10.4` runs `cleartoship@0.10.4` and pinning the ref pins the behaviour. If
244
244
  that version is not on the registry, it builds from its own checkout instead, so
245
245
  `uses: …@ref` works against an unpublished commit.
246
246
 
@@ -303,6 +303,12 @@ uploaded, and no database is connected to.
303
303
  call chained to it, and stand down when the values are bound. They still fire
304
304
  when any interpolation reads from the request, so a query that binds one value
305
305
  and concatenates another is reported.
306
+ - **The scan stays inside the directory you pointed at.** A symlink that
307
+ resolves outside the scan root is not followed. It sounds academic until you
308
+ run this in CI on a pull request: `vendor-config -> /home/runner/.ssh` would
309
+ otherwise be read, and quoted, into a public comment. Symlink loops are walked
310
+ once rather than a dozen times, and the report says how many links were
311
+ refused.
306
312
  - **A match in a comment is prose about code, not code.** The scanner lexes each
307
313
  file once for strings and comments — properly, tracking quotes, so a URL
308
314
  inside a string is not mistaken for the start of one — and a community rule
package/SECURITY.md CHANGED
@@ -22,7 +22,7 @@ supported one; older versions are not backported.
22
22
  | **Never writes to the scanned project** | The only writes in the codebase are the report file you ask for with `--output <path>` and the registry cache. Verify: `grep -rn "writeFileSync\|mkdirSync\|rmSync\|unlink" src/ --exclude-dir=vendor` — five lines, of which two are imports and three are those call sites. Neither path is derived from the scan root. |
23
23
  | **Never executes your code** | The scanner has no `child_process`, `exec`, `spawn`, `eval`, `new Function`, or dynamic `import()` of scanned files. Your code is read as text, parsed by Babel into an AST, and matched against regexes. Verify: `grep -rn "child_process\|execSync\|spawn\|eval(\|new Function" src/ --exclude-dir=vendor` — one hit, and it is a *pattern string* in the rule that detects those calls in **your** install hooks. (Drop `--exclude-dir=vendor` and the extra hits are rule text in the vendored ruleset, matched against your code, never run.) |
24
24
  | **Never connects to your database** | The Row Level Security scanner replays your `.sql` migration files to model the resulting schema. There is no database driver in the dependency tree and no connection string is ever read. |
25
- | **Reads only what it scans** | Files are gathered by walking the scan root, skipping `node_modules`, build output, virtualenvs and anything your own `.gitignore` excludes. Ignore rules are read from the scan root downwards only — never from a parent directory so nothing outside the root is read except the cache directory. |
25
+ | **Reads only what it scans** | Files are gathered by walking the scan root, skipping `node_modules`, build output, virtualenvs and anything your own `.gitignore` excludes. Ignore rules are read from the scan root downwards only — never from a parent directory. **A symlink that resolves outside the root is not followed**, so a repository cannot make the scanner read `~/.ssh` and quote it back into a report; the count of refused links appears in the output. Nothing outside the root is read except the cache directory. |
26
26
 
27
27
  ## What leaves your machine
28
28
 
@@ -65,6 +65,9 @@ are visible in the diff and cannot change under you between releases:
65
65
  categories no static scanner can reach.
66
66
  - **Not a sandbox.** It reads whatever you point it at. Pointing it at a
67
67
  repository you do not trust is as safe as opening that repository in an
68
- editor — no more, and no less.
68
+ editor — no more, and no less. What it will not do is read *beyond* what you
69
+ pointed it at: symlinks out of the tree are refused, and a malformed pattern
70
+ in the repository's own `.gitignore` is skipped rather than allowed to end
71
+ the scan.
69
72
  - **Not a secret scanner of record.** Secrets already committed to git history
70
73
  are out of scope; ClearToShip reads the working tree, not past commits.
package/action.yml CHANGED
@@ -29,7 +29,7 @@ inputs:
29
29
  version:
30
30
  description: >-
31
31
  Version of the cleartoship npm package to run. Defaults to the version this
32
- action's own ref declares, so `uses: …@v0.10.3` runs cleartoship@0.10.3. Set
32
+ action's own ref declares, so `uses: …@v0.10.4` runs cleartoship@0.10.4. Set
33
33
  `latest` to always track the newest release, or `local` to build from the checkout.
34
34
  required: false
35
35
  default: ''
@@ -76,7 +76,7 @@ runs:
76
76
  run: |
77
77
  # With no version pinned, run the exact version this action's checkout
78
78
  # declares. That keeps the action ref and the scanner in lockstep:
79
- # `uses: <owner>/cleartoship@v0.10.3` runs cleartoship@0.10.3 instead of
79
+ # `uses: <owner>/cleartoship@v0.10.4` runs cleartoship@0.10.4 instead of
80
80
  # whatever npm happens to tag `latest` at the time.
81
81
  ver="$INPUT_VERSION"
82
82
  if [ -z "$ver" ]; then
package/dist/report.js CHANGED
@@ -118,6 +118,7 @@ export function renderJson(scan) {
118
118
  framework: scan.framework,
119
119
  fileCount: scan.fileCount,
120
120
  gitIgnoredCount: scan.gitIgnoredCount,
121
+ escapingSymlinkCount: scan.escapingSymlinkCount,
121
122
  durationMs: scan.durationMs,
122
123
  verdict: scan.counts.critical > 0 ? 'hold' : scan.counts.high > 0 ? 'conditional' : 'clear',
123
124
  counts: scan.counts,
package/dist/scan.d.ts CHANGED
@@ -19,6 +19,8 @@ export interface FullScan {
19
19
  fileCount: number;
20
20
  /** Paths left unscanned because the repository's own ignore rules exclude them. */
21
21
  gitIgnoredCount: number;
22
+ /** Symlinks that pointed outside the scan root and were not followed. */
23
+ escapingSymlinkCount: number;
22
24
  findings: Finding[];
23
25
  checks: CheckSummary[];
24
26
  warnings: string[];
package/dist/scan.js CHANGED
@@ -13,6 +13,7 @@ export async function scan(options) {
13
13
  const walked = roots.map((r) => walk(r, { respectGitignore: !options.noGitignore }));
14
14
  const files = [...new Set(walked.flatMap((w) => w.files))];
15
15
  const gitIgnoredCount = walked.reduce((n, w) => n + w.gitIgnored, 0);
16
+ const escapingSymlinkCount = walked.reduce((n, w) => n + w.escapingSymlinks, 0);
16
17
  const framework = detectFramework(root, files);
17
18
  const ctx = {
18
19
  root,
@@ -76,6 +77,14 @@ export async function scan(options) {
76
77
  'checkout. Use --no-gitignore to scan them anyway.',
77
78
  });
78
79
  }
80
+ if (escapingSymlinkCount > 0) {
81
+ checks.push({
82
+ label: `Symlinks leaving the scan root not followed (${escapingSymlinkCount})`,
83
+ passed: true,
84
+ note: 'they point outside the directory you asked about, so their contents are not ' +
85
+ 'this project and are never read or quoted in this report',
86
+ });
87
+ }
79
88
  const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
80
89
  for (const f of filtered)
81
90
  counts[f.severity]++;
@@ -84,6 +93,7 @@ export async function scan(options) {
84
93
  framework: framework.describe(),
85
94
  fileCount: files.length,
86
95
  gitIgnoredCount,
96
+ escapingSymlinkCount,
87
97
  findings: filtered,
88
98
  checks,
89
99
  warnings,
@@ -2,6 +2,8 @@ export interface WalkResult {
2
2
  files: string[];
3
3
  /** How many paths were left out because the repository ignores them. */
4
4
  gitIgnored: number;
5
+ /** How many symlinks pointed outside the scan root and were not followed. */
6
+ escapingSymlinks: number;
5
7
  }
6
8
  export declare function walk(root: string, options?: {
7
9
  respectGitignore?: boolean;
@@ -1,4 +1,4 @@
1
- import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
1
+ import { readdirSync, statSync, readFileSync, existsSync, realpathSync } from 'node:fs';
2
2
  import { join, relative, sep } from 'node:path';
3
3
  import { Gitignore, extendedAt, repositoryExcludes } from './gitignore.js';
4
4
  const SKIP_DIRS = new Set([
@@ -63,6 +63,25 @@ export function walk(root, options = {}) {
63
63
  ? extendedAt(repositoryExcludes(root, read), root, read)
64
64
  : Gitignore.empty();
65
65
  const stack = [{ dir: root, rules: rootRules }];
66
+ // A symlinked directory that points back into the tree — `self -> .`, or the
67
+ // A→B→A pair a workspace layout can produce — otherwise gets walked again on
68
+ // every pass, reporting the same file at a dozen different paths. Resolving
69
+ // each directory once and remembering it costs one syscall per directory.
70
+ const visited = new Set();
71
+ let escapingSymlinks = 0;
72
+ // A symlink that leaves the tree is not part of the project, and following one
73
+ // would be worse than useless: `vendor-config -> /home/runner/.ssh` makes the
74
+ // scanner read that directory and quote what it finds — into a pull-request
75
+ // comment, in the Action. So the scan stays inside what it was pointed at.
76
+ let rootReal;
77
+ try {
78
+ rootReal = realpathSync(root);
79
+ }
80
+ catch {
81
+ rootReal = root;
82
+ }
83
+ visited.add(rootReal);
84
+ const insideRoot = (real) => real === rootReal || real.startsWith(rootReal.endsWith('/') ? rootReal : rootReal + '/');
66
85
  while (stack.length) {
67
86
  const { dir, rules } = stack.pop();
68
87
  let entries;
@@ -81,9 +100,23 @@ export function walk(root, options = {}) {
81
100
  catch {
82
101
  continue;
83
102
  }
103
+ let real;
104
+ try {
105
+ real = realpathSync(full);
106
+ }
107
+ catch {
108
+ continue;
109
+ }
110
+ if (!insideRoot(real)) {
111
+ escapingSymlinks++;
112
+ continue;
113
+ }
84
114
  if (st.isDirectory()) {
85
115
  if (SKIP_DIRS.has(entry))
86
116
  continue;
117
+ if (visited.has(real))
118
+ continue;
119
+ visited.add(real);
87
120
  // git never descends into an ignored directory, and neither do we —
88
121
  // which is also where most of the saving comes from.
89
122
  if (respect && rules.ignores(full, true)) {
@@ -113,7 +146,7 @@ export function walk(root, options = {}) {
113
146
  }
114
147
  }
115
148
  }
116
- return { files: found.sort(), gitIgnored };
149
+ return { files: found.sort(), gitIgnored, escapingSymlinks };
117
150
  }
118
151
  export function read(file) {
119
152
  try {
@@ -22,7 +22,7 @@ jobs:
22
22
  runs-on: ubuntu-latest
23
23
  steps:
24
24
  - uses: actions/checkout@v7
25
- - uses: murtazaozdemir/cleartoship@v0.10.3
25
+ - uses: murtazaozdemir/cleartoship@v0.10.4
26
26
  with:
27
27
  fail-on: critical # block the PR only on criticals
28
28
  comment: true # post a summary comment on the PR
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cleartoship",
3
- "version": "0.10.3",
3
+ "version": "0.10.4",
4
4
  "description": "The 30-second pre-launch security clearance for AI-built & vibe-coded apps. Catches missing Server Action auth, Supabase RLS holes, hallucinated npm packages and leaked keys.",
5
5
  "keywords": [
6
6
  "security",