canship 0.2.1 → 0.3.1
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-zh-CN.md +108 -33
- package/README.md +103 -28
- package/dist/cli.js +359 -132
- package/package.json +6 -1
- package/schemas/scan-report-v1.schema.json +95 -0
package/dist/cli.js
CHANGED
|
@@ -779,7 +779,7 @@ function isExampleContext(relPath) {
|
|
|
779
779
|
if (/\.(test|spec)\.[jt]sx?$/i.test(name)) return true;
|
|
780
780
|
return false;
|
|
781
781
|
}
|
|
782
|
-
function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}) {
|
|
782
|
+
function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root), limits = {}, honorIgnoreMarkers = true) {
|
|
783
783
|
const skipped = [];
|
|
784
784
|
const ignored = [];
|
|
785
785
|
const fromGit = isGitRepo ? listViaGit(root, gitExecutable) : null;
|
|
@@ -863,7 +863,7 @@ function collectFiles(root, isGitRepo, gitExecutable = resolveGitExecutable(root
|
|
|
863
863
|
continue;
|
|
864
864
|
}
|
|
865
865
|
const lines = content.split(/\r?\n/);
|
|
866
|
-
if (hasIgnoreMarker(lines)) {
|
|
866
|
+
if (honorIgnoreMarkers && hasIgnoreMarker(lines)) {
|
|
867
867
|
ignored.push(relPath);
|
|
868
868
|
continue;
|
|
869
869
|
}
|
|
@@ -952,71 +952,68 @@ function endOfString(src, start, quote) {
|
|
|
952
952
|
}
|
|
953
953
|
return length;
|
|
954
954
|
}
|
|
955
|
-
function maskTemplate(src, out, start) {
|
|
955
|
+
function maskTemplate(src, out, start, maskStrings = true) {
|
|
956
956
|
const length = src.length;
|
|
957
|
+
const stack = [{ kind: "literal", from: start + 1 }];
|
|
957
958
|
let i = start + 1;
|
|
958
|
-
|
|
959
|
-
|
|
959
|
+
while (i < length && stack.length > 0) {
|
|
960
|
+
const frame = stack[stack.length - 1];
|
|
960
961
|
const ch = src.charCodeAt(i);
|
|
961
|
-
if (
|
|
962
|
-
|
|
962
|
+
if (frame.kind === "literal") {
|
|
963
|
+
if (ch === BACKSLASH) {
|
|
964
|
+
i = Math.min(i + 2, length);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (ch === BACKTICK) {
|
|
968
|
+
if (maskStrings) blank(out, frame.from, i);
|
|
969
|
+
stack.pop();
|
|
970
|
+
i++;
|
|
971
|
+
continue;
|
|
972
|
+
}
|
|
973
|
+
if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
|
|
974
|
+
if (maskStrings) blank(out, frame.from, i);
|
|
975
|
+
stack.push({ kind: "expression", depth: 1 });
|
|
976
|
+
i += 2;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
i++;
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (ch === SLASH) {
|
|
983
|
+
const next = src.charCodeAt(i + 1);
|
|
984
|
+
if (next === SLASH || next === STAR) {
|
|
985
|
+
const close = next === SLASH ? src.indexOf("\n", i) : src.indexOf("*/", i + 2);
|
|
986
|
+
const stop = close === -1 ? length : close + (next === STAR ? 2 : 0);
|
|
987
|
+
blank(out, i, stop);
|
|
988
|
+
i = stop;
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
|
|
993
|
+
const stop = endOfString(src, i, ch);
|
|
994
|
+
if (maskStrings) blank(out, i + 1, stop - 1);
|
|
995
|
+
i = stop;
|
|
963
996
|
continue;
|
|
964
997
|
}
|
|
965
998
|
if (ch === BACKTICK) {
|
|
966
|
-
|
|
967
|
-
|
|
999
|
+
stack.push({ kind: "literal", from: i + 1 });
|
|
1000
|
+
i++;
|
|
1001
|
+
continue;
|
|
968
1002
|
}
|
|
969
|
-
if (ch ===
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
const
|
|
975
|
-
if (
|
|
976
|
-
const next = src.charCodeAt(j + 1);
|
|
977
|
-
if (next === SLASH) {
|
|
978
|
-
const end = src.indexOf("\n", j);
|
|
979
|
-
const stop = end === -1 ? length : end;
|
|
980
|
-
blank(out, j, stop);
|
|
981
|
-
j = stop;
|
|
982
|
-
continue;
|
|
983
|
-
}
|
|
984
|
-
if (next === STAR) {
|
|
985
|
-
const close = src.indexOf("*/", j + 2);
|
|
986
|
-
const stop = close === -1 ? length : close + 2;
|
|
987
|
-
blank(out, j, stop);
|
|
988
|
-
j = stop;
|
|
989
|
-
continue;
|
|
990
|
-
}
|
|
991
|
-
}
|
|
992
|
-
if (inner === DOUBLE_QUOTE || inner === SINGLE_QUOTE) {
|
|
993
|
-
const stop = endOfString(src, j, inner);
|
|
994
|
-
blank(out, j + 1, stop - 1);
|
|
995
|
-
j = stop;
|
|
996
|
-
continue;
|
|
997
|
-
}
|
|
998
|
-
if (inner === BACKTICK) {
|
|
999
|
-
j = maskTemplate(src, out, j);
|
|
1000
|
-
continue;
|
|
1001
|
-
}
|
|
1002
|
-
if (inner === OPEN_BRACE) depth++;
|
|
1003
|
-
else if (inner === CLOSE_BRACE) {
|
|
1004
|
-
depth--;
|
|
1005
|
-
if (depth === 0) {
|
|
1006
|
-
j++;
|
|
1007
|
-
break;
|
|
1008
|
-
}
|
|
1009
|
-
}
|
|
1010
|
-
j++;
|
|
1003
|
+
if (ch === OPEN_BRACE) frame.depth++;
|
|
1004
|
+
else if (ch === CLOSE_BRACE) {
|
|
1005
|
+
frame.depth--;
|
|
1006
|
+
if (frame.depth === 0) {
|
|
1007
|
+
stack.pop();
|
|
1008
|
+
const parent = stack[stack.length - 1];
|
|
1009
|
+
if (parent.kind === "literal") parent.from = i + 1;
|
|
1011
1010
|
}
|
|
1012
|
-
i = j;
|
|
1013
|
-
literalFrom = i;
|
|
1014
|
-
continue;
|
|
1015
1011
|
}
|
|
1016
1012
|
i++;
|
|
1017
1013
|
}
|
|
1018
|
-
|
|
1019
|
-
|
|
1014
|
+
const last = stack[stack.length - 1];
|
|
1015
|
+
if (maskStrings && last?.kind === "literal") blank(out, last.from, length);
|
|
1016
|
+
return i;
|
|
1020
1017
|
}
|
|
1021
1018
|
function maskJsComments(src) {
|
|
1022
1019
|
const length = src.length;
|
|
@@ -1041,10 +1038,14 @@ function maskJsComments(src) {
|
|
|
1041
1038
|
continue;
|
|
1042
1039
|
}
|
|
1043
1040
|
}
|
|
1044
|
-
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE
|
|
1041
|
+
if (ch === DOUBLE_QUOTE || ch === SINGLE_QUOTE) {
|
|
1045
1042
|
i = endOfString(src, i, ch);
|
|
1046
1043
|
continue;
|
|
1047
1044
|
}
|
|
1045
|
+
if (ch === BACKTICK) {
|
|
1046
|
+
i = maskTemplate(src, out, i, false);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
1048
1049
|
i++;
|
|
1049
1050
|
}
|
|
1050
1051
|
return stringOf(out);
|
|
@@ -2102,8 +2103,8 @@ var supabaseRlsRule = {
|
|
|
2102
2103
|
line: entry.line,
|
|
2103
2104
|
excerpt: null,
|
|
2104
2105
|
why: [
|
|
2105
|
-
`Supabase
|
|
2106
|
-
`
|
|
2106
|
+
`Supabase's browser clients use public identifiers. For exposed tables, database grants and Row Level Security determine which operations and rows a caller can access.`,
|
|
2107
|
+
`After replaying the scanned migrations, the final recorded state of ${renderIdent(entry.table)} does not have RLS enabled. It may never have been enabled or may have been disabled by a later migration. Without RLS, any access granted to the caller is not restricted by row policies.`,
|
|
2107
2108
|
`If you enabled RLS from the Supabase dashboard instead, this file simply cannot show it. Check the Authentication -> Policies page to confirm.`
|
|
2108
2109
|
],
|
|
2109
2110
|
fix: [
|
|
@@ -2130,9 +2131,7 @@ var supabaseRlsRule = {
|
|
|
2130
2131
|
// src/rules/firebase.ts
|
|
2131
2132
|
import { basename as basename5 } from "path";
|
|
2132
2133
|
function isRulesFile(file) {
|
|
2133
|
-
|
|
2134
|
-
if (name.endsWith(".rules")) return true;
|
|
2135
|
-
return name === "firestore.rules" || name === "storage.rules";
|
|
2134
|
+
return basename5(file.path).toLowerCase().endsWith(".rules");
|
|
2136
2135
|
}
|
|
2137
2136
|
function productOf(path) {
|
|
2138
2137
|
const name = basename5(path).toLowerCase();
|
|
@@ -2244,7 +2243,7 @@ var firebaseRulesRule = {
|
|
|
2244
2243
|
excerpt: m[0].trim(),
|
|
2245
2244
|
why: canWrite ? [
|
|
2246
2245
|
`"if true" grants access unconditionally \u2014 no sign-in, no ownership check, nothing. The Firebase client SDK talks to your database straight from the browser, so these rules are the only access control that exists.`,
|
|
2247
|
-
`Anyone
|
|
2246
|
+
`Anyone can perform the allowed ${ops} operations on this matched path without authentication. Other operations depend on their own rules. Project ids are public identifiers, not access controls.`
|
|
2248
2247
|
] : [
|
|
2249
2248
|
`"if true" grants read access unconditionally, so anyone who finds your project id can list every document in this collection. Project ids are not secret; they ship inside your frontend bundle.`,
|
|
2250
2249
|
`Writes are still denied by default, so this is only a problem if the data is not meant to be public. If it is a public catalogue or announcements, ignore this \u2014 and consider adding "allow write: if false;" to make that intent explicit.`
|
|
@@ -2922,9 +2921,9 @@ var apiAuthRule = {
|
|
|
2922
2921
|
line,
|
|
2923
2922
|
excerpt,
|
|
2924
2923
|
why: [
|
|
2925
|
-
`This route
|
|
2926
|
-
`
|
|
2927
|
-
`
|
|
2924
|
+
`This route references a recognized admin client. Such clients can bypass normal per-user access checks; verify the effective credentials and granted permissions.`,
|
|
2925
|
+
`The scan did not recognize an authentication guard protecting this operation or applicable middleware. Indirect wrappers and deployment-level controls may not be recognized; verify them before exposing this route.`,
|
|
2926
|
+
`If ${url} is reachable without authentication, callers can trigger the admin-backed operations implemented by this handler.`,
|
|
2928
2927
|
`If this endpoint is meant to be public \u2014 handing out a guest session, taking a waitlist signup \u2014 then the problem is not that it is open, it is that it is open *and* holds the admin key. Give it a client that can only do the one thing it needs.`
|
|
2929
2928
|
],
|
|
2930
2929
|
fix: [
|
|
@@ -2950,8 +2949,8 @@ var apiAuthRule = {
|
|
|
2950
2949
|
line,
|
|
2951
2950
|
excerpt,
|
|
2952
2951
|
why: [
|
|
2953
|
-
`This route changes data, and
|
|
2954
|
-
`
|
|
2952
|
+
`This route changes data, and the scan did not recognize an authentication guard before the operation. Indirect guards and runtime controls require manual verification.`,
|
|
2953
|
+
`If this route is publicly reachable and no other control rejects the caller, unauthenticated requests can trigger this write.`,
|
|
2955
2954
|
`If this is a public form \u2014 a waitlist, a contact box \u2014 that may be intentional. It is still worth rate limiting, because an open write endpoint is what gets a database filled with spam overnight.`
|
|
2956
2955
|
],
|
|
2957
2956
|
fix: [
|
|
@@ -2968,8 +2967,31 @@ var apiAuthRule = {
|
|
|
2968
2967
|
|
|
2969
2968
|
// src/rules/cors.ts
|
|
2970
2969
|
var CORS_MARKER = /Access-Control-Allow-Origin|\bcors\s*\(/i;
|
|
2971
|
-
var ACAO = /['"]Access-Control-Allow-Origin['"]\s*(?:,|:)\s*(?:value\s*:\s*)
|
|
2972
|
-
|
|
2970
|
+
var ACAO = /['"]Access-Control-Allow-Origin['"]\s*(?:,|:)\s*(?:value\s*:\s*)?/gi;
|
|
2971
|
+
function headerExpression(content, start) {
|
|
2972
|
+
const closes = [];
|
|
2973
|
+
let quote = null;
|
|
2974
|
+
let end = start;
|
|
2975
|
+
for (; end < content.length; end++) {
|
|
2976
|
+
const ch = content[end];
|
|
2977
|
+
if (ch === "\n" || ch === "\r") break;
|
|
2978
|
+
if (quote !== null) {
|
|
2979
|
+
if (ch === "\\") end++;
|
|
2980
|
+
else if (ch === quote) quote = null;
|
|
2981
|
+
continue;
|
|
2982
|
+
}
|
|
2983
|
+
if (ch === "'" || ch === '"' || ch === "`") quote = ch;
|
|
2984
|
+
else if (ch === "(") closes.push(")");
|
|
2985
|
+
else if (ch === "[") closes.push("]");
|
|
2986
|
+
else if (ch === "{") closes.push("}");
|
|
2987
|
+
else if (ch === ")" || ch === "]" || ch === "}") {
|
|
2988
|
+
if (closes.at(-1) !== ch) break;
|
|
2989
|
+
closes.pop();
|
|
2990
|
+
} else if (closes.length === 0 && (ch === "," || ch === ";")) break;
|
|
2991
|
+
}
|
|
2992
|
+
return content.slice(start, end);
|
|
2993
|
+
}
|
|
2994
|
+
var CORS_ORIGIN_OPTION = /\borigin\s*:\s*/gi;
|
|
2973
2995
|
var CORS_ORIGIN_PROPERTY_START = /\borigin\s*:\s*(?:async\s+)?(?:(function)\s*(?:[A-Za-z_$][\w$]*)?\s*)?\(/gi;
|
|
2974
2996
|
var CORS_ORIGIN_METHOD_START = /\borigin\s*\(/gi;
|
|
2975
2997
|
function closingParameterList(source, open) {
|
|
@@ -3010,6 +3032,8 @@ function readOriginCallback(content, match, kind) {
|
|
|
3010
3032
|
}
|
|
3011
3033
|
return {
|
|
3012
3034
|
index: match.index,
|
|
3035
|
+
parametersStart: open + 1,
|
|
3036
|
+
parametersEnd: close,
|
|
3013
3037
|
params: content.slice(open + 1, close),
|
|
3014
3038
|
body: content.slice(bodyStart, bodyStart + 400)
|
|
3015
3039
|
};
|
|
@@ -3118,31 +3142,63 @@ function classifyOrigin(raw) {
|
|
|
3118
3142
|
return /\b(?:req|request|headers?|ctx|event)/i.test(compact) ? "reflected" : "unknown";
|
|
3119
3143
|
}
|
|
3120
3144
|
var PAIRING_DISTANCE = 25;
|
|
3145
|
+
function optionScopes(file, positions) {
|
|
3146
|
+
const scopes = /* @__PURE__ */ new Map();
|
|
3147
|
+
const wanted = [...new Set(positions)].sort((a, b) => a - b);
|
|
3148
|
+
if (wanted.length === 0) return scopes;
|
|
3149
|
+
const content = noiseMaskedOf(file);
|
|
3150
|
+
const stack = [-1];
|
|
3151
|
+
let next = 0;
|
|
3152
|
+
for (let at = 0; at < content.length && next < wanted.length; at++) {
|
|
3153
|
+
if (at === wanted[next]) {
|
|
3154
|
+
scopes.set(at, stack[stack.length - 1]);
|
|
3155
|
+
next++;
|
|
3156
|
+
}
|
|
3157
|
+
if (content[at] === "{") stack.push(at);
|
|
3158
|
+
else if (content[at] === "}" && stack.length > 1) stack.pop();
|
|
3159
|
+
}
|
|
3160
|
+
return scopes;
|
|
3161
|
+
}
|
|
3121
3162
|
function collectOrigins(file) {
|
|
3122
3163
|
const marks = [];
|
|
3123
3164
|
let m;
|
|
3124
3165
|
const content = commentsMaskedOf(file);
|
|
3125
3166
|
const contentLines = lineStartsOf(content);
|
|
3167
|
+
const code = noiseMaskedOf(file);
|
|
3168
|
+
const callbacks = originCallbacks(content).filter((callback) => code[callback.index] === content[callback.index]);
|
|
3169
|
+
const callbackIndices = new Set(callbacks.map((callback) => callback.index));
|
|
3170
|
+
const parameterRanges = [...callbacks].sort((a, b) => a.parametersStart - b.parametersStart);
|
|
3171
|
+
let parameterRange = 0;
|
|
3126
3172
|
ACAO.lastIndex = 0;
|
|
3127
3173
|
while ((m = ACAO.exec(content)) !== null) {
|
|
3174
|
+
if (code[m.index] !== content[m.index]) continue;
|
|
3128
3175
|
const line = lineNumberAt(contentLines, m.index);
|
|
3129
|
-
|
|
3176
|
+
const expression = headerExpression(content, ACAO.lastIndex);
|
|
3177
|
+
ACAO.lastIndex += expression.length;
|
|
3178
|
+
marks.push({ line, at: m.index, mode: "header", kind: classifyOrigin(expression), excerpt: (file.lines[line - 1] ?? "").trim() });
|
|
3130
3179
|
}
|
|
3131
3180
|
CORS_ORIGIN_OPTION.lastIndex = 0;
|
|
3132
3181
|
while ((m = CORS_ORIGIN_OPTION.exec(content)) !== null) {
|
|
3182
|
+
if (code[m.index] !== content[m.index]) continue;
|
|
3183
|
+
while (parameterRange < parameterRanges.length && parameterRanges[parameterRange].parametersEnd < m.index) parameterRange++;
|
|
3184
|
+
if (parameterRanges[parameterRange] && parameterRanges[parameterRange].parametersStart <= m.index) continue;
|
|
3185
|
+
if (callbackIndices.has(m.index)) continue;
|
|
3133
3186
|
const line = lineNumberAt(contentLines, m.index);
|
|
3187
|
+
const expression = headerExpression(content, CORS_ORIGIN_OPTION.lastIndex);
|
|
3188
|
+
CORS_ORIGIN_OPTION.lastIndex += expression.length;
|
|
3134
3189
|
marks.push({
|
|
3135
3190
|
line,
|
|
3136
|
-
|
|
3191
|
+
at: m.index,
|
|
3192
|
+
mode: "option",
|
|
3193
|
+
kind: expression.trim() === "true" ? "reflected" : classifyOrigin(expression),
|
|
3137
3194
|
excerpt: (file.lines[line - 1] ?? "").trim()
|
|
3138
3195
|
});
|
|
3139
3196
|
}
|
|
3140
|
-
for (const callback of
|
|
3197
|
+
for (const callback of callbacks) {
|
|
3141
3198
|
const answer = callbackAnswer(callback.params, callback.body);
|
|
3142
|
-
|
|
3143
|
-
if (ORIGIN_IS_CHECKED.test(callback.body.slice(0, answer.index))) continue;
|
|
3199
|
+
const kind = answer !== null && !ORIGIN_IS_CHECKED.test(callback.body.slice(0, answer.index)) ? answer.kind : "unknown";
|
|
3144
3200
|
const line = lineNumberAt(contentLines, callback.index);
|
|
3145
|
-
marks.push({ line,
|
|
3201
|
+
marks.push({ line, at: callback.index, mode: "option", kind, excerpt: (file.lines[line - 1] ?? "").trim() });
|
|
3146
3202
|
}
|
|
3147
3203
|
return marks;
|
|
3148
3204
|
}
|
|
@@ -3150,11 +3206,16 @@ function collectCredentialLines(file) {
|
|
|
3150
3206
|
const lines = [];
|
|
3151
3207
|
let m;
|
|
3152
3208
|
const content = commentsMaskedOf(file);
|
|
3209
|
+
const code = noiseMaskedOf(file);
|
|
3153
3210
|
const contentLines = lineStartsOf(content);
|
|
3154
3211
|
ACAC_HEADER.lastIndex = 0;
|
|
3155
|
-
while ((m = ACAC_HEADER.exec(content)) !== null)
|
|
3212
|
+
while ((m = ACAC_HEADER.exec(content)) !== null) {
|
|
3213
|
+
if (code[m.index] === content[m.index]) lines.push({ line: lineNumberAt(contentLines, m.index), at: m.index, mode: "header" });
|
|
3214
|
+
}
|
|
3156
3215
|
CORS_CREDENTIALS_OPTION.lastIndex = 0;
|
|
3157
|
-
while ((m = CORS_CREDENTIALS_OPTION.exec(content)) !== null)
|
|
3216
|
+
while ((m = CORS_CREDENTIALS_OPTION.exec(content)) !== null) {
|
|
3217
|
+
if (code[m.index] === content[m.index]) lines.push({ line: lineNumberAt(contentLines, m.index), at: m.index, mode: "option" });
|
|
3218
|
+
}
|
|
3158
3219
|
return lines;
|
|
3159
3220
|
}
|
|
3160
3221
|
function nearestOrigin(origins, credLine) {
|
|
@@ -3184,10 +3245,12 @@ var corsRule = {
|
|
|
3184
3245
|
if (credentialLines.length === 0) return [];
|
|
3185
3246
|
const origins = collectOrigins(file);
|
|
3186
3247
|
if (origins.length === 0) return [];
|
|
3248
|
+
const scopes = optionScopes(file, [...origins, ...credentialLines].filter((mark) => mark.mode === "option").map((mark) => mark.at));
|
|
3187
3249
|
const findings = [];
|
|
3188
3250
|
const reported = /* @__PURE__ */ new Set();
|
|
3189
|
-
for (const
|
|
3190
|
-
const
|
|
3251
|
+
for (const credential of credentialLines) {
|
|
3252
|
+
const candidates = origins.filter((origin2) => origin2.mode === credential.mode && (credential.mode === "header" || scopes.get(origin2.at) === scopes.get(credential.at)));
|
|
3253
|
+
const origin = nearestOrigin(candidates, credential.line);
|
|
3191
3254
|
if (!origin) continue;
|
|
3192
3255
|
if (origin.kind !== "reflected" && origin.kind !== "wildcard") continue;
|
|
3193
3256
|
if (reported.has(origin.kind)) continue;
|
|
@@ -3390,11 +3453,12 @@ async function scan(root, options = {}) {
|
|
|
3390
3453
|
const started = Date.now();
|
|
3391
3454
|
const gitExecutable = resolveGitExecutable(root);
|
|
3392
3455
|
const git2 = detectGitRepo(root, gitExecutable);
|
|
3393
|
-
const
|
|
3456
|
+
const honorIgnoreMarkers = options.honorIgnoreMarkers !== false;
|
|
3457
|
+
const { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable, {}, honorIgnoreMarkers);
|
|
3394
3458
|
const findings = [];
|
|
3395
3459
|
const errors = [];
|
|
3396
|
-
const fileRules = FILE_RULES.filter((
|
|
3397
|
-
const projectRules = PROJECT_RULES.filter((
|
|
3460
|
+
const fileRules = FILE_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
|
|
3461
|
+
const projectRules = PROJECT_RULES.filter((rule2) => shouldRunRule(rule2.id, options.only ?? [], options.skip ?? []));
|
|
3398
3462
|
const incompleteSeen = /* @__PURE__ */ new Set();
|
|
3399
3463
|
const ctx = {
|
|
3400
3464
|
root,
|
|
@@ -3409,26 +3473,24 @@ async function scan(root, options = {}) {
|
|
|
3409
3473
|
}
|
|
3410
3474
|
};
|
|
3411
3475
|
for (const file of files) {
|
|
3412
|
-
for (const
|
|
3476
|
+
for (const rule2 of fileRules) {
|
|
3413
3477
|
try {
|
|
3414
|
-
if (!
|
|
3415
|
-
findings.push(...
|
|
3478
|
+
if (!rule2.appliesTo(file)) continue;
|
|
3479
|
+
findings.push(...rule2.check(file, ctx));
|
|
3416
3480
|
} catch (err) {
|
|
3417
|
-
errors.push({ ruleId:
|
|
3481
|
+
errors.push({ ruleId: rule2.id, file: file.path, message: messageOf(err), kind: "crashed" });
|
|
3418
3482
|
}
|
|
3419
3483
|
}
|
|
3420
3484
|
}
|
|
3421
|
-
for (const
|
|
3485
|
+
for (const rule2 of projectRules) {
|
|
3422
3486
|
try {
|
|
3423
|
-
findings.push(...await
|
|
3487
|
+
findings.push(...await rule2.check(ctx));
|
|
3424
3488
|
} catch (err) {
|
|
3425
|
-
errors.push({ ruleId:
|
|
3489
|
+
errors.push({ ruleId: rule2.id, file: null, message: messageOf(err), kind: "crashed" });
|
|
3426
3490
|
}
|
|
3427
3491
|
}
|
|
3428
|
-
const
|
|
3429
|
-
|
|
3430
|
-
files
|
|
3431
|
-
);
|
|
3492
|
+
const deduped = dedupe(downgradeExampleContext(findings, files));
|
|
3493
|
+
const { kept, ignored: ignoredFindings } = honorIgnoreMarkers ? suppressIgnoredLines(deduped, files) : { kept: deduped, ignored: [] };
|
|
3432
3494
|
const selected = applyRuleSelection(kept, options);
|
|
3433
3495
|
const counts = /* @__PURE__ */ new Map();
|
|
3434
3496
|
const bounded = sortFindings(selected.kept).filter((finding) => {
|
|
@@ -4188,9 +4250,10 @@ function applyBaseline(findings, baseline) {
|
|
|
4188
4250
|
const kept = [];
|
|
4189
4251
|
let suppressed = 0;
|
|
4190
4252
|
for (const f of findings) {
|
|
4191
|
-
const
|
|
4253
|
+
const fp = fingerprintOf(f);
|
|
4254
|
+
const budget = remaining.get(fp) ?? 0;
|
|
4192
4255
|
if (budget > 0) {
|
|
4193
|
-
remaining.set(
|
|
4256
|
+
remaining.set(fp, budget - 1);
|
|
4194
4257
|
suppressed++;
|
|
4195
4258
|
continue;
|
|
4196
4259
|
}
|
|
@@ -4326,6 +4389,29 @@ function renderSarif(result, opts) {
|
|
|
4326
4389
|
`;
|
|
4327
4390
|
}
|
|
4328
4391
|
|
|
4392
|
+
// src/report/json.ts
|
|
4393
|
+
function createJsonReport(result, options) {
|
|
4394
|
+
return {
|
|
4395
|
+
schemaVersion: 1,
|
|
4396
|
+
version: options.version,
|
|
4397
|
+
root: options.root,
|
|
4398
|
+
filesScanned: result.filesScanned,
|
|
4399
|
+
durationMs: result.durationMs,
|
|
4400
|
+
partial: result.partial,
|
|
4401
|
+
errors: result.errors,
|
|
4402
|
+
skipped: result.skipped,
|
|
4403
|
+
ignored: result.ignored,
|
|
4404
|
+
ignoredFindings: result.ignoredFindings,
|
|
4405
|
+
ruleSelection: result.ruleSelection,
|
|
4406
|
+
vendored: result.vendored,
|
|
4407
|
+
hiddenLikely: options.hiddenLikely,
|
|
4408
|
+
baselineSuppressed: options.baselineSuppressed,
|
|
4409
|
+
baselineStale: options.baselineStale,
|
|
4410
|
+
excerptsOmitted: options.excerptsOmitted ?? false,
|
|
4411
|
+
findings: result.findings
|
|
4412
|
+
};
|
|
4413
|
+
}
|
|
4414
|
+
|
|
4329
4415
|
// src/config.ts
|
|
4330
4416
|
import { existsSync as existsSync2, readFileSync as readFileSync4, statSync as statSync4 } from "fs";
|
|
4331
4417
|
import { join as join3 } from "path";
|
|
@@ -4416,12 +4502,137 @@ function loadConfig(root) {
|
|
|
4416
4502
|
return { config: parseConfig(text, path), path };
|
|
4417
4503
|
}
|
|
4418
4504
|
|
|
4505
|
+
// src/rules/catalog.ts
|
|
4506
|
+
var rule = (id, name, severity, confidence, scope, limitation) => ({ id, name, severity, confidence, scope, limitation, reportsFindings: true });
|
|
4507
|
+
var RULE_CATALOG = [
|
|
4508
|
+
rule(
|
|
4509
|
+
"api/admin-db-access-without-auth",
|
|
4510
|
+
"Admin database access without a recognized auth guard",
|
|
4511
|
+
"P0",
|
|
4512
|
+
"certain",
|
|
4513
|
+
"Next.js App/Pages API handlers, including workspace applications.",
|
|
4514
|
+
"Syntactic guards only; runtime authentication and business authorization are not verified."
|
|
4515
|
+
),
|
|
4516
|
+
rule(
|
|
4517
|
+
"api/db-write-without-auth",
|
|
4518
|
+
"Database write without a recognized auth guard",
|
|
4519
|
+
"P1",
|
|
4520
|
+
"likely",
|
|
4521
|
+
"Next.js App/Pages API handlers.",
|
|
4522
|
+
"Session-scoped clients and indirect guards may need manual review."
|
|
4523
|
+
),
|
|
4524
|
+
rule(
|
|
4525
|
+
"cors/reflected-origin-with-credentials",
|
|
4526
|
+
"Reflected origin with credentials",
|
|
4527
|
+
"P1",
|
|
4528
|
+
"certain",
|
|
4529
|
+
"Response headers and cors middleware options.",
|
|
4530
|
+
"Static pairing does not prove the deployed policy or cookie behavior."
|
|
4531
|
+
),
|
|
4532
|
+
rule(
|
|
4533
|
+
"cors/wildcard-with-credentials",
|
|
4534
|
+
"Wildcard origin with credentials",
|
|
4535
|
+
"P2",
|
|
4536
|
+
"certain",
|
|
4537
|
+
"Response headers and cors middleware options.",
|
|
4538
|
+
"Browsers reject this combination; it is not proof of data exposure."
|
|
4539
|
+
),
|
|
4540
|
+
rule(
|
|
4541
|
+
"exposure/private-name-in-public-env",
|
|
4542
|
+
"Private-looking name with a public env prefix",
|
|
4543
|
+
"P0",
|
|
4544
|
+
"likely",
|
|
4545
|
+
"Environment files and source references using recognized public prefixes.",
|
|
4546
|
+
"A variable name is not proof that its value is a secret."
|
|
4547
|
+
),
|
|
4548
|
+
rule(
|
|
4549
|
+
"exposure/secret-in-public-env",
|
|
4550
|
+
"Recognized secret in a public env variable",
|
|
4551
|
+
"P0",
|
|
4552
|
+
"certain",
|
|
4553
|
+
"Environment files using recognized public prefixes.",
|
|
4554
|
+
"Credential validity and actual deployment are not verified."
|
|
4555
|
+
),
|
|
4556
|
+
rule(
|
|
4557
|
+
"exposure/supabase-service-role-in-client",
|
|
4558
|
+
"Supabase admin credential exposed in source or public env",
|
|
4559
|
+
"P0",
|
|
4560
|
+
"certain",
|
|
4561
|
+
"Recognized service_role JWTs in source; Supabase admin credentials in public environment variables.",
|
|
4562
|
+
"Source presence is observable; actual browser delivery and key validity are not verified."
|
|
4563
|
+
),
|
|
4564
|
+
rule(
|
|
4565
|
+
"firebase/open-rules",
|
|
4566
|
+
"Unconditional Firebase access",
|
|
4567
|
+
"P1",
|
|
4568
|
+
"varies",
|
|
4569
|
+
"Firebase .rules files: unconditional writes are certain; public reads require review.",
|
|
4570
|
+
"Public reads may be intentional; runtime rules and business authorization are not verified."
|
|
4571
|
+
),
|
|
4572
|
+
rule(
|
|
4573
|
+
"firebase/test-mode-rules",
|
|
4574
|
+
"Date-based Firebase test rules",
|
|
4575
|
+
"P1",
|
|
4576
|
+
"certain",
|
|
4577
|
+
"Firebase .rules files using a fixed timestamp cutoff.",
|
|
4578
|
+
"Expired rules may deny access; deployment state is not verified."
|
|
4579
|
+
),
|
|
4580
|
+
rule(
|
|
4581
|
+
"gitleak/env-in-history",
|
|
4582
|
+
"Private env values in local Git history",
|
|
4583
|
+
"P0",
|
|
4584
|
+
"varies",
|
|
4585
|
+
"Relevant historical versions of environment files in the local repository.",
|
|
4586
|
+
"History and resource limits apply; remote refs not available locally are not checked."
|
|
4587
|
+
),
|
|
4588
|
+
rule(
|
|
4589
|
+
"gitleak/env-tracked",
|
|
4590
|
+
"Private env values tracked by Git",
|
|
4591
|
+
"P0",
|
|
4592
|
+
"varies",
|
|
4593
|
+
"Tracked environment files; templates and public-only values are treated separately.",
|
|
4594
|
+
"Unrecognized private values are heuristic findings, not verified credentials."
|
|
4595
|
+
),
|
|
4596
|
+
rule(
|
|
4597
|
+
"supabase/rls-not-enabled",
|
|
4598
|
+
"Table without RLS in migrations",
|
|
4599
|
+
"P1",
|
|
4600
|
+
"certain",
|
|
4601
|
+
"Supabase SQL migrations, replayed by application scope.",
|
|
4602
|
+
"Migration state is not deployed database state; grants and policy correctness are outside this check."
|
|
4603
|
+
),
|
|
4604
|
+
...SECRET_PATTERNS.map((pattern) => ({
|
|
4605
|
+
...rule(
|
|
4606
|
+
`secrets/hardcoded/${pattern.id}`,
|
|
4607
|
+
pattern.name,
|
|
4608
|
+
"P0",
|
|
4609
|
+
"certain",
|
|
4610
|
+
"Recognized credential formats in scanned text files.",
|
|
4611
|
+
"Validity, revocation, and use are not checked; example contexts lower confidence."
|
|
4612
|
+
),
|
|
4613
|
+
reportsFindings: !pattern.publicByDesign,
|
|
4614
|
+
...pattern.publicByDesign ? { limitation: "Treated as a public identifier; this format alone does not produce a finding." } : {}
|
|
4615
|
+
}))
|
|
4616
|
+
];
|
|
4617
|
+
function renderRuleCatalog() {
|
|
4618
|
+
return "canship rules\n\n" + RULE_CATALOG.map(
|
|
4619
|
+
(item) => `${item.id}
|
|
4620
|
+
${item.name}
|
|
4621
|
+
${item.reportsFindings ? `${item.severity}; ${item.confidence}` : "Public identifier; not reported"}
|
|
4622
|
+
Scope: ${item.scope}
|
|
4623
|
+
Limit: ${item.limitation}`
|
|
4624
|
+
).join("\n\n") + "\n\nExample and fixture contexts may lower confidence. No credential validity or deployed configuration is verified.\n";
|
|
4625
|
+
}
|
|
4626
|
+
|
|
4419
4627
|
// src/cli.ts
|
|
4420
|
-
var VERSION = true ? "0.
|
|
4628
|
+
var VERSION = true ? "0.3.1" : "0.0.0-dev";
|
|
4629
|
+
function finish(code) {
|
|
4630
|
+
process.exitCode = code;
|
|
4631
|
+
}
|
|
4632
|
+
var ArgumentError = class extends Error {
|
|
4633
|
+
};
|
|
4421
4634
|
function argumentError(message) {
|
|
4422
|
-
|
|
4423
|
-
`);
|
|
4424
|
-
process.exit(3);
|
|
4635
|
+
throw new ArgumentError(cleanForOutput(message));
|
|
4425
4636
|
}
|
|
4426
4637
|
function optionalValue(arg, name, fallback) {
|
|
4427
4638
|
if (arg === name) return fallback;
|
|
@@ -4479,8 +4690,11 @@ function parseArgs(argv) {
|
|
|
4479
4690
|
skip: [],
|
|
4480
4691
|
sarif: null,
|
|
4481
4692
|
noConfig: false,
|
|
4693
|
+
noIgnoreMarkers: false,
|
|
4482
4694
|
help: false,
|
|
4483
|
-
version: false
|
|
4695
|
+
version: false,
|
|
4696
|
+
listRules: false,
|
|
4697
|
+
noExcerpts: false
|
|
4484
4698
|
};
|
|
4485
4699
|
const positional = [];
|
|
4486
4700
|
for (const arg of argv) {
|
|
@@ -4545,6 +4759,15 @@ function parseArgs(argv) {
|
|
|
4545
4759
|
case "--no-config":
|
|
4546
4760
|
args.noConfig = true;
|
|
4547
4761
|
break;
|
|
4762
|
+
case "--no-ignore-markers":
|
|
4763
|
+
args.noIgnoreMarkers = true;
|
|
4764
|
+
break;
|
|
4765
|
+
case "--list-rules":
|
|
4766
|
+
args.listRules = true;
|
|
4767
|
+
break;
|
|
4768
|
+
case "--no-excerpts":
|
|
4769
|
+
args.noExcerpts = true;
|
|
4770
|
+
break;
|
|
4548
4771
|
case "--help":
|
|
4549
4772
|
case "-h":
|
|
4550
4773
|
args.help = true;
|
|
@@ -4587,6 +4810,11 @@ var HELP = `
|
|
|
4587
4810
|
--sarif[=F] Write a SARIF 2.1.0 log for CI code scanning
|
|
4588
4811
|
(default canship.sarif)
|
|
4589
4812
|
--no-config Ignore canship.config.json in the scanned directory
|
|
4813
|
+
--no-ignore-markers
|
|
4814
|
+
Disregard canship-ignore-file and canship-ignore-next-line
|
|
4815
|
+
markers; use with --no-config for untrusted projects
|
|
4816
|
+
--list-rules List rule IDs, scope, and limits; add --json for structured output
|
|
4817
|
+
--no-excerpts Omit source excerpts from every report; paths and descriptions remain
|
|
4590
4818
|
-h, --help Show this help
|
|
4591
4819
|
-v, --version Show version
|
|
4592
4820
|
|
|
@@ -4609,12 +4837,20 @@ async function main() {
|
|
|
4609
4837
|
if (args.help) {
|
|
4610
4838
|
process.stdout.write(`${HELP}
|
|
4611
4839
|
`);
|
|
4612
|
-
return
|
|
4840
|
+
return finish(0);
|
|
4613
4841
|
}
|
|
4614
4842
|
if (args.version) {
|
|
4615
4843
|
process.stdout.write(`${VERSION}
|
|
4616
4844
|
`);
|
|
4617
|
-
return
|
|
4845
|
+
return finish(0);
|
|
4846
|
+
}
|
|
4847
|
+
if (args.listRules) {
|
|
4848
|
+
if (process.argv.slice(2).some((arg) => arg !== "--list-rules" && arg !== "--json")) {
|
|
4849
|
+
argumentError("--list-rules only supports --json; scan options and paths cannot be combined with it");
|
|
4850
|
+
}
|
|
4851
|
+
process.stdout.write(args.json ? `${JSON.stringify({ schemaVersion: 1, kind: "rule-catalog", version: VERSION, rules: RULE_CATALOG }, null, 2)}
|
|
4852
|
+
` : renderRuleCatalog());
|
|
4853
|
+
return;
|
|
4618
4854
|
}
|
|
4619
4855
|
if (args.json && args.fixPrompt) {
|
|
4620
4856
|
argumentError("--json and --fix-prompt are mutually exclusive");
|
|
@@ -4625,7 +4861,7 @@ async function main() {
|
|
|
4625
4861
|
if (!existsSync3(args.root) || !statSync5(args.root).isDirectory()) {
|
|
4626
4862
|
process.stderr.write(`${red("canship:")} not a directory: ${cleanForOutput(args.root)}
|
|
4627
4863
|
`);
|
|
4628
|
-
return
|
|
4864
|
+
return finish(3);
|
|
4629
4865
|
}
|
|
4630
4866
|
let config;
|
|
4631
4867
|
try {
|
|
@@ -4634,7 +4870,7 @@ async function main() {
|
|
|
4634
4870
|
if (err instanceof ConfigError) {
|
|
4635
4871
|
process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
|
|
4636
4872
|
`);
|
|
4637
|
-
return
|
|
4873
|
+
return finish(3);
|
|
4638
4874
|
}
|
|
4639
4875
|
throw err;
|
|
4640
4876
|
}
|
|
@@ -4657,7 +4893,7 @@ async function main() {
|
|
|
4657
4893
|
const showAll = args.showAll || config.all === true;
|
|
4658
4894
|
const bestEffort = args.bestEffort;
|
|
4659
4895
|
const baselinePath = args.baseline !== null ? resolve2(args.baseline) : args.baselineDefault ? resolve2(args.root, DEFAULT_BASELINE_PATH) : config.baseline !== void 0 ? insideProject(args.root, config.baseline) : null;
|
|
4660
|
-
const scanned = await scan(args.root, { only, skip });
|
|
4896
|
+
const scanned = await scan(args.root, { only, skip, honorIgnoreMarkers: !args.noIgnoreMarkers });
|
|
4661
4897
|
if (args.baselineWrite !== null || args.baselineWriteDefault) {
|
|
4662
4898
|
const target = args.baselineWrite !== null ? resolve2(args.baselineWrite) : resolve2(args.root, DEFAULT_BASELINE_PATH);
|
|
4663
4899
|
const baseline = buildBaseline(scanned.findings);
|
|
@@ -4669,7 +4905,7 @@ async function main() {
|
|
|
4669
4905
|
${cleanForOutput(String(err))}
|
|
4670
4906
|
`
|
|
4671
4907
|
);
|
|
4672
|
-
return
|
|
4908
|
+
return finish(3);
|
|
4673
4909
|
}
|
|
4674
4910
|
const accepted = scanned.findings.length;
|
|
4675
4911
|
process.stdout.write(
|
|
@@ -4696,7 +4932,7 @@ ${cleanForOutput(String(err))}
|
|
|
4696
4932
|
`
|
|
4697
4933
|
);
|
|
4698
4934
|
}
|
|
4699
|
-
return
|
|
4935
|
+
return finish(0);
|
|
4700
4936
|
}
|
|
4701
4937
|
let baselineSuppressed = 0;
|
|
4702
4938
|
let baselineStale = 0;
|
|
@@ -4712,12 +4948,13 @@ ${cleanForOutput(String(err))}
|
|
|
4712
4948
|
if (err instanceof BaselineError) {
|
|
4713
4949
|
process.stderr.write(`${red("canship:")} ${cleanForOutput(err.message)}
|
|
4714
4950
|
`);
|
|
4715
|
-
return
|
|
4951
|
+
return finish(3);
|
|
4716
4952
|
}
|
|
4717
4953
|
throw err;
|
|
4718
4954
|
}
|
|
4719
4955
|
}
|
|
4720
4956
|
const displayRoot = cleanForOutput(args.root);
|
|
4957
|
+
if (args.noExcerpts) result = { ...result, findings: result.findings.map((finding) => ({ ...finding, excerpt: null })) };
|
|
4721
4958
|
const shown = showAll ? result.findings : result.findings.filter((f) => f.confidence === "certain");
|
|
4722
4959
|
const hiddenLikely = showAll ? 0 : result.findings.filter((f) => f.confidence === "likely").length;
|
|
4723
4960
|
if (args.fixPrompt) {
|
|
@@ -4737,26 +4974,14 @@ ${cleanForOutput(String(err))}
|
|
|
4737
4974
|
} else if (args.json) {
|
|
4738
4975
|
process.stdout.write(
|
|
4739
4976
|
`${JSON.stringify(
|
|
4740
|
-
{
|
|
4977
|
+
createJsonReport({ ...result, findings: shown }, {
|
|
4741
4978
|
version: VERSION,
|
|
4742
4979
|
root: displayRoot,
|
|
4743
|
-
filesScanned: result.filesScanned,
|
|
4744
|
-
durationMs: result.durationMs,
|
|
4745
|
-
// 空结果不能掩盖扫描未完成。
|
|
4746
|
-
partial: result.partial,
|
|
4747
|
-
errors: result.errors,
|
|
4748
|
-
skipped: result.skipped,
|
|
4749
|
-
ignored: result.ignored,
|
|
4750
|
-
ignoredFindings: result.ignoredFindings,
|
|
4751
|
-
ruleSelection: result.ruleSelection,
|
|
4752
|
-
vendored: result.vendored,
|
|
4753
|
-
// 隐藏详情时仍披露疑似结果数量。
|
|
4754
4980
|
hiddenLikely,
|
|
4755
|
-
// 明确披露基线抑制和过期条目数量。
|
|
4756
4981
|
baselineSuppressed,
|
|
4757
4982
|
baselineStale,
|
|
4758
|
-
|
|
4759
|
-
},
|
|
4983
|
+
excerptsOmitted: args.noExcerpts
|
|
4984
|
+
}),
|
|
4760
4985
|
null,
|
|
4761
4986
|
2
|
|
4762
4987
|
)}
|
|
@@ -4805,7 +5030,7 @@ ${cleanForOutput(String(err))}
|
|
|
4805
5030
|
${cleanForOutput(String(err))}
|
|
4806
5031
|
`
|
|
4807
5032
|
);
|
|
4808
|
-
return
|
|
5033
|
+
return finish(3);
|
|
4809
5034
|
}
|
|
4810
5035
|
}
|
|
4811
5036
|
if (args.report) {
|
|
@@ -4837,17 +5062,19 @@ ${cleanForOutput(String(err))}
|
|
|
4837
5062
|
${cleanForOutput(String(err))}
|
|
4838
5063
|
`
|
|
4839
5064
|
);
|
|
4840
|
-
return
|
|
5065
|
+
return finish(3);
|
|
4841
5066
|
}
|
|
4842
5067
|
}
|
|
4843
|
-
if (verdictOf(result.findings).blocking > 0) return
|
|
4844
|
-
if (result.findings.length > 0) return
|
|
4845
|
-
if (result.partial && !bestEffort) return
|
|
4846
|
-
return
|
|
5068
|
+
if (verdictOf(result.findings).blocking > 0) return finish(1);
|
|
5069
|
+
if (result.findings.length > 0) return finish(2);
|
|
5070
|
+
if (result.partial && !bestEffort) return finish(3);
|
|
5071
|
+
return finish(0);
|
|
4847
5072
|
}
|
|
4848
5073
|
main().catch((err) => {
|
|
4849
|
-
process.stderr.write(
|
|
5074
|
+
if (err instanceof ArgumentError) process.stderr.write(`canship: ${err.message}
|
|
5075
|
+
`);
|
|
5076
|
+
else process.stderr.write(`${red("canship: unexpected error")}
|
|
4850
5077
|
${cleanForOutput(String(err))}
|
|
4851
5078
|
`);
|
|
4852
|
-
|
|
5079
|
+
finish(3);
|
|
4853
5080
|
});
|