cleartoship 0.10.3 → 0.11.0
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 +45 -4
- package/SECURITY.md +5 -2
- package/action.yml +2 -2
- package/dist/report.js +1 -0
- package/dist/scan.d.ts +2 -0
- package/dist/scan.js +29 -0
- package/dist/utils/files.d.ts +2 -0
- package/dist/utils/files.js +35 -2
- package/dist/utils/owasp.d.ts +48 -0
- package/dist/utils/owasp.js +132 -0
- package/examples/security.yml +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -109,7 +109,12 @@ could act on. Each list carries a reason per rule in
|
|
|
109
109
|
`src/scanners/community.ts`. Run `--no-community` to use only ClearToShip's
|
|
110
110
|
rules. See [ATTRIBUTION.md](ATTRIBUTION.md).
|
|
111
111
|
|
|
112
|
-
Findings map to **OWASP Top 10:2025** and CWE
|
|
112
|
+
Findings map to **OWASP Top 10:2025** and CWE, and the ones that are about an
|
|
113
|
+
LLM or an agent carry an **OWASP Top 10 for LLM Applications** category as well
|
|
114
|
+
(in `meta.llm`). The vendored ruleset labels categories inconsistently —
|
|
115
|
+
Injection arrives as both `A02:2025` and `A03:2025`, Security Misconfiguration
|
|
116
|
+
as `A05:2025` and `A05:2021` — so labels are normalised to one taxonomy on the
|
|
117
|
+
way out, with the original kept in `meta.owaspUpstream`.
|
|
113
118
|
|
|
114
119
|
## OWASP Top 10:2025 coverage — honest version
|
|
115
120
|
|
|
@@ -127,7 +132,37 @@ it is strongest exactly where AI-generated code fails. Coverage by category:
|
|
|
127
132
|
| **A09** Logging & Alerting Failures | 🟡 Targeted | **Secrets / PII written to logs** (CTS070) — the statically knowable slice |
|
|
128
133
|
| **A10** Mishandling Exceptions | 🟡 Targeted | **Fail-open / swallowed error on a security check** (CTS071) |
|
|
129
134
|
| **A08** Data & Integrity Failures | 🟡 Targeted | Unverified webhooks (CTS042), **insecure deserialization** (CTS072) |
|
|
130
|
-
| **A06** Insecure Design | 🔴 Not
|
|
135
|
+
| **A06** Insecure Design | 🔴 Not detectable first-party | Missing threat modeling is an architecture concern. 13 vendored rules carry the label; none of ClearToShip's own do, deliberately |
|
|
136
|
+
|
|
137
|
+
Counts, measured across the vendored ruleset after normalisation: A01 121,
|
|
138
|
+
A05 112, A04 78, A02 59, A03 32, A07 17, A06 13, A08 11, A09 2. ClearToShip's
|
|
139
|
+
own 45 rules add A01 19, A03 9, A04 7, A08 3, A05 2, and one each for A07, A09
|
|
140
|
+
and A10 — which is the category no vendored rule reaches.
|
|
141
|
+
|
|
142
|
+
## OWASP Top 10 for LLM Applications — coverage
|
|
143
|
+
|
|
144
|
+
Worth stating separately, because "we cover the OWASP Top 10" and "we cover the
|
|
145
|
+
LLM Top 10" are different claims and only one of them is usually meant. **41
|
|
146
|
+
vendored rules map to 7 of the 10 LLM categories**, and first-party findings
|
|
147
|
+
join them when the finding itself names a provider — a hardcoded OpenAI key
|
|
148
|
+
(CTS030), an AI client configured to run in the browser (CTS045).
|
|
149
|
+
|
|
150
|
+
| Category | Rules | What we detect |
|
|
151
|
+
| --- | --- | --- |
|
|
152
|
+
| **LLM01** Prompt Injection | 12 | User input, fetched pages and query results reaching a prompt unbounded; instructions hidden in a tool description |
|
|
153
|
+
| **LLM02** Sensitive Information Disclosure | 11 (+ CTS030, CTS045) | Provider keys in client code or a `NEXT_PUBLIC_` variable, `dangerouslyAllowBrowser`, a base URL pointed at somebody else's endpoint |
|
|
154
|
+
| **LLM06** Excessive Agency | 9 | MCP servers with permissive tool access, `allowedTools` wildcards, auto-approve bypassing the permission prompt, settings hooks that fetch or pipe |
|
|
155
|
+
| **LLM05** Improper Output Handling | 4 | Model output rendered as raw HTML or markdown images, or used in a dangerous sink |
|
|
156
|
+
| **LLM08** Vector & Embedding Weaknesses | 3 | Retrieval results interpolated into a prompt, unauthenticated vector upserts |
|
|
157
|
+
| **LLM03** Supply Chain | 1 | MCP server pinned to `@latest` |
|
|
158
|
+
| **LLM07** System Prompt Leakage | 1 | System prompt returned in an error response |
|
|
159
|
+
| **LLM04** Data & Model Poisoning | — | Needs training-pipeline and dataset provenance, which is not in the source tree |
|
|
160
|
+
| **LLM09** Misinformation | — | A model-output-quality property; nothing static to check |
|
|
161
|
+
| **LLM10** Unbounded Consumption | — | Real and detectable in principle (no token ceiling, no rate limit on an inference route) — an honest gap, not a claim |
|
|
162
|
+
|
|
163
|
+
The mapping is derived from each rule's own text rather than a hand-kept list of
|
|
164
|
+
ids, so re-vendoring upstream cannot silently drop it, and it is deliberately
|
|
165
|
+
conservative: a rule that does not clearly belong to a category gets none.
|
|
131
166
|
|
|
132
167
|
Two honest points a reviewer would raise, answered up front:
|
|
133
168
|
|
|
@@ -218,7 +253,7 @@ jobs:
|
|
|
218
253
|
runs-on: ubuntu-latest
|
|
219
254
|
steps:
|
|
220
255
|
- uses: actions/checkout@v7
|
|
221
|
-
- uses: murtazaozdemir/cleartoship@v0.
|
|
256
|
+
- uses: murtazaozdemir/cleartoship@v0.11.0
|
|
222
257
|
with:
|
|
223
258
|
fail-on: critical
|
|
224
259
|
comment: true
|
|
@@ -240,7 +275,7 @@ above `fail-on`) for use in later steps. The comment is *sticky* — re-runs edi
|
|
|
240
275
|
the same comment instead of piling up.
|
|
241
276
|
|
|
242
277
|
By default the action runs the scanner version its own ref declares, so
|
|
243
|
-
`@v0.
|
|
278
|
+
`@v0.11.0` runs `cleartoship@0.11.0` and pinning the ref pins the behaviour. If
|
|
244
279
|
that version is not on the registry, it builds from its own checkout instead, so
|
|
245
280
|
`uses: …@ref` works against an unpublished commit.
|
|
246
281
|
|
|
@@ -303,6 +338,12 @@ uploaded, and no database is connected to.
|
|
|
303
338
|
call chained to it, and stand down when the values are bound. They still fire
|
|
304
339
|
when any interpolation reads from the request, so a query that binds one value
|
|
305
340
|
and concatenates another is reported.
|
|
341
|
+
- **The scan stays inside the directory you pointed at.** A symlink that
|
|
342
|
+
resolves outside the scan root is not followed. It sounds academic until you
|
|
343
|
+
run this in CI on a pull request: `vendor-config -> /home/runner/.ssh` would
|
|
344
|
+
otherwise be read, and quoted, into a public comment. Symlink loops are walked
|
|
345
|
+
once rather than a dozen times, and the report says how many links were
|
|
346
|
+
refused.
|
|
306
347
|
- **A match in a comment is prose about code, not code.** The scanner lexes each
|
|
307
348
|
file once for strings and comments — properly, tracking quotes, so a URL
|
|
308
349
|
inside a string is not mistaken for the start of one — and a community rule
|
package/SECURITY.md
CHANGED
|
@@ -22,7 +22,7 @@ supported one; older versions are not backported.
|
|
|
22
22
|
| **Never writes to the scanned project** | The only writes in the codebase are the report file you ask for with `--output <path>` and the registry cache. Verify: `grep -rn "writeFileSync\|mkdirSync\|rmSync\|unlink" src/ --exclude-dir=vendor` — five lines, of which two are imports and three are those call sites. Neither path is derived from the scan root. |
|
|
23
23
|
| **Never executes your code** | The scanner has no `child_process`, `exec`, `spawn`, `eval`, `new Function`, or dynamic `import()` of scanned files. Your code is read as text, parsed by Babel into an AST, and matched against regexes. Verify: `grep -rn "child_process\|execSync\|spawn\|eval(\|new Function" src/ --exclude-dir=vendor` — one hit, and it is a *pattern string* in the rule that detects those calls in **your** install hooks. (Drop `--exclude-dir=vendor` and the extra hits are rule text in the vendored ruleset, matched against your code, never run.) |
|
|
24
24
|
| **Never connects to your database** | The Row Level Security scanner replays your `.sql` migration files to model the resulting schema. There is no database driver in the dependency tree and no connection string is ever read. |
|
|
25
|
-
| **Reads only what it scans** | Files are gathered by walking the scan root, skipping `node_modules`, build output, virtualenvs and anything your own `.gitignore` excludes. Ignore rules are read from the scan root downwards only — never from a parent directory
|
|
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.
|
|
32
|
+
action's own ref declares, so `uses: …@v0.11.0` runs cleartoship@0.11.0. 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.
|
|
79
|
+
# `uses: <owner>/cleartoship@v0.11.0` runs cleartoship@0.11.0 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
|
@@ -6,6 +6,7 @@ import { detectFramework } from './utils/detect.js';
|
|
|
6
6
|
import { SCANNERS, communityScanner } from './scanners/index.js';
|
|
7
7
|
import { GUARDVIBE_CVE_RULE_IDS } from './vendor/guardvibe/index.js';
|
|
8
8
|
import { SEVERITY_ORDER } from './types.js';
|
|
9
|
+
import { normaliseOwasp, llmCategory } from './utils/owasp.js';
|
|
9
10
|
export async function scan(options) {
|
|
10
11
|
const started = Date.now();
|
|
11
12
|
const root = resolve(options.root);
|
|
@@ -13,6 +14,7 @@ export async function scan(options) {
|
|
|
13
14
|
const walked = roots.map((r) => walk(r, { respectGitignore: !options.noGitignore }));
|
|
14
15
|
const files = [...new Set(walked.flatMap((w) => w.files))];
|
|
15
16
|
const gitIgnoredCount = walked.reduce((n, w) => n + w.gitIgnored, 0);
|
|
17
|
+
const escapingSymlinkCount = walked.reduce((n, w) => n + w.escapingSymlinks, 0);
|
|
16
18
|
const framework = detectFramework(root, files);
|
|
17
19
|
const ctx = {
|
|
18
20
|
root,
|
|
@@ -58,6 +60,24 @@ export async function scan(options) {
|
|
|
58
60
|
const floor = SEVERITY_ORDER[options.minSeverity];
|
|
59
61
|
filtered = filtered.filter((f) => SEVERITY_ORDER[f.severity] >= floor);
|
|
60
62
|
}
|
|
63
|
+
// One taxonomy on the way out, and a second label for the findings that are
|
|
64
|
+
// about an LLM or an agent rather than a web app. Both are applied here, so
|
|
65
|
+
// every scanner's output is consistent without each one having to know.
|
|
66
|
+
filtered = filtered.map((f) => {
|
|
67
|
+
const canonical = normaliseOwasp(f.owasp);
|
|
68
|
+
const llm = llmCategory(`${f.title} ${f.detail}`);
|
|
69
|
+
if (!canonical && !llm)
|
|
70
|
+
return f;
|
|
71
|
+
return {
|
|
72
|
+
...f,
|
|
73
|
+
owasp: canonical ?? f.owasp,
|
|
74
|
+
meta: {
|
|
75
|
+
...f.meta,
|
|
76
|
+
...(canonical && canonical !== f.owasp ? { owaspUpstream: f.owasp } : {}),
|
|
77
|
+
...(llm ? { llm } : {}),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
});
|
|
61
81
|
filtered.sort((a, b) => {
|
|
62
82
|
const bySeverity = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];
|
|
63
83
|
if (bySeverity !== 0)
|
|
@@ -76,6 +96,14 @@ export async function scan(options) {
|
|
|
76
96
|
'checkout. Use --no-gitignore to scan them anyway.',
|
|
77
97
|
});
|
|
78
98
|
}
|
|
99
|
+
if (escapingSymlinkCount > 0) {
|
|
100
|
+
checks.push({
|
|
101
|
+
label: `Symlinks leaving the scan root not followed (${escapingSymlinkCount})`,
|
|
102
|
+
passed: true,
|
|
103
|
+
note: 'they point outside the directory you asked about, so their contents are not ' +
|
|
104
|
+
'this project and are never read or quoted in this report',
|
|
105
|
+
});
|
|
106
|
+
}
|
|
79
107
|
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
80
108
|
for (const f of filtered)
|
|
81
109
|
counts[f.severity]++;
|
|
@@ -84,6 +112,7 @@ export async function scan(options) {
|
|
|
84
112
|
framework: framework.describe(),
|
|
85
113
|
fileCount: files.length,
|
|
86
114
|
gitIgnoredCount,
|
|
115
|
+
escapingSymlinkCount,
|
|
87
116
|
findings: filtered,
|
|
88
117
|
checks,
|
|
89
118
|
warnings,
|
package/dist/utils/files.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/files.js
CHANGED
|
@@ -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 {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One taxonomy out, whatever went in.
|
|
3
|
+
*
|
|
4
|
+
* ClearToShip's own rules label findings against the OWASP Top 10:2025. The
|
|
5
|
+
* vendored ruleset does not: measured across it, "Injection" arrives as both
|
|
6
|
+
* `A02:2025` and `A03:2025`, "Security Misconfiguration" as `A05:2025` and
|
|
7
|
+
* `A05:2021`, and a handful of rules carry API Top 10 categories instead. A
|
|
8
|
+
* user filtering a report by category, or reading the SARIF in a dashboard,
|
|
9
|
+
* would see three Injections and no way to total them.
|
|
10
|
+
*
|
|
11
|
+
* So the labels are normalised on the way out. The upstream string is kept in
|
|
12
|
+
* `meta.owaspUpstream` — this is a relabelling, not a correction of somebody
|
|
13
|
+
* else's judgement, and it should stay checkable.
|
|
14
|
+
*/
|
|
15
|
+
export declare const OWASP_2025: {
|
|
16
|
+
readonly A01: 'A01:2025 - Broken Access Control';
|
|
17
|
+
readonly A02: 'A02:2025 - Security Misconfiguration';
|
|
18
|
+
readonly A03: 'A03:2025 - Software Supply Chain Failures';
|
|
19
|
+
readonly A04: 'A04:2025 - Cryptographic Failures';
|
|
20
|
+
readonly A05: 'A05:2025 - Injection';
|
|
21
|
+
readonly A06: 'A06:2025 - Insecure Design';
|
|
22
|
+
readonly A07: 'A07:2025 - Authentication Failures';
|
|
23
|
+
readonly A08: 'A08:2025 - Software & Data Integrity Failures';
|
|
24
|
+
readonly A09: 'A09:2025 - Security Logging & Alerting Failures';
|
|
25
|
+
readonly A10: 'A10:2025 - Mishandling of Exceptional Conditions';
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* The canonical 2025 category for a rule's own label, or null when it belongs
|
|
29
|
+
* to a different standard. API Top 10 categories are left alone: they are
|
|
30
|
+
* accurate about a different list, and flattening them into the web Top 10
|
|
31
|
+
* would invent a mapping nobody published.
|
|
32
|
+
*/
|
|
33
|
+
export declare function normaliseOwasp(raw: string | undefined): string | null;
|
|
34
|
+
/** OWASP Top 10 for LLM Applications (2025). */
|
|
35
|
+
export declare const OWASP_LLM: {
|
|
36
|
+
readonly LLM01: 'LLM01:2025 - Prompt Injection';
|
|
37
|
+
readonly LLM02: 'LLM02:2025 - Sensitive Information Disclosure';
|
|
38
|
+
readonly LLM03: 'LLM03:2025 - Supply Chain';
|
|
39
|
+
readonly LLM04: 'LLM04:2025 - Data and Model Poisoning';
|
|
40
|
+
readonly LLM05: 'LLM05:2025 - Improper Output Handling';
|
|
41
|
+
readonly LLM06: 'LLM06:2025 - Excessive Agency';
|
|
42
|
+
readonly LLM07: 'LLM07:2025 - System Prompt Leakage';
|
|
43
|
+
readonly LLM08: 'LLM08:2025 - Vector and Embedding Weaknesses';
|
|
44
|
+
readonly LLM09: 'LLM09:2025 - Misinformation';
|
|
45
|
+
readonly LLM10: 'LLM10:2025 - Unbounded Consumption';
|
|
46
|
+
};
|
|
47
|
+
/** The LLM category a rule's own words place it in, if any. */
|
|
48
|
+
export declare function llmCategory(text: string): string | null;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One taxonomy out, whatever went in.
|
|
3
|
+
*
|
|
4
|
+
* ClearToShip's own rules label findings against the OWASP Top 10:2025. The
|
|
5
|
+
* vendored ruleset does not: measured across it, "Injection" arrives as both
|
|
6
|
+
* `A02:2025` and `A03:2025`, "Security Misconfiguration" as `A05:2025` and
|
|
7
|
+
* `A05:2021`, and a handful of rules carry API Top 10 categories instead. A
|
|
8
|
+
* user filtering a report by category, or reading the SARIF in a dashboard,
|
|
9
|
+
* would see three Injections and no way to total them.
|
|
10
|
+
*
|
|
11
|
+
* So the labels are normalised on the way out. The upstream string is kept in
|
|
12
|
+
* `meta.owaspUpstream` — this is a relabelling, not a correction of somebody
|
|
13
|
+
* else's judgement, and it should stay checkable.
|
|
14
|
+
*/
|
|
15
|
+
export const OWASP_2025 = {
|
|
16
|
+
A01: 'A01:2025 - Broken Access Control',
|
|
17
|
+
A02: 'A02:2025 - Security Misconfiguration',
|
|
18
|
+
A03: 'A03:2025 - Software Supply Chain Failures',
|
|
19
|
+
A04: 'A04:2025 - Cryptographic Failures',
|
|
20
|
+
A05: 'A05:2025 - Injection',
|
|
21
|
+
A06: 'A06:2025 - Insecure Design',
|
|
22
|
+
A07: 'A07:2025 - Authentication Failures',
|
|
23
|
+
A08: 'A08:2025 - Software & Data Integrity Failures',
|
|
24
|
+
A09: 'A09:2025 - Security Logging & Alerting Failures',
|
|
25
|
+
A10: 'A10:2025 - Mishandling of Exceptional Conditions',
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Two of these are judgement calls, made once and written down rather than
|
|
29
|
+
* left to vary per rule: the 2025 list has no standalone SSRF category (it sits
|
|
30
|
+
* under Broken Access Control) and no standalone Vulnerable Components category
|
|
31
|
+
* (it sits under Software Supply Chain Failures).
|
|
32
|
+
*/
|
|
33
|
+
const NORMALISE = [
|
|
34
|
+
[/broken access control|object level authorization|object property level|function level auth/i, OWASP_2025.A01],
|
|
35
|
+
[/server-?side request forgery|\bssrf\b/i, OWASP_2025.A01],
|
|
36
|
+
[/security misconfiguration/i, OWASP_2025.A02],
|
|
37
|
+
[/supply chain|vulnerable (and outdated )?components/i, OWASP_2025.A03],
|
|
38
|
+
[/cryptographic failures|sensitive data exposure/i, OWASP_2025.A04],
|
|
39
|
+
[/injection|cross-?site scripting|\bxss\b/i, OWASP_2025.A05],
|
|
40
|
+
[/insecure design/i, OWASP_2025.A06],
|
|
41
|
+
[/auth(entication)? failures|identification and auth|broken auth/i, OWASP_2025.A07],
|
|
42
|
+
[/data integrity failures/i, OWASP_2025.A08],
|
|
43
|
+
[/logging|monitoring|alerting/i, OWASP_2025.A09],
|
|
44
|
+
[/mishandling|exceptional conditions/i, OWASP_2025.A10],
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* The canonical 2025 category for a rule's own label, or null when it belongs
|
|
48
|
+
* to a different standard. API Top 10 categories are left alone: they are
|
|
49
|
+
* accurate about a different list, and flattening them into the web Top 10
|
|
50
|
+
* would invent a mapping nobody published.
|
|
51
|
+
*/
|
|
52
|
+
export function normaliseOwasp(raw) {
|
|
53
|
+
if (!raw)
|
|
54
|
+
return null;
|
|
55
|
+
// The API Security Top 10 is a different list with its own numbering, and a
|
|
56
|
+
// few rules cite it — sometimes without the `API` prefix, as `A04:2023`.
|
|
57
|
+
// Anything from the 2023 list is left as it is rather than flattened into a
|
|
58
|
+
// web category nobody published a mapping for.
|
|
59
|
+
if (/^API\d/i.test(raw.trim()) || /:2023\b/.test(raw))
|
|
60
|
+
return null;
|
|
61
|
+
for (const [pattern, canonical] of NORMALISE) {
|
|
62
|
+
if (pattern.test(raw))
|
|
63
|
+
return canonical;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
/** OWASP Top 10 for LLM Applications (2025). */
|
|
68
|
+
export const OWASP_LLM = {
|
|
69
|
+
LLM01: 'LLM01:2025 - Prompt Injection',
|
|
70
|
+
LLM02: 'LLM02:2025 - Sensitive Information Disclosure',
|
|
71
|
+
LLM03: 'LLM03:2025 - Supply Chain',
|
|
72
|
+
LLM04: 'LLM04:2025 - Data and Model Poisoning',
|
|
73
|
+
LLM05: 'LLM05:2025 - Improper Output Handling',
|
|
74
|
+
LLM06: 'LLM06:2025 - Excessive Agency',
|
|
75
|
+
LLM07: 'LLM07:2025 - System Prompt Leakage',
|
|
76
|
+
LLM08: 'LLM08:2025 - Vector and Embedding Weaknesses',
|
|
77
|
+
LLM09: 'LLM09:2025 - Misinformation',
|
|
78
|
+
LLM10: 'LLM10:2025 - Unbounded Consumption',
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* A second, independent label for the findings that are about an LLM or agent
|
|
82
|
+
* rather than a web app. Matched on the rule's own words, not on a hand-kept
|
|
83
|
+
* list of ids, so re-vendoring the upstream ruleset does not silently drop the
|
|
84
|
+
* mapping. Deliberately conservative: a rule that does not clearly belong to a
|
|
85
|
+
* category gets none, because a wrong category is worse than no category.
|
|
86
|
+
*/
|
|
87
|
+
const LLM_RULES = [
|
|
88
|
+
// Ordered deliberately: "LLM output used in a dangerous sink" is about
|
|
89
|
+
// handling the output, even though its description discusses injection.
|
|
90
|
+
[
|
|
91
|
+
/(llm|ai|model) output[^.]{0,60}(unescaped|innerhtml|dangerouslysetinnerhtml|render|eval|exec|sink|shell|command|markdown)|(unescaped|unsanitised|unsanitized)[^.]{0,30}(llm|ai|model) output/i,
|
|
92
|
+
OWASP_LLM.LLM05,
|
|
93
|
+
],
|
|
94
|
+
[
|
|
95
|
+
/prompt injection|injected instruction|hidden instruction|jailbreak|(tool|skill) (description|definition)[^.]{0,40}(instruction|encoded|obfuscat|inject)|untrusted content into (the )?prompt/i,
|
|
96
|
+
OWASP_LLM.LLM01,
|
|
97
|
+
],
|
|
98
|
+
[/system prompt[^.]{0,40}(leak|expos|client|bundle|browser)/i, OWASP_LLM.LLM07],
|
|
99
|
+
[
|
|
100
|
+
// A hardcoded provider key is disclosure wherever it sits, so no exposure
|
|
101
|
+
// word is required after a *named* provider. The generic "llm"/"ai" wording
|
|
102
|
+
// still needs one, or every mention of an AI feature would qualify.
|
|
103
|
+
/(openai|anthropic|gemini|claude|mistral|cohere|huggingface|replicate|groq|perplexity|xai|pinecone)[^.]{0,40}(api[ _-]?key|token|secret)|(llm|\bai\b)[^.]{0,40}(api[ _-]?key|token|secret)[^.]{0,40}(expos|public|client|browser|bundle)|base_?url[^.]{0,40}(non-|redirect)|dangerouslyallowbrowser/i,
|
|
104
|
+
OWASP_LLM.LLM02,
|
|
105
|
+
],
|
|
106
|
+
[/(mcp|model|agent)[^.]{0,40}(@latest|unpinned|unverified|untrusted (source|registry))/i, OWASP_LLM.LLM03],
|
|
107
|
+
[
|
|
108
|
+
// "Hook" is overloaded: an npm `postinstall` hook that shells out is a
|
|
109
|
+
// supply-chain finding, not an agent given too much authority. The AI
|
|
110
|
+
// context has to be in the text.
|
|
111
|
+
/auto[- ]?approve|allowedtools|excessive agency|(mcp|agent|assistant|settings|claude|ai)[- ]?(config|hook|tool)[^.]{0,60}(execut|pipes|network|write|permissive|broad|access)|permission prompt[^.]{0,40}(bypass|skip)|overly (broad|permissive)[^.]{0,30}tool/i,
|
|
112
|
+
OWASP_LLM.LLM06,
|
|
113
|
+
],
|
|
114
|
+
// "Embedding media" is not a vector embedding — matching the bare word put a
|
|
115
|
+
// TinyMCE XSS rule in this category.
|
|
116
|
+
[
|
|
117
|
+
/vector (store|database|db|index|search)\b|\bembeddings\b|embedding (vector|model|store)|\brag\b[^.]{0,30}(poison|inject)/i,
|
|
118
|
+
OWASP_LLM.LLM08,
|
|
119
|
+
],
|
|
120
|
+
[
|
|
121
|
+
/(llm|ai|model|token)[^.]{0,40}(unbounded|no (rate|token) limit|runaway|budget)|unbounded consumption/i,
|
|
122
|
+
OWASP_LLM.LLM10,
|
|
123
|
+
],
|
|
124
|
+
];
|
|
125
|
+
/** The LLM category a rule's own words place it in, if any. */
|
|
126
|
+
export function llmCategory(text) {
|
|
127
|
+
for (const [pattern, category] of LLM_RULES) {
|
|
128
|
+
if (pattern.test(text))
|
|
129
|
+
return category;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
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.
|
|
25
|
+
- uses: murtazaozdemir/cleartoship@v0.11.0
|
|
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.
|
|
3
|
+
"version": "0.11.0",
|
|
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",
|