@sphior/scan 0.1.5 → 0.1.7

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.md CHANGED
@@ -31,20 +31,44 @@ sphior-scan run <dir> [options]
31
31
 
32
32
  Options:
33
33
  --sarif <file> write SARIF 2.1.0 (GitHub code scanning, most CI tools)
34
- --json <file> write raw findings as JSON
35
- --fix-context <file> write finding context for an AI assistant to act on
36
- --fail-on <severity> exit non-zero at or above this severity
37
- (critical | high | medium | low)
34
+ --json <file> write the findings as JSON
35
+ --fix-context <file> write one entry per place to fix, for an AI assistant to act on
36
+ --suppressed <file> write the findings accepted via .sphiorignore or inline comments
37
+ --fail-on <severity> exit 1 at or above this severity
38
+ (critical | high | medium | low | info)
39
+ --no-ignore-file do not read .sphiorignore
38
40
  ```
39
41
 
40
- SARIF is written to stdout as well, so it pipes cleanly:
42
+ With none of `--sarif`, `--json` or `--fix-context`, results go to stdout: a table in a
43
+ terminal, SARIF when piped. A one-line summary (with engine and ruleset versions) always goes
44
+ to stderr.
41
45
 
42
46
  ```bash
43
47
  sphior-scan run ./src --fail-on high
44
48
  ```
45
49
 
46
- Exit code is `0` when nothing at or above `--fail-on` was found, non-zero otherwise —
47
- which is what you want in CI.
50
+ Exit codes: `0` when nothing reached `--fail-on` (or no threshold was given), `1` when something
51
+ did, `2` when the arguments are wrong or the directory does not exist — so a typo in CI never
52
+ passes as a clean scan.
53
+
54
+ ### Excluding paths and accepting findings
55
+
56
+ `.sphiorignore` at the scanned root, one entry per line — `<glob> [rule_id] [# reason]`:
57
+
58
+ ```gitignore
59
+ generated/** # no rule id: the files are not read
60
+ test/fixtures/** secret.* # rule id: scanned, then accepted with a reason
61
+ ```
62
+
63
+ Inline, with any line-comment syntax:
64
+
65
+ ```js
66
+ eval(template); // sphior-ignore: constant input from build time
67
+ // sphior-ignore-next-line secret.private_key: documentation string
68
+ ```
69
+
70
+ Accepted findings are left out of SARIF (GitHub code scanning ignores SARIF suppressions) and
71
+ written to `--suppressed` with their reason.
48
72
 
49
73
  ### GitHub Actions
50
74
 
@@ -89,7 +113,8 @@ upload, no telemetry, no language model, no account. It runs correctly with netw
89
113
  ## Links
90
114
 
91
115
  - Product: <https://sphior.com/code>
92
- - Issues: <https://github.com/Toraastrta/Sphior/issues>
116
+ - Source and full documentation: <https://github.com/Toraastrta/sphior-code>
117
+ - Issues: <https://github.com/Toraastrta/sphior-code/issues>
93
118
 
94
119
  ## License
95
120
 
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));
@@ -15237,6 +15274,24 @@ var PATTERN_RULES = [
15237
15274
  exts: JS,
15238
15275
  pattern: /createHash\s*\(\s*['"`](?:md5|sha1)['"`]\s*\)/gi
15239
15276
  },
15277
+ {
15278
+ // 2026-10-23: 自己完結コーパス(measure-gate/sast-corpus.ts)が検出欠落として
15279
+ // 検出した。pattern.go.weak_cipher と pattern.java.weak_cipher は存在するのに
15280
+ // **JS/TS 版が無く**、同じ弱点が Go/Java では検出され Node では無検出だった。
15281
+ //
15282
+ // 0-FP の作り: アルゴリズム名を**引用符の直後に固定**する。これが無いと
15283
+ // "description" のような語が `des` に当たる。安全な算法(aes-256-gcm /
15284
+ // aes-256-cbc 等)に当たらないことはテストで固定している。
15285
+ id: "pattern.js.weak_cipher",
15286
+ cwe: "CWE-327",
15287
+ owasp: "A02:2021",
15288
+ severity: "medium",
15289
+ title: "Weak cipher algorithm",
15290
+ message: "DES/3DES, RC2/RC4, Blowfish and IDEA are broken; ECB mode leaks plaintext structure.",
15291
+ remediation: "Use aes-256-gcm (AEAD) with a random IV via crypto.createCipheriv().",
15292
+ exts: JS,
15293
+ pattern: /create(?:De)?Cipher(?:iv)?\s*\(\s*['"`](?:des(?:-[a-z0-9-]*)?|rc[24](?:-[a-z0-9-]*)?|bf(?:-[a-z0-9-]*)?|blowfish|idea(?:-[a-z0-9-]*)?|[a-z0-9-]*-ecb)['"`]/gi
15294
+ },
15240
15295
  // ── Python ──
15241
15296
  {
15242
15297
  id: "pattern.py.requests_verify_false",
@@ -16460,6 +16515,7 @@ var SOURCE_CALL_SUFFIXES = [
16460
16515
  "formData.get",
16461
16516
  "urlParams.get"
16462
16517
  ];
16518
+ var TAINT_WRAPPER_CTORS = /* @__PURE__ */ new Set(["URLSearchParams", "URL"]);
16463
16519
  var SCOPE_TYPES = /* @__PURE__ */ new Set([
16464
16520
  "FunctionDeclaration",
16465
16521
  "FunctionExpression",
@@ -16517,6 +16573,11 @@ function exprIsTainted(node, tainted) {
16517
16573
  return exprIsTainted(node.left, tainted) || exprIsTainted(node.right, tainted);
16518
16574
  case "ConditionalExpression":
16519
16575
  return exprIsTainted(node.consequent, tainted) || exprIsTainted(node.alternate, tainted);
16576
+ case "NewExpression": {
16577
+ const ctor = calleeName(node);
16578
+ if (!ctor || !TAINT_WRAPPER_CTORS.has(ctor)) return false;
16579
+ return (node.arguments ?? []).some((a) => exprIsTainted(a, tainted));
16580
+ }
16520
16581
  case "ParenthesizedExpression":
16521
16582
  case "TSAsExpression":
16522
16583
  case "TSNonNullExpression":
@@ -16571,7 +16632,39 @@ function collectTainted(scope, seed) {
16571
16632
  }
16572
16633
  return tainted;
16573
16634
  }
16574
- function scanSinks(scope, tainted) {
16635
+ function isFsModule(spec) {
16636
+ if (typeof spec !== "string") return false;
16637
+ const m = spec.replace(/^node:/, "");
16638
+ return m === "fs" || m === "fs/promises";
16639
+ }
16640
+ function collectFsImportedNames(ast) {
16641
+ const names = /* @__PURE__ */ new Set();
16642
+ walk(ast, (n) => {
16643
+ if (n.type === "ImportDeclaration" && isFsModule(n.source?.value)) {
16644
+ for (const spec of n.specifiers ?? []) {
16645
+ if (spec.type !== "ImportSpecifier") continue;
16646
+ const imported = spec.imported?.name;
16647
+ const local = spec.local?.name;
16648
+ if (typeof imported === "string" && FS_METHODS.has(imported) && typeof local === "string") names.add(local);
16649
+ }
16650
+ return;
16651
+ }
16652
+ if (n.type === "VariableDeclarator") {
16653
+ const init3 = n.init;
16654
+ const id = n.id;
16655
+ if (!init3 || !id || id.type !== "ObjectPattern") return;
16656
+ if (calleeName(init3) !== "require") return;
16657
+ if (!isFsModule(init3.arguments?.[0]?.value)) return;
16658
+ for (const prop of id.properties ?? []) {
16659
+ const key = prop.key?.name;
16660
+ const val = prop.value?.name;
16661
+ if (typeof key === "string" && FS_METHODS.has(key) && typeof val === "string") names.add(val);
16662
+ }
16663
+ }
16664
+ });
16665
+ return names;
16666
+ }
16667
+ function scanSinks(scope, tainted, fsNames = /* @__PURE__ */ new Set()) {
16575
16668
  const hits = [];
16576
16669
  const hit = (ruleId, node) => {
16577
16670
  hits.push({ ruleId, node });
@@ -16590,6 +16683,7 @@ function scanSinks(scope, tainted) {
16590
16683
  else if (memberPath(callee) === "document.write" && exprIsTainted(arg0, tainted)) hit("taint.js.xss", n);
16591
16684
  else if ((idName === "fetch" || idName === "axios" || idName === "got") && exprIsTainted(arg0, tainted) || !!method && HTTP_METHODS.has(method) && !!objPath && HTTP_CLIENT_OBJECTS.has(objPath) && exprIsTainted(arg0, tainted)) hit("taint.js.ssrf", n);
16592
16685
  else if (!!method && FS_METHODS.has(method) && !!objPath && (objPath === "fs" || objPath === "fs.promises" || objPath === "fsp") && exprIsTainted(arg0, tainted)) hit("taint.js.path_traversal", n);
16686
+ else if (!!idName && fsNames.has(idName) && exprIsTainted(arg0, tainted)) hit("taint.js.path_traversal", n);
16593
16687
  else if (REDIRECT_CALLS.has(memberPath(callee) ?? "") && exprIsTainted(arg0, tainted)) hit("taint.js.open_redirect", n);
16594
16688
  else if (!!method && ["send", "write", "end"].includes(method) && !!objPath && (objPath === "res" || objPath === "response") && exprIsTainted(arg0, tainted)) hit("taint.js.xss", n);
16595
16689
  else if (!!method && MONGO_QUERY_METHODS.has(method) && exprIsTainted(arg0, tainted)) hit("taint.js.nosql_injection", n);
@@ -16669,6 +16763,7 @@ function detectTaintFromAst(ast, path, text) {
16669
16763
  fingerprint
16670
16764
  });
16671
16765
  };
16766
+ const fsNames = collectFsImportedNames(ast);
16672
16767
  const functions = collectLocalFunctions(ast);
16673
16768
  const paramSinks = /* @__PURE__ */ new Map();
16674
16769
  const hitKey2 = (h) => `${h.ruleId}@${h.node.start ?? -1}`;
@@ -16680,7 +16775,7 @@ function detectTaintFromAst(ast, path, text) {
16680
16775
  fn.params.forEach((p, i2) => {
16681
16776
  if (!p) return;
16682
16777
  const tainted = collectTainted(fn.scope, [p]);
16683
- const hits = scanSinks(fn.scope, tainted);
16778
+ const hits = scanSinks(fn.scope, tainted, fsNames);
16684
16779
  walkScope(fn.scope, (n) => {
16685
16780
  if (n.type !== "CallExpression") return;
16686
16781
  const callee = calleeName(n);
@@ -16717,7 +16812,7 @@ function detectTaintFromAst(ast, path, text) {
16717
16812
  });
16718
16813
  for (const scope of scopes) {
16719
16814
  const tainted = collectTainted(scope, []);
16720
- for (const h of scanSinks(scope, tainted)) addFinding(h.ruleId, h.node);
16815
+ for (const h of scanSinks(scope, tainted, fsNames)) addFinding(h.ruleId, h.node);
16721
16816
  walkScope(scope, (n) => {
16722
16817
  if (n.type !== "CallExpression") return;
16723
16818
  const name2 = calleeName(n);
@@ -16775,6 +16870,11 @@ function inLineComment(text, lineStart, matchIndex, isPython) {
16775
16870
  const quotes = (upto.match(/["'`]/g) ?? []).length;
16776
16871
  return quotes % 2 === 0;
16777
16872
  }
16873
+ function isBlockCommentLine(text, lineStart) {
16874
+ const lineEnd = text.indexOf("\n", lineStart);
16875
+ const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd).trimStart();
16876
+ return line.startsWith("/*") || /^\*(\s|\/|$)/.test(line);
16877
+ }
16778
16878
  function detectPatterns(path, text, opts = {}) {
16779
16879
  if (opts.ignorePaths?.some((re) => re.test(path))) return [];
16780
16880
  const ext = extOf(path);
@@ -16818,7 +16918,7 @@ function detectPatterns(path, text, opts = {}) {
16818
16918
  while ((m = re.exec(text)) !== null) {
16819
16919
  const idx = m.index;
16820
16920
  const pos = toPos2(starts, idx);
16821
- if (inLineComment(text, pos.lineStart, idx, isPython)) {
16921
+ if (inLineComment(text, pos.lineStart, idx, isPython) || isBlockCommentLine(text, pos.lineStart)) {
16822
16922
  if (re.lastIndex === idx) re.lastIndex++;
16823
16923
  continue;
16824
16924
  }
@@ -16914,7 +17014,160 @@ function scaToCanonical(input) {
16914
17014
 
16915
17015
  // src/engine/version.ts
16916
17016
  var ENGINE_VERSION = "0.1.0";
16917
- var RULESET_VERSION = "2026.07.12-22";
17017
+ var RULESET_VERSION = "2026.10.23-24";
17018
+
17019
+ // src/engine/suppress.ts
17020
+ var EMPTY_CONFIG = { excludes: [], scoped: [], problems: [] };
17021
+ var SPHIORIGNORE_FILE = ".sphiorignore";
17022
+ function escapeRe(s) {
17023
+ return s.replace(/[.+^$(){}|[\]\\]/g, "\\$&");
17024
+ }
17025
+ function globToRegExp(glob) {
17026
+ let out2 = "";
17027
+ for (let i2 = 0; i2 < glob.length; i2++) {
17028
+ const c = glob[i2];
17029
+ if (c === "*") {
17030
+ if (glob[i2 + 1] === "*") {
17031
+ if (glob[i2 + 2] === "/") {
17032
+ out2 += "(?:[^/]+/)*";
17033
+ i2 += 2;
17034
+ } else {
17035
+ out2 += ".*";
17036
+ i2 += 1;
17037
+ }
17038
+ } else {
17039
+ out2 += "[^/]*";
17040
+ }
17041
+ continue;
17042
+ }
17043
+ if (c === "?") {
17044
+ out2 += "[^/]";
17045
+ continue;
17046
+ }
17047
+ out2 += escapeRe(c);
17048
+ }
17049
+ return new RegExp(`^${out2}$`);
17050
+ }
17051
+ function globMatches(glob, relPath) {
17052
+ const g = glob.endsWith("/") ? `${glob.slice(0, -1)}` : glob;
17053
+ if (globToRegExp(g).test(relPath)) return true;
17054
+ return globToRegExp(`${g}/**`).test(relPath);
17055
+ }
17056
+ function ruleMatches(pattern, ruleId) {
17057
+ if (pattern.endsWith("*")) return ruleId.startsWith(pattern.slice(0, -1));
17058
+ return pattern === ruleId;
17059
+ }
17060
+ function looksLikeRuleId(tok) {
17061
+ return /^[A-Za-z][A-Za-z0-9_.-]*\*?$/.test(tok) && !tok.includes("/");
17062
+ }
17063
+ function parseSphiorignore(text) {
17064
+ const excludes = [];
17065
+ const scoped = [];
17066
+ const problems = [];
17067
+ const lines = text.split(/\r?\n/);
17068
+ for (let i2 = 0; i2 < lines.length; i2++) {
17069
+ const raw = lines[i2];
17070
+ const lineNo = i2 + 1;
17071
+ if (!raw.trim() || raw.trim().startsWith("#")) continue;
17072
+ const hashAt = raw.indexOf("#");
17073
+ const body2 = (hashAt >= 0 ? raw.slice(0, hashAt) : raw).trim();
17074
+ const justification = hashAt >= 0 ? raw.slice(hashAt + 1).trim() || void 0 : void 0;
17075
+ if (!body2) continue;
17076
+ const tokens = body2.split(/\s+/);
17077
+ if (tokens.length > 2) {
17078
+ problems.push({ line: lineNo, text: body2, reason: "expected `<glob> [rule_id]` (too many fields)" });
17079
+ continue;
17080
+ }
17081
+ const [glob, ruleId] = tokens;
17082
+ if (glob.startsWith("/")) {
17083
+ problems.push({ line: lineNo, text: body2, reason: "paths are relative to the scan root \u2014 drop the leading `/`" });
17084
+ continue;
17085
+ }
17086
+ if (ruleId !== void 0 && !looksLikeRuleId(ruleId)) {
17087
+ problems.push({ line: lineNo, text: body2, reason: `not a rule id: ${ruleId}` });
17088
+ continue;
17089
+ }
17090
+ const entry = { line: lineNo, glob, ruleId, justification };
17091
+ (ruleId === void 0 ? excludes : scoped).push(entry);
17092
+ }
17093
+ return { excludes, scoped, problems };
17094
+ }
17095
+ function makePathExcluder(cfg) {
17096
+ if (cfg.excludes.length === 0) return () => false;
17097
+ return (relPath) => cfg.excludes.some((e) => globMatches(e.glob, relPath));
17098
+ }
17099
+ function configSuppressionFor(cfg, path, ruleId) {
17100
+ return cfg.scoped.find((e) => e.ruleId && ruleMatches(e.ruleId, ruleId) && globMatches(e.glob, path));
17101
+ }
17102
+ var INLINE_RE = /sphior-ignore(-next-line)?(?:[ \t]+([A-Za-z][A-Za-z0-9_.-]*\*?))?(?:[ \t]*:[ \t]*(.*))?$/;
17103
+ function parseInlineSuppression(lineText) {
17104
+ const m = INLINE_RE.exec(lineText);
17105
+ if (!m) return void 0;
17106
+ return {
17107
+ nextLine: m[1] === "-next-line",
17108
+ ruleId: m[2],
17109
+ justification: m[3]?.trim() || void 0
17110
+ };
17111
+ }
17112
+ function inlineSuppressionFor(lines, startLine, ruleId) {
17113
+ const own = lines[startLine - 1] !== void 0 ? parseInlineSuppression(lines[startLine - 1]) : void 0;
17114
+ if (own && !own.nextLine && (!own.ruleId || ruleMatches(own.ruleId, ruleId))) return own;
17115
+ const prev = startLine >= 2 && lines[startLine - 2] !== void 0 ? parseInlineSuppression(lines[startLine - 2]) : void 0;
17116
+ if (prev && prev.nextLine && (!prev.ruleId || ruleMatches(prev.ruleId, ruleId))) return prev;
17117
+ return void 0;
17118
+ }
17119
+ function applySuppressions(findings, input = {}) {
17120
+ const cfg = input.config;
17121
+ const hasConfig = !!cfg && cfg.scoped.length > 0;
17122
+ const hasText = !!input.textByPath && input.textByPath.size > 0;
17123
+ if (!hasConfig && !hasText) return { active: findings, suppressed: [] };
17124
+ const linesCache = /* @__PURE__ */ new Map();
17125
+ const linesOf = (path) => {
17126
+ const cached = linesCache.get(path);
17127
+ if (cached) return cached;
17128
+ const text = input.textByPath?.get(path);
17129
+ if (text === void 0) return void 0;
17130
+ const lines = text.split(/\r?\n/);
17131
+ linesCache.set(path, lines);
17132
+ return lines;
17133
+ };
17134
+ const active = [];
17135
+ const suppressed = [];
17136
+ for (const f of findings) {
17137
+ const byConfig = hasConfig ? configSuppressionFor(cfg, f.location.path, f.rule_id) : void 0;
17138
+ if (byConfig) {
17139
+ suppressed.push({
17140
+ ...f,
17141
+ suppression: {
17142
+ by: "config",
17143
+ rule_scoped: true,
17144
+ justification: byConfig.justification,
17145
+ source: `${SPHIORIGNORE_FILE}:${byConfig.line}`
17146
+ }
17147
+ });
17148
+ continue;
17149
+ }
17150
+ const lines = linesOf(f.location.path);
17151
+ const byInline = lines ? inlineSuppressionFor(lines, f.location.start_line, f.rule_id) : void 0;
17152
+ if (byInline) {
17153
+ suppressed.push({
17154
+ ...f,
17155
+ suppression: {
17156
+ by: "inline",
17157
+ rule_scoped: !!byInline.ruleId,
17158
+ justification: byInline.justification,
17159
+ source: `${f.location.path}:${byInline.nextLine ? f.location.start_line - 1 : f.location.start_line}`
17160
+ }
17161
+ });
17162
+ continue;
17163
+ }
17164
+ active.push(f);
17165
+ }
17166
+ return { active, suppressed };
17167
+ }
17168
+ function countWithoutJustification(suppressed) {
17169
+ return suppressed.reduce((n, f) => f.suppression && !f.suppression.justification ? n + 1 : n, 0);
17170
+ }
16918
17171
 
16919
17172
  // src/engine/run.ts
16920
17173
  var kindOrder = {
@@ -16926,43 +17179,79 @@ var kindOrder = {
16926
17179
  function runScan(input) {
16927
17180
  const files = input.files ?? [];
16928
17181
  const opts = { allowFingerprints: input.allowFingerprints, ignorePaths: input.ignorePaths };
16929
- const findings = [
17182
+ const detected = [
16930
17183
  ...detectSecretsInFiles(files, opts),
16931
17184
  ...detectPatternsInFiles(files, opts),
16932
17185
  ...scaToCanonical(input.sca ?? [])
16933
17186
  ];
16934
- findings.sort((a, b) => {
17187
+ detected.sort((a, b) => {
16935
17188
  if (a.location.path !== b.location.path) return a.location.path < b.location.path ? -1 : 1;
16936
17189
  if (a.location.start_line !== b.location.start_line) return a.location.start_line - b.location.start_line;
16937
17190
  if (a.location.start_col !== b.location.start_col) return a.location.start_col - b.location.start_col;
16938
17191
  if (kindOrder[a.kind] !== kindOrder[b.kind]) return kindOrder[a.kind] - kindOrder[b.kind];
16939
17192
  return a.rule_id < b.rule_id ? -1 : a.rule_id > b.rule_id ? 1 : 0;
16940
17193
  });
17194
+ const { active: findings, suppressed } = applySuppressions(detected, {
17195
+ config: input.suppress,
17196
+ textByPath: new Map(files.map((f) => [f.path, f.text]))
17197
+ });
16941
17198
  const sarif = toSarif(findings, { engineVersion: ENGINE_VERSION, rulesetVersion: RULESET_VERSION });
16942
- return { findings, sarif, engine_version: ENGINE_VERSION, ruleset_version: RULESET_VERSION };
17199
+ return { findings, suppressed, sarif, engine_version: ENGINE_VERSION, ruleset_version: RULESET_VERSION };
16943
17200
  }
16944
17201
 
16945
17202
  // src/engine/collect.ts
16946
17203
  import { readdirSync, statSync, readFileSync } from "node:fs";
16947
17204
  import { join, relative, sep } from "node:path";
16948
17205
  var IGNORE_DIRS = /* @__PURE__ */ new Set([
17206
+ // VCS
16949
17207
  ".git",
17208
+ ".hg",
17209
+ ".svn",
17210
+ // JS/TS の依存と出力
16950
17211
  "node_modules",
17212
+ "bower_components",
17213
+ ".yarn",
17214
+ ".pnp",
17215
+ ".pnpm-store",
16951
17216
  "dist",
16952
17217
  "build",
16953
- ".next",
16954
17218
  "out",
16955
17219
  "coverage",
16956
- "vendor",
17220
+ ".cache",
17221
+ ".turbo",
17222
+ ".swc",
17223
+ ".parcel-cache",
17224
+ // フレームワーク/デプロイのビルド出力
17225
+ ".next",
17226
+ ".open-next",
17227
+ ".vercel",
17228
+ ".wrangler",
17229
+ ".serverless",
17230
+ ".sst",
17231
+ ".svelte-kit",
17232
+ ".nuxt",
17233
+ ".output",
17234
+ ".astro",
17235
+ ".docusaurus",
17236
+ // Python
16957
17237
  ".venv",
16958
17238
  "venv",
16959
17239
  "__pycache__",
17240
+ ".mypy_cache",
17241
+ ".pytest_cache",
17242
+ ".ruff_cache",
17243
+ ".tox",
17244
+ ".nox",
17245
+ ".eggs",
17246
+ // JVM / その他言語
16960
17247
  "target",
16961
- ".turbo",
16962
- ".cache",
16963
- ".yarn",
16964
- ".pnp",
16965
- "bower_components"
17248
+ ".gradle",
17249
+ "vendor",
17250
+ "Pods",
17251
+ ".dart_tool",
17252
+ ".pub-cache",
17253
+ // IaC のプロバイダバイナリ(`.tf` 本体は配下に無いので検査対象は失わない)
17254
+ ".terraform"
16966
17255
  ]);
16967
17256
  function toPosix(p) {
16968
17257
  return sep === "/" ? p : p.split(sep).join("/");
@@ -16993,9 +17282,11 @@ function collectSourceFiles(rootDir, opts = {}) {
16993
17282
  }
16994
17283
  if (st.isDirectory()) {
16995
17284
  if (ignore.has(name2)) continue;
17285
+ if (opts.exclude?.(toPosix(relative(rootDir, full)))) continue;
16996
17286
  walk2(full);
16997
17287
  } else if (st.isFile()) {
16998
17288
  if (st.size > maxBytes) continue;
17289
+ if (opts.exclude?.(toPosix(relative(rootDir, full)))) continue;
16999
17290
  let buf;
17000
17291
  try {
17001
17292
  buf = readFileSync(full);
@@ -17011,6 +17302,13 @@ function collectSourceFiles(rootDir, opts = {}) {
17011
17302
  files.sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
17012
17303
  return files;
17013
17304
  }
17305
+ function readSphiorignore(rootDir) {
17306
+ try {
17307
+ return parseSphiorignore(readFileSync(join(rootDir, SPHIORIGNORE_FILE), "utf8"));
17308
+ } catch {
17309
+ return EMPTY_CONFIG;
17310
+ }
17311
+ }
17014
17312
 
17015
17313
  // src/engine/cli.ts
17016
17314
  import { existsSync, statSync as statSync2, writeFileSync } from "node:fs";
@@ -17165,7 +17463,7 @@ function runCli(argv, ctx = {}) {
17165
17463
  const writeFile = ctx.writeFile ?? ((p, d) => writeFileSync(p, d));
17166
17464
  const [cmd, dir, ...rest] = argv;
17167
17465
  if (cmd !== "run" || !dir) {
17168
- logErr("usage: sphior-scan run <dir> [--sarif out.sarif] [--json out.json] [--fix-context out.json] [--fail-on high]");
17466
+ 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
17467
  return { exitCode: 2, error: "bad_args" };
17170
17468
  }
17171
17469
  const flags2 = parseFlags(rest);
@@ -17174,11 +17472,23 @@ function runCli(argv, ctx = {}) {
17174
17472
  logErr(" Nothing was scanned, so this is not a clean result. Check the path.");
17175
17473
  return { exitCode: 2, error: "dir_not_found" };
17176
17474
  }
17177
- const files = collectSourceFiles(dir);
17178
- const result = runScan({ files });
17475
+ const ignoreCfg = flags2["no-ignore-file"] ? EMPTY_CONFIG : readSphiorignore(dir);
17476
+ const isExcluded = makePathExcluder(ignoreCfg);
17477
+ let excludedCount = 0;
17478
+ const exclude = (relPath) => {
17479
+ if (!isExcluded(relPath)) return false;
17480
+ excludedCount++;
17481
+ return true;
17482
+ };
17483
+ for (const p of ignoreCfg.problems) {
17484
+ logErr(`warning: ${SPHIORIGNORE_FILE}:${p.line}: ${p.reason} \u2014 ignored this line (\u201C${p.text}\u201D)`);
17485
+ }
17486
+ const files = collectSourceFiles(dir, { exclude });
17487
+ const result = runScan({ files, suppress: ignoreCfg });
17179
17488
  const sarifJson = canonicalJson(result.sarif);
17180
17489
  if (flags2.sarif) writeFile(flags2.sarif, sarifJson);
17181
17490
  if (flags2.json) writeFile(flags2.json, canonicalJson(result.findings));
17491
+ if (flags2.suppressed) writeFile(flags2.suppressed, canonicalJson(result.suppressed));
17182
17492
  if (flags2["fix-context"]) writeFile(flags2["fix-context"], canonicalJson(buildFixContexts(result.findings)));
17183
17493
  if (!flags2.sarif && !flags2.json && !flags2["fix-context"]) {
17184
17494
  if (ctx.isTty) {
@@ -17201,14 +17511,20 @@ function runCli(argv, ctx = {}) {
17201
17511
  acc[f.severity] = (acc[f.severity] ?? 0) + 1;
17202
17512
  return acc;
17203
17513
  }, {});
17514
+ const scope = (excludedCount > 0 ? ` ${excludedCount} files excluded` : "") + (result.suppressed.length > 0 ? `${excludedCount > 0 ? "," : ""} ${result.suppressed.length} suppressed` : "");
17204
17515
  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}`
17516
+ `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
17517
  );
17518
+ const unexplained = countWithoutJustification(result.suppressed);
17519
+ if (unexplained > 0) {
17520
+ logErr(` note: ${unexplained} of the suppressions carry no reason. Add \`: why\` so an auditor can read it later.`);
17521
+ }
17207
17522
  if (ctx.isTty && !flags2.sarif && !flags2.json && !flags2["fix-context"]) {
17208
17523
  logErr("");
17209
17524
  logErr(" --fix-context fc.json hand findings to an AI agent to fix");
17210
17525
  logErr(" --sarif out.sarif for GitHub code scanning / CI dashboards");
17211
17526
  logErr(" --fail-on high exit 1 if anything at or above this severity (CI)");
17527
+ logErr(` ${SPHIORIGNORE_FILE} exclude paths, or accept a rule with a reason`);
17212
17528
  }
17213
17529
  let exitCode = 0;
17214
17530
  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));
@@ -24314,6 +24351,24 @@ var PATTERN_RULES = [
24314
24351
  exts: JS,
24315
24352
  pattern: /createHash\s*\(\s*['"`](?:md5|sha1)['"`]\s*\)/gi
24316
24353
  },
24354
+ {
24355
+ // 2026-10-23: 自己完結コーパス(measure-gate/sast-corpus.ts)が検出欠落として
24356
+ // 検出した。pattern.go.weak_cipher と pattern.java.weak_cipher は存在するのに
24357
+ // **JS/TS 版が無く**、同じ弱点が Go/Java では検出され Node では無検出だった。
24358
+ //
24359
+ // 0-FP の作り: アルゴリズム名を**引用符の直後に固定**する。これが無いと
24360
+ // "description" のような語が `des` に当たる。安全な算法(aes-256-gcm /
24361
+ // aes-256-cbc 等)に当たらないことはテストで固定している。
24362
+ id: "pattern.js.weak_cipher",
24363
+ cwe: "CWE-327",
24364
+ owasp: "A02:2021",
24365
+ severity: "medium",
24366
+ title: "Weak cipher algorithm",
24367
+ message: "DES/3DES, RC2/RC4, Blowfish and IDEA are broken; ECB mode leaks plaintext structure.",
24368
+ remediation: "Use aes-256-gcm (AEAD) with a random IV via crypto.createCipheriv().",
24369
+ exts: JS,
24370
+ pattern: /create(?:De)?Cipher(?:iv)?\s*\(\s*['"`](?:des(?:-[a-z0-9-]*)?|rc[24](?:-[a-z0-9-]*)?|bf(?:-[a-z0-9-]*)?|blowfish|idea(?:-[a-z0-9-]*)?|[a-z0-9-]*-ecb)['"`]/gi
24371
+ },
24317
24372
  // ── Python ──
24318
24373
  {
24319
24374
  id: "pattern.py.requests_verify_false",
@@ -25537,6 +25592,7 @@ var SOURCE_CALL_SUFFIXES = [
25537
25592
  "formData.get",
25538
25593
  "urlParams.get"
25539
25594
  ];
25595
+ var TAINT_WRAPPER_CTORS = /* @__PURE__ */ new Set(["URLSearchParams", "URL"]);
25540
25596
  var SCOPE_TYPES = /* @__PURE__ */ new Set([
25541
25597
  "FunctionDeclaration",
25542
25598
  "FunctionExpression",
@@ -25594,6 +25650,11 @@ function exprIsTainted(node, tainted) {
25594
25650
  return exprIsTainted(node.left, tainted) || exprIsTainted(node.right, tainted);
25595
25651
  case "ConditionalExpression":
25596
25652
  return exprIsTainted(node.consequent, tainted) || exprIsTainted(node.alternate, tainted);
25653
+ case "NewExpression": {
25654
+ const ctor = calleeName(node);
25655
+ if (!ctor || !TAINT_WRAPPER_CTORS.has(ctor)) return false;
25656
+ return (node.arguments ?? []).some((a) => exprIsTainted(a, tainted));
25657
+ }
25597
25658
  case "ParenthesizedExpression":
25598
25659
  case "TSAsExpression":
25599
25660
  case "TSNonNullExpression":
@@ -25648,7 +25709,39 @@ function collectTainted(scope, seed) {
25648
25709
  }
25649
25710
  return tainted;
25650
25711
  }
25651
- function scanSinks(scope, tainted) {
25712
+ function isFsModule(spec) {
25713
+ if (typeof spec !== "string") return false;
25714
+ const m = spec.replace(/^node:/, "");
25715
+ return m === "fs" || m === "fs/promises";
25716
+ }
25717
+ function collectFsImportedNames(ast) {
25718
+ const names = /* @__PURE__ */ new Set();
25719
+ walk(ast, (n) => {
25720
+ if (n.type === "ImportDeclaration" && isFsModule(n.source?.value)) {
25721
+ for (const spec of n.specifiers ?? []) {
25722
+ if (spec.type !== "ImportSpecifier") continue;
25723
+ const imported = spec.imported?.name;
25724
+ const local = spec.local?.name;
25725
+ if (typeof imported === "string" && FS_METHODS.has(imported) && typeof local === "string") names.add(local);
25726
+ }
25727
+ return;
25728
+ }
25729
+ if (n.type === "VariableDeclarator") {
25730
+ const init3 = n.init;
25731
+ const id = n.id;
25732
+ if (!init3 || !id || id.type !== "ObjectPattern") return;
25733
+ if (calleeName(init3) !== "require") return;
25734
+ if (!isFsModule(init3.arguments?.[0]?.value)) return;
25735
+ for (const prop of id.properties ?? []) {
25736
+ const key = prop.key?.name;
25737
+ const val = prop.value?.name;
25738
+ if (typeof key === "string" && FS_METHODS.has(key) && typeof val === "string") names.add(val);
25739
+ }
25740
+ }
25741
+ });
25742
+ return names;
25743
+ }
25744
+ function scanSinks(scope, tainted, fsNames = /* @__PURE__ */ new Set()) {
25652
25745
  const hits = [];
25653
25746
  const hit = (ruleId, node) => {
25654
25747
  hits.push({ ruleId, node });
@@ -25667,6 +25760,7 @@ function scanSinks(scope, tainted) {
25667
25760
  else if (memberPath(callee) === "document.write" && exprIsTainted(arg0, tainted)) hit("taint.js.xss", n);
25668
25761
  else if ((idName === "fetch" || idName === "axios" || idName === "got") && exprIsTainted(arg0, tainted) || !!method && HTTP_METHODS.has(method) && !!objPath && HTTP_CLIENT_OBJECTS.has(objPath) && exprIsTainted(arg0, tainted)) hit("taint.js.ssrf", n);
25669
25762
  else if (!!method && FS_METHODS.has(method) && !!objPath && (objPath === "fs" || objPath === "fs.promises" || objPath === "fsp") && exprIsTainted(arg0, tainted)) hit("taint.js.path_traversal", n);
25763
+ else if (!!idName && fsNames.has(idName) && exprIsTainted(arg0, tainted)) hit("taint.js.path_traversal", n);
25670
25764
  else if (REDIRECT_CALLS.has(memberPath(callee) ?? "") && exprIsTainted(arg0, tainted)) hit("taint.js.open_redirect", n);
25671
25765
  else if (!!method && ["send", "write", "end"].includes(method) && !!objPath && (objPath === "res" || objPath === "response") && exprIsTainted(arg0, tainted)) hit("taint.js.xss", n);
25672
25766
  else if (!!method && MONGO_QUERY_METHODS.has(method) && exprIsTainted(arg0, tainted)) hit("taint.js.nosql_injection", n);
@@ -25746,6 +25840,7 @@ function detectTaintFromAst(ast, path, text) {
25746
25840
  fingerprint
25747
25841
  });
25748
25842
  };
25843
+ const fsNames = collectFsImportedNames(ast);
25749
25844
  const functions = collectLocalFunctions(ast);
25750
25845
  const paramSinks = /* @__PURE__ */ new Map();
25751
25846
  const hitKey2 = (h) => `${h.ruleId}@${h.node.start ?? -1}`;
@@ -25757,7 +25852,7 @@ function detectTaintFromAst(ast, path, text) {
25757
25852
  fn.params.forEach((p, i2) => {
25758
25853
  if (!p) return;
25759
25854
  const tainted = collectTainted(fn.scope, [p]);
25760
- const hits = scanSinks(fn.scope, tainted);
25855
+ const hits = scanSinks(fn.scope, tainted, fsNames);
25761
25856
  walkScope(fn.scope, (n) => {
25762
25857
  if (n.type !== "CallExpression") return;
25763
25858
  const callee = calleeName(n);
@@ -25794,7 +25889,7 @@ function detectTaintFromAst(ast, path, text) {
25794
25889
  });
25795
25890
  for (const scope of scopes) {
25796
25891
  const tainted = collectTainted(scope, []);
25797
- for (const h of scanSinks(scope, tainted)) addFinding(h.ruleId, h.node);
25892
+ for (const h of scanSinks(scope, tainted, fsNames)) addFinding(h.ruleId, h.node);
25798
25893
  walkScope(scope, (n) => {
25799
25894
  if (n.type !== "CallExpression") return;
25800
25895
  const name2 = calleeName(n);
@@ -25852,6 +25947,11 @@ function inLineComment(text, lineStart, matchIndex, isPython) {
25852
25947
  const quotes = (upto.match(/["'`]/g) ?? []).length;
25853
25948
  return quotes % 2 === 0;
25854
25949
  }
25950
+ function isBlockCommentLine(text, lineStart) {
25951
+ const lineEnd = text.indexOf("\n", lineStart);
25952
+ const line = text.slice(lineStart, lineEnd < 0 ? text.length : lineEnd).trimStart();
25953
+ return line.startsWith("/*") || /^\*(\s|\/|$)/.test(line);
25954
+ }
25855
25955
  function detectPatterns(path, text, opts = {}) {
25856
25956
  if (opts.ignorePaths?.some((re) => re.test(path))) return [];
25857
25957
  const ext = extOf(path);
@@ -25895,7 +25995,7 @@ function detectPatterns(path, text, opts = {}) {
25895
25995
  while ((m = re.exec(text)) !== null) {
25896
25996
  const idx = m.index;
25897
25997
  const pos = toPos2(starts, idx);
25898
- if (inLineComment(text, pos.lineStart, idx, isPython)) {
25998
+ if (inLineComment(text, pos.lineStart, idx, isPython) || isBlockCommentLine(text, pos.lineStart)) {
25899
25999
  if (re.lastIndex === idx) re.lastIndex++;
25900
26000
  continue;
25901
26001
  }
@@ -25991,7 +26091,117 @@ function scaToCanonical(input) {
25991
26091
 
25992
26092
  // src/engine/version.ts
25993
26093
  var ENGINE_VERSION = "0.1.0";
25994
- var RULESET_VERSION = "2026.07.12-22";
26094
+ var RULESET_VERSION = "2026.10.23-24";
26095
+
26096
+ // src/engine/suppress.ts
26097
+ var SPHIORIGNORE_FILE = ".sphiorignore";
26098
+ function escapeRe(s) {
26099
+ return s.replace(/[.+^$(){}|[\]\\]/g, "\\$&");
26100
+ }
26101
+ function globToRegExp(glob) {
26102
+ let out2 = "";
26103
+ for (let i2 = 0; i2 < glob.length; i2++) {
26104
+ const c = glob[i2];
26105
+ if (c === "*") {
26106
+ if (glob[i2 + 1] === "*") {
26107
+ if (glob[i2 + 2] === "/") {
26108
+ out2 += "(?:[^/]+/)*";
26109
+ i2 += 2;
26110
+ } else {
26111
+ out2 += ".*";
26112
+ i2 += 1;
26113
+ }
26114
+ } else {
26115
+ out2 += "[^/]*";
26116
+ }
26117
+ continue;
26118
+ }
26119
+ if (c === "?") {
26120
+ out2 += "[^/]";
26121
+ continue;
26122
+ }
26123
+ out2 += escapeRe(c);
26124
+ }
26125
+ return new RegExp(`^${out2}$`);
26126
+ }
26127
+ function globMatches(glob, relPath) {
26128
+ const g = glob.endsWith("/") ? `${glob.slice(0, -1)}` : glob;
26129
+ if (globToRegExp(g).test(relPath)) return true;
26130
+ return globToRegExp(`${g}/**`).test(relPath);
26131
+ }
26132
+ function ruleMatches(pattern, ruleId) {
26133
+ if (pattern.endsWith("*")) return ruleId.startsWith(pattern.slice(0, -1));
26134
+ return pattern === ruleId;
26135
+ }
26136
+ function configSuppressionFor(cfg, path, ruleId) {
26137
+ return cfg.scoped.find((e) => e.ruleId && ruleMatches(e.ruleId, ruleId) && globMatches(e.glob, path));
26138
+ }
26139
+ var INLINE_RE = /sphior-ignore(-next-line)?(?:[ \t]+([A-Za-z][A-Za-z0-9_.-]*\*?))?(?:[ \t]*:[ \t]*(.*))?$/;
26140
+ function parseInlineSuppression(lineText) {
26141
+ const m = INLINE_RE.exec(lineText);
26142
+ if (!m) return void 0;
26143
+ return {
26144
+ nextLine: m[1] === "-next-line",
26145
+ ruleId: m[2],
26146
+ justification: m[3]?.trim() || void 0
26147
+ };
26148
+ }
26149
+ function inlineSuppressionFor(lines, startLine, ruleId) {
26150
+ const own = lines[startLine - 1] !== void 0 ? parseInlineSuppression(lines[startLine - 1]) : void 0;
26151
+ if (own && !own.nextLine && (!own.ruleId || ruleMatches(own.ruleId, ruleId))) return own;
26152
+ const prev = startLine >= 2 && lines[startLine - 2] !== void 0 ? parseInlineSuppression(lines[startLine - 2]) : void 0;
26153
+ if (prev && prev.nextLine && (!prev.ruleId || ruleMatches(prev.ruleId, ruleId))) return prev;
26154
+ return void 0;
26155
+ }
26156
+ function applySuppressions(findings, input = {}) {
26157
+ const cfg = input.config;
26158
+ const hasConfig = !!cfg && cfg.scoped.length > 0;
26159
+ const hasText = !!input.textByPath && input.textByPath.size > 0;
26160
+ if (!hasConfig && !hasText) return { active: findings, suppressed: [] };
26161
+ const linesCache = /* @__PURE__ */ new Map();
26162
+ const linesOf = (path) => {
26163
+ const cached = linesCache.get(path);
26164
+ if (cached) return cached;
26165
+ const text = input.textByPath?.get(path);
26166
+ if (text === void 0) return void 0;
26167
+ const lines = text.split(/\r?\n/);
26168
+ linesCache.set(path, lines);
26169
+ return lines;
26170
+ };
26171
+ const active = [];
26172
+ const suppressed = [];
26173
+ for (const f of findings) {
26174
+ const byConfig = hasConfig ? configSuppressionFor(cfg, f.location.path, f.rule_id) : void 0;
26175
+ if (byConfig) {
26176
+ suppressed.push({
26177
+ ...f,
26178
+ suppression: {
26179
+ by: "config",
26180
+ rule_scoped: true,
26181
+ justification: byConfig.justification,
26182
+ source: `${SPHIORIGNORE_FILE}:${byConfig.line}`
26183
+ }
26184
+ });
26185
+ continue;
26186
+ }
26187
+ const lines = linesOf(f.location.path);
26188
+ const byInline = lines ? inlineSuppressionFor(lines, f.location.start_line, f.rule_id) : void 0;
26189
+ if (byInline) {
26190
+ suppressed.push({
26191
+ ...f,
26192
+ suppression: {
26193
+ by: "inline",
26194
+ rule_scoped: !!byInline.ruleId,
26195
+ justification: byInline.justification,
26196
+ source: `${f.location.path}:${byInline.nextLine ? f.location.start_line - 1 : f.location.start_line}`
26197
+ }
26198
+ });
26199
+ continue;
26200
+ }
26201
+ active.push(f);
26202
+ }
26203
+ return { active, suppressed };
26204
+ }
25995
26205
 
25996
26206
  // src/engine/run.ts
25997
26207
  var kindOrder = {
@@ -26003,20 +26213,24 @@ var kindOrder = {
26003
26213
  function runScan(input) {
26004
26214
  const files = input.files ?? [];
26005
26215
  const opts = { allowFingerprints: input.allowFingerprints, ignorePaths: input.ignorePaths };
26006
- const findings = [
26216
+ const detected = [
26007
26217
  ...detectSecretsInFiles(files, opts),
26008
26218
  ...detectPatternsInFiles(files, opts),
26009
26219
  ...scaToCanonical(input.sca ?? [])
26010
26220
  ];
26011
- findings.sort((a, b) => {
26221
+ detected.sort((a, b) => {
26012
26222
  if (a.location.path !== b.location.path) return a.location.path < b.location.path ? -1 : 1;
26013
26223
  if (a.location.start_line !== b.location.start_line) return a.location.start_line - b.location.start_line;
26014
26224
  if (a.location.start_col !== b.location.start_col) return a.location.start_col - b.location.start_col;
26015
26225
  if (kindOrder[a.kind] !== kindOrder[b.kind]) return kindOrder[a.kind] - kindOrder[b.kind];
26016
26226
  return a.rule_id < b.rule_id ? -1 : a.rule_id > b.rule_id ? 1 : 0;
26017
26227
  });
26228
+ const { active: findings, suppressed } = applySuppressions(detected, {
26229
+ config: input.suppress,
26230
+ textByPath: new Map(files.map((f) => [f.path, f.text]))
26231
+ });
26018
26232
  const sarif = toSarif(findings, { engineVersion: ENGINE_VERSION, rulesetVersion: RULESET_VERSION });
26019
- return { findings, sarif, engine_version: ENGINE_VERSION, ruleset_version: RULESET_VERSION };
26233
+ return { findings, suppressed, sarif, engine_version: ENGINE_VERSION, ruleset_version: RULESET_VERSION };
26020
26234
  }
26021
26235
 
26022
26236
  // 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.7",
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": {