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 +15 -3
- package/action.yml +2 -2
- package/dist/scanners/community.js +99 -29
- package/dist/utils/gitignore.d.ts +6 -0
- package/dist/utils/gitignore.js +40 -2
- package/dist/utils/spans.d.ts +32 -0
- package/dist/utils/spans.js +98 -0
- package/examples/security.yml +1 -1
- package/package.json +1 -1
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,
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
package/dist/utils/gitignore.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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
|
+
}
|
package/examples/security.yml
CHANGED
|
@@ -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.
|
|
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.
|
|
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",
|