shipwarden 0.0.0-stage → 0.2.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 +154 -2
- package/action.yml +67 -0
- package/package.json +44 -4
- package/src/action.js +94 -0
- package/src/ai-judge.js +138 -0
- package/src/checklist.js +255 -0
- package/src/cli.js +88 -0
- package/src/github.js +52 -0
- package/src/report.js +129 -0
- package/src/rules/firebase.js +116 -0
- package/src/rules/index.js +7 -0
- package/src/rules/secrets.js +121 -0
- package/src/rules/supabase.js +293 -0
- package/src/rules/web.js +193 -0
- package/src/scanner.js +177 -0
- package/src/util.js +118 -0
package/src/scanner.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { execFileSync } from 'node:child_process';
|
|
4
|
+
import { RULES, RULE_IDS } from './rules/index.js';
|
|
5
|
+
import { runChecklist } from './checklist.js';
|
|
6
|
+
import { TEST_FILE, globToRegExp, severityRank } from './util.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULT_IGNORES = [
|
|
9
|
+
'node_modules', '.git', '.next', '.nuxt', '.output', '.svelte-kit', '.vercel', '.netlify', '.turbo', '.cache',
|
|
10
|
+
'dist', 'build', 'out', 'coverage', 'vendor', '*.min.js', '*.map',
|
|
11
|
+
'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb',
|
|
12
|
+
];
|
|
13
|
+
const TEXT_EXT = new Set([
|
|
14
|
+
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts', '.vue', '.svelte', '.astro',
|
|
15
|
+
'.sql', '.rules', '.json', '.yml', '.yaml', '.toml', '.py', '.rb', '.go', '.php', '.html',
|
|
16
|
+
'.md', '.txt', '.sh', '.ini', '.conf', '.xml', '.kt', '.swift', '.dart',
|
|
17
|
+
]);
|
|
18
|
+
const MAX_BYTES = 1024 * 1024;
|
|
19
|
+
const MAX_SQL_BYTES = 32 * 1024 * 1024; // schema dumps get big
|
|
20
|
+
|
|
21
|
+
function walk(root, rel = '', out = []) {
|
|
22
|
+
for (const entry of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
23
|
+
const p = rel ? `${rel}/${entry.name}` : entry.name;
|
|
24
|
+
if (entry.isDirectory()) {
|
|
25
|
+
if (!DEFAULT_IGNORES.includes(entry.name)) walk(root, p, out);
|
|
26
|
+
} else if (entry.isFile()) {
|
|
27
|
+
out.push(p);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Files git would commit (tracked + untracked-but-not-ignored); falls back to a directory walk. */
|
|
34
|
+
function listFiles(root) {
|
|
35
|
+
try {
|
|
36
|
+
const out = execFileSync('git', ['-C', root, 'ls-files', '-z', '--cached', '--others', '--exclude-standard'], {
|
|
37
|
+
encoding: 'utf8',
|
|
38
|
+
maxBuffer: 512 * 1024 * 1024,
|
|
39
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
40
|
+
});
|
|
41
|
+
return { paths: out.split('\0').filter(Boolean), tracked: true };
|
|
42
|
+
} catch {
|
|
43
|
+
return { paths: walk(root), tracked: false };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `.shipwardenignore`: path globs, plus `check:<id>` lines that turn off a checklist item. */
|
|
48
|
+
function readIgnoreFile(root) {
|
|
49
|
+
try {
|
|
50
|
+
return fs
|
|
51
|
+
.readFileSync(path.join(root, '.shipwardenignore'), 'utf8')
|
|
52
|
+
.split('\n')
|
|
53
|
+
.map((l) => l.trim())
|
|
54
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
55
|
+
} catch {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function loadFiles(root, paths, ignores) {
|
|
61
|
+
const files = [];
|
|
62
|
+
for (const rel of paths) {
|
|
63
|
+
const base = rel.split('/').pop();
|
|
64
|
+
if (!base.startsWith('.env') && !TEXT_EXT.has(path.extname(base).toLowerCase())) continue;
|
|
65
|
+
if (ignores.some((re) => re.test(rel))) continue;
|
|
66
|
+
const abs = path.join(root, rel);
|
|
67
|
+
let stat;
|
|
68
|
+
try {
|
|
69
|
+
stat = fs.statSync(abs);
|
|
70
|
+
} catch {
|
|
71
|
+
continue; // tracked but deleted in the working tree
|
|
72
|
+
}
|
|
73
|
+
if (!stat.isFile() || stat.size > (rel.endsWith('.sql') ? MAX_SQL_BYTES : MAX_BYTES)) continue;
|
|
74
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
75
|
+
if (content.slice(0, 8000).includes('\0')) continue;
|
|
76
|
+
files.push({ path: rel, content });
|
|
77
|
+
}
|
|
78
|
+
return files;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function detectKind(deps) {
|
|
82
|
+
if (deps.has('next')) return 'next';
|
|
83
|
+
if (deps.has('@sveltejs/kit')) return 'sveltekit';
|
|
84
|
+
if (deps.has('nuxt')) return 'nuxt';
|
|
85
|
+
if (deps.has('astro')) return 'astro';
|
|
86
|
+
if ([...deps].some((d) => d.startsWith('@remix-run/'))) return 'remix';
|
|
87
|
+
if (deps.has('vite') || deps.has('react-scripts')) return 'spa';
|
|
88
|
+
return 'other';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function buildContext(files, tracked, paths) {
|
|
92
|
+
const deps = new Set();
|
|
93
|
+
for (const f of files) {
|
|
94
|
+
if (!f.path.endsWith('package.json')) continue;
|
|
95
|
+
try {
|
|
96
|
+
const pkg = JSON.parse(f.content);
|
|
97
|
+
for (const key of ['dependencies', 'devDependencies', 'peerDependencies']) {
|
|
98
|
+
for (const name of Object.keys(pkg[key] ?? {})) deps.add(name);
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// not valid JSON; ignore
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const anyPath = (re) => files.some((f) => re.test(f.path));
|
|
105
|
+
const usesSupabase = [...deps].some((d) => d.startsWith('@supabase/')) || anyPath(/(^|\/)supabase\/(config\.toml|migrations\/|functions\/)/);
|
|
106
|
+
const usesFirebase = deps.has('firebase') || deps.has('firebase-admin') || anyPath(/(^|\/)(firebase\.json|firestore\.rules)$/);
|
|
107
|
+
const kind = detectKind(deps);
|
|
108
|
+
// Only a plain SPA ships everything under src/ to the browser.
|
|
109
|
+
const framework = kind === 'next' || kind === 'spa' ? kind : 'other';
|
|
110
|
+
const app = kind !== 'other' || paths.some((p) => /(^|\/)index\.html$/.test(p));
|
|
111
|
+
|
|
112
|
+
const stack = [];
|
|
113
|
+
if (framework === 'next') stack.push('Next.js');
|
|
114
|
+
if (framework === 'spa') stack.push(deps.has('vite') ? 'Vite' : 'React');
|
|
115
|
+
if (usesSupabase) stack.push('Supabase');
|
|
116
|
+
if (usesFirebase) stack.push('Firebase');
|
|
117
|
+
if (deps.has('stripe') || deps.has('@stripe/stripe-js')) stack.push('Stripe');
|
|
118
|
+
if (deps.has('@prisma/client') || deps.has('prisma')) stack.push('Prisma');
|
|
119
|
+
return { tracked, deps, usesSupabase, usesFirebase, framework, kind, app, paths, stack };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const IGNORE_COMMENT = /shipwarden-ignore(?:[\s:]+([\w-]+(?:\s*,\s*[\w-]+)*))?/;
|
|
123
|
+
|
|
124
|
+
/** `shipwarden-ignore` (optionally followed by rule ids) on the finding's line or the line above. */
|
|
125
|
+
function isSuppressed(finding, fileContents) {
|
|
126
|
+
const lines = fileContents.get(finding.file)?.split('\n') ?? [];
|
|
127
|
+
for (const text of [lines[finding.line - 1], lines[finding.line - 2]]) {
|
|
128
|
+
const m = text && IGNORE_COMMENT.exec(text);
|
|
129
|
+
if (!m) continue;
|
|
130
|
+
const ids = (m[1] ?? '').split(/\s*,\s*/).filter((id) => RULE_IDS.has(id));
|
|
131
|
+
if (!ids.length || ids.includes(finding.ruleId)) return true;
|
|
132
|
+
}
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* @param {string} root
|
|
138
|
+
* @param {{ignore?: string[], checklist?: boolean, skipChecks?: string[]}} [opts]
|
|
139
|
+
* checklist: also run the whole-codebase launch checklist (skip it for PR-scoped scans).
|
|
140
|
+
*/
|
|
141
|
+
export async function scan(root, { ignore = [], checklist = true, skipChecks = [] } = {}) {
|
|
142
|
+
const started = Date.now();
|
|
143
|
+
const { paths, tracked } = listFiles(root);
|
|
144
|
+
const ignoreLines = readIgnoreFile(root);
|
|
145
|
+
const skip = new Set([...skipChecks, ...ignoreLines.filter((l) => l.startsWith('check:')).map((l) => l.slice(6).trim())]);
|
|
146
|
+
const ignores = [...DEFAULT_IGNORES, ...ignoreLines.filter((l) => !l.startsWith('check:')), ...ignore].map(globToRegExp);
|
|
147
|
+
const files = loadFiles(root, paths, ignores);
|
|
148
|
+
const visiblePaths = paths.filter((p) => !ignores.some((re) => re.test(p)) && !TEST_FILE.test(p));
|
|
149
|
+
const ctx = buildContext(files, tracked, visiblePaths);
|
|
150
|
+
const fileContents = new Map(files.map((f) => [f.path, f.content]));
|
|
151
|
+
|
|
152
|
+
const raw = [];
|
|
153
|
+
for (const rule of RULES) {
|
|
154
|
+
if (rule.checkAll) raw.push(...rule.checkAll(files, ctx));
|
|
155
|
+
if (rule.check) for (const file of files) if (!TEST_FILE.test(file.path)) raw.push(...rule.check(file, ctx));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
const findings = raw
|
|
160
|
+
.filter((f) => {
|
|
161
|
+
const key = `${f.ruleId}:${f.file}:${f.line}`;
|
|
162
|
+
if (seen.has(key)) return false;
|
|
163
|
+
seen.add(key);
|
|
164
|
+
return !isSuppressed(f, fileContents);
|
|
165
|
+
})
|
|
166
|
+
.sort((a, b) => severityRank(a.severity) - severityRank(b.severity) || a.file.localeCompare(b.file) || a.line - b.line);
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
root,
|
|
170
|
+
filesScanned: files.length,
|
|
171
|
+
stack: ctx.stack,
|
|
172
|
+
findings,
|
|
173
|
+
checklist: checklist ? runChecklist(files.filter((f) => !TEST_FILE.test(f.path)), ctx, skip) : [],
|
|
174
|
+
fileContents,
|
|
175
|
+
durationMs: Date.now() - started,
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/util.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
export const SEVERITIES = ['critical', 'high', 'medium', 'low'];
|
|
2
|
+
|
|
3
|
+
// Tests and fixtures are full of fake keys and deliberately unsafe code.
|
|
4
|
+
export const TEST_FILE = /(^|\/)(__tests__|__mocks__|__fixtures__|tests?|fixtures?|e2e|cypress|spec)\/|\.(test|spec|stories)\.[cm]?[jt]sx?$/;
|
|
5
|
+
|
|
6
|
+
export function severityRank(severity) {
|
|
7
|
+
const i = SEVERITIES.indexOf(severity);
|
|
8
|
+
return i === -1 ? SEVERITIES.length : i;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** True when `severity` is at least as bad as `threshold` ("none" disables). */
|
|
12
|
+
export function atLeast(severity, threshold) {
|
|
13
|
+
if (!threshold || threshold === 'none') return false;
|
|
14
|
+
return severityRank(severity) <= severityRank(threshold);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const lineStarts = new Map();
|
|
18
|
+
|
|
19
|
+
export function lineAt(content, index) {
|
|
20
|
+
let starts = lineStarts.get(content);
|
|
21
|
+
if (!starts) {
|
|
22
|
+
starts = [0];
|
|
23
|
+
for (let i = content.indexOf('\n'); i !== -1; i = content.indexOf('\n', i + 1)) starts.push(i + 1);
|
|
24
|
+
lineStarts.set(content, starts);
|
|
25
|
+
}
|
|
26
|
+
let lo = 0;
|
|
27
|
+
let hi = starts.length - 1;
|
|
28
|
+
while (lo < hi) {
|
|
29
|
+
const mid = (lo + hi + 1) >> 1;
|
|
30
|
+
if (starts[mid] <= index) lo = mid;
|
|
31
|
+
else hi = mid - 1;
|
|
32
|
+
}
|
|
33
|
+
return lo + 1;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Replace text with spaces but keep newlines, so offsets and line numbers stay valid. */
|
|
37
|
+
export function blank(text) {
|
|
38
|
+
return text.replace(/[^\n]/g, ' ');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Does the file start with a directive such as 'use client' (after comments/whitespace)? */
|
|
42
|
+
export function hasDirective(content, directive) {
|
|
43
|
+
const re = new RegExp(String.raw`^(?:\s|//[^\n]*\n|/\*[\s\S]*?\*/)*(['"])${directive}\1`);
|
|
44
|
+
return re.test(content.slice(0, 4000));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Minimal .gitignore-style glob: `*`, `**`, `?`, trailing `/` for directories. */
|
|
48
|
+
export function globToRegExp(glob) {
|
|
49
|
+
let g = glob.trim().replace(/^\.\//, '');
|
|
50
|
+
if (g.endsWith('/')) g = g.slice(0, -1);
|
|
51
|
+
let re = '';
|
|
52
|
+
for (let i = 0; i < g.length; i++) {
|
|
53
|
+
const c = g[i];
|
|
54
|
+
if (c === '*') {
|
|
55
|
+
if (g[i + 1] === '*') {
|
|
56
|
+
if (g[i + 2] === '/') {
|
|
57
|
+
re += '(?:.*/)?';
|
|
58
|
+
i += 2;
|
|
59
|
+
} else {
|
|
60
|
+
re += '.*';
|
|
61
|
+
i += 1;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
re += '[^/]*';
|
|
65
|
+
}
|
|
66
|
+
} else if (c === '?') {
|
|
67
|
+
re += '[^/]';
|
|
68
|
+
} else {
|
|
69
|
+
re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Like .gitignore: a pattern without a slash matches at any depth.
|
|
73
|
+
const anchor = g.includes('/') ? '^' : '(?:^|/)';
|
|
74
|
+
return new RegExp(`${anchor}${re}(?:/.*)?$`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const PLACEHOLDER = /(x{4,}|\*{3,}|\.{3}|<|>|your|example|placeholder|dummy|sample|changeme|redacted|fake)/i;
|
|
78
|
+
|
|
79
|
+
export function looksLikePlaceholder(value) {
|
|
80
|
+
return PLACEHOLDER.test(value) || /(.)\1{7,}/.test(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function makeFinding(rule, file, line, fields = {}) {
|
|
84
|
+
return {
|
|
85
|
+
ruleId: rule.id,
|
|
86
|
+
severity: fields.severity ?? rule.severity,
|
|
87
|
+
confidence: fields.confidence ?? rule.confidence ?? 'high',
|
|
88
|
+
title: fields.title ?? rule.title,
|
|
89
|
+
file,
|
|
90
|
+
line,
|
|
91
|
+
message: fields.message ?? rule.message,
|
|
92
|
+
fix: fields.fix ?? rule.fix,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Code that ends up in the browser bundle. */
|
|
97
|
+
export function isClientCode(file, ctx) {
|
|
98
|
+
if (!/\.[cm]?[jt]sx?$|\.(vue|svelte|astro)$/.test(file.path)) return false;
|
|
99
|
+
if (hasDirective(file.content, 'use client')) return true;
|
|
100
|
+
if (ctx.framework === 'spa') {
|
|
101
|
+
return /(^|\/)src\//.test(file.path) && !/(^|\/)src\/(?:.*\/)?(server|api)\//.test(file.path);
|
|
102
|
+
}
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const ROUTE_HANDLER = /(^|\/)app\/(?:.*\/)?route\.[cm]?[jt]sx?$/;
|
|
107
|
+
export const PAGES_API = /(^|\/)pages\/api\/.*\.[cm]?[jt]sx?$/;
|
|
108
|
+
export const EDGE_FUNCTION = /(^|\/)supabase\/functions\/[^/]+\/index\.[jt]sx?$/;
|
|
109
|
+
|
|
110
|
+
/** Code that runs as a public HTTP endpoint. */
|
|
111
|
+
export function isServerEndpoint(file) {
|
|
112
|
+
return (
|
|
113
|
+
ROUTE_HANDLER.test(file.path) ||
|
|
114
|
+
PAGES_API.test(file.path) ||
|
|
115
|
+
EDGE_FUNCTION.test(file.path) ||
|
|
116
|
+
(/\.[cm]?[jt]sx?$/.test(file.path) && hasDirective(file.content, 'use server'))
|
|
117
|
+
);
|
|
118
|
+
}
|