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/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
- let literalFrom = i;
959
- while (i < length) {
959
+ while (i < length && stack.length > 0) {
960
+ const frame = stack[stack.length - 1];
960
961
  const ch = src.charCodeAt(i);
961
- if (ch === BACKSLASH) {
962
- i += 2;
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
- blank(out, literalFrom, i);
967
- return i + 1;
999
+ stack.push({ kind: "literal", from: i + 1 });
1000
+ i++;
1001
+ continue;
968
1002
  }
969
- if (ch === DOLLAR && src.charCodeAt(i + 1) === OPEN_BRACE) {
970
- blank(out, literalFrom, i);
971
- let depth = 0;
972
- let j = i + 1;
973
- while (j < length) {
974
- const inner = src.charCodeAt(j);
975
- if (inner === SLASH) {
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
- blank(out, literalFrom, length);
1019
- return length;
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 || ch === BACKTICK) {
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 exposes your database to the browser directly, and the anon key that reaches it is public by design \u2014 it ships inside your frontend. Row Level Security is the only thing that decides who can read or write a row.`,
2106
- `No "ALTER TABLE ${renderIdent(entry.table)} ENABLE ROW LEVEL SECURITY" appears anywhere in your SQL, and new tables do not get it by default. If that is the real state, anyone who visits your site can list this entire table with a single request \u2014 and depending on your policies, write to it too.`,
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
- const name = basename5(file.path).toLowerCase();
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 who finds your project id can read every document here, overwrite it, or delete all of it. Project ids are not secret; they ship inside your frontend bundle.`
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 uses the service_role key, which bypasses every Row Level Security policy you have. Whatever your database would normally refuse, this route performs.`,
2926
- `Nothing in this file checks who is calling \u2014 no session lookup, no token check, no 401 anywhere \u2014 and no middleware covers it. The URL is not a secret either: it is spelled out by the file path, and it appears in your frontend bundle as soon as anything calls it.`,
2927
- `So a single curl to ${url} gets the same access your admin key has.`,
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 nothing in the file checks who is calling \u2014 no session lookup, no token check, no 401 anywhere.`,
2954
- `Route URLs are not secret; this one is spelled out by its file path. Anyone who sends a request can trigger the same write.`,
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*)?([^\n]+)/gi;
2972
- var CORS_ORIGIN_OPTION = /\borigin\s*:\s*(true|['"`]\*['"`])/gi;
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
- marks.push({ line, kind: classifyOrigin(m[1] ?? ""), excerpt: (file.lines[line - 1] ?? "").trim() });
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
- kind: (m[1] ?? "") === "true" ? "reflected" : "wildcard",
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 originCallbacks(content)) {
3197
+ for (const callback of callbacks) {
3141
3198
  const answer = callbackAnswer(callback.params, callback.body);
3142
- if (answer === null) continue;
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, kind: answer.kind, excerpt: (file.lines[line - 1] ?? "").trim() });
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) lines.push(lineNumberAt(contentLines, m.index));
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) lines.push(lineNumberAt(contentLines, m.index));
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 credLine of credentialLines) {
3190
- const origin = nearestOrigin(origins, credLine);
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 { files, skipped, ignored, vendored } = collectFiles(root, git2 === "repo", gitExecutable);
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((rule) => shouldRunRule(rule.id, options.only ?? [], options.skip ?? []));
3397
- const projectRules = PROJECT_RULES.filter((rule) => shouldRunRule(rule.id, options.only ?? [], options.skip ?? []));
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 rule of fileRules) {
3476
+ for (const rule2 of fileRules) {
3413
3477
  try {
3414
- if (!rule.appliesTo(file)) continue;
3415
- findings.push(...rule.check(file, ctx));
3478
+ if (!rule2.appliesTo(file)) continue;
3479
+ findings.push(...rule2.check(file, ctx));
3416
3480
  } catch (err) {
3417
- errors.push({ ruleId: rule.id, file: file.path, message: messageOf(err), kind: "crashed" });
3481
+ errors.push({ ruleId: rule2.id, file: file.path, message: messageOf(err), kind: "crashed" });
3418
3482
  }
3419
3483
  }
3420
3484
  }
3421
- for (const rule of projectRules) {
3485
+ for (const rule2 of projectRules) {
3422
3486
  try {
3423
- findings.push(...await rule.check(ctx));
3487
+ findings.push(...await rule2.check(ctx));
3424
3488
  } catch (err) {
3425
- errors.push({ ruleId: rule.id, file: null, message: messageOf(err), kind: "crashed" });
3489
+ errors.push({ ruleId: rule2.id, file: null, message: messageOf(err), kind: "crashed" });
3426
3490
  }
3427
3491
  }
3428
- const { kept, ignored: ignoredFindings } = suppressIgnoredLines(
3429
- dedupe(downgradeExampleContext(findings, files)),
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 budget = remaining.get(fingerprintOf(f)) ?? 0;
4253
+ const fp = fingerprintOf(f);
4254
+ const budget = remaining.get(fp) ?? 0;
4192
4255
  if (budget > 0) {
4193
- remaining.set(fingerprintOf(f), budget - 1);
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.2.1" : "0.0.0-dev";
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
- process.stderr.write(`canship: ${cleanForOutput(message)}
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 process.exit(0);
4840
+ return finish(0);
4613
4841
  }
4614
4842
  if (args.version) {
4615
4843
  process.stdout.write(`${VERSION}
4616
4844
  `);
4617
- return process.exit(0);
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 process.exit(3);
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 process.exit(3);
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 process.exit(3);
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 process.exit(0);
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 process.exit(3);
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
- findings: shown
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 process.exit(3);
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 process.exit(3);
5065
+ return finish(3);
4841
5066
  }
4842
5067
  }
4843
- if (verdictOf(result.findings).blocking > 0) return process.exit(1);
4844
- if (result.findings.length > 0) return process.exit(2);
4845
- if (result.partial && !bestEffort) return process.exit(3);
4846
- return process.exit(0);
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(`${red("canship: unexpected error")}
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
- process.exit(3);
5079
+ finish(3);
4853
5080
  });