cleartoship 0.4.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 CHANGED
@@ -58,7 +58,7 @@ npx cleartoship
58
58
  | **CTS021** | high | Dependency registered days ago with near-zero downloads (slopsquat shape) |
59
59
  | **CTS022** | low | Runtime dependency with almost no users |
60
60
  | **CTS023** | high/medium | Name is one edit from a popular package (`expres` → `express`) |
61
- | **CTS024** | by CVSS | Dependency version has a **published advisory**, resolved live from [OSV.dev](https://osv.dev) |
61
+ | **CTS024** | by CVSS | Dependency version has a **published advisory**, resolved live from [OSV.dev](https://osv.dev). Vulns in `devDependencies` are labeled and held below the gate — a linter CVE never blocks a deploy the way a shipping one does. |
62
62
  | **CTS025** | low | Dependency deprecated upstream |
63
63
  | **CTS026** | critical | Registry serves HTTP 451 — the package was pulled for malware |
64
64
  | **CTS027** | critical | Package was unpublished but still has installs; the name is open to takeover |
@@ -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.3.0
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
- Outputs `verdict` (`clear`/`conditional`/`hold`), `critical`, `high` and `total`
159
- for use in later steps. The comment is *sticky* re-runs edit the same comment
160
- instead of piling up. Until the npm package is published, the action builds
161
- itself from its own checkout, so `uses: …@ref` works immediately.
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## 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: Version of the cleartoship npm package to run.
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: latest
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
- # Prefer the published npm package. If it cannot be resolved — which is
64
- # the case before the first publish, or when a pinned version is not on
65
- # the registry fall back to building from this action's own checkout,
66
- # so `uses: <owner>/cleartoship@ref` works with no npm dependency.
67
- ver="${{ inputs.version }}"
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 "Published package not found; building from the action checkout."
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 "${{ inputs.paths }}" ] && args+=(${{ inputs.paths }})
86
- [ "${{ inputs.offline }}" = "true" ] && args+=(--offline)
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 [ "${{ inputs.sarif }}" = "true" ]; then
122
+ if [ "$INPUT_SARIF" = "true" ]; then
93
123
  $CTS "${args[@]}" --sarif --fail-on=none > results.sarif || true
94
124
  fi
95
125
 
96
- node -e '
97
- const r = require("./cleartoship.json");
98
- const out = process.env.GITHUB_OUTPUT;
99
- const fs = require("fs");
100
- fs.appendFileSync(out, `verdict=${r.verdict}\n`);
101
- fs.appendFileSync(out, `critical=${r.counts.critical}\n`);
102
- fs.appendFileSync(out, `high=${r.counts.high}\n`);
103
- fs.appendFileSync(out, `total=${r.findings.length}\n`);
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/${{ github.repository }}/issues/${PR}/comments" \
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/${{ github.repository }}/issues/comments/${id}" -F body=@body.md >/dev/null
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/${{ github.repository }}/issues/${PR}/comments" -F body=@body.md >/dev/null
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
- gate="${{ inputs.fail-on }}"
141
- [ "$gate" = "none" ] && exit 0
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 '$gate'."
197
+ echo "ClearToShip: clear to ship at '${GATE}'."
@@ -89,7 +89,8 @@ function collectFromPyproject(source, relPath) {
89
89
  }
90
90
  /** Valid npm package name, optionally scoped. */
91
91
  const NPM_NAME = String.raw `(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*`;
92
- const INSTALL_COMMAND = new RegExp(String.raw `\b(?:npm\s+(?:i|install|add)|yarn\s+add|pnpm\s+(?:i|install|add)|bun\s+(?:i|install|add)|npx|pnpm\s+dlx|bunx)\s+([^\n\`|;&>]+)`, 'gi');
92
+ const INSTALL_COMMAND = new RegExp(String.raw `\b(?:npm\s+(?:i|install|add)|yarn\s+add|pnpm\s+(?:i|install|add)|bun\s+(?:i|install|add))\s+([^\n\`|;&>]+)`, 'gi');
93
+ const RUNNER_COMMAND = new RegExp(String.raw `\b(?:npx|pnpm\s+dlx|bunx)\s+([^\n\`|;&>]+)`, 'gi');
93
94
  const PIP_COMMAND = /\b(?:pip3?\s+install|uv\s+pip\s+install|poetry\s+add|uv\s+add)\s+([^\n`|;&>]+)/gi;
94
95
  /**
95
96
  * Pulls package names out of install commands written in prose. Agent
@@ -115,7 +116,9 @@ function collectFromProse(source, relPath) {
115
116
  if (/^[.~/]|:/.test(bare))
116
117
  continue;
117
118
  const ok = ecosystem === 'npm' ? npmName.test(bare) : /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(bare);
118
- if (!ok)
119
+ // A real package name contains letters; reject list bullets, ports and
120
+ // version-ish tokens ("3003", "1.", "2") that show up in prose.
121
+ if (!ok || !/[a-z]/i.test(bare) || bare.length < 2)
119
122
  continue;
120
123
  out.push({ name: bare, range: '', ecosystem, file: relPath, line, dev: false, fromProse: true });
121
124
  if (firstArgOnly)
@@ -123,6 +126,10 @@ function collectFromProse(source, relPath) {
123
126
  }
124
127
  }
125
128
  };
129
+ // For `npx pkg <args>` / `pnpm dlx` / `bunx`, only the first token is a
130
+ // package — the rest are that command's arguments (so `wrangler d1 create
131
+ // my-db` must not read `d1`, `create`, `my-db` as packages).
132
+ harvest(RUNNER_COMMAND, 'npm', true);
126
133
  harvest(INSTALL_COMMAND, 'npm', false);
127
134
  harvest(PIP_COMMAND, 'pypi', false);
128
135
  return out;
@@ -498,15 +505,28 @@ export const dependencyScanner = {
498
505
  const worst = vulns.reduce((a, b) => ((b.cvss ?? 0) > (a.cvss ?? 0) ? b : a));
499
506
  const cve = worst.aliases.find((a) => a.startsWith('CVE-')) ?? worst.id;
500
507
  const others = vulns.length - 1;
508
+ // A devDependency's vulnerability lives in your build/CI toolchain, not
509
+ // in what your users run — a real concern, but not the same class as a
510
+ // CVE in a package that ships. Label it and hold its severity below the
511
+ // gate so a linter CVE never blocks a deploy the way a runtime one does.
512
+ const shipsToProd = !d.dev;
513
+ const severity = shipsToProd
514
+ ? severityFromCvss(worst.cvss)
515
+ : 'low';
501
516
  result.findings.push({
502
517
  id: 'CTS024',
503
- severity: severityFromCvss(worst.cvss),
504
- title: `Dependency has a known vulnerability (${cve})`,
518
+ severity,
519
+ title: shipsToProd
520
+ ? `Dependency has a known vulnerability (${cve})`
521
+ : `Dev dependency has a known vulnerability (${cve})`,
505
522
  detail: `\`${d.name}@${q.version}\` is affected by ${cve}: ${worst.summary}` +
506
523
  (worst.cvss !== null ? ` CVSS ${worst.cvss}.` : '') +
507
524
  (others > 0
508
525
  ? ` ${others} further advisor${others === 1 ? 'y' : 'ies'} also affect this version.`
509
- : ''),
526
+ : '') +
527
+ (shipsToProd
528
+ ? ''
529
+ : ' It is a dev/build dependency, so it does not ship to production — fix it, but it does not gate a deploy.'),
510
530
  fix: worst.fixedIn
511
531
  ? `Upgrade \`${d.name}\` to ${worst.fixedIn} or later.`
512
532
  : `No fixed version is published yet. Check https://osv.dev/vulnerability/${worst.id} for mitigations.`,
@@ -517,6 +537,7 @@ export const dependencyScanner = {
517
537
  meta: {
518
538
  package: d.name,
519
539
  version: q.version,
540
+ production: shipsToProd,
520
541
  resolvedFrom: versions.has(d.name) ? 'lockfile-or-range' : 'range',
521
542
  advisories: vulns.map((v) => ({
522
543
  id: v.id,
@@ -528,11 +549,16 @@ export const dependencyScanner = {
528
549
  });
529
550
  });
530
551
  if (osvChecked > 0) {
531
- const vulnerable = result.findings.filter((f) => f.id === 'CTS024').length;
552
+ const cts024 = result.findings.filter((f) => f.id === 'CTS024');
553
+ const shipping = cts024.filter((f) => f.meta?.production === true).length;
554
+ const devOnly = cts024.length - shipping;
532
555
  result.checks.push({
533
556
  label: `Known vulnerabilities (${osvChecked} resolved versions checked against OSV.dev)`,
534
- passed: vulnerable === 0,
535
- note: vulnerable > 0 ? `${vulnerable} affected` : undefined,
557
+ // Only shipping vulnerabilities fail the check; dev/build ones are noted.
558
+ passed: shipping === 0,
559
+ note: cts024.length === 0
560
+ ? undefined
561
+ : `${shipping} shipping` + (devOnly > 0 ? `, ${devOnly} dev/build (non-blocking)` : ''),
536
562
  });
537
563
  }
538
564
  }
@@ -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, };
@@ -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,2 @@
1
+ import type { Scanner } from '../types.js';
2
+ export declare const logicScanner: Scanner;
@@ -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
+ };
@@ -56,7 +56,25 @@ function parseColumns(body) {
56
56
  export const rlsScanner = {
57
57
  name: 'Supabase / PostgreSQL Row Level Security',
58
58
  applies(ctx) {
59
- return ctx.files.some(isSql);
59
+ if (!ctx.files.some(isSql))
60
+ return false;
61
+ // Row Level Security is a PostgreSQL feature that Supabase builds on. This
62
+ // scanner must not fire on SQLite / Cloudflare D1 / Prisma-sqlite schemas,
63
+ // which have no RLS concept at all — doing so turns every CREATE TABLE into
64
+ // a false "RLS disabled" critical. Require a genuine Postgres/Supabase
65
+ // signal: the dependency set, a supabase/ directory, or RLS/auth idioms in
66
+ // the SQL itself.
67
+ if (ctx.framework.supabase)
68
+ return true;
69
+ for (const file of ctx.files) {
70
+ if (!isSql(file))
71
+ continue;
72
+ const src = read(file);
73
+ if (src && /\brow\s+level\s+security\b|\bauth\.(uid|jwt|role)\s*\(|\bto\s+(anon|authenticated)\b|\bcreate\s+policy\b/i.test(src)) {
74
+ return true;
75
+ }
76
+ }
77
+ return false;
60
78
  },
61
79
  async run(ctx) {
62
80
  const result = emptyResult();
@@ -82,7 +82,7 @@ const HEX_DIGEST = /^[a-f0-9]{40}$|^[a-f0-9]{64}$/i;
82
82
  */
83
83
  const NON_PRODUCTION_PATH = /(^|\/)(tests?|__tests__|__mocks__|__fixtures__|fixtures?|spec|specs|examples?|docs?|demo|samples?|e2e|cypress|playwright|stories)(\/|$)|\.(test|spec|stories|fixture)\.[a-z]+$|(^|\/)(README|CHANGELOG|CONTRIBUTING)/i;
84
84
  /** Env var names that are meant to be public even though they read like secrets. */
85
- const PUBLIC_BY_DESIGN = /(ANON_KEY|PUBLISHABLE_KEY|PUBLIC_KEY|CLIENT_ID|MEASUREMENT_ID|PROJECT_ID|APP_ID|SENDER_ID|FIREBASE_API_KEY|MAPBOX_TOKEN|POSTHOG_KEY|SENTRY_DSN)$/;
85
+ const PUBLIC_BY_DESIGN = /(ANON_KEY|PUBLISHABLE_KEY|PUBLIC_KEY|CLIENT_ID|MEASUREMENT_ID|PROJECT_ID|APP_ID|SENDER_ID|FIREBASE_API_KEY|MAPBOX_TOKEN|POSTHOG_KEY|SENTRY_DSN|SHOPIFY_API_KEY)$/;
86
86
  const SECRETY_NAME = /(SECRET|SERVICE_ROLE|PRIVATE|PASSWORD|PASSWD|_TOKEN|API_KEY|ACCESS_KEY|CREDENTIAL)/;
87
87
  const LOCKFILES = new Set([
88
88
  'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
@@ -153,6 +153,8 @@ export const secretsScanner = {
153
153
  if (pattern.id === 'supabase-anon')
154
154
  continue; // informational only, not reported
155
155
  const line = lineAt(source, m.index);
156
+ if (isCommentedOut(source, m.index))
157
+ continue; // documented example, not a live secret
156
158
  if (suppress.suppressed(line, 'CTS030'))
157
159
  continue;
158
160
  const key = `${relPath}:${line}:${pattern.id}`;
@@ -40,7 +40,18 @@ const SIGNATURE_CHECKS = [
40
40
  'webhooks.constructEvent', 'constructEvent', 'constructEventAsync', 'verifyHeader',
41
41
  'verify', 'verifySignature', 'createHmac', 'timingSafeEqual', 'Webhook', 'validateRequest',
42
42
  'verifyWebhook', 'verifyWebhookSignature',
43
+ // Framework webhook handlers that verify the signature internally, so the
44
+ // route body delegates rather than calling an hmac primitive directly.
45
+ 'authenticate.webhook', 'webhooks.process', 'webhooks.validate', 'processWebhook',
46
+ 'handleWebhook', 'validateWebhook', 'wh.verify', 'svix.verify',
43
47
  ];
48
+ /**
49
+ * Request headers that only exist to carry a webhook signature. A handler that
50
+ * reads one is participating in signature verification — a route that genuinely
51
+ * forgot it would not reference the header at all — so reading one clears the
52
+ * "unverified webhook" check.
53
+ */
54
+ const SIGNATURE_HEADERS = /(x-shopify-hmac-sha256|x-hub-signature(-256)?|stripe-signature|svix-signature|svix-id|x-signature|x-webhook-signature|x-slack-signature|x-line-signature|paypal-transmission-sig)/i;
44
55
  /** Schema escape hatches that make validation decorative. */
45
56
  const LOOSE_SCHEMA = /\.passthrough\s*\(|z\s*\.\s*(any|unknown)\s*\(|\.catchall\s*\(/g;
46
57
  const SERVICE_ROLE_HINTS = [
@@ -121,6 +132,13 @@ function analyseFunction(path, name) {
121
132
  if (tail === 'from' || tail === 'select' || tail === 'findMany' || tail === 'findUnique' || tail === 'findFirst') {
122
133
  info.hasRead = true;
123
134
  }
135
+ // Reading a webhook-signature header counts as participating in
136
+ // verification (see SIGNATURE_HEADERS).
137
+ for (const arg of inner.node.arguments ?? []) {
138
+ if (arg?.type === 'StringLiteral' && SIGNATURE_HEADERS.test(arg.value)) {
139
+ info.hasSignatureCheck = true;
140
+ }
141
+ }
124
142
  // Raw SQL: db.query(`DELETE FROM ...`) / sql`UPDATE ...`
125
143
  if (tail === 'query' || tail === 'execute' || tail === 'unsafe' || tail === 'raw') {
126
144
  for (const arg of inner.node.arguments ?? []) {
@@ -249,13 +267,24 @@ export const serverActionsScanner = {
249
267
  const writes = info.hasMutation || (isRoute && HTTP_MUTATION_METHODS.has(httpMethod));
250
268
  const isWebhook = isRoute && httpMethod === 'POST' && /webhook|\bhooks?\b|stripe|clerk|svix/i.test(relPath);
251
269
  const isCron = isRoute && /(^|\/)(cron|scheduled|jobs?)(\/|$)/i.test(relPath);
270
+ // Some endpoints are unauthenticated by design — the sign-in and
271
+ // account-recovery flow (you have no session yet), and public intake
272
+ // forms. Flagging "missing auth" there is a false positive, so it is
273
+ // reported at low rather than as a blocking critical.
274
+ const PUBLIC_BY_DESIGN_ROUTE = /(^|\/|-)(login|signin|sign-in|register|signup|sign-up|forgot-password|reset-password|verify-email|resend-verification|magic-link|contact|lead|leads|waitlist|subscribe|unsubscribe|newsletter)(\/|-|\.|$)/i;
275
+ const intentionallyPublic = isRoute && PUBLIC_BY_DESIGN_ROUTE.test(relPath);
252
276
  if (writes && !info.hasAuth && info.getSessionLine === null) {
253
277
  push({
254
278
  id: 'CTS001',
255
- severity: 'critical',
256
- title: `Missing ${kind} authorization`,
279
+ severity: intentionallyPublic ? 'low' : 'critical',
280
+ title: intentionallyPublic
281
+ ? `${kind} is unauthenticated (appears public by design)`
282
+ : `Missing ${kind} authorization`,
257
283
  detail: `${kind} \`${name}\` performs a database mutation without verifying the caller. ` +
258
- exposure,
284
+ exposure +
285
+ (intentionallyPublic
286
+ ? ' This route name suggests a sign-in / account-recovery or public-intake endpoint, which is unauthenticated by design — confirm it has rate limiting and does not trust caller-supplied identifiers.'
287
+ : ''),
259
288
  fix: 'Resolve and check the session before touching the database, e.g.\n' +
260
289
  ' const { data: { user } } = await supabase.auth.getUser()\n' +
261
290
  " if (!user) throw new Error('Unauthorized')\n" +
@@ -3,8 +3,9 @@ import { join, relative, sep } from 'node:path';
3
3
  const SKIP_DIRS = new Set([
4
4
  'node_modules', '.git', '.next', '.turbo', '.vercel', '.wrangler',
5
5
  'dist', 'build', 'out', 'coverage', '.venv', 'venv', '__pycache__',
6
+ '.open-next', '.sst', '.vercel',
6
7
  '.cache', '.pnpm-store', 'vendor', 'target', '.svelte-kit', '.nuxt',
7
- '.cts-cache', '_reference',
8
+ '.cts-cache', '_reference', 'site',
8
9
  ]);
9
10
  const SCAN_EXTS = new Set([
10
11
  '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.mts', '.cts', '.sql',
@@ -24,6 +25,29 @@ const AGENT_FILES = new Set([
24
25
  ]);
25
26
  /** Files bigger than this are almost certainly bundles or fixtures, not source. */
26
27
  const MAX_FILE_BYTES = 2_000_000;
28
+ /**
29
+ * Precise generated-code detection — deliberately NOT a blanket skip of any
30
+ * `generated/` directory, which can legitimately hold hand-written code. Matches
31
+ * the codegen outputs that produce false positives (a Prisma client ships its
32
+ * own credentials, queryRawUnsafe and dynamic requires that are safe in vendor
33
+ * code but read as findings), and files carrying a generated banner.
34
+ */
35
+ const GENERATED_PATH = /(^|\/)(generated|__generated__)\/(prisma|graphql|gql|client)(\/|$)|(^|\/)\.prisma(\/|$)|\.(generated|gen)\.[cm]?[jt]sx?$/i;
36
+ const GENERATED_BANNER = /(^|\n).{0,4}(@generated\b|this (file|code) (is|was) (auto[- ]?)?generated|code generated by|do not edit|prisma client js|autogenerated)/i;
37
+ function looksGenerated(fullPath, size) {
38
+ if (GENERATED_PATH.test(fullPath))
39
+ return true;
40
+ // Cheap banner check on the first chunk for the ambiguous cases only.
41
+ if (!/(^|\/)(generated|gen|__generated__|codegen)(\/|$)/i.test(fullPath))
42
+ return false;
43
+ try {
44
+ const head = readFileSync(fullPath, 'utf8').slice(0, 400);
45
+ return GENERATED_BANNER.test(head);
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ }
27
51
  export function walk(root) {
28
52
  const found = [];
29
53
  const stack = [root];
@@ -56,12 +80,13 @@ export function walk(root) {
56
80
  const dot = entry.lastIndexOf('.');
57
81
  const ext = dot === -1 ? '' : entry.slice(dot);
58
82
  // .env, .env.local, requirements.txt and friends have no useful extension.
59
- if (SCAN_EXTS.has(ext) ||
83
+ const scannable = SCAN_EXTS.has(ext) ||
60
84
  AGENT_FILES.has(entry) ||
61
85
  NAMED_FILES.has(entry) ||
62
86
  entry.startsWith('Dockerfile') ||
63
87
  entry.startsWith('.env') ||
64
- entry === 'requirements.txt') {
88
+ entry === 'requirements.txt';
89
+ if (scannable && !looksGenerated(full, st.size)) {
65
90
  found.push(full);
66
91
  }
67
92
  }
@@ -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.3.0
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.4.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",