cleartoship 0.10.1 → 0.10.3

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
@@ -98,7 +98,7 @@ runtimes, React Native, Go and shell.
98
98
 
99
99
  27 upstream rules are **superseded** where ClearToShip's own AST check is more
100
100
  precise, 6 are **withheld** as measurably noisy, 10 React Native rules are
101
- **skipped as inapplicable** on a project that is not React Native, 9 carry a
101
+ **skipped as inapplicable** on a project that is not React Native, 11 carry a
102
102
  **match guard** for a
103
103
  shape their regex cannot exclude (a `"link": true` lockfile entry has no
104
104
  integrity hash by design; `querySelectorAll` is not a SQL call; `eval()` inside
@@ -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.1
221
+ - uses: murtazaozdemir/cleartoship@v0.10.3
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.1` runs `cleartoship@0.10.1` and pinning the ref pins the behaviour. If
243
+ `@v0.10.3` runs `cleartoship@0.10.3` 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
 
@@ -290,6 +290,12 @@ uploaded, and no database is connected to.
290
290
  excluded: one is a published CLI's entry point, the other is production schema.
291
291
  Across five dogfooded repos this moved 21 findings out of `critical` without
292
292
  hiding one of them.
293
+ - **A CVE that only runs on a build machine is not a shipping vulnerability.**
294
+ CTS024 already split those when OSV answers; the vendored CVE rules — what
295
+ runs under `--offline` — now make the same split, dropping a match under
296
+ `devDependencies` (or a lockfile entry marked `"dev": true`) to `low` with the
297
+ reason attached. The same advisory against a dependency your users run keeps
298
+ its full severity.
293
299
  - **A bound parameter is not an injection.** `db.prepare(\`UPDATE ${table} SET
294
300
  csv = ? WHERE id = ?\`).bind(...)` interpolates an identifier while its values
295
301
  go through placeholders — the correct pattern, and the one a SQL-injection
@@ -297,6 +303,12 @@ uploaded, and no database is connected to.
297
303
  call chained to it, and stand down when the values are bound. They still fire
298
304
  when any interpolation reads from the request, so a query that binds one value
299
305
  and concatenates another is reported.
306
+ - **A match in a comment is prose about code, not code.** The scanner lexes each
307
+ file once for strings and comments — properly, tracking quotes, so a URL
308
+ inside a string is not mistaken for the start of one — and a community rule
309
+ that matched inside a comment is dropped. Our own source found this: a comment
310
+ reading *"merely calling `jwt.verify(token, secret)`"* was reported as a JWT
311
+ vulnerability.
300
312
  - **A rule that cannot apply here is not run.** The vendored ruleset covers
301
313
  ground this project may not stand on: certificate pinning and WebView
302
314
  hardening are React Native concerns, and a browser will not let a page pin a
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.1` runs cleartoship@0.10.1. Set
32
+ action's own ref declares, so `uses: …@v0.10.3` runs cleartoship@0.10.3. 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.1` runs cleartoship@0.10.1 instead of
79
+ # `uses: <owner>/cleartoship@v0.10.3` runs cleartoship@0.10.3 instead of
80
80
  # whatever npm happens to tag `latest` at the time.
81
81
  ver="$INPUT_VERSION"
82
82
  if [ -z "$ver" ]; then
@@ -1,7 +1,8 @@
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 { GUARDVIBE_RULES, GUARDVIBE_ATTRIBUTION, GUARDVIBE_REACT_NATIVE_RULE_IDS, } from '../vendor/guardvibe/index.js';
4
+ import { commentStyleFor, lexSpans, isInside } from '../utils/spans.js';
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
  /**
7
8
  * Upstream rules that restate a check ClearToShip already performs against the
@@ -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.
@@ -229,6 +219,35 @@ const MATCH_GUARDS = {
229
219
  // interpolated expression may read from the request.
230
220
  VG010: (match, source, index) => sqlGuard(match, source, index),
231
221
  VG123: (_match, source, index) => !isParameterized(statementAround(source, index)),
222
+ // The "base64 payload" test is a run of 20+ characters from the base64
223
+ // alphabet, which any long camelCase identifier satisfies:
224
+ // `description: \`${pct(clusteredAroundMedian, …)}\`` matched on the
225
+ // identifier. Interpolated expressions are code, not the description text,
226
+ // and real encoded content is not purely alphabetic.
227
+ VG881: (match) => {
228
+ const text = match.replace(/\$\{[^}]*\}/g, ' ');
229
+ if (/(?:\\x[0-9a-f]{2}){4,}|(?:\\u[0-9a-f]{4}){4,}|(?:&#\d{2,4};){4,}/i.test(text))
230
+ return true;
231
+ const run = /[A-Za-z0-9+/]{20,}={0,2}/.exec(text)?.[0];
232
+ // A slash is not evidence: "new/used/refurbished" is twenty characters of
233
+ // the base64 alphabet and a sentence. Real encoded content carries digits
234
+ // or padding.
235
+ return run !== undefined && /[0-9+=]/.test(run);
236
+ },
237
+ // `eval("require")` is the documented escape hatch for keeping a bundler from
238
+ // statically resolving a require — a constant the author typed, with no input
239
+ // reaching it. Dynamic code execution is about the dynamic part.
240
+ VG014: (match, source, index, spans) => {
241
+ if (isInside(spans, index, 'string'))
242
+ return false;
243
+ const after = source.slice(index, index + 60);
244
+ return !/^(?:eval|new\s+Function)\s*\(\s*(['"])[A-Za-z_$][\w$]*\1\s*\)/.test(after);
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)),
232
251
  // SSRF is a *server* being made to fetch a URL it should not. A module marked
233
252
  // `'use client'` runs in the browser, where the request leaves the user's own
234
253
  // machine and crosses no trust boundary of yours.
@@ -245,11 +264,6 @@ const MATCH_GUARDS = {
245
264
  // query filtered to the caller's own rows does not let them. An unbounded
246
265
  // fetch of your own data is a scalability question, not a security finding.
247
266
  VG955: (match) => !/\bwhere\b[\s\S]{0,200}?\b(userId|user_id|ownerId|owner_id|orgId|org_id|organizationId|tenantId|tenant_id|workspaceId|workspace_id|accountId|account_id|teamId|team_id|shop|shopDomain|storeId|store_id)\b/i.test(match),
248
- // `description: 'eval() executes arbitrary code…'` is prose about eval, not a
249
- // call to it — and security tooling, which is a good deal of what gets
250
- // scanned, is full of that prose. Code held in a string is not code running
251
- // here; the eval that would run it is its own match, outside the quotes.
252
- VG014: (_match, source, index) => !insideStringLiteral(source, index),
253
267
  };
254
268
  /** Regexes over very large files are where catastrophic backtracking bites. */
255
269
  const MAX_BYTES = 400_000;
@@ -280,6 +294,38 @@ function inapplicable(ctx) {
280
294
  }
281
295
  return { ids, why };
282
296
  }
297
+ /**
298
+ * Whether a dependency-manifest match sits under `devDependencies`, or in a
299
+ * lockfile entry marked `"dev": true`. Both mean the package is a build-time
300
+ * tool that no user ever runs — the split CTS024 already makes for CVEs found
301
+ * through OSV, applied to the vendored CVE rules that run when offline.
302
+ */
303
+ function inDevDependencies(source, index) {
304
+ const before = source.slice(Math.max(0, index - 4000), index);
305
+ if (/"dev"\s*:\s*true[\s\S]{0,600}$/.test(before))
306
+ return true;
307
+ const nearest = /"(dev|peer|optional)?[dD]ependencies"\s*:\s*\{(?![\s\S]*"[a-z]*[dD]ependencies"\s*:\s*\{)/.exec(before);
308
+ return nearest?.[1] === 'dev';
309
+ }
310
+ /**
311
+ * Rules whose upstream severity is right for one shape they match and wrong for
312
+ * another. Returning null leaves the rule's own severity alone.
313
+ */
314
+ const SEVERITY_ADJUSTERS = {
315
+ // The rule matches two different things. Explicitly accepting `alg: none` is
316
+ // the critical it is named for. Merely calling `jwt.verify(token, secret)`
317
+ // without pinning `algorithms` is not: jsonwebtoken has rejected `none` on a
318
+ // keyed verify since v9, so what is left is defence against algorithm
319
+ // confusion — worth doing, not worth blocking a deploy over.
320
+ VG105: (match) => /algorithms\s*:\s*\[\s*['"]none['"]/i.test(match)
321
+ ? null
322
+ : {
323
+ severity: 'medium',
324
+ note: ' (Reported at medium: no `algorithms` option is pinned, but nothing here accepts ' +
325
+ '`alg: none` — a keyed `jwt.verify` rejects it. Pinning the algorithm is defence ' +
326
+ 'against algorithm confusion, which matters most when the key could be a public key.)',
327
+ },
328
+ };
283
329
  export const communityScanner = {
284
330
  name: `Community ruleset (${GUARDVIBE_RULES.length - SUPERSEDED.size - WITHHELD.size} rules)`,
285
331
  applies() {
@@ -300,6 +346,7 @@ export const communityScanner = {
300
346
  continue;
301
347
  const relPath = rel(ctx.root, file);
302
348
  const lockfile = LOCKFILE.test(relPath);
349
+ const spans = lexSpans(source, commentStyleFor(languages));
303
350
  const suppress = new Suppressions(source);
304
351
  filesScanned++;
305
352
  for (const rule of active) {
@@ -320,7 +367,15 @@ export const communityScanner = {
320
367
  }
321
368
  // Skipping a match must not skip the non-global `break` below, or a
322
369
  // rule without /g would rescan from zero forever.
323
- 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)) {
324
379
  if (!re.global)
325
380
  break;
326
381
  continue;
@@ -329,12 +384,27 @@ export const communityScanner = {
329
384
  const key = `${relPath}:${line}:${rule.id}`;
330
385
  if (!seen.has(key) && !suppress.suppressed(line, rule.id)) {
331
386
  seen.add(key);
332
- const placed = adjustForPath(severityOf(rule.severity), relPath);
387
+ let adjusted = SEVERITY_ADJUSTERS[rule.id]?.(m[0], source, m.index) ?? null;
388
+ // A CVE in something that only ever runs on a build machine is not
389
+ // a shipping vulnerability. OSV-sourced findings are already split
390
+ // this way (CTS024); this is the same split for the vendored CVE
391
+ // rules, which are what runs with --offline.
392
+ if (!adjusted &&
393
+ GUARDVIBE_CVE_RULE_IDS.has(rule.id) &&
394
+ (lockfile || /(^|\/)package\.json$/.test(relPath)) &&
395
+ inDevDependencies(source, m.index)) {
396
+ adjusted = {
397
+ severity: 'low',
398
+ note: ' (Reported at low: this version is declared under devDependencies, so it is a ' +
399
+ 'build-time tool rather than something your users run.)',
400
+ };
401
+ }
402
+ const placed = adjustForPath(adjusted?.severity ?? severityOf(rule.severity), relPath);
333
403
  result.findings.push({
334
404
  id: rule.id,
335
405
  severity: placed.severity,
336
406
  title: rule.name,
337
- detail: rule.description + placed.note,
407
+ detail: rule.description + (adjusted?.note ?? '') + placed.note,
338
408
  fix: rule.fixCode ? `${rule.fix}\n\n${rule.fixCode}` : rule.fix,
339
409
  file: relPath,
340
410
  line,
@@ -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.1
25
+ - uses: murtazaozdemir/cleartoship@v0.10.3
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.1",
3
+ "version": "0.10.3",
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",