cleartoship 0.4.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/LICENSE +21 -0
- package/README.md +264 -0
- package/action.yml +154 -0
- package/dist/banner.d.ts +1 -0
- package/dist/banner.js +14 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +103 -0
- package/dist/data/popular.d.ts +17 -0
- package/dist/data/popular.js +103 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/report.d.ts +15 -0
- package/dist/report.js +317 -0
- package/dist/scan.d.ts +24 -0
- package/dist/scan.js +81 -0
- package/dist/scanners/community.d.ts +2 -0
- package/dist/scanners/community.js +155 -0
- package/dist/scanners/dependencies.d.ts +21 -0
- package/dist/scanners/dependencies.js +549 -0
- package/dist/scanners/index.d.ts +8 -0
- package/dist/scanners/index.js +13 -0
- package/dist/scanners/rls.d.ts +7 -0
- package/dist/scanners/rls.js +531 -0
- package/dist/scanners/secrets.d.ts +2 -0
- package/dist/scanners/secrets.js +439 -0
- package/dist/scanners/server-actions.d.ts +2 -0
- package/dist/scanners/server-actions.js +466 -0
- package/dist/types.d.ts +65 -0
- package/dist/types.js +10 -0
- package/dist/utils/ast.d.ts +13 -0
- package/dist/utils/ast.js +107 -0
- package/dist/utils/detect.d.ts +2 -0
- package/dist/utils/detect.js +77 -0
- package/dist/utils/entropy.d.ts +7 -0
- package/dist/utils/entropy.js +19 -0
- package/dist/utils/files.d.ts +13 -0
- package/dist/utils/files.js +154 -0
- package/dist/utils/osv.d.ts +26 -0
- package/dist/utils/osv.js +123 -0
- package/dist/utils/registry.d.ts +44 -0
- package/dist/utils/registry.js +195 -0
- package/dist/utils/sql.d.ts +20 -0
- package/dist/utils/sql.js +157 -0
- package/dist/utils/suppress.d.ts +19 -0
- package/dist/utils/suppress.js +56 -0
- package/dist/utils/traverse.d.ts +2 -0
- package/dist/utils/traverse.js +14 -0
- package/dist/vendor/gitleaks/rules.d.ts +13 -0
- package/dist/vendor/gitleaks/rules.js +1571 -0
- package/dist/vendor/guardvibe/index.d.ts +11 -0
- package/dist/vendor/guardvibe/index.js +60 -0
- package/dist/vendor/guardvibe/rules/advanced-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/advanced-security.js +418 -0
- package/dist/vendor/guardvibe/rules/ai-host-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/ai-host-security.js +190 -0
- package/dist/vendor/guardvibe/rules/ai-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/ai-security.js +413 -0
- package/dist/vendor/guardvibe/rules/ai-tool-runtime.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/ai-tool-runtime.js +194 -0
- package/dist/vendor/guardvibe/rules/api-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/api-security.js +154 -0
- package/dist/vendor/guardvibe/rules/auth.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/auth.js +222 -0
- package/dist/vendor/guardvibe/rules/cicd.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/cicd.js +108 -0
- package/dist/vendor/guardvibe/rules/core.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/core.js +484 -0
- package/dist/vendor/guardvibe/rules/cve-versions.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/cve-versions.js +1130 -0
- package/dist/vendor/guardvibe/rules/database.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/database.js +173 -0
- package/dist/vendor/guardvibe/rules/deployment.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/deployment.js +269 -0
- package/dist/vendor/guardvibe/rules/dockerfile.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/dockerfile.js +99 -0
- package/dist/vendor/guardvibe/rules/firebase.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/firebase.js +96 -0
- package/dist/vendor/guardvibe/rules/go.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/go.js +85 -0
- package/dist/vendor/guardvibe/rules/modern-stack.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/modern-stack.js +631 -0
- package/dist/vendor/guardvibe/rules/nextjs.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/nextjs.js +233 -0
- package/dist/vendor/guardvibe/rules/other-services.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/other-services.js +72 -0
- package/dist/vendor/guardvibe/rules/payments.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/payments.js +123 -0
- package/dist/vendor/guardvibe/rules/react-native.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/react-native.js +132 -0
- package/dist/vendor/guardvibe/rules/services.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/services.js +149 -0
- package/dist/vendor/guardvibe/rules/shell.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/shell.js +72 -0
- package/dist/vendor/guardvibe/rules/sql.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/sql.js +60 -0
- package/dist/vendor/guardvibe/rules/supply-chain.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/supply-chain.js +243 -0
- package/dist/vendor/guardvibe/rules/terraform.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/terraform.js +84 -0
- package/dist/vendor/guardvibe/rules/types.d.ts +14 -0
- package/dist/vendor/guardvibe/rules/types.js +11 -0
- package/dist/vendor/guardvibe/rules/web-security.d.ts +2 -0
- package/dist/vendor/guardvibe/rules/web-security.js +258 -0
- package/examples/security.yml +23 -0
- package/package.json +70 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frequently-installed packages, used as typosquat bait references. A candidate
|
|
3
|
+
* that sits one edit away from one of these — and has almost no downloads of its
|
|
4
|
+
* own — is very likely a squat or an LLM misremembering the real name.
|
|
5
|
+
*/
|
|
6
|
+
export const POPULAR_NPM = [
|
|
7
|
+
'react', 'react-dom', 'next', 'vue', 'svelte', 'angular', 'express', 'fastify',
|
|
8
|
+
'koa', 'nest', 'axios', 'lodash', 'underscore', 'moment', 'dayjs', 'date-fns',
|
|
9
|
+
'chalk', 'commander', 'yargs', 'inquirer', 'dotenv', 'zod', 'yup', 'joi',
|
|
10
|
+
'valibot', 'typescript', 'eslint', 'prettier', 'jest', 'vitest', 'mocha',
|
|
11
|
+
'chai', 'sinon', 'webpack', 'rollup', 'vite', 'esbuild', 'babel', 'postcss',
|
|
12
|
+
'tailwindcss', 'autoprefixer', 'sass', 'less', 'styled-components', 'emotion',
|
|
13
|
+
'redux', 'zustand', 'jotai', 'recoil', 'mobx', 'rxjs', 'graphql', 'apollo',
|
|
14
|
+
'prisma', 'sequelize', 'typeorm', 'mongoose', 'knex', 'drizzle-orm', 'pg',
|
|
15
|
+
'mysql2', 'sqlite3', 'redis', 'ioredis', 'bcrypt', 'bcryptjs', 'jsonwebtoken',
|
|
16
|
+
'passport', 'cors', 'helmet', 'morgan', 'winston', 'pino', 'debug', 'uuid',
|
|
17
|
+
'nanoid', 'cuid', 'classnames', 'clsx', 'ramda', 'immer', 'formik',
|
|
18
|
+
'react-hook-form', 'react-router', 'react-router-dom', 'react-query',
|
|
19
|
+
'swr', 'socket.io', 'ws', 'node-fetch', 'got', 'undici', 'cheerio',
|
|
20
|
+
'puppeteer', 'playwright', 'selenium-webdriver', 'sharp', 'jimp', 'multer',
|
|
21
|
+
'formidable', 'busboy', 'archiver', 'tar', 'glob', 'fast-glob', 'globby',
|
|
22
|
+
'rimraf', 'fs-extra', 'chokidar', 'nodemon', 'concurrently', 'cross-env',
|
|
23
|
+
'husky', 'lint-staged', 'semantic-release', 'stripe', 'twilio', 'nodemailer',
|
|
24
|
+
'resend', 'openai', 'langchain', 'framer-motion', 'three', 'd3', 'chart.js',
|
|
25
|
+
'recharts', 'plotly.js', 'leaflet', 'mapbox-gl', 'marked', 'markdown-it',
|
|
26
|
+
'remark', 'rehype', 'gray-matter', 'slugify', 'validator', 'sanitize-html',
|
|
27
|
+
'dompurify', 'he', 'qs', 'query-string', 'form-data', 'body-parser',
|
|
28
|
+
'cookie-parser', 'express-session', 'connect-redis', 'compression',
|
|
29
|
+
'http-proxy-middleware', 'async', 'bluebird', 'p-limit',
|
|
30
|
+
'p-queue', 'ora', 'boxen', 'figlet', 'cli-table3', 'enquirer', 'prompts',
|
|
31
|
+
'minimist', 'meow', 'execa', 'shelljs', 'zx', 'tslib', 'core-js',
|
|
32
|
+
'regenerator-runtime', 'whatwg-fetch', 'abort-controller',
|
|
33
|
+
'jsdom', 'happy-dom', 'cypress', 'supertest', 'msw',
|
|
34
|
+
'faker', 'chance', 'nock', 'ts-node', 'tsx', 'tsup', 'unbuild',
|
|
35
|
+
'microbundle', 'parcel', 'turbo', 'nx', 'lerna',
|
|
36
|
+
];
|
|
37
|
+
export const POPULAR_PYPI = [
|
|
38
|
+
'requests', 'urllib3', 'numpy', 'pandas', 'scipy', 'matplotlib', 'seaborn',
|
|
39
|
+
'scikit-learn', 'tensorflow', 'torch', 'transformers', 'flask', 'django',
|
|
40
|
+
'fastapi', 'starlette', 'uvicorn', 'gunicorn', 'celery', 'redis', 'sqlalchemy',
|
|
41
|
+
'alembic', 'psycopg2', 'pymongo', 'boto3', 'botocore', 'click', 'typer',
|
|
42
|
+
'rich', 'pydantic', 'attrs', 'marshmallow', 'jinja2', 'pyyaml', 'toml',
|
|
43
|
+
'python-dotenv', 'httpx', 'aiohttp', 'beautifulsoup4', 'lxml', 'selenium',
|
|
44
|
+
'pillow', 'opencv-python', 'pytest', 'tox', 'black', 'flake8', 'mypy',
|
|
45
|
+
'pylint', 'ruff', 'isort', 'setuptools', 'wheel', 'pip', 'poetry', 'openai',
|
|
46
|
+
'anthropic', 'langchain', 'cryptography', 'pyjwt', 'passlib', 'bcrypt',
|
|
47
|
+
'python-multipart', 'supabase', 'stripe', 'twilio', 'sendgrid',
|
|
48
|
+
];
|
|
49
|
+
/** Damerau-Levenshtein distance, capped: returns `max + 1` once it exceeds `max`. */
|
|
50
|
+
export function editDistance(a, b, max = 2) {
|
|
51
|
+
if (a === b)
|
|
52
|
+
return 0;
|
|
53
|
+
if (Math.abs(a.length - b.length) > max)
|
|
54
|
+
return max + 1;
|
|
55
|
+
let prev2 = [];
|
|
56
|
+
let prev = [];
|
|
57
|
+
let curr = [];
|
|
58
|
+
for (let j = 0; j <= b.length; j++)
|
|
59
|
+
prev[j] = j;
|
|
60
|
+
for (let i = 1; i <= a.length; i++) {
|
|
61
|
+
curr = [i];
|
|
62
|
+
let rowMin = i;
|
|
63
|
+
for (let j = 1; j <= b.length; j++) {
|
|
64
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
65
|
+
let value = Math.min((curr[j - 1] ?? Infinity) + 1, (prev[j] ?? Infinity) + 1, (prev[j - 1] ?? Infinity) + cost);
|
|
66
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
67
|
+
value = Math.min(value, (prev2[j - 2] ?? Infinity) + 1);
|
|
68
|
+
}
|
|
69
|
+
curr[j] = value;
|
|
70
|
+
if (value < rowMin)
|
|
71
|
+
rowMin = value;
|
|
72
|
+
}
|
|
73
|
+
if (rowMin > max)
|
|
74
|
+
return max + 1;
|
|
75
|
+
prev2 = prev;
|
|
76
|
+
prev = curr;
|
|
77
|
+
}
|
|
78
|
+
return prev[b.length] ?? max + 1;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Closest popular package within `max` edits, or null.
|
|
82
|
+
*
|
|
83
|
+
* A name that *is* one of the popular packages is never its own lookalike, and
|
|
84
|
+
* names shorter than five characters are skipped: at that length almost every
|
|
85
|
+
* real package has a one-edit neighbour, so the signal is noise.
|
|
86
|
+
*/
|
|
87
|
+
export function nearestPopular(name, list, max = 1) {
|
|
88
|
+
const bare = name.replace(/^@[^/]+\//, '').toLowerCase();
|
|
89
|
+
if (bare.length < 5)
|
|
90
|
+
return null;
|
|
91
|
+
if (list.includes(bare))
|
|
92
|
+
return null;
|
|
93
|
+
let best = null;
|
|
94
|
+
let bestScore = max + 1;
|
|
95
|
+
for (const candidate of list) {
|
|
96
|
+
const d = editDistance(bare, candidate, max);
|
|
97
|
+
if (d > 0 && d < bestScore) {
|
|
98
|
+
bestScore = d;
|
|
99
|
+
best = candidate;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return best;
|
|
103
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { scan } from './scan.js';
|
|
2
|
+
export type { ScanOptions, FullScan } from './scan.js';
|
|
3
|
+
export { renderTerminal, renderJson, renderSarif, renderFixPrompt, renderBadge, renderMarkdown, } from './report.js';
|
|
4
|
+
export { SCANNERS } from './scanners/index.js';
|
|
5
|
+
export * from './types.js';
|
package/dist/index.js
ADDED
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FullScan } from './scan.js';
|
|
2
|
+
export declare function renderTerminal(scan: FullScan, opts?: {
|
|
3
|
+
showPassed: boolean;
|
|
4
|
+
}): string;
|
|
5
|
+
export declare function renderJson(scan: FullScan): string;
|
|
6
|
+
export declare function renderSarif(scan: FullScan, version: string): string;
|
|
7
|
+
export declare function renderFixPrompt(scan: FullScan): string;
|
|
8
|
+
/**
|
|
9
|
+
* Markdown for a GitHub pull-request comment or an Actions job summary. Kept
|
|
10
|
+
* compact: the verdict and counts up top, findings grouped by severity in a
|
|
11
|
+
* collapsible block, so a passing PR shows one green line and a failing one puts
|
|
12
|
+
* the blocking issues first without burying the diff.
|
|
13
|
+
*/
|
|
14
|
+
export declare function renderMarkdown(scan: FullScan): string;
|
|
15
|
+
export declare function renderBadge(scan: FullScan): string;
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
import { SEVERITY_ORDER } from './types.js';
|
|
3
|
+
const RULE = '─'.repeat(74);
|
|
4
|
+
const SEVERITY_STYLE = {
|
|
5
|
+
critical: { label: 'CRITICAL', paint: (s) => pc.bold(pc.red(s)) },
|
|
6
|
+
high: { label: 'HIGH ', paint: pc.red },
|
|
7
|
+
medium: { label: 'MEDIUM ', paint: pc.yellow },
|
|
8
|
+
low: { label: 'LOW ', paint: pc.blue },
|
|
9
|
+
info: { label: 'INFO ', paint: pc.dim },
|
|
10
|
+
};
|
|
11
|
+
function indent(text, prefix) {
|
|
12
|
+
return text
|
|
13
|
+
.split('\n')
|
|
14
|
+
.map((l) => prefix + l)
|
|
15
|
+
.join('\n');
|
|
16
|
+
}
|
|
17
|
+
function wrap(text, width) {
|
|
18
|
+
const words = text.split(/\s+/);
|
|
19
|
+
const lines = [];
|
|
20
|
+
let line = '';
|
|
21
|
+
for (const word of words) {
|
|
22
|
+
if (line.length + word.length + 1 > width && line) {
|
|
23
|
+
lines.push(line);
|
|
24
|
+
line = word;
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
line = line ? `${line} ${word}` : word;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (line)
|
|
31
|
+
lines.push(line);
|
|
32
|
+
return lines.join('\n');
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Wraps the prose lines of a fix while leaving indented lines — SQL and code
|
|
36
|
+
* snippets — exactly as written, since reflowing those would break them.
|
|
37
|
+
*/
|
|
38
|
+
function renderFix(fix) {
|
|
39
|
+
return fix
|
|
40
|
+
.split('\n')
|
|
41
|
+
.map((line, i) => {
|
|
42
|
+
if (/^\s/.test(line))
|
|
43
|
+
return line;
|
|
44
|
+
const prefixed = i === 0 ? `\u2192 ${line}` : line;
|
|
45
|
+
return wrap(prefixed, 68);
|
|
46
|
+
})
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
49
|
+
export function renderTerminal(scan, opts = { showPassed: true }) {
|
|
50
|
+
const out = [];
|
|
51
|
+
const total = scan.findings.length;
|
|
52
|
+
if (total > 0) {
|
|
53
|
+
out.push('');
|
|
54
|
+
out.push(pc.dim(RULE));
|
|
55
|
+
const summary = ['critical', 'high', 'medium', 'low', 'info']
|
|
56
|
+
.filter((s) => scan.counts[s] > 0)
|
|
57
|
+
.map((s) => SEVERITY_STYLE[s].paint(`${scan.counts[s]} ${s}`))
|
|
58
|
+
.join(pc.dim(' · '));
|
|
59
|
+
out.push(` ${pc.bold('SCAN FINDINGS')} ${pc.dim('(')}${summary}${pc.dim(')')}`);
|
|
60
|
+
out.push(pc.dim(RULE));
|
|
61
|
+
out.push('');
|
|
62
|
+
for (const f of scan.findings) {
|
|
63
|
+
const style = SEVERITY_STYLE[f.severity];
|
|
64
|
+
const location = f.file ? `${f.file}${f.line ? `:${f.line}` : ''}` : scan.root;
|
|
65
|
+
out.push(` ${style.paint('✖ ' + style.label)} ${pc.bold(f.title)} ${pc.dim(f.id)}`);
|
|
66
|
+
out.push(` ${pc.dim('at')} ${pc.cyan(location)}`);
|
|
67
|
+
if (f.snippet)
|
|
68
|
+
out.push(` ${pc.dim('│')} ${pc.dim(f.snippet)}`);
|
|
69
|
+
out.push(indent(wrap(f.detail, 68), ' '));
|
|
70
|
+
out.push(indent(pc.green(renderFix(f.fix)), ' '));
|
|
71
|
+
if (f.owasp)
|
|
72
|
+
out.push(` ${pc.dim(f.owasp)}${f.cwe ? pc.dim(' · ' + f.cwe) : ''}`);
|
|
73
|
+
out.push('');
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (opts.showPassed) {
|
|
77
|
+
const passed = scan.checks.filter((c) => c.passed);
|
|
78
|
+
if (passed.length) {
|
|
79
|
+
for (const c of passed) {
|
|
80
|
+
out.push(` ${pc.green('✔ PASS')} ${c.label}${c.note ? pc.dim(` — ${c.note}`) : ''}`);
|
|
81
|
+
}
|
|
82
|
+
out.push('');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (scan.warnings.length) {
|
|
86
|
+
for (const w of scan.warnings)
|
|
87
|
+
out.push(` ${pc.yellow('! WARN')} ${w}`);
|
|
88
|
+
out.push('');
|
|
89
|
+
}
|
|
90
|
+
out.push(pc.dim(RULE));
|
|
91
|
+
const blocking = scan.counts.critical + scan.counts.high;
|
|
92
|
+
if (scan.counts.critical > 0) {
|
|
93
|
+
out.push(` ${pc.bold(pc.red('VERDICT: 🔴 HOLD — resolve the critical findings before deploying'))}`);
|
|
94
|
+
}
|
|
95
|
+
else if (blocking > 0) {
|
|
96
|
+
out.push(` ${pc.bold(pc.yellow('VERDICT: 🟡 CONDITIONAL — no criticals, but high-severity gaps remain'))}`);
|
|
97
|
+
}
|
|
98
|
+
else if (total > 0) {
|
|
99
|
+
out.push(` ${pc.bold(pc.green('VERDICT: 🟢 CLEAR TO SHIP'))} ${pc.dim(`— ${total} low-priority note${total === 1 ? '' : 's'}`)}`);
|
|
100
|
+
}
|
|
101
|
+
else {
|
|
102
|
+
out.push(` ${pc.bold(pc.green('VERDICT: 🟢 CLEAR TO SHIP — all checks passed'))}`);
|
|
103
|
+
}
|
|
104
|
+
out.push(pc.dim(RULE));
|
|
105
|
+
out.push(pc.dim(` ${scan.fileCount} files · ${(scan.durationMs / 1000).toFixed(1)}s`));
|
|
106
|
+
if (total > 0) {
|
|
107
|
+
out.push('');
|
|
108
|
+
out.push(` ${pc.dim('Hand the fixes to your AI editor:')} ${pc.cyan('npx cleartoship --fix-prompt')}`);
|
|
109
|
+
}
|
|
110
|
+
out.push('');
|
|
111
|
+
return out.join('\n');
|
|
112
|
+
}
|
|
113
|
+
export function renderJson(scan) {
|
|
114
|
+
return JSON.stringify({
|
|
115
|
+
version: 1,
|
|
116
|
+
tool: 'cleartoship',
|
|
117
|
+
root: scan.root,
|
|
118
|
+
framework: scan.framework,
|
|
119
|
+
fileCount: scan.fileCount,
|
|
120
|
+
durationMs: scan.durationMs,
|
|
121
|
+
verdict: scan.counts.critical > 0 ? 'hold' : scan.counts.high > 0 ? 'conditional' : 'clear',
|
|
122
|
+
counts: scan.counts,
|
|
123
|
+
findings: scan.findings,
|
|
124
|
+
checks: scan.checks,
|
|
125
|
+
warnings: scan.warnings,
|
|
126
|
+
}, null, 2);
|
|
127
|
+
}
|
|
128
|
+
const SARIF_LEVEL = {
|
|
129
|
+
critical: 'error',
|
|
130
|
+
high: 'error',
|
|
131
|
+
medium: 'warning',
|
|
132
|
+
low: 'note',
|
|
133
|
+
info: 'note',
|
|
134
|
+
};
|
|
135
|
+
export function renderSarif(scan, version) {
|
|
136
|
+
const rules = new Map();
|
|
137
|
+
for (const f of scan.findings) {
|
|
138
|
+
if (rules.has(f.id))
|
|
139
|
+
continue;
|
|
140
|
+
rules.set(f.id, {
|
|
141
|
+
id: f.id,
|
|
142
|
+
name: f.title.replace(/\s+/g, ''),
|
|
143
|
+
shortDescription: { text: f.title },
|
|
144
|
+
fullDescription: { text: f.detail },
|
|
145
|
+
help: { text: f.fix },
|
|
146
|
+
defaultConfiguration: { level: SARIF_LEVEL[f.severity] },
|
|
147
|
+
properties: {
|
|
148
|
+
tags: [f.owasp, f.cwe].filter(Boolean),
|
|
149
|
+
'security-severity': String(SEVERITY_ORDER[f.severity] * 2.4),
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
return JSON.stringify({
|
|
154
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
155
|
+
version: '2.1.0',
|
|
156
|
+
runs: [
|
|
157
|
+
{
|
|
158
|
+
tool: {
|
|
159
|
+
driver: {
|
|
160
|
+
name: 'ClearToShip',
|
|
161
|
+
version,
|
|
162
|
+
informationUri: 'https://cleartoship.app',
|
|
163
|
+
rules: [...rules.values()],
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
results: scan.findings.map((f) => ({
|
|
167
|
+
ruleId: f.id,
|
|
168
|
+
level: SARIF_LEVEL[f.severity],
|
|
169
|
+
message: { text: `${f.title}. ${f.detail}` },
|
|
170
|
+
locations: [
|
|
171
|
+
{
|
|
172
|
+
physicalLocation: {
|
|
173
|
+
artifactLocation: { uri: f.file ?? '.' },
|
|
174
|
+
region: { startLine: Math.max(1, f.line ?? 1) },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
],
|
|
178
|
+
})),
|
|
179
|
+
},
|
|
180
|
+
],
|
|
181
|
+
}, null, 2);
|
|
182
|
+
}
|
|
183
|
+
export function renderFixPrompt(scan) {
|
|
184
|
+
if (scan.findings.length === 0) {
|
|
185
|
+
return 'ClearToShip found no issues to fix.\n';
|
|
186
|
+
}
|
|
187
|
+
const lines = [];
|
|
188
|
+
lines.push('# ClearToShip — security fixes to apply');
|
|
189
|
+
lines.push('');
|
|
190
|
+
lines.push('You are fixing security findings in this repository. Apply every fix below. ' +
|
|
191
|
+
'Do not change unrelated behaviour, do not weaken any existing check, and keep the ' +
|
|
192
|
+
'project’s existing conventions and helper functions. After each fix, briefly state ' +
|
|
193
|
+
'what you changed.');
|
|
194
|
+
lines.push('');
|
|
195
|
+
lines.push(`Project: ${scan.framework}`);
|
|
196
|
+
lines.push('');
|
|
197
|
+
const bySeverity = ['critical', 'high', 'medium', 'low'];
|
|
198
|
+
for (const sev of bySeverity) {
|
|
199
|
+
const group = scan.findings.filter((f) => f.severity === sev);
|
|
200
|
+
if (!group.length)
|
|
201
|
+
continue;
|
|
202
|
+
lines.push(`## ${sev.toUpperCase()} (${group.length})`);
|
|
203
|
+
lines.push('');
|
|
204
|
+
group.forEach((f, i) => {
|
|
205
|
+
lines.push(`### ${i + 1}. ${f.title} \`${f.id}\``);
|
|
206
|
+
lines.push('');
|
|
207
|
+
if (f.file)
|
|
208
|
+
lines.push(`**Location:** \`${f.file}${f.line ? `:${f.line}` : ''}\``);
|
|
209
|
+
if (f.snippet)
|
|
210
|
+
lines.push(`**Offending line:** \`${f.snippet}\``);
|
|
211
|
+
lines.push('');
|
|
212
|
+
lines.push(`**Problem:** ${f.detail}`);
|
|
213
|
+
lines.push('');
|
|
214
|
+
lines.push('**Required fix:**');
|
|
215
|
+
lines.push('');
|
|
216
|
+
lines.push('```');
|
|
217
|
+
lines.push(f.fix);
|
|
218
|
+
lines.push('```');
|
|
219
|
+
lines.push('');
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
lines.push('---');
|
|
223
|
+
lines.push('');
|
|
224
|
+
lines.push('When you are done, re-run `npx cleartoship` and confirm the findings above are gone ' +
|
|
225
|
+
'and no new ones appeared.');
|
|
226
|
+
lines.push('');
|
|
227
|
+
return lines.join('\n');
|
|
228
|
+
}
|
|
229
|
+
const MD_SEVERITY = {
|
|
230
|
+
critical: '🔴 Critical',
|
|
231
|
+
high: '🟠 High',
|
|
232
|
+
medium: '🟡 Medium',
|
|
233
|
+
low: '🔵 Low',
|
|
234
|
+
info: '⚪ Info',
|
|
235
|
+
};
|
|
236
|
+
/**
|
|
237
|
+
* Markdown for a GitHub pull-request comment or an Actions job summary. Kept
|
|
238
|
+
* compact: the verdict and counts up top, findings grouped by severity in a
|
|
239
|
+
* collapsible block, so a passing PR shows one green line and a failing one puts
|
|
240
|
+
* the blocking issues first without burying the diff.
|
|
241
|
+
*/
|
|
242
|
+
export function renderMarkdown(scan) {
|
|
243
|
+
const out = [];
|
|
244
|
+
const total = scan.findings.length;
|
|
245
|
+
const verdict = scan.counts.critical > 0 ? 'hold' : scan.counts.high > 0 ? 'conditional' : 'clear';
|
|
246
|
+
const heading = verdict === 'hold'
|
|
247
|
+
? '## 🔴 ClearToShip — hold before shipping'
|
|
248
|
+
: verdict === 'conditional'
|
|
249
|
+
? '## 🟡 ClearToShip — clear, with high-severity gaps'
|
|
250
|
+
: total > 0
|
|
251
|
+
? '## 🟢 ClearToShip — clear to ship'
|
|
252
|
+
: '## 🟢 ClearToShip — clear to ship, all checks passed';
|
|
253
|
+
out.push(heading);
|
|
254
|
+
out.push('');
|
|
255
|
+
const counts = ['critical', 'high', 'medium', 'low']
|
|
256
|
+
.filter((sev) => scan.counts[sev] > 0)
|
|
257
|
+
.map((sev) => `**${scan.counts[sev]}** ${sev}`)
|
|
258
|
+
.join(' · ');
|
|
259
|
+
out.push(`\`${scan.framework}\` · ${scan.fileCount} files · ${(scan.durationMs / 1000).toFixed(1)}s` +
|
|
260
|
+
(counts ? ` · ${counts}` : ''));
|
|
261
|
+
out.push('');
|
|
262
|
+
if (total === 0) {
|
|
263
|
+
out.push('No security findings. ✅');
|
|
264
|
+
out.push('');
|
|
265
|
+
out.push('<sub>Static pre-flight for AI-built apps · [cleartoship.app](https://cleartoship.app)</sub>');
|
|
266
|
+
return out.join('\n');
|
|
267
|
+
}
|
|
268
|
+
const bySeverity = ['critical', 'high', 'medium', 'low'];
|
|
269
|
+
const blocking = scan.findings.filter((f) => f.severity === 'critical' || f.severity === 'high');
|
|
270
|
+
const rest = scan.findings.filter((f) => f.severity === 'medium' || f.severity === 'low');
|
|
271
|
+
const table = (findings) => {
|
|
272
|
+
const rows = ['| Severity | Rule | Finding | Location |', '| --- | --- | --- | --- |'];
|
|
273
|
+
for (const f of findings) {
|
|
274
|
+
const loc = f.file ? `\`${f.file}${f.line ? `:${f.line}` : ''}\`` : '—';
|
|
275
|
+
const title = f.title.replace(/\|/g, '\\|');
|
|
276
|
+
rows.push(`| ${MD_SEVERITY[f.severity]} | \`${f.id}\` | ${title} | ${loc} |`);
|
|
277
|
+
}
|
|
278
|
+
return rows.join('\n');
|
|
279
|
+
};
|
|
280
|
+
if (blocking.length > 0) {
|
|
281
|
+
out.push(table(blocking));
|
|
282
|
+
out.push('');
|
|
283
|
+
}
|
|
284
|
+
if (rest.length > 0) {
|
|
285
|
+
out.push('<details><summary>' + `${rest.length} lower-severity finding${rest.length === 1 ? '' : 's'}` + '</summary>');
|
|
286
|
+
out.push('');
|
|
287
|
+
out.push(table(rest));
|
|
288
|
+
out.push('');
|
|
289
|
+
out.push('</details>');
|
|
290
|
+
out.push('');
|
|
291
|
+
}
|
|
292
|
+
// The single most severe finding gets its fix shown inline; the rest are a
|
|
293
|
+
// command away, so the comment stays scannable.
|
|
294
|
+
const worst = scan.findings[0];
|
|
295
|
+
if (worst) {
|
|
296
|
+
out.push(`<details><summary>How to fix <code>${worst.id}</code> — ${worst.title}</summary>`);
|
|
297
|
+
out.push('');
|
|
298
|
+
out.push(worst.detail);
|
|
299
|
+
out.push('');
|
|
300
|
+
out.push('```');
|
|
301
|
+
out.push(worst.fix);
|
|
302
|
+
out.push('```');
|
|
303
|
+
out.push('');
|
|
304
|
+
out.push('</details>');
|
|
305
|
+
out.push('');
|
|
306
|
+
}
|
|
307
|
+
out.push('Run `npx cleartoship --fix-prompt` for a prompt that fixes all of these in Cursor or Claude Code.');
|
|
308
|
+
out.push('');
|
|
309
|
+
out.push('<sub>Static pre-flight for AI-built apps · [cleartoship.app](https://cleartoship.app)</sub>');
|
|
310
|
+
return out.join('\n');
|
|
311
|
+
}
|
|
312
|
+
export function renderBadge(scan) {
|
|
313
|
+
const verdict = scan.counts.critical > 0 ? 'hold' : scan.counts.high > 0 ? 'conditional' : 'clear';
|
|
314
|
+
const colour = verdict === 'clear' ? '10b981' : verdict === 'conditional' ? 'f59e0b' : 'ef4444';
|
|
315
|
+
const label = verdict === 'clear' ? 'clear%20to%20ship' : verdict;
|
|
316
|
+
return `[](https://cleartoship.app)`;
|
|
317
|
+
}
|
package/dist/scan.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CheckSummary, Finding, Severity } from './types.js';
|
|
2
|
+
export interface ScanOptions {
|
|
3
|
+
root: string;
|
|
4
|
+
paths?: string[];
|
|
5
|
+
offline?: boolean;
|
|
6
|
+
ignore?: string[];
|
|
7
|
+
only?: string[];
|
|
8
|
+
minSeverity?: Severity;
|
|
9
|
+
/** Skip the vendored community ruleset, leaving only ClearToShip's own checks. */
|
|
10
|
+
noCommunity?: boolean;
|
|
11
|
+
verbose?: boolean;
|
|
12
|
+
onProgress?: (step: number, total: number, name: string) => void;
|
|
13
|
+
}
|
|
14
|
+
export interface FullScan {
|
|
15
|
+
root: string;
|
|
16
|
+
framework: string;
|
|
17
|
+
fileCount: number;
|
|
18
|
+
findings: Finding[];
|
|
19
|
+
checks: CheckSummary[];
|
|
20
|
+
warnings: string[];
|
|
21
|
+
counts: Record<Severity, number>;
|
|
22
|
+
durationMs: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function scan(options: ScanOptions): Promise<FullScan>;
|
package/dist/scan.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { walk } from './utils/files.js';
|
|
5
|
+
import { detectFramework } from './utils/detect.js';
|
|
6
|
+
import { SCANNERS, communityScanner } from './scanners/index.js';
|
|
7
|
+
import { GUARDVIBE_CVE_RULE_IDS } from './vendor/guardvibe/index.js';
|
|
8
|
+
import { SEVERITY_ORDER } from './types.js';
|
|
9
|
+
export async function scan(options) {
|
|
10
|
+
const started = Date.now();
|
|
11
|
+
const root = resolve(options.root);
|
|
12
|
+
const roots = options.paths?.length ? options.paths.map((p) => resolve(root, p)) : [root];
|
|
13
|
+
const files = [...new Set(roots.flatMap((r) => walk(r)))];
|
|
14
|
+
const framework = detectFramework(root, files);
|
|
15
|
+
const ctx = {
|
|
16
|
+
root,
|
|
17
|
+
files,
|
|
18
|
+
framework,
|
|
19
|
+
offline: Boolean(options.offline),
|
|
20
|
+
cacheDir: process.env.CLEARTOSHIP_CACHE ?? join(homedir(), '.cache', 'cleartoship'),
|
|
21
|
+
verbose: Boolean(options.verbose),
|
|
22
|
+
};
|
|
23
|
+
const active = SCANNERS.filter((s) => s.applies(ctx) && !(options.noCommunity && s === communityScanner));
|
|
24
|
+
const findings = [];
|
|
25
|
+
const checks = [];
|
|
26
|
+
const warnings = [];
|
|
27
|
+
for (let i = 0; i < active.length; i++) {
|
|
28
|
+
const scanner = active[i];
|
|
29
|
+
options.onProgress?.(i + 1, active.length, scanner.name);
|
|
30
|
+
try {
|
|
31
|
+
const result = await scanner.run(ctx);
|
|
32
|
+
findings.push(...result.findings);
|
|
33
|
+
checks.push(...result.checks);
|
|
34
|
+
warnings.push(...result.warnings);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
warnings.push(`${scanner.name} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
// OSV.dev is authoritative and current; the vendored CVE-version regexes are
|
|
41
|
+
// neither. If OSV answered for this project, stand them down rather than
|
|
42
|
+
// report the same advisory twice from two sources of differing freshness.
|
|
43
|
+
const osvAnswered = checks.some((c) => c.label.startsWith('Known vulnerabilities'));
|
|
44
|
+
let filtered = osvAnswered
|
|
45
|
+
? findings.filter((f) => !GUARDVIBE_CVE_RULE_IDS.has(f.id))
|
|
46
|
+
: findings;
|
|
47
|
+
if (options.ignore?.length) {
|
|
48
|
+
const ignored = new Set(options.ignore.map((s) => s.toUpperCase()));
|
|
49
|
+
filtered = filtered.filter((f) => !ignored.has(f.id.toUpperCase()));
|
|
50
|
+
}
|
|
51
|
+
if (options.only?.length) {
|
|
52
|
+
const only = new Set(options.only.map((s) => s.toUpperCase()));
|
|
53
|
+
filtered = filtered.filter((f) => only.has(f.id.toUpperCase()));
|
|
54
|
+
}
|
|
55
|
+
if (options.minSeverity) {
|
|
56
|
+
const floor = SEVERITY_ORDER[options.minSeverity];
|
|
57
|
+
filtered = filtered.filter((f) => SEVERITY_ORDER[f.severity] >= floor);
|
|
58
|
+
}
|
|
59
|
+
filtered.sort((a, b) => {
|
|
60
|
+
const bySeverity = SEVERITY_ORDER[b.severity] - SEVERITY_ORDER[a.severity];
|
|
61
|
+
if (bySeverity !== 0)
|
|
62
|
+
return bySeverity;
|
|
63
|
+
const byFile = (a.file ?? '').localeCompare(b.file ?? '');
|
|
64
|
+
if (byFile !== 0)
|
|
65
|
+
return byFile;
|
|
66
|
+
return (a.line ?? 0) - (b.line ?? 0);
|
|
67
|
+
});
|
|
68
|
+
const counts = { critical: 0, high: 0, medium: 0, low: 0, info: 0 };
|
|
69
|
+
for (const f of filtered)
|
|
70
|
+
counts[f.severity]++;
|
|
71
|
+
return {
|
|
72
|
+
root,
|
|
73
|
+
framework: framework.describe(),
|
|
74
|
+
fileCount: files.length,
|
|
75
|
+
findings: filtered,
|
|
76
|
+
checks,
|
|
77
|
+
warnings,
|
|
78
|
+
counts,
|
|
79
|
+
durationMs: Date.now() - started,
|
|
80
|
+
};
|
|
81
|
+
}
|