cleartoship 0.7.0 → 0.8.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 +55 -6
- package/action.yml +80 -37
- package/dist/scanners/index.d.ts +2 -1
- package/dist/scanners/index.js +3 -1
- package/dist/scanners/logic.d.ts +2 -0
- package/dist/scanners/logic.js +226 -0
- package/examples/security.yml +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -79,6 +79,14 @@ reaches a manifest.
|
|
|
79
79
|
| **CTS033** | critical | `'use client'` component reaching for a server-only secret |
|
|
80
80
|
| **GL-\*** | high/critical | 219 further credential providers, vendored from [gitleaks](https://github.com/gitleaks/gitleaks) (MIT), gated on Shannon entropy |
|
|
81
81
|
|
|
82
|
+
**Logging, error-handling & deserialization** — the detectable slices of A08/A09/A10
|
|
83
|
+
|
|
84
|
+
| Rule | Severity | What it catches |
|
|
85
|
+
| --- | --- | --- |
|
|
86
|
+
| **CTS070** | high/medium | Secret, token or PII (or a whole request body) written to a log (A09) |
|
|
87
|
+
| **CTS071** | high/medium | A security check that **fails open** — `catch { return true }` — or swallows its error (A10) |
|
|
88
|
+
| **CTS072** | high | Insecure deserialization of untrusted data — `unserialize`, `pickle.loads`, unsafe `yaml.load` (A08) |
|
|
89
|
+
|
|
82
90
|
**Community ruleset** — 436 additional rules vendored from
|
|
83
91
|
[GuardVibe](https://github.com/goklab/guardvibe) (Apache-2.0)
|
|
84
92
|
|
|
@@ -95,6 +103,41 @@ ClearToShip's rules. See [ATTRIBUTION.md](ATTRIBUTION.md).
|
|
|
95
103
|
|
|
96
104
|
Findings map to **OWASP Top 10:2025** and CWE.
|
|
97
105
|
|
|
106
|
+
## OWASP Top 10:2025 coverage — honest version
|
|
107
|
+
|
|
108
|
+
ClearToShip is not an even, "100% coverage" scanner and does not claim to be —
|
|
109
|
+
it is strongest exactly where AI-generated code fails. Coverage by category:
|
|
110
|
+
|
|
111
|
+
| Category | Coverage | What we detect |
|
|
112
|
+
| --- | --- | --- |
|
|
113
|
+
| **A01** Broken Access Control | 🟢 Strong | Missing Server Action / route auth, RLS holes, IDOR, definer bypasses |
|
|
114
|
+
| **A03** Supply Chain Failures | 🟢 Strong | Hallucinated / slopsquat / typosquat packages, live CVEs (OSV), install hooks |
|
|
115
|
+
| **A07** Authentication Failures | 🟢 Strong | `getSession` misuse, weak sessions, JWT (mostly vendored) |
|
|
116
|
+
| **A02** Security Misconfiguration | 🟢 Strong | Docker, Terraform, headers, CORS — via the vendored pack |
|
|
117
|
+
| **A05** Injection | 🟢 Strong | SQLi, XSS, command injection |
|
|
118
|
+
| **A04** Cryptographic Failures | 🟡 Moderate | Hardcoded keys (234 credential patterns), weak hashing |
|
|
119
|
+
| **A09** Logging & Alerting Failures | 🟡 Targeted | **Secrets / PII written to logs** (CTS070) — the statically knowable slice |
|
|
120
|
+
| **A10** Mishandling Exceptions | 🟡 Targeted | **Fail-open / swallowed error on a security check** (CTS071) |
|
|
121
|
+
| **A08** Data & Integrity Failures | 🟡 Targeted | Unverified webhooks (CTS042), **insecure deserialization** (CTS072) |
|
|
122
|
+
| **A06** Insecure Design | 🔴 Not statically detectable | Missing threat modeling is an architecture concern — no static scanner covers it, and we don't pretend to |
|
|
123
|
+
|
|
124
|
+
Two honest points a reviewer would raise, answered up front:
|
|
125
|
+
|
|
126
|
+
- **A06 and A09 are hard for _any_ static tool.** A06 (Insecure Design) is about
|
|
127
|
+
missing threat modeling — you cannot grep for "the developer didn't consider an
|
|
128
|
+
abuse case." A09 (Logging failures) is largely a runtime/ops concern. Semgrep,
|
|
129
|
+
Snyk and CodeQL have the same limits. ClearToShip covers the *detectable slices*
|
|
130
|
+
(secrets in logs, fail-open error handling) and is honest that the rest needs a
|
|
131
|
+
human threat model and runtime observability, not a scanner.
|
|
132
|
+
- **Coverage is uneven on purpose.** The thesis is "the gaps LLM-generated code
|
|
133
|
+
leaves," which cluster in A01/A03/A07/A04 — so that is where the rules cluster.
|
|
134
|
+
|
|
135
|
+
Running with `--no-community` (first-party rules only) covers **8 of 10**
|
|
136
|
+
categories directly (A01, A03, A04, A05, A07, A08, A09, A10); the community pack
|
|
137
|
+
adds A02 and broadens A05/A07.
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
98
141
|
## Usage
|
|
99
142
|
|
|
100
143
|
```bash
|
|
@@ -140,7 +183,7 @@ jobs:
|
|
|
140
183
|
runs-on: ubuntu-latest
|
|
141
184
|
steps:
|
|
142
185
|
- uses: actions/checkout@v4
|
|
143
|
-
- uses: murtazaozdemir/cleartoship@v0.
|
|
186
|
+
- uses: murtazaozdemir/cleartoship@v0.8.0
|
|
144
187
|
with:
|
|
145
188
|
fail-on: critical
|
|
146
189
|
comment: true
|
|
@@ -154,11 +197,17 @@ jobs:
|
|
|
154
197
|
| `sarif` | `false` | Upload results to GitHub code scanning |
|
|
155
198
|
| `offline` | `false` | Skip registry and OSV lookups |
|
|
156
199
|
| `working-directory` | `.` | Directory to scan from |
|
|
200
|
+
| `version` | *(matches the action ref)* | npm version of the scanner to run; `latest` to always track the newest, `local` to build from the checkout |
|
|
201
|
+
|
|
202
|
+
Outputs `verdict` (`clear`/`conditional`/`hold`), the per-severity counts
|
|
203
|
+
`critical`, `high`, `medium`, `low`, plus `total` and `blocking` (findings at or
|
|
204
|
+
above `fail-on`) for use in later steps. The comment is *sticky* — re-runs edit
|
|
205
|
+
the same comment instead of piling up.
|
|
157
206
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
207
|
+
By default the action runs the scanner version its own ref declares, so
|
|
208
|
+
`@v0.8.0` runs `cleartoship@0.8.0` and pinning the ref pins the behaviour. If
|
|
209
|
+
that version is not on the registry, it builds from its own checkout instead, so
|
|
210
|
+
`uses: …@ref` works against an unpublished commit.
|
|
162
211
|
|
|
163
212
|
### Plain CLI
|
|
164
213
|
|
|
@@ -174,7 +223,7 @@ To feed findings into GitHub's Security tab without the Action:
|
|
|
174
223
|
with: { sarif_file: results.sarif }
|
|
175
224
|
```
|
|
176
225
|
|
|
177
|
-
## How it works
|
|
226
|
+
## How it works
|
|
178
227
|
|
|
179
228
|
Four scanners, all static — nothing is uploaded and no database is contacted.
|
|
180
229
|
|
package/action.yml
CHANGED
|
@@ -27,9 +27,12 @@ inputs:
|
|
|
27
27
|
required: false
|
|
28
28
|
default: 'false'
|
|
29
29
|
version:
|
|
30
|
-
description:
|
|
30
|
+
description: >-
|
|
31
|
+
Version of the cleartoship npm package to run. Defaults to the version this
|
|
32
|
+
action's own ref declares, so `uses: …@v0.8.0` runs cleartoship@0.8.0. Set
|
|
33
|
+
`latest` to always track the newest release, or `local` to build from the checkout.
|
|
31
34
|
required: false
|
|
32
|
-
default:
|
|
35
|
+
default: ''
|
|
33
36
|
working-directory:
|
|
34
37
|
description: Directory to run the scan from.
|
|
35
38
|
required: false
|
|
@@ -49,9 +52,18 @@ outputs:
|
|
|
49
52
|
high:
|
|
50
53
|
description: Number of high findings.
|
|
51
54
|
value: ${{ steps.scan.outputs.high }}
|
|
55
|
+
medium:
|
|
56
|
+
description: Number of medium findings.
|
|
57
|
+
value: ${{ steps.scan.outputs.medium }}
|
|
58
|
+
low:
|
|
59
|
+
description: Number of low findings.
|
|
60
|
+
value: ${{ steps.scan.outputs.low }}
|
|
52
61
|
total:
|
|
53
62
|
description: Total number of findings.
|
|
54
63
|
value: ${{ steps.scan.outputs.total }}
|
|
64
|
+
blocking:
|
|
65
|
+
description: Number of findings at or above the fail-on severity.
|
|
66
|
+
value: ${{ steps.scan.outputs.blocking }}
|
|
55
67
|
|
|
56
68
|
runs:
|
|
57
69
|
using: composite
|
|
@@ -59,17 +71,28 @@ runs:
|
|
|
59
71
|
- name: Resolve ClearToShip
|
|
60
72
|
id: resolve
|
|
61
73
|
shell: bash
|
|
74
|
+
env:
|
|
75
|
+
INPUT_VERSION: ${{ inputs.version }}
|
|
62
76
|
run: |
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
ver="$
|
|
77
|
+
# With no version pinned, run the exact version this action's checkout
|
|
78
|
+
# declares. That keeps the action ref and the scanner in lockstep:
|
|
79
|
+
# `uses: <owner>/cleartoship@v0.8.0` runs cleartoship@0.8.0 instead of
|
|
80
|
+
# whatever npm happens to tag `latest` at the time.
|
|
81
|
+
ver="$INPUT_VERSION"
|
|
82
|
+
if [ -z "$ver" ]; then
|
|
83
|
+
ver=$(node -p "require('${GITHUB_ACTION_PATH}/package.json').version")
|
|
84
|
+
echo "No version pinned; matching this action's checkout: ${ver}"
|
|
85
|
+
fi
|
|
86
|
+
|
|
87
|
+
# Prefer the published npm package. If it cannot be resolved — an
|
|
88
|
+
# unpublished version, or a pinned version that is not on the registry —
|
|
89
|
+
# fall back to building from this action's own checkout, so
|
|
90
|
+
# `uses: <owner>/cleartoship@ref` works with no npm dependency.
|
|
68
91
|
if [ "$ver" != "local" ] && npm view "cleartoship@${ver}" version >/dev/null 2>&1; then
|
|
69
92
|
echo "cmd=npx --yes cleartoship@${ver}" >> "$GITHUB_OUTPUT"
|
|
70
93
|
echo "Using published cleartoship@${ver}"
|
|
71
94
|
else
|
|
72
|
-
echo "
|
|
95
|
+
echo "cleartoship@${ver} not on the registry; building from the action checkout."
|
|
73
96
|
( cd "$GITHUB_ACTION_PATH" && npm ci --silent && npm run build --silent )
|
|
74
97
|
echo "cmd=node ${GITHUB_ACTION_PATH}/dist/cli.js" >> "$GITHUB_OUTPUT"
|
|
75
98
|
fi
|
|
@@ -78,30 +101,56 @@ runs:
|
|
|
78
101
|
id: scan
|
|
79
102
|
shell: bash
|
|
80
103
|
working-directory: ${{ inputs.working-directory }}
|
|
104
|
+
env:
|
|
105
|
+
# Inputs are read through the environment rather than interpolated into
|
|
106
|
+
# the script body, so a caller's value is never parsed as shell.
|
|
107
|
+
CTS: ${{ steps.resolve.outputs.cmd }}
|
|
108
|
+
INPUT_PATHS: ${{ inputs.paths }}
|
|
109
|
+
INPUT_OFFLINE: ${{ inputs.offline }}
|
|
110
|
+
INPUT_SARIF: ${{ inputs.sarif }}
|
|
111
|
+
INPUT_FAIL_ON: ${{ inputs.fail-on }}
|
|
81
112
|
run: |
|
|
82
113
|
set -o pipefail
|
|
83
|
-
CTS="${{ steps.resolve.outputs.cmd }}"
|
|
84
114
|
args=()
|
|
85
|
-
[ -n "$
|
|
86
|
-
[ "$
|
|
115
|
+
if [ -n "$INPUT_PATHS" ]; then read -ra args <<< "$INPUT_PATHS"; fi
|
|
116
|
+
if [ "$INPUT_OFFLINE" = "true" ]; then args+=(--offline); fi
|
|
87
117
|
|
|
88
118
|
# The gate is applied at the end so the report and comment are produced
|
|
89
119
|
# regardless of pass/fail; --fail-on=none keeps this step from aborting early.
|
|
90
120
|
$CTS "${args[@]}" --json --fail-on=none > cleartoship.json
|
|
91
121
|
$CTS "${args[@]}" --markdown --fail-on=none > cleartoship.md || true
|
|
92
|
-
if [ "$
|
|
122
|
+
if [ "$INPUT_SARIF" = "true" ]; then
|
|
93
123
|
$CTS "${args[@]}" --sarif --fail-on=none > results.sarif || true
|
|
94
124
|
fi
|
|
95
125
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
126
|
+
# Counting blocking findings here — off the full report, which carries every
|
|
127
|
+
# severity — is what makes `fail-on: medium` and `fail-on: low` mean what they
|
|
128
|
+
# say. A gate rebuilt from the critical/high outputs alone silently cannot.
|
|
129
|
+
# Fed to node over stdin with a quoted heredoc: nothing here is parsed by
|
|
130
|
+
# bash, so an apostrophe in a comment cannot silently truncate the script.
|
|
131
|
+
node <<'NODE'
|
|
132
|
+
const fs = require("fs");
|
|
133
|
+
const r = JSON.parse(fs.readFileSync("cleartoship.json", "utf8"));
|
|
134
|
+
const rank = { critical: 4, high: 3, medium: 2, low: 1, info: 0 };
|
|
135
|
+
const gate = process.env.INPUT_FAIL_ON;
|
|
136
|
+
if (gate !== "none" && !(gate in rank)) {
|
|
137
|
+
console.error(`::error::Unknown fail-on value "${gate}". Use critical, high, medium, low or none.`);
|
|
138
|
+
process.exit(2);
|
|
139
|
+
}
|
|
140
|
+
const blocking = gate === "none"
|
|
141
|
+
? 0
|
|
142
|
+
: r.findings.filter((f) => rank[f.severity] >= rank[gate]).length;
|
|
143
|
+
// Written out one by one rather than in a loop, so every declared output
|
|
144
|
+
// stays greppable and the action test can prove each one is produced.
|
|
145
|
+
const out = process.env.GITHUB_OUTPUT;
|
|
146
|
+
fs.appendFileSync(out, `verdict=${r.verdict}\n`);
|
|
147
|
+
fs.appendFileSync(out, `critical=${r.counts.critical}\n`);
|
|
148
|
+
fs.appendFileSync(out, `high=${r.counts.high}\n`);
|
|
149
|
+
fs.appendFileSync(out, `medium=${r.counts.medium}\n`);
|
|
150
|
+
fs.appendFileSync(out, `low=${r.counts.low}\n`);
|
|
151
|
+
fs.appendFileSync(out, `total=${r.findings.length}\n`);
|
|
152
|
+
fs.appendFileSync(out, `blocking=${blocking}\n`);
|
|
153
|
+
NODE
|
|
105
154
|
|
|
106
155
|
# Job summary — always shown on the run.
|
|
107
156
|
cat cleartoship.md >> "$GITHUB_STEP_SUMMARY"
|
|
@@ -113,6 +162,7 @@ runs:
|
|
|
113
162
|
env:
|
|
114
163
|
GH_TOKEN: ${{ inputs.github-token }}
|
|
115
164
|
PR: ${{ github.event.pull_request.number }}
|
|
165
|
+
REPO: ${{ github.repository }}
|
|
116
166
|
run: |
|
|
117
167
|
# A sticky comment: find a previous ClearToShip comment and edit it, so
|
|
118
168
|
# re-runs update in place instead of piling up.
|
|
@@ -120,12 +170,12 @@ runs:
|
|
|
120
170
|
printf '%s\n\n' "$marker" > body.md
|
|
121
171
|
cat cleartoship.md >> body.md
|
|
122
172
|
|
|
123
|
-
id=$(gh api "repos/${
|
|
173
|
+
id=$(gh api "repos/${REPO}/issues/${PR}/comments" \
|
|
124
174
|
--jq "map(select(.body | contains(\"$marker\"))) | .[0].id // empty" 2>/dev/null || true)
|
|
125
175
|
if [ -n "$id" ]; then
|
|
126
|
-
gh api -X PATCH "repos/${
|
|
176
|
+
gh api -X PATCH "repos/${REPO}/issues/comments/${id}" -F body=@body.md >/dev/null
|
|
127
177
|
else
|
|
128
|
-
gh api -X POST "repos/${
|
|
178
|
+
gh api -X POST "repos/${REPO}/issues/${PR}/comments" -F body=@body.md >/dev/null
|
|
129
179
|
fi
|
|
130
180
|
|
|
131
181
|
- name: Upload SARIF
|
|
@@ -136,19 +186,12 @@ runs:
|
|
|
136
186
|
|
|
137
187
|
- name: Apply severity gate
|
|
138
188
|
shell: bash
|
|
189
|
+
env:
|
|
190
|
+
GATE: ${{ inputs.fail-on }}
|
|
191
|
+
BLOCKING: ${{ steps.scan.outputs.blocking }}
|
|
139
192
|
run: |
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
declare -A rank=( [critical]=4 [high]=3 [medium]=2 [low]=1 )
|
|
143
|
-
floor=${rank[$gate]:-4}
|
|
144
|
-
crit=${{ steps.scan.outputs.critical }}
|
|
145
|
-
high=${{ steps.scan.outputs.high }}
|
|
146
|
-
# Only critical and high are wired as outputs; medium/low never block by default.
|
|
147
|
-
blocking=0
|
|
148
|
-
[ "$floor" -le 4 ] && blocking=$((blocking + crit))
|
|
149
|
-
[ "$floor" -le 3 ] && blocking=$((blocking + high))
|
|
150
|
-
if [ "$blocking" -gt 0 ]; then
|
|
151
|
-
echo "::error::ClearToShip found $blocking finding(s) at or above '$gate'."
|
|
193
|
+
if [ "$BLOCKING" -gt 0 ]; then
|
|
194
|
+
echo "::error::ClearToShip found ${BLOCKING} finding(s) at or above '${GATE}'."
|
|
152
195
|
exit 1
|
|
153
196
|
fi
|
|
154
|
-
echo "ClearToShip: clear to ship at '$
|
|
197
|
+
echo "ClearToShip: clear to ship at '${GATE}'."
|
package/dist/scanners/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { rlsScanner } from './rls.js';
|
|
|
3
3
|
import { dependencyScanner } from './dependencies.js';
|
|
4
4
|
import { secretsScanner } from './secrets.js';
|
|
5
5
|
import { communityScanner } from './community.js';
|
|
6
|
+
import { logicScanner } from './logic.js';
|
|
6
7
|
import type { Scanner } from '../types.js';
|
|
7
8
|
export declare const SCANNERS: Scanner[];
|
|
8
|
-
export { serverActionsScanner, rlsScanner, dependencyScanner, secretsScanner, communityScanner, };
|
|
9
|
+
export { serverActionsScanner, rlsScanner, dependencyScanner, secretsScanner, communityScanner, logicScanner, };
|
package/dist/scanners/index.js
CHANGED
|
@@ -3,11 +3,13 @@ import { rlsScanner } from './rls.js';
|
|
|
3
3
|
import { dependencyScanner } from './dependencies.js';
|
|
4
4
|
import { secretsScanner } from './secrets.js';
|
|
5
5
|
import { communityScanner } from './community.js';
|
|
6
|
+
import { logicScanner } from './logic.js';
|
|
6
7
|
export const SCANNERS = [
|
|
7
8
|
dependencyScanner,
|
|
8
9
|
serverActionsScanner,
|
|
9
10
|
rlsScanner,
|
|
10
11
|
secretsScanner,
|
|
12
|
+
logicScanner,
|
|
11
13
|
communityScanner,
|
|
12
14
|
];
|
|
13
|
-
export { serverActionsScanner, rlsScanner, dependencyScanner, secretsScanner, communityScanner, };
|
|
15
|
+
export { serverActionsScanner, rlsScanner, dependencyScanner, secretsScanner, communityScanner, logicScanner, };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { read, rel, isScript, snippetAt } from '../utils/files.js';
|
|
2
|
+
import { parseSource, calleeName, calleeTail } from '../utils/ast.js';
|
|
3
|
+
import { traverse } from '../utils/traverse.js';
|
|
4
|
+
import { Suppressions } from '../utils/suppress.js';
|
|
5
|
+
import { emptyResult } from '../types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Cross-cutting logic checks for OWASP categories a pattern scanner can only
|
|
8
|
+
* touch in specific, high-signal slices:
|
|
9
|
+
* A09 (Logging Failures) — secrets/PII written to logs
|
|
10
|
+
* A10 (Exceptional Conditions)— fail-open / swallowed errors on a security path
|
|
11
|
+
* A08 (Integrity Failures) — insecure deserialization of untrusted data
|
|
12
|
+
*
|
|
13
|
+
* Each rule is deliberately narrow: the goal is a true positive a developer will
|
|
14
|
+
* act on, not coverage for its own sake.
|
|
15
|
+
*/
|
|
16
|
+
const LOG_METHODS = new Set(['log', 'info', 'warn', 'error', 'debug', 'trace', 'fatal', 'verbose']);
|
|
17
|
+
const LOG_OBJECTS = /^(console|logger|log|pino|winston|fastify\.log|req\.log|ctx\.log|this\.logger)$/i;
|
|
18
|
+
/** A property/identifier name that names a real secret or piece of PII. */
|
|
19
|
+
const SENSITIVE_NAME = /(?:^|[._])(password|passwd|pwd|secret|secrets|token|tokens|apikey|api_key|authorization|auth_?token|accesstoken|access_token|refreshtoken|refresh_token|sessiontoken|session_token|privatekey|private_key|clientsecret|client_secret|creditcard|card_?number|cardnumber|cvv|cvc|ssn|social_?security|passport|jwt)(?:$|[._])/i;
|
|
20
|
+
/** Reading a whole request/response body into a log dumps everything in it. */
|
|
21
|
+
const BODY_EXPR = /(?:^|\.)(body|rawBody|payload)$/;
|
|
22
|
+
/** Deserializers that can instantiate objects or run code from their input. */
|
|
23
|
+
const UNSAFE_DESERIALIZE = [
|
|
24
|
+
'unserialize', // node-serialize / serialize-to-js — RCE on crafted input
|
|
25
|
+
'deserialize',
|
|
26
|
+
'funcster.deepDeserialize', // cleartoship-ignore VG070 — rule-name data, not a call
|
|
27
|
+
'pickle.loads', // python
|
|
28
|
+
'pickle.load',
|
|
29
|
+
'cPickle.loads',
|
|
30
|
+
'yaml.unsafe_load', // python
|
|
31
|
+
];
|
|
32
|
+
/** Function names that signal the body is making a security decision. */
|
|
33
|
+
const SECURITY_FN = /(verify|validate|authenticate|authorize|auth|check(?:auth|access|permission)?|hasaccess|haspermission|isallowed|isauthorized|canaccess|ensure|guard|require(?:auth|user|admin)?)/i;
|
|
34
|
+
/** Values a security check must never return from a swallowed error (fail-open). */
|
|
35
|
+
function isPermissiveReturn(node) {
|
|
36
|
+
if (!node)
|
|
37
|
+
return false;
|
|
38
|
+
if (node.type === 'BooleanLiteral' && node.value === true)
|
|
39
|
+
return true;
|
|
40
|
+
if (node.type === 'Identifier' && /^(user|session|account|token|claims)$/i.test(node.name))
|
|
41
|
+
return true;
|
|
42
|
+
if (node.type === 'ObjectExpression') {
|
|
43
|
+
return node.properties.some((p) => p?.key &&
|
|
44
|
+
/^(authorized|authenticated|valid|allowed|ok|success)$/i.test(p.key.name ?? p.key.value) &&
|
|
45
|
+
p.value?.value === true);
|
|
46
|
+
}
|
|
47
|
+
if (node.type === 'CallExpression') {
|
|
48
|
+
// next() with no error argument lets the request continue.
|
|
49
|
+
return calleeTail(node.callee) === 'next' && (node.arguments ?? []).length === 0;
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
export const logicScanner = {
|
|
54
|
+
name: 'Logging, exception-handling & deserialization',
|
|
55
|
+
applies(ctx) {
|
|
56
|
+
return ctx.files.some((f) => isScript(f) || f.endsWith('.py'));
|
|
57
|
+
},
|
|
58
|
+
async run(ctx) {
|
|
59
|
+
const result = emptyResult();
|
|
60
|
+
let analysed = 0;
|
|
61
|
+
for (const file of ctx.files) {
|
|
62
|
+
const script = isScript(file);
|
|
63
|
+
const python = file.endsWith('.py');
|
|
64
|
+
if (!script && !python)
|
|
65
|
+
continue;
|
|
66
|
+
const source = read(file);
|
|
67
|
+
if (source === null)
|
|
68
|
+
continue;
|
|
69
|
+
const relPath = rel(ctx.root, file);
|
|
70
|
+
const suppress = new Suppressions(source);
|
|
71
|
+
const push = (f) => {
|
|
72
|
+
if (suppress.suppressed(f.line, f.id))
|
|
73
|
+
return;
|
|
74
|
+
result.findings.push({ ...f, file: relPath, snippet: snippetAt(source, f.line) });
|
|
75
|
+
};
|
|
76
|
+
// Python is regex-only (no JS AST). Cover its highest-signal cases.
|
|
77
|
+
if (python) {
|
|
78
|
+
analysed++;
|
|
79
|
+
source.split('\n').forEach((lineText, i) => {
|
|
80
|
+
const line = i + 1;
|
|
81
|
+
if (/\b(pickle|cPickle)\.loads?\s*\(/.test(lineText) || /\byaml\.load\s*\((?![^)]*Loader\s*=\s*yaml\.SafeLoader)/.test(lineText)) {
|
|
82
|
+
push({
|
|
83
|
+
id: 'CTS072',
|
|
84
|
+
severity: 'high',
|
|
85
|
+
title: 'Insecure deserialization of untrusted data',
|
|
86
|
+
detail: 'This call deserializes input with a loader that can construct arbitrary objects — ' +
|
|
87
|
+
'`pickle` and unqualified `yaml.load` both execute code embedded in a crafted payload.',
|
|
88
|
+
fix: 'Use `yaml.safe_load` (or `Loader=yaml.SafeLoader`); never `pickle.loads` on data that crossed a trust boundary. Prefer JSON for untrusted input.',
|
|
89
|
+
line,
|
|
90
|
+
cwe: 'CWE-502: Deserialization of Untrusted Data',
|
|
91
|
+
owasp: 'A08:2025 - Software & Data Integrity Failures',
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
if (/\b(logging|logger|log|print)\b[^\n]*\b(password|secret|token|api_?key|authorization)\b/i.test(lineText) && !/["'][^"']*(password|secret|token)[^"']*["']\s*[,)]/i.test(lineText)) {
|
|
95
|
+
// heuristic: a sensitive identifier appears as a logged value, not just in a message string
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const ast = parseSource(source, file);
|
|
101
|
+
if (!ast)
|
|
102
|
+
continue;
|
|
103
|
+
analysed++;
|
|
104
|
+
traverse(ast, {
|
|
105
|
+
CallExpression(path) {
|
|
106
|
+
const node = path.node;
|
|
107
|
+
const tail = calleeTail(node.callee);
|
|
108
|
+
const full = calleeName(node.callee);
|
|
109
|
+
// --- A09: secrets / PII written to a log ---
|
|
110
|
+
const base = full.slice(0, full.lastIndexOf('.'));
|
|
111
|
+
const isLog = LOG_METHODS.has(tail) && (LOG_OBJECTS.test(base) || base === '' || /log$/i.test(base));
|
|
112
|
+
if (isLog) {
|
|
113
|
+
for (const arg of node.arguments ?? []) {
|
|
114
|
+
// Only value expressions count — a string message that merely
|
|
115
|
+
// contains the word "password" is not logging a password.
|
|
116
|
+
if (arg.type === 'StringLiteral' || arg.type === 'TemplateLiteral')
|
|
117
|
+
continue;
|
|
118
|
+
const name = arg.type === 'MemberExpression' || arg.type === 'Identifier' ? calleeName(arg) : '';
|
|
119
|
+
const sensitive = name && SENSITIVE_NAME.test(name);
|
|
120
|
+
const wholeBody = name && BODY_EXPR.test(name);
|
|
121
|
+
if (sensitive || wholeBody) {
|
|
122
|
+
push({
|
|
123
|
+
id: 'CTS070',
|
|
124
|
+
severity: wholeBody && !sensitive ? 'medium' : 'high',
|
|
125
|
+
title: wholeBody && !sensitive ? 'Request body written to logs' : 'Secret or PII written to logs',
|
|
126
|
+
detail: (wholeBody && !sensitive
|
|
127
|
+
? `\`${name}\` is logged in full, so whatever a caller put in the request body — ` +
|
|
128
|
+
'passwords, tokens, personal data — lands in your log store and anywhere it is shipped.'
|
|
129
|
+
: `\`${name}\` is written to a log. Logs are retained, replicated to aggregators and ` +
|
|
130
|
+
'often world-readable to a whole team; a credential or piece of PII there is a ' +
|
|
131
|
+
'leak that outlives the request.'),
|
|
132
|
+
fix: 'Do not log the raw value. Log a non-reversible reference (a user id, a hash prefix) ' +
|
|
133
|
+
'or redact the field before logging.',
|
|
134
|
+
line: node.loc?.start.line ?? 0,
|
|
135
|
+
cwe: 'CWE-532: Insertion of Sensitive Information into Log File',
|
|
136
|
+
owasp: 'A09:2025 - Security Logging & Alerting Failures',
|
|
137
|
+
meta: { logged: name },
|
|
138
|
+
});
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
// --- A08: insecure deserialization ---
|
|
144
|
+
if (UNSAFE_DESERIALIZE.some((d) => full === d || full.endsWith('.' + d) || tail === d)) {
|
|
145
|
+
const arg = (node.arguments ?? [])[0];
|
|
146
|
+
// A string literal is a fixed, trusted payload; only flag dynamic input.
|
|
147
|
+
if (arg && arg.type !== 'StringLiteral') {
|
|
148
|
+
push({
|
|
149
|
+
id: 'CTS072',
|
|
150
|
+
severity: 'high',
|
|
151
|
+
title: 'Insecure deserialization of untrusted data',
|
|
152
|
+
detail: `\`${full}(...)\` reconstructs objects from its input. Deserializers of this kind can ` +
|
|
153
|
+
'instantiate arbitrary types and invoke their side effects, so a crafted payload is ' +
|
|
154
|
+
'remote code execution — this is why `JSON.parse` is safe and these are not.',
|
|
155
|
+
fix: 'Deserialize untrusted data with `JSON.parse` only. If you need richer types, validate ' +
|
|
156
|
+
'the parsed shape with a schema; never hand attacker-controlled bytes to an object ' +
|
|
157
|
+
'deserializer.',
|
|
158
|
+
line: node.loc?.start.line ?? 0,
|
|
159
|
+
cwe: 'CWE-502: Deserialization of Untrusted Data',
|
|
160
|
+
owasp: 'A08:2025 - Software & Data Integrity Failures',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
// --- A10: fail-open / swallowed error on a security path ---
|
|
166
|
+
CatchClause(path) {
|
|
167
|
+
const node = path.node;
|
|
168
|
+
const body = node.body?.body ?? [];
|
|
169
|
+
// Name of the nearest enclosing function, to tell whether this catch
|
|
170
|
+
// guards a security decision.
|
|
171
|
+
let fnName = '';
|
|
172
|
+
let p = path.parentPath;
|
|
173
|
+
let hops = 0;
|
|
174
|
+
while (p && hops++ < 8) {
|
|
175
|
+
const n = p.node;
|
|
176
|
+
if (n?.type === 'FunctionDeclaration' || n?.type === 'FunctionExpression') {
|
|
177
|
+
fnName = n.id?.name ?? '';
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
if (n?.type === 'ArrowFunctionExpression' || n?.type === 'VariableDeclarator') {
|
|
181
|
+
fnName = n.id?.name ?? fnName;
|
|
182
|
+
}
|
|
183
|
+
p = p.parentPath;
|
|
184
|
+
}
|
|
185
|
+
const securityContext = SENSITIVE_NAME.test(fnName) || SECURITY_FN.test(fnName);
|
|
186
|
+
if (!securityContext)
|
|
187
|
+
return;
|
|
188
|
+
const returns = body.filter((s) => s.type === 'ReturnStatement');
|
|
189
|
+
const permissive = returns.some((r) => isPermissiveReturn(r.argument));
|
|
190
|
+
const empty = body.length === 0;
|
|
191
|
+
const swallows = empty ||
|
|
192
|
+
body.every((s) => s.type === 'ReturnStatement' ||
|
|
193
|
+
(s.type === 'ExpressionStatement' &&
|
|
194
|
+
/console|log/i.test(calleeName(s.expression?.callee ?? {}))));
|
|
195
|
+
if (permissive || (empty && securityContext) || (swallows && permissive)) {
|
|
196
|
+
push({
|
|
197
|
+
id: 'CTS071',
|
|
198
|
+
severity: permissive ? 'high' : 'medium',
|
|
199
|
+
title: permissive
|
|
200
|
+
? 'Security check fails open on error'
|
|
201
|
+
: 'Security check swallows its error',
|
|
202
|
+
detail: `The catch block in \`${fnName || 'this function'}\` ` +
|
|
203
|
+
(permissive
|
|
204
|
+
? 'returns a permissive value (authorized / a user / next()) when the guarded ' +
|
|
205
|
+
'operation throws. An attacker who can make the check error — a malformed token, a ' +
|
|
206
|
+
'timeout — is then let straight through.'
|
|
207
|
+
: 'discards the error from a security-relevant operation. A verification that throws ' +
|
|
208
|
+
'is a failure, not a pass; swallowing it hides the failure and risks failing open.'),
|
|
209
|
+
fix: 'Fail closed: on error, deny — `return false` / throw / respond 401 — and log the error. ' +
|
|
210
|
+
'Never return a success value from the catch of a security check.',
|
|
211
|
+
line: node.loc?.start.line ?? 0,
|
|
212
|
+
cwe: 'CWE-703: Improper Check or Handling of Exceptional Conditions',
|
|
213
|
+
owasp: 'A10:2025 - Mishandling of Exceptional Conditions',
|
|
214
|
+
meta: { function: fnName },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
result.checks.push({
|
|
221
|
+
label: `Logging, error-handling & deserialization (${analysed} file${analysed === 1 ? '' : 's'})`,
|
|
222
|
+
passed: !result.findings.some((f) => f.severity === 'critical' || f.severity === 'high'),
|
|
223
|
+
});
|
|
224
|
+
return result;
|
|
225
|
+
},
|
|
226
|
+
};
|
package/examples/security.yml
CHANGED
|
@@ -16,7 +16,7 @@ jobs:
|
|
|
16
16
|
runs-on: ubuntu-latest
|
|
17
17
|
steps:
|
|
18
18
|
- uses: actions/checkout@v4
|
|
19
|
-
- uses: murtazaozdemir/cleartoship@v0.
|
|
19
|
+
- uses: murtazaozdemir/cleartoship@v0.8.0
|
|
20
20
|
with:
|
|
21
21
|
fail-on: critical # block the PR only on criticals
|
|
22
22
|
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.8.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",
|