cleartoship 0.10.2 → 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.2
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.2` runs `cleartoship@0.10.2` 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,18 @@ 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.
312
+ - **A match in a comment is prose about code, not code.** The scanner lexes each
313
+ file once for strings and comments — properly, tracking quotes, so a URL
314
+ inside a string is not mistaken for the start of one — and a community rule
315
+ that matched inside a comment is dropped. Our own source found this: a comment
316
+ reading *"merely calling `jwt.verify(token, secret)`"* was reported as a JWT
317
+ vulnerability.
306
318
  - **A rule that cannot apply here is not run.** The vendored ruleset covers
307
319
  ground this project may not stand on: certificate pinning and WebView
308
320
  hardening are React Native concerns, and a browser will not let a page pin a
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.2` runs cleartoship@0.10.2. 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.2` runs cleartoship@0.10.2 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,
@@ -1,6 +1,7 @@
1
1
  import { read, rel, lineAt, snippetAt, languagesFor } from '../utils/files.js';
2
2
  import { Suppressions } from '../utils/suppress.js';
3
3
  import { adjustForPath } from '../utils/paths.js';
4
+ import { commentStyleFor, lexSpans, isInside } from '../utils/spans.js';
4
5
  import { GUARDVIBE_RULES, GUARDVIBE_ATTRIBUTION, GUARDVIBE_REACT_NATIVE_RULE_IDS, GUARDVIBE_CVE_RULE_IDS, } from '../vendor/guardvibe/index.js';
5
6
  import { emptyResult } from '../types.js';
6
7
  /**
@@ -160,6 +161,15 @@ function isParameterized(statement) {
160
161
  // '?').join(',')})`, or a constant SQL fragment interpolated beside binds.
161
162
  if (/\.\s*bind\s*\(\s*[^)\s]/.test(statement))
162
163
  return true;
164
+ // The other calling convention: the values follow the query.
165
+ // `$executeRawUnsafe(\`… VALUES ${placeholders}\`, ...params)`,
166
+ // `db.query(sql, [id])`. A closing backtick with an argument after it.
167
+ if (/`\s*,\s*[^)\s]/.test(statement))
168
+ return true;
169
+ // An interpolation that builds placeholders is parameterizing too:
170
+ // `chunk.map(() => "(?, ?, ?)").join(", ")`.
171
+ if (/\$\{[^}]*['"][^'"]*\?[^'"]*['"]/.test(statement))
172
+ return true;
163
173
  // Otherwise look for the placeholders themselves — with interpolations
164
174
  // dropped first, so a JavaScript ternary is not read as a `?` parameter.
165
175
  return BIND_PLACEHOLDER.test(statement.replace(/\$\{[^}]*\}/g, ' '));
@@ -171,26 +181,6 @@ function sqlGuard(match, source, index) {
171
181
  return false;
172
182
  return !isParameterized(statementAround(source, index));
173
183
  }
174
- /** Whether `index` falls inside a quoted string on its own line. */
175
- function insideStringLiteral(source, index) {
176
- const lineStart = source.lastIndexOf('\n', index - 1) + 1;
177
- let quote = null;
178
- for (let i = lineStart; i < index; i++) {
179
- const ch = source[i];
180
- if (ch === '\\') {
181
- i++;
182
- continue;
183
- }
184
- if (quote) {
185
- if (ch === quote)
186
- quote = null;
187
- }
188
- else if (ch === '"' || ch === "'" || ch === '`') {
189
- quote = ch;
190
- }
191
- }
192
- return quote !== null;
193
- }
194
184
  /**
195
185
  * Per-rule filters for a match shape the upstream regex cannot exclude on its
196
186
  * own. Given the matched text plus where it sat, so a guard can look around it.
@@ -247,12 +237,17 @@ const MATCH_GUARDS = {
247
237
  // `eval("require")` is the documented escape hatch for keeping a bundler from
248
238
  // statically resolving a require — a constant the author typed, with no input
249
239
  // reaching it. Dynamic code execution is about the dynamic part.
250
- VG014: (match, source, index) => {
251
- if (insideStringLiteral(source, index))
240
+ VG014: (match, source, index, spans) => {
241
+ if (isInside(spans, index, 'string'))
252
242
  return false;
253
243
  const after = source.slice(index, index + 60);
254
244
  return !/^(?:eval|new\s+Function)\s*\(\s*(['"])[A-Za-z_$][\w$]*\1\s*\)/.test(after);
255
245
  },
246
+ // `$executeRawUnsafe` is named for who builds the SQL string, not for whether
247
+ // values can be bound — Prisma takes positional parameters after the query.
248
+ // `$executeRawUnsafe(\`INSERT … VALUES ${placeholders}\`, ...params)`, where
249
+ // `placeholders` is `chunk.map(() => "(?, ?)").join(",")`, binds every value.
250
+ VG433: (_match, source, index) => !isParameterized(statementAround(source, index)),
256
251
  // SSRF is a *server* being made to fetch a URL it should not. A module marked
257
252
  // `'use client'` runs in the browser, where the request leaves the user's own
258
253
  // machine and crosses no trust boundary of yours.
@@ -351,6 +346,7 @@ export const communityScanner = {
351
346
  continue;
352
347
  const relPath = rel(ctx.root, file);
353
348
  const lockfile = LOCKFILE.test(relPath);
349
+ const spans = lexSpans(source, commentStyleFor(languages));
354
350
  const suppress = new Suppressions(source);
355
351
  filesScanned++;
356
352
  for (const rule of active) {
@@ -371,7 +367,15 @@ export const communityScanner = {
371
367
  }
372
368
  // Skipping a match must not skip the non-global `break` below, or a
373
369
  // rule without /g would rescan from zero forever.
374
- if (guard && !guard(m[0], source, m.index)) {
370
+ // A rule that matched inside a comment matched prose about code, not
371
+ // code. Nothing in the vendored ruleset targets comment content, and
372
+ // a commented-out call is not a call.
373
+ if (isInside(spans, m.index, 'comment')) {
374
+ if (!re.global)
375
+ break;
376
+ continue;
377
+ }
378
+ if (guard && !guard(m[0], source, m.index, spans)) {
375
379
  if (!re.global)
376
380
  break;
377
381
  continue;
@@ -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 {
@@ -17,5 +17,11 @@ export declare function extendedAt(parent: Gitignore, dir: string, read: (path:
17
17
  * Repository-local excludes. Same syntax, same precedence as a root
18
18
  * `.gitignore`, but kept out of version control — so a machine-specific
19
19
  * scratch directory is invisible here without appearing in anyone's diff.
20
+ *
21
+ * The user's *global* ignore file (`core.excludesFile`, usually
22
+ * `~/.config/git/ignore`) is deliberately not read: it lives outside the scan
23
+ * root, which SECURITY.md promises we do not touch, and it is per-machine — a
24
+ * CI checkout would not have it, so honouring it would make a local scan
25
+ * quieter than the one that gates the merge.
20
26
  */
21
27
  export declare function repositoryExcludes(root: string, read: (path: string) => string | null): Gitignore;
@@ -12,6 +12,10 @@ function translate(pattern) {
12
12
  continue;
13
13
  }
14
14
  if (ch === '*') {
15
+ // `****` means no more than `**` does, and collapsing it keeps the
16
+ // translated regex free of the nested quantifiers that backtrack badly.
17
+ while (pattern[i + 1] === '*' && pattern[i + 2] === '*')
18
+ i++;
15
19
  const doubled = pattern[i + 1] === '*';
16
20
  if (doubled) {
17
21
  const atStart = i === 0 || pattern[i - 1] === '/';
@@ -51,11 +55,22 @@ function translate(pattern) {
51
55
  }
52
56
  return out;
53
57
  }
58
+ /**
59
+ * A pattern longer than this is not a real ignore rule. The cap exists because
60
+ * this text comes from the repository being scanned, which for a security tool
61
+ * is untrusted input: it reaches a regex compiler, and a regex compiler is a
62
+ * place where hostile input has leverage.
63
+ */
64
+ const MAX_PATTERN = 500;
65
+ /** Likewise, a `.gitignore` with more rules than this is not one. */
66
+ const MAX_RULES = 2000;
54
67
  function compile(line) {
55
68
  // Trailing whitespace is not part of the pattern unless it was escaped.
56
69
  let pattern = line.replace(/(?<!\\)\s+$/, '');
57
70
  if (pattern === '' || pattern.startsWith('#'))
58
71
  return null;
72
+ if (pattern.length > MAX_PATTERN)
73
+ return null;
59
74
  let negated = false;
60
75
  if (pattern.startsWith('!')) {
61
76
  negated = true;
@@ -77,8 +92,23 @@ function compile(line) {
77
92
  if (pattern.startsWith('/'))
78
93
  pattern = pattern.slice(1);
79
94
  const body = translate(pattern);
80
- const source = anchored ? `^${body}(?:/.*)?$` : `(?:^|/)${body}(?:/.*)?$`;
81
- return { re: new RegExp(source), negated, dirOnly };
95
+ // No subtree suffix: a pattern matches a path, not everything beneath it.
96
+ // Git prunes ignored directories during traversal instead, which is what the
97
+ // walk does — and only that order makes `logos/*` followed by
98
+ // `!logos/logos-in-app/` mean what git means. Matching the subtree here made
99
+ // the negation unreachable: every file under the re-included directory stayed
100
+ // ignored, 28 of them tracked, in one real repository.
101
+ const source = anchored ? `^${body}$` : `(?:^|/)${body}$`;
102
+ try {
103
+ return { re: new RegExp(source), negated, dirOnly };
104
+ }
105
+ catch {
106
+ // `[z-a]` is a reversed range, and one line of it used to end the scan:
107
+ // the throw escaped `walk()`, which runs before any scanner's error
108
+ // handling. A pattern git itself would treat as literal is not worth
109
+ // dying over — skip it and read the rest of the file.
110
+ return null;
111
+ }
82
112
  }
83
113
  export class Gitignore {
84
114
  layers;
@@ -92,6 +122,8 @@ export class Gitignore {
92
122
  extend(base, content) {
93
123
  const rules = [];
94
124
  for (const line of content.split(/\r?\n/)) {
125
+ if (rules.length >= MAX_RULES)
126
+ break;
95
127
  const rule = compile(line);
96
128
  if (rule)
97
129
  rules.push(rule);
@@ -133,6 +165,12 @@ export function extendedAt(parent, dir, read) {
133
165
  * Repository-local excludes. Same syntax, same precedence as a root
134
166
  * `.gitignore`, but kept out of version control — so a machine-specific
135
167
  * scratch directory is invisible here without appearing in anyone's diff.
168
+ *
169
+ * The user's *global* ignore file (`core.excludesFile`, usually
170
+ * `~/.config/git/ignore`) is deliberately not read: it lives outside the scan
171
+ * root, which SECURITY.md promises we do not touch, and it is per-machine — a
172
+ * CI checkout would not have it, so honouring it would make a local scan
173
+ * quieter than the one that gates the merge.
136
174
  */
137
175
  export function repositoryExcludes(root, read) {
138
176
  const content = read(join(root, '.git', 'info', 'exclude'));
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Where the strings and comments are in a file.
3
+ *
4
+ * A regex ruleset has no idea whether it matched code, a sentence about code,
5
+ * or a URL — and the difference decides whether a finding is real. Our own
6
+ * source proved it: a comment reading "merely calling `jwt.verify(token,
7
+ * secret)`" was reported as a JWT vulnerability, and a rule *description*
8
+ * quoting `eval()` was reported as dynamic code execution.
9
+ *
10
+ * The naive fix — treat everything after `//` as a comment — is worse than the
11
+ * bug, because `"https://api.example.com"` would silence every finding on that
12
+ * line. So this walks the file once, tracking quotes as it goes, and both
13
+ * answers come out correct.
14
+ */
15
+ export interface Span {
16
+ start: number;
17
+ end: number;
18
+ kind: 'string' | 'comment';
19
+ }
20
+ export interface CommentStyle {
21
+ /** `//` line comments and `/* *\/` blocks. */
22
+ slashes: boolean;
23
+ /** `#` line comments: shell, Python, Ruby, YAML, Terraform, Dockerfile. */
24
+ hash: boolean;
25
+ /** `--` line comments: SQL. */
26
+ dashes: boolean;
27
+ }
28
+ export declare function commentStyleFor(languages: readonly string[]): CommentStyle;
29
+ export declare function lexSpans(source: string, style: CommentStyle): Span[];
30
+ /** Binary search: is `index` inside a span of this kind? */
31
+ export declare function spanAt(spans: readonly Span[], index: number): Span | null;
32
+ export declare function isInside(spans: readonly Span[], index: number, kind: Span['kind']): boolean;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Where the strings and comments are in a file.
3
+ *
4
+ * A regex ruleset has no idea whether it matched code, a sentence about code,
5
+ * or a URL — and the difference decides whether a finding is real. Our own
6
+ * source proved it: a comment reading "merely calling `jwt.verify(token,
7
+ * secret)`" was reported as a JWT vulnerability, and a rule *description*
8
+ * quoting `eval()` was reported as dynamic code execution.
9
+ *
10
+ * The naive fix — treat everything after `//` as a comment — is worse than the
11
+ * bug, because `"https://api.example.com"` would silence every finding on that
12
+ * line. So this walks the file once, tracking quotes as it goes, and both
13
+ * answers come out correct.
14
+ */
15
+ export function commentStyleFor(languages) {
16
+ const has = (l) => languages.includes(l);
17
+ return {
18
+ slashes: has('javascript') || has('typescript') || has('go') || has('php') || has('sql'),
19
+ hash: has('python') ||
20
+ has('shell') ||
21
+ has('ruby') ||
22
+ has('yaml') ||
23
+ has('terraform') ||
24
+ has('dockerfile'),
25
+ dashes: has('sql'),
26
+ };
27
+ }
28
+ /** Files past this size are skipped by the caller anyway; this is belt and braces. */
29
+ const MAX_SOURCE = 1_000_000;
30
+ export function lexSpans(source, style) {
31
+ const spans = [];
32
+ if (source.length > MAX_SOURCE)
33
+ return spans;
34
+ for (let i = 0; i < source.length; i++) {
35
+ const ch = source[i];
36
+ // Strings. A template literal is taken whole, interpolations included —
37
+ // nothing here needs to reason about what is inside one.
38
+ if (ch === '"' || ch === "'" || ch === '`') {
39
+ const start = i;
40
+ const quote = ch;
41
+ i++;
42
+ while (i < source.length) {
43
+ const c = source[i];
44
+ if (c === '\\') {
45
+ i += 2;
46
+ continue;
47
+ }
48
+ if (c === quote)
49
+ break;
50
+ // An unterminated single- or double-quoted string ends at the newline,
51
+ // which is what an apostrophe in a comment looks like.
52
+ if (c === '\n' && quote !== '`')
53
+ break;
54
+ i++;
55
+ }
56
+ spans.push({ start, end: Math.min(i, source.length - 1), kind: 'string' });
57
+ continue;
58
+ }
59
+ const two = source.slice(i, i + 2);
60
+ if (style.slashes && two === '/*') {
61
+ const end = source.indexOf('*/', i + 2);
62
+ const close = end === -1 ? source.length - 1 : end + 1;
63
+ spans.push({ start: i, end: close, kind: 'comment' });
64
+ i = close;
65
+ continue;
66
+ }
67
+ const lineComment = (style.slashes && two === '//') ||
68
+ (style.dashes && two === '--') ||
69
+ (style.hash && ch === '#');
70
+ if (lineComment) {
71
+ const newline = source.indexOf('\n', i);
72
+ const close = newline === -1 ? source.length - 1 : newline - 1;
73
+ spans.push({ start: i, end: close, kind: 'comment' });
74
+ i = close;
75
+ continue;
76
+ }
77
+ }
78
+ return spans;
79
+ }
80
+ /** Binary search: is `index` inside a span of this kind? */
81
+ export function spanAt(spans, index) {
82
+ let low = 0;
83
+ let high = spans.length - 1;
84
+ while (low <= high) {
85
+ const mid = (low + high) >> 1;
86
+ const span = spans[mid];
87
+ if (index < span.start)
88
+ high = mid - 1;
89
+ else if (index > span.end)
90
+ low = mid + 1;
91
+ else
92
+ return span;
93
+ }
94
+ return null;
95
+ }
96
+ export function isInside(spans, index, kind) {
97
+ return spanAt(spans, index)?.kind === kind;
98
+ }
@@ -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.2
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.2",
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",