cleartoship 0.10.2 → 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 +8 -2
- package/action.yml +2 -2
- package/dist/scanners/community.js +27 -23
- 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
|
@@ -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
|
|
|
@@ -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
|
+
- **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.
|
|
306
312
|
- **A rule that cannot apply here is not run.** The vendored ruleset covers
|
|
307
313
|
ground this project may not stand on: certificate pinning and WebView
|
|
308
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,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 (
|
|
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
|
-
|
|
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;
|
|
@@ -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",
|