@sphior/scan 0.1.5 → 0.1.6

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.
Files changed (3) hide show
  1. package/dist/cli.js +283 -19
  2. package/dist/server.cjs +170 -8
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -14650,6 +14650,30 @@ function shannonEntropy(s) {
14650
14650
  }
14651
14651
  return h;
14652
14652
  }
14653
+ var PLACEHOLDER_MARKERS = [
14654
+ "your",
14655
+ "here",
14656
+ "example",
14657
+ "sample",
14658
+ "placeholder",
14659
+ "dummy",
14660
+ "changeme",
14661
+ "change_me",
14662
+ "hardcoded",
14663
+ "redacted",
14664
+ "insert",
14665
+ "replace",
14666
+ "todo",
14667
+ "fixme",
14668
+ "xxxx",
14669
+ "...",
14670
+ "<",
14671
+ ">"
14672
+ ];
14673
+ function looksLikePlaceholder(s) {
14674
+ const lower = s.toLowerCase();
14675
+ return PLACEHOLDER_MARKERS.some((m) => lower.includes(m));
14676
+ }
14653
14677
  function looksHighEntropy(s, minLen = 16, minEntropy = 3.5) {
14654
14678
  if (s.length < minLen) return false;
14655
14679
  if (!/^[A-Za-z0-9+/=_\-.]+$/.test(s)) return false;
@@ -14796,7 +14820,20 @@ var SECRET_RULES = [
14796
14820
  provider: "PEM",
14797
14821
  title: "Private Key",
14798
14822
  severity: "critical",
14799
- pattern: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----)/g,
14823
+ // PEM ヘッダ**単体**では鍵ではない。実測(自社リポジトリ 1 回の走査)で
14824
+ // secret.private_key は 6 件すべて誤検知だった:
14825
+ // - 日本語コメント中の「-----BEGIN RSA PRIVATE KEY----- で配布される」という説明
14826
+ // - `.replace(/-----BEGIN PRIVATE KEY-----/g, '')` という除去コード
14827
+ // - `const PEM = "-----BEGIN RSA PRIVATE KEY-----"` 等のテストベクタ(本体なし)
14828
+ // - ヘッダ + `btoa(...)` のようにコードで本体を作る箇所
14829
+ // severity=critical のルールが 6/6 外れると、本物(この走査で実際に見つかった
14830
+ // 本番 Stripe キー等)が埋まる。そこで「ヘッダの直後に base64 の鍵本体が続く」
14831
+ // ことを先読みで要求する。
14832
+ //
14833
+ // 先読み(キャプチャしない)にしてある点が重要: group 1 はヘッダのままなので
14834
+ // snippet_hash と fingerprint が変わらず、既存の真検出の delta 追跡が途切れない。
14835
+ // 区切りは実鍵の書かれ方に合わせる — 生改行・`\n` のエスケープ列・引用符・カンマ。
14836
+ pattern: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----)(?=(?:\s|\\+[nrt]|["'`,;+]){0,40}[A-Za-z0-9+/]{40,})/g,
14800
14837
  group: 1,
14801
14838
  references: REF_CWE798
14802
14839
  },
@@ -15053,7 +15090,7 @@ function detectSecrets(path, text, opts = {}) {
15053
15090
  const g = rule.group ?? 0;
15054
15091
  const secret = m[g];
15055
15092
  if (!secret) continue;
15056
- if (PLACEHOLDER_SECRETS.has(secret)) continue;
15093
+ if (PLACEHOLDER_SECRETS.has(secret) || looksLikePlaceholder(secret)) continue;
15057
15094
  const absStart = m.index + m[0].indexOf(secret);
15058
15095
  push(build(path, rule, secret, absStart, starts, false));
15059
15096
  if (re.lastIndex === m.index) re.lastIndex++;
@@ -15064,7 +15101,7 @@ function detectSecrets(path, text, opts = {}) {
15064
15101
  let m;
15065
15102
  while ((m = re.exec(text)) !== null) {
15066
15103
  const secret = m[1];
15067
- if (!secret || PLACEHOLDER_SECRETS.has(secret)) continue;
15104
+ if (!secret || PLACEHOLDER_SECRETS.has(secret) || looksLikePlaceholder(secret)) continue;
15068
15105
  if (!looksHighEntropy(secret)) continue;
15069
15106
  const absStart = m.index + m[0].lastIndexOf(secret);
15070
15107
  push(build(path, GENERIC_ASSIGNMENT, secret, absStart, starts, true));
@@ -16460,6 +16497,7 @@ var SOURCE_CALL_SUFFIXES = [
16460
16497
  "formData.get",
16461
16498
  "urlParams.get"
16462
16499
  ];
16500
+ var TAINT_WRAPPER_CTORS = /* @__PURE__ */ new Set(["URLSearchParams", "URL"]);
16463
16501
  var SCOPE_TYPES = /* @__PURE__ */ new Set([
16464
16502
  "FunctionDeclaration",
16465
16503
  "FunctionExpression",
@@ -16517,6 +16555,11 @@ function exprIsTainted(node, tainted) {
16517
16555
  return exprIsTainted(node.left, tainted) || exprIsTainted(node.right, tainted);
16518
16556
  case "ConditionalExpression":
16519
16557
  return exprIsTainted(node.consequent, tainted) || exprIsTainted(node.alternate, tainted);
16558
+ case "NewExpression": {
16559
+ const ctor = calleeName(node);
16560
+ if (!ctor || !TAINT_WRAPPER_CTORS.has(ctor)) return false;
16561
+ return (node.arguments ?? []).some((a) => exprIsTainted(a, tainted));
16562
+ }
16520
16563
  case "ParenthesizedExpression":
16521
16564
  case "TSAsExpression":
16522
16565
  case "TSNonNullExpression":
@@ -16775,6 +16818,11 @@ function inLineComment(text, lineStart, matchIndex, isPython) {
16775
16818
  const quotes = (upto.match(/["'`]/g) ?? []).length;
16776
16819
  return quotes % 2 === 0;
16777
16820
  }
16821
+ function isBlockCommentLine(text, lineStart) {
16822
+ const lineEnd = text.indexOf("\n", lineStart);
16823
+ const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd).trimStart();
16824
+ return line.startsWith("/*") || /^\*(\s|\/|$)/.test(line);
16825
+ }
16778
16826
  function detectPatterns(path, text, opts = {}) {
16779
16827
  if (opts.ignorePaths?.some((re) => re.test(path))) return [];
16780
16828
  const ext = extOf(path);
@@ -16818,7 +16866,7 @@ function detectPatterns(path, text, opts = {}) {
16818
16866
  while ((m = re.exec(text)) !== null) {
16819
16867
  const idx = m.index;
16820
16868
  const pos = toPos2(starts, idx);
16821
- if (inLineComment(text, pos.lineStart, idx, isPython)) {
16869
+ if (inLineComment(text, pos.lineStart, idx, isPython) || isBlockCommentLine(text, pos.lineStart)) {
16822
16870
  if (re.lastIndex === idx) re.lastIndex++;
16823
16871
  continue;
16824
16872
  }
@@ -16914,7 +16962,160 @@ function scaToCanonical(input) {
16914
16962
 
16915
16963
  // src/engine/version.ts
16916
16964
  var ENGINE_VERSION = "0.1.0";
16917
- var RULESET_VERSION = "2026.07.12-22";
16965
+ var RULESET_VERSION = "2026.10.20-23";
16966
+
16967
+ // src/engine/suppress.ts
16968
+ var EMPTY_CONFIG = { excludes: [], scoped: [], problems: [] };
16969
+ var SPHIORIGNORE_FILE = ".sphiorignore";
16970
+ function escapeRe(s) {
16971
+ return s.replace(/[.+^$(){}|[\]\\]/g, "\\$&");
16972
+ }
16973
+ function globToRegExp(glob) {
16974
+ let out2 = "";
16975
+ for (let i2 = 0; i2 < glob.length; i2++) {
16976
+ const c = glob[i2];
16977
+ if (c === "*") {
16978
+ if (glob[i2 + 1] === "*") {
16979
+ if (glob[i2 + 2] === "/") {
16980
+ out2 += "(?:[^/]+/)*";
16981
+ i2 += 2;
16982
+ } else {
16983
+ out2 += ".*";
16984
+ i2 += 1;
16985
+ }
16986
+ } else {
16987
+ out2 += "[^/]*";
16988
+ }
16989
+ continue;
16990
+ }
16991
+ if (c === "?") {
16992
+ out2 += "[^/]";
16993
+ continue;
16994
+ }
16995
+ out2 += escapeRe(c);
16996
+ }
16997
+ return new RegExp(`^${out2}$`);
16998
+ }
16999
+ function globMatches(glob, relPath) {
17000
+ const g = glob.endsWith("/") ? `${glob.slice(0, -1)}` : glob;
17001
+ if (globToRegExp(g).test(relPath)) return true;
17002
+ return globToRegExp(`${g}/**`).test(relPath);
17003
+ }
17004
+ function ruleMatches(pattern, ruleId) {
17005
+ if (pattern.endsWith("*")) return ruleId.startsWith(pattern.slice(0, -1));
17006
+ return pattern === ruleId;
17007
+ }
17008
+ function looksLikeRuleId(tok) {
17009
+ return /^[A-Za-z][A-Za-z0-9_.-]*\*?$/.test(tok) && !tok.includes("/");
17010
+ }
17011
+ function parseSphiorignore(text) {
17012
+ const excludes = [];
17013
+ const scoped = [];
17014
+ const problems = [];
17015
+ const lines = text.split(/\r?\n/);
17016
+ for (let i2 = 0; i2 < lines.length; i2++) {
17017
+ const raw = lines[i2];
17018
+ const lineNo = i2 + 1;
17019
+ if (!raw.trim() || raw.trim().startsWith("#")) continue;
17020
+ const hashAt = raw.indexOf("#");
17021
+ const body2 = (hashAt >= 0 ? raw.slice(0, hashAt) : raw).trim();
17022
+ const justification = hashAt >= 0 ? raw.slice(hashAt + 1).trim() || void 0 : void 0;
17023
+ if (!body2) continue;
17024
+ const tokens = body2.split(/\s+/);
17025
+ if (tokens.length > 2) {
17026
+ problems.push({ line: lineNo, text: body2, reason: "expected `<glob> [rule_id]` (too many fields)" });
17027
+ continue;
17028
+ }
17029
+ const [glob, ruleId] = tokens;
17030
+ if (glob.startsWith("/")) {
17031
+ problems.push({ line: lineNo, text: body2, reason: "paths are relative to the scan root \u2014 drop the leading `/`" });
17032
+ continue;
17033
+ }
17034
+ if (ruleId !== void 0 && !looksLikeRuleId(ruleId)) {
17035
+ problems.push({ line: lineNo, text: body2, reason: `not a rule id: ${ruleId}` });
17036
+ continue;
17037
+ }
17038
+ const entry = { line: lineNo, glob, ruleId, justification };
17039
+ (ruleId === void 0 ? excludes : scoped).push(entry);
17040
+ }
17041
+ return { excludes, scoped, problems };
17042
+ }
17043
+ function makePathExcluder(cfg) {
17044
+ if (cfg.excludes.length === 0) return () => false;
17045
+ return (relPath) => cfg.excludes.some((e) => globMatches(e.glob, relPath));
17046
+ }
17047
+ function configSuppressionFor(cfg, path, ruleId) {
17048
+ return cfg.scoped.find((e) => e.ruleId && ruleMatches(e.ruleId, ruleId) && globMatches(e.glob, path));
17049
+ }
17050
+ var INLINE_RE = /sphior-ignore(-next-line)?(?:[ \t]+([A-Za-z][A-Za-z0-9_.-]*\*?))?(?:[ \t]*:[ \t]*(.*))?$/;
17051
+ function parseInlineSuppression(lineText) {
17052
+ const m = INLINE_RE.exec(lineText);
17053
+ if (!m) return void 0;
17054
+ return {
17055
+ nextLine: m[1] === "-next-line",
17056
+ ruleId: m[2],
17057
+ justification: m[3]?.trim() || void 0
17058
+ };
17059
+ }
17060
+ function inlineSuppressionFor(lines, startLine, ruleId) {
17061
+ const own = lines[startLine - 1] !== void 0 ? parseInlineSuppression(lines[startLine - 1]) : void 0;
17062
+ if (own && !own.nextLine && (!own.ruleId || ruleMatches(own.ruleId, ruleId))) return own;
17063
+ const prev = startLine >= 2 && lines[startLine - 2] !== void 0 ? parseInlineSuppression(lines[startLine - 2]) : void 0;
17064
+ if (prev && prev.nextLine && (!prev.ruleId || ruleMatches(prev.ruleId, ruleId))) return prev;
17065
+ return void 0;
17066
+ }
17067
+ function applySuppressions(findings, input = {}) {
17068
+ const cfg = input.config;
17069
+ const hasConfig = !!cfg && cfg.scoped.length > 0;
17070
+ const hasText = !!input.textByPath && input.textByPath.size > 0;
17071
+ if (!hasConfig && !hasText) return { active: findings, suppressed: [] };
17072
+ const linesCache = /* @__PURE__ */ new Map();
17073
+ const linesOf = (path) => {
17074
+ const cached = linesCache.get(path);
17075
+ if (cached) return cached;
17076
+ const text = input.textByPath?.get(path);
17077
+ if (text === void 0) return void 0;
17078
+ const lines = text.split(/\r?\n/);
17079
+ linesCache.set(path, lines);
17080
+ return lines;
17081
+ };
17082
+ const active = [];
17083
+ const suppressed = [];
17084
+ for (const f of findings) {
17085
+ const byConfig = hasConfig ? configSuppressionFor(cfg, f.location.path, f.rule_id) : void 0;
17086
+ if (byConfig) {
17087
+ suppressed.push({
17088
+ ...f,
17089
+ suppression: {
17090
+ by: "config",
17091
+ rule_scoped: true,
17092
+ justification: byConfig.justification,
17093
+ source: `${SPHIORIGNORE_FILE}:${byConfig.line}`
17094
+ }
17095
+ });
17096
+ continue;
17097
+ }
17098
+ const lines = linesOf(f.location.path);
17099
+ const byInline = lines ? inlineSuppressionFor(lines, f.location.start_line, f.rule_id) : void 0;
17100
+ if (byInline) {
17101
+ suppressed.push({
17102
+ ...f,
17103
+ suppression: {
17104
+ by: "inline",
17105
+ rule_scoped: !!byInline.ruleId,
17106
+ justification: byInline.justification,
17107
+ source: `${f.location.path}:${byInline.nextLine ? f.location.start_line - 1 : f.location.start_line}`
17108
+ }
17109
+ });
17110
+ continue;
17111
+ }
17112
+ active.push(f);
17113
+ }
17114
+ return { active, suppressed };
17115
+ }
17116
+ function countWithoutJustification(suppressed) {
17117
+ return suppressed.reduce((n, f) => f.suppression && !f.suppression.justification ? n + 1 : n, 0);
17118
+ }
16918
17119
 
16919
17120
  // src/engine/run.ts
16920
17121
  var kindOrder = {
@@ -16926,43 +17127,79 @@ var kindOrder = {
16926
17127
  function runScan(input) {
16927
17128
  const files = input.files ?? [];
16928
17129
  const opts = { allowFingerprints: input.allowFingerprints, ignorePaths: input.ignorePaths };
16929
- const findings = [
17130
+ const detected = [
16930
17131
  ...detectSecretsInFiles(files, opts),
16931
17132
  ...detectPatternsInFiles(files, opts),
16932
17133
  ...scaToCanonical(input.sca ?? [])
16933
17134
  ];
16934
- findings.sort((a, b) => {
17135
+ detected.sort((a, b) => {
16935
17136
  if (a.location.path !== b.location.path) return a.location.path < b.location.path ? -1 : 1;
16936
17137
  if (a.location.start_line !== b.location.start_line) return a.location.start_line - b.location.start_line;
16937
17138
  if (a.location.start_col !== b.location.start_col) return a.location.start_col - b.location.start_col;
16938
17139
  if (kindOrder[a.kind] !== kindOrder[b.kind]) return kindOrder[a.kind] - kindOrder[b.kind];
16939
17140
  return a.rule_id < b.rule_id ? -1 : a.rule_id > b.rule_id ? 1 : 0;
16940
17141
  });
17142
+ const { active: findings, suppressed } = applySuppressions(detected, {
17143
+ config: input.suppress,
17144
+ textByPath: new Map(files.map((f) => [f.path, f.text]))
17145
+ });
16941
17146
  const sarif = toSarif(findings, { engineVersion: ENGINE_VERSION, rulesetVersion: RULESET_VERSION });
16942
- return { findings, sarif, engine_version: ENGINE_VERSION, ruleset_version: RULESET_VERSION };
17147
+ return { findings, suppressed, sarif, engine_version: ENGINE_VERSION, ruleset_version: RULESET_VERSION };
16943
17148
  }
16944
17149
 
16945
17150
  // src/engine/collect.ts
16946
17151
  import { readdirSync, statSync, readFileSync } from "node:fs";
16947
17152
  import { join, relative, sep } from "node:path";
16948
17153
  var IGNORE_DIRS = /* @__PURE__ */ new Set([
17154
+ // VCS
16949
17155
  ".git",
17156
+ ".hg",
17157
+ ".svn",
17158
+ // JS/TS の依存と出力
16950
17159
  "node_modules",
17160
+ "bower_components",
17161
+ ".yarn",
17162
+ ".pnp",
17163
+ ".pnpm-store",
16951
17164
  "dist",
16952
17165
  "build",
16953
- ".next",
16954
17166
  "out",
16955
17167
  "coverage",
16956
- "vendor",
17168
+ ".cache",
17169
+ ".turbo",
17170
+ ".swc",
17171
+ ".parcel-cache",
17172
+ // フレームワーク/デプロイのビルド出力
17173
+ ".next",
17174
+ ".open-next",
17175
+ ".vercel",
17176
+ ".wrangler",
17177
+ ".serverless",
17178
+ ".sst",
17179
+ ".svelte-kit",
17180
+ ".nuxt",
17181
+ ".output",
17182
+ ".astro",
17183
+ ".docusaurus",
17184
+ // Python
16957
17185
  ".venv",
16958
17186
  "venv",
16959
17187
  "__pycache__",
17188
+ ".mypy_cache",
17189
+ ".pytest_cache",
17190
+ ".ruff_cache",
17191
+ ".tox",
17192
+ ".nox",
17193
+ ".eggs",
17194
+ // JVM / その他言語
16960
17195
  "target",
16961
- ".turbo",
16962
- ".cache",
16963
- ".yarn",
16964
- ".pnp",
16965
- "bower_components"
17196
+ ".gradle",
17197
+ "vendor",
17198
+ "Pods",
17199
+ ".dart_tool",
17200
+ ".pub-cache",
17201
+ // IaC のプロバイダバイナリ(`.tf` 本体は配下に無いので検査対象は失わない)
17202
+ ".terraform"
16966
17203
  ]);
16967
17204
  function toPosix(p) {
16968
17205
  return sep === "/" ? p : p.split(sep).join("/");
@@ -16993,9 +17230,11 @@ function collectSourceFiles(rootDir, opts = {}) {
16993
17230
  }
16994
17231
  if (st.isDirectory()) {
16995
17232
  if (ignore.has(name2)) continue;
17233
+ if (opts.exclude?.(toPosix(relative(rootDir, full)))) continue;
16996
17234
  walk2(full);
16997
17235
  } else if (st.isFile()) {
16998
17236
  if (st.size > maxBytes) continue;
17237
+ if (opts.exclude?.(toPosix(relative(rootDir, full)))) continue;
16999
17238
  let buf;
17000
17239
  try {
17001
17240
  buf = readFileSync(full);
@@ -17011,6 +17250,13 @@ function collectSourceFiles(rootDir, opts = {}) {
17011
17250
  files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
17012
17251
  return files;
17013
17252
  }
17253
+ function readSphiorignore(rootDir) {
17254
+ try {
17255
+ return parseSphiorignore(readFileSync(join(rootDir, SPHIORIGNORE_FILE), "utf8"));
17256
+ } catch {
17257
+ return EMPTY_CONFIG;
17258
+ }
17259
+ }
17014
17260
 
17015
17261
  // src/engine/cli.ts
17016
17262
  import { existsSync, statSync as statSync2, writeFileSync } from "node:fs";
@@ -17165,7 +17411,7 @@ function runCli(argv, ctx = {}) {
17165
17411
  const writeFile = ctx.writeFile ?? ((p, d) => writeFileSync(p, d));
17166
17412
  const [cmd, dir, ...rest] = argv;
17167
17413
  if (cmd !== "run" || !dir) {
17168
- logErr("usage: sphior-scan run <dir> [--sarif out.sarif] [--json out.json] [--fix-context out.json] [--fail-on high]");
17414
+ logErr("usage: sphior-scan run <dir> [--sarif out.sarif] [--json out.json] [--fix-context out.json] [--suppressed out.json] [--fail-on high] [--no-ignore-file]");
17169
17415
  return { exitCode: 2, error: "bad_args" };
17170
17416
  }
17171
17417
  const flags2 = parseFlags(rest);
@@ -17174,11 +17420,23 @@ function runCli(argv, ctx = {}) {
17174
17420
  logErr(" Nothing was scanned, so this is not a clean result. Check the path.");
17175
17421
  return { exitCode: 2, error: "dir_not_found" };
17176
17422
  }
17177
- const files = collectSourceFiles(dir);
17178
- const result = runScan({ files });
17423
+ const ignoreCfg = flags2["no-ignore-file"] ? EMPTY_CONFIG : readSphiorignore(dir);
17424
+ const isExcluded = makePathExcluder(ignoreCfg);
17425
+ let excludedCount = 0;
17426
+ const exclude = (relPath) => {
17427
+ if (!isExcluded(relPath)) return false;
17428
+ excludedCount++;
17429
+ return true;
17430
+ };
17431
+ for (const p of ignoreCfg.problems) {
17432
+ logErr(`warning: ${SPHIORIGNORE_FILE}:${p.line}: ${p.reason} \u2014 ignored this line (\u201C${p.text}\u201D)`);
17433
+ }
17434
+ const files = collectSourceFiles(dir, { exclude });
17435
+ const result = runScan({ files, suppress: ignoreCfg });
17179
17436
  const sarifJson = canonicalJson(result.sarif);
17180
17437
  if (flags2.sarif) writeFile(flags2.sarif, sarifJson);
17181
17438
  if (flags2.json) writeFile(flags2.json, canonicalJson(result.findings));
17439
+ if (flags2.suppressed) writeFile(flags2.suppressed, canonicalJson(result.suppressed));
17182
17440
  if (flags2["fix-context"]) writeFile(flags2["fix-context"], canonicalJson(buildFixContexts(result.findings)));
17183
17441
  if (!flags2.sarif && !flags2.json && !flags2["fix-context"]) {
17184
17442
  if (ctx.isTty) {
@@ -17201,14 +17459,20 @@ function runCli(argv, ctx = {}) {
17201
17459
  acc[f.severity] = (acc[f.severity] ?? 0) + 1;
17202
17460
  return acc;
17203
17461
  }, {});
17462
+ const scope = (excludedCount > 0 ? ` ${excludedCount} files excluded` : "") + (result.suppressed.length > 0 ? `${excludedCount > 0 ? "," : ""} ${result.suppressed.length} suppressed` : "");
17204
17463
  logErr(
17205
- `SPHIOR CODE: ${result.findings.length} findings (critical=${counts.critical ?? 0} high=${counts.high ?? 0} medium=${counts.medium ?? 0} low=${counts.low ?? 0}) engine=${result.engine_version} ruleset=${result.ruleset_version}`
17464
+ `SPHIOR CODE: ${result.findings.length} findings (critical=${counts.critical ?? 0} high=${counts.high ?? 0} medium=${counts.medium ?? 0} low=${counts.low ?? 0}) ${files.length} files scanned${scope ? ` \u2022${scope}` : ""} engine=${result.engine_version} ruleset=${result.ruleset_version}`
17206
17465
  );
17466
+ const unexplained = countWithoutJustification(result.suppressed);
17467
+ if (unexplained > 0) {
17468
+ logErr(` note: ${unexplained} of the suppressions carry no reason. Add \`: why\` so an auditor can read it later.`);
17469
+ }
17207
17470
  if (ctx.isTty && !flags2.sarif && !flags2.json && !flags2["fix-context"]) {
17208
17471
  logErr("");
17209
17472
  logErr(" --fix-context fc.json hand findings to an AI agent to fix");
17210
17473
  logErr(" --sarif out.sarif for GitHub code scanning / CI dashboards");
17211
17474
  logErr(" --fail-on high exit 1 if anything at or above this severity (CI)");
17475
+ logErr(` ${SPHIORIGNORE_FILE} exclude paths, or accept a rule with a reason`);
17212
17476
  }
17213
17477
  let exitCode = 0;
17214
17478
  const failOn = flags2["fail-on"];
package/dist/server.cjs CHANGED
@@ -23727,6 +23727,30 @@ function shannonEntropy(s) {
23727
23727
  }
23728
23728
  return h;
23729
23729
  }
23730
+ var PLACEHOLDER_MARKERS = [
23731
+ "your",
23732
+ "here",
23733
+ "example",
23734
+ "sample",
23735
+ "placeholder",
23736
+ "dummy",
23737
+ "changeme",
23738
+ "change_me",
23739
+ "hardcoded",
23740
+ "redacted",
23741
+ "insert",
23742
+ "replace",
23743
+ "todo",
23744
+ "fixme",
23745
+ "xxxx",
23746
+ "...",
23747
+ "<",
23748
+ ">"
23749
+ ];
23750
+ function looksLikePlaceholder(s) {
23751
+ const lower = s.toLowerCase();
23752
+ return PLACEHOLDER_MARKERS.some((m) => lower.includes(m));
23753
+ }
23730
23754
  function looksHighEntropy(s, minLen = 16, minEntropy = 3.5) {
23731
23755
  if (s.length < minLen) return false;
23732
23756
  if (!/^[A-Za-z0-9+/=_\-.]+$/.test(s)) return false;
@@ -23873,7 +23897,20 @@ var SECRET_RULES = [
23873
23897
  provider: "PEM",
23874
23898
  title: "Private Key",
23875
23899
  severity: "critical",
23876
- pattern: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----)/g,
23900
+ // PEM ヘッダ**単体**では鍵ではない。実測(自社リポジトリ 1 回の走査)で
23901
+ // secret.private_key は 6 件すべて誤検知だった:
23902
+ // - 日本語コメント中の「-----BEGIN RSA PRIVATE KEY----- で配布される」という説明
23903
+ // - `.replace(/-----BEGIN PRIVATE KEY-----/g, '')` という除去コード
23904
+ // - `const PEM = "-----BEGIN RSA PRIVATE KEY-----"` 等のテストベクタ(本体なし)
23905
+ // - ヘッダ + `btoa(...)` のようにコードで本体を作る箇所
23906
+ // severity=critical のルールが 6/6 外れると、本物(この走査で実際に見つかった
23907
+ // 本番 Stripe キー等)が埋まる。そこで「ヘッダの直後に base64 の鍵本体が続く」
23908
+ // ことを先読みで要求する。
23909
+ //
23910
+ // 先読み(キャプチャしない)にしてある点が重要: group 1 はヘッダのままなので
23911
+ // snippet_hash と fingerprint が変わらず、既存の真検出の delta 追跡が途切れない。
23912
+ // 区切りは実鍵の書かれ方に合わせる — 生改行・`\n` のエスケープ列・引用符・カンマ。
23913
+ pattern: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----)(?=(?:\s|\\+[nrt]|["'`,;+]){0,40}[A-Za-z0-9+/]{40,})/g,
23877
23914
  group: 1,
23878
23915
  references: REF_CWE798
23879
23916
  },
@@ -24130,7 +24167,7 @@ function detectSecrets(path, text, opts = {}) {
24130
24167
  const g = rule.group ?? 0;
24131
24168
  const secret = m[g];
24132
24169
  if (!secret) continue;
24133
- if (PLACEHOLDER_SECRETS.has(secret)) continue;
24170
+ if (PLACEHOLDER_SECRETS.has(secret) || looksLikePlaceholder(secret)) continue;
24134
24171
  const absStart = m.index + m[0].indexOf(secret);
24135
24172
  push(build(path, rule, secret, absStart, starts, false));
24136
24173
  if (re.lastIndex === m.index) re.lastIndex++;
@@ -24141,7 +24178,7 @@ function detectSecrets(path, text, opts = {}) {
24141
24178
  let m;
24142
24179
  while ((m = re.exec(text)) !== null) {
24143
24180
  const secret = m[1];
24144
- if (!secret || PLACEHOLDER_SECRETS.has(secret)) continue;
24181
+ if (!secret || PLACEHOLDER_SECRETS.has(secret) || looksLikePlaceholder(secret)) continue;
24145
24182
  if (!looksHighEntropy(secret)) continue;
24146
24183
  const absStart = m.index + m[0].lastIndexOf(secret);
24147
24184
  push(build(path, GENERIC_ASSIGNMENT, secret, absStart, starts, true));
@@ -25537,6 +25574,7 @@ var SOURCE_CALL_SUFFIXES = [
25537
25574
  "formData.get",
25538
25575
  "urlParams.get"
25539
25576
  ];
25577
+ var TAINT_WRAPPER_CTORS = /* @__PURE__ */ new Set(["URLSearchParams", "URL"]);
25540
25578
  var SCOPE_TYPES = /* @__PURE__ */ new Set([
25541
25579
  "FunctionDeclaration",
25542
25580
  "FunctionExpression",
@@ -25594,6 +25632,11 @@ function exprIsTainted(node, tainted) {
25594
25632
  return exprIsTainted(node.left, tainted) || exprIsTainted(node.right, tainted);
25595
25633
  case "ConditionalExpression":
25596
25634
  return exprIsTainted(node.consequent, tainted) || exprIsTainted(node.alternate, tainted);
25635
+ case "NewExpression": {
25636
+ const ctor = calleeName(node);
25637
+ if (!ctor || !TAINT_WRAPPER_CTORS.has(ctor)) return false;
25638
+ return (node.arguments ?? []).some((a) => exprIsTainted(a, tainted));
25639
+ }
25597
25640
  case "ParenthesizedExpression":
25598
25641
  case "TSAsExpression":
25599
25642
  case "TSNonNullExpression":
@@ -25852,6 +25895,11 @@ function inLineComment(text, lineStart, matchIndex, isPython) {
25852
25895
  const quotes = (upto.match(/["'`]/g) ?? []).length;
25853
25896
  return quotes % 2 === 0;
25854
25897
  }
25898
+ function isBlockCommentLine(text, lineStart) {
25899
+ const lineEnd = text.indexOf("\n", lineStart);
25900
+ const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd).trimStart();
25901
+ return line.startsWith("/*") || /^\*(\s|\/|$)/.test(line);
25902
+ }
25855
25903
  function detectPatterns(path, text, opts = {}) {
25856
25904
  if (opts.ignorePaths?.some((re) => re.test(path))) return [];
25857
25905
  const ext = extOf(path);
@@ -25895,7 +25943,7 @@ function detectPatterns(path, text, opts = {}) {
25895
25943
  while ((m = re.exec(text)) !== null) {
25896
25944
  const idx = m.index;
25897
25945
  const pos = toPos2(starts, idx);
25898
- if (inLineComment(text, pos.lineStart, idx, isPython)) {
25946
+ if (inLineComment(text, pos.lineStart, idx, isPython) || isBlockCommentLine(text, pos.lineStart)) {
25899
25947
  if (re.lastIndex === idx) re.lastIndex++;
25900
25948
  continue;
25901
25949
  }
@@ -25991,7 +26039,117 @@ function scaToCanonical(input) {
25991
26039
 
25992
26040
  // src/engine/version.ts
25993
26041
  var ENGINE_VERSION = "0.1.0";
25994
- var RULESET_VERSION = "2026.07.12-22";
26042
+ var RULESET_VERSION = "2026.10.20-23";
26043
+
26044
+ // src/engine/suppress.ts
26045
+ var SPHIORIGNORE_FILE = ".sphiorignore";
26046
+ function escapeRe(s) {
26047
+ return s.replace(/[.+^$(){}|[\]\\]/g, "\\$&");
26048
+ }
26049
+ function globToRegExp(glob) {
26050
+ let out2 = "";
26051
+ for (let i2 = 0; i2 < glob.length; i2++) {
26052
+ const c = glob[i2];
26053
+ if (c === "*") {
26054
+ if (glob[i2 + 1] === "*") {
26055
+ if (glob[i2 + 2] === "/") {
26056
+ out2 += "(?:[^/]+/)*";
26057
+ i2 += 2;
26058
+ } else {
26059
+ out2 += ".*";
26060
+ i2 += 1;
26061
+ }
26062
+ } else {
26063
+ out2 += "[^/]*";
26064
+ }
26065
+ continue;
26066
+ }
26067
+ if (c === "?") {
26068
+ out2 += "[^/]";
26069
+ continue;
26070
+ }
26071
+ out2 += escapeRe(c);
26072
+ }
26073
+ return new RegExp(`^${out2}$`);
26074
+ }
26075
+ function globMatches(glob, relPath) {
26076
+ const g = glob.endsWith("/") ? `${glob.slice(0, -1)}` : glob;
26077
+ if (globToRegExp(g).test(relPath)) return true;
26078
+ return globToRegExp(`${g}/**`).test(relPath);
26079
+ }
26080
+ function ruleMatches(pattern, ruleId) {
26081
+ if (pattern.endsWith("*")) return ruleId.startsWith(pattern.slice(0, -1));
26082
+ return pattern === ruleId;
26083
+ }
26084
+ function configSuppressionFor(cfg, path, ruleId) {
26085
+ return cfg.scoped.find((e) => e.ruleId && ruleMatches(e.ruleId, ruleId) && globMatches(e.glob, path));
26086
+ }
26087
+ var INLINE_RE = /sphior-ignore(-next-line)?(?:[ \t]+([A-Za-z][A-Za-z0-9_.-]*\*?))?(?:[ \t]*:[ \t]*(.*))?$/;
26088
+ function parseInlineSuppression(lineText) {
26089
+ const m = INLINE_RE.exec(lineText);
26090
+ if (!m) return void 0;
26091
+ return {
26092
+ nextLine: m[1] === "-next-line",
26093
+ ruleId: m[2],
26094
+ justification: m[3]?.trim() || void 0
26095
+ };
26096
+ }
26097
+ function inlineSuppressionFor(lines, startLine, ruleId) {
26098
+ const own = lines[startLine - 1] !== void 0 ? parseInlineSuppression(lines[startLine - 1]) : void 0;
26099
+ if (own && !own.nextLine && (!own.ruleId || ruleMatches(own.ruleId, ruleId))) return own;
26100
+ const prev = startLine >= 2 && lines[startLine - 2] !== void 0 ? parseInlineSuppression(lines[startLine - 2]) : void 0;
26101
+ if (prev && prev.nextLine && (!prev.ruleId || ruleMatches(prev.ruleId, ruleId))) return prev;
26102
+ return void 0;
26103
+ }
26104
+ function applySuppressions(findings, input = {}) {
26105
+ const cfg = input.config;
26106
+ const hasConfig = !!cfg && cfg.scoped.length > 0;
26107
+ const hasText = !!input.textByPath && input.textByPath.size > 0;
26108
+ if (!hasConfig && !hasText) return { active: findings, suppressed: [] };
26109
+ const linesCache = /* @__PURE__ */ new Map();
26110
+ const linesOf = (path) => {
26111
+ const cached = linesCache.get(path);
26112
+ if (cached) return cached;
26113
+ const text = input.textByPath?.get(path);
26114
+ if (text === void 0) return void 0;
26115
+ const lines = text.split(/\r?\n/);
26116
+ linesCache.set(path, lines);
26117
+ return lines;
26118
+ };
26119
+ const active = [];
26120
+ const suppressed = [];
26121
+ for (const f of findings) {
26122
+ const byConfig = hasConfig ? configSuppressionFor(cfg, f.location.path, f.rule_id) : void 0;
26123
+ if (byConfig) {
26124
+ suppressed.push({
26125
+ ...f,
26126
+ suppression: {
26127
+ by: "config",
26128
+ rule_scoped: true,
26129
+ justification: byConfig.justification,
26130
+ source: `${SPHIORIGNORE_FILE}:${byConfig.line}`
26131
+ }
26132
+ });
26133
+ continue;
26134
+ }
26135
+ const lines = linesOf(f.location.path);
26136
+ const byInline = lines ? inlineSuppressionFor(lines, f.location.start_line, f.rule_id) : void 0;
26137
+ if (byInline) {
26138
+ suppressed.push({
26139
+ ...f,
26140
+ suppression: {
26141
+ by: "inline",
26142
+ rule_scoped: !!byInline.ruleId,
26143
+ justification: byInline.justification,
26144
+ source: `${f.location.path}:${byInline.nextLine ? f.location.start_line - 1 : f.location.start_line}`
26145
+ }
26146
+ });
26147
+ continue;
26148
+ }
26149
+ active.push(f);
26150
+ }
26151
+ return { active, suppressed };
26152
+ }
25995
26153
 
25996
26154
  // src/engine/run.ts
25997
26155
  var kindOrder = {
@@ -26003,20 +26161,24 @@ var kindOrder = {
26003
26161
  function runScan(input) {
26004
26162
  const files = input.files ?? [];
26005
26163
  const opts = { allowFingerprints: input.allowFingerprints, ignorePaths: input.ignorePaths };
26006
- const findings = [
26164
+ const detected = [
26007
26165
  ...detectSecretsInFiles(files, opts),
26008
26166
  ...detectPatternsInFiles(files, opts),
26009
26167
  ...scaToCanonical(input.sca ?? [])
26010
26168
  ];
26011
- findings.sort((a, b) => {
26169
+ detected.sort((a, b) => {
26012
26170
  if (a.location.path !== b.location.path) return a.location.path < b.location.path ? -1 : 1;
26013
26171
  if (a.location.start_line !== b.location.start_line) return a.location.start_line - b.location.start_line;
26014
26172
  if (a.location.start_col !== b.location.start_col) return a.location.start_col - b.location.start_col;
26015
26173
  if (kindOrder[a.kind] !== kindOrder[b.kind]) return kindOrder[a.kind] - kindOrder[b.kind];
26016
26174
  return a.rule_id < b.rule_id ? -1 : a.rule_id > b.rule_id ? 1 : 0;
26017
26175
  });
26176
+ const { active: findings, suppressed } = applySuppressions(detected, {
26177
+ config: input.suppress,
26178
+ textByPath: new Map(files.map((f) => [f.path, f.text]))
26179
+ });
26018
26180
  const sarif = toSarif(findings, { engineVersion: ENGINE_VERSION, rulesetVersion: RULESET_VERSION });
26019
- return { findings, sarif, engine_version: ENGINE_VERSION, ruleset_version: RULESET_VERSION };
26181
+ return { findings, suppressed, sarif, engine_version: ENGINE_VERSION, ruleset_version: RULESET_VERSION };
26020
26182
  }
26021
26183
 
26022
26184
  // src/engine/mcp/fix-context.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sphior/scan",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Deterministic source scanner — hardcoded secrets and insecure patterns (CWE) with AST/taint analysis. No AI, no network, no source upload. CLI + LSP for editors and CI.",
5
5
  "type": "module",
6
6
  "bin": {