@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 +33 -8
- package/dist/cli.js +338 -22
- package/dist/server.cjs +225 -11
- package/package.json +1 -1
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
|
|
35
|
-
--fix-context <file> write
|
|
36
|
-
--
|
|
37
|
-
|
|
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
|
-
|
|
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
|
|
47
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
17182
|
+
const detected = [
|
|
16930
17183
|
...detectSecretsInFiles(files, opts),
|
|
16931
17184
|
...detectPatternsInFiles(files, opts),
|
|
16932
17185
|
...scaToCanonical(input.sca ?? [])
|
|
16933
17186
|
];
|
|
16934
|
-
|
|
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
|
-
"
|
|
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
|
-
".
|
|
16962
|
-
"
|
|
16963
|
-
"
|
|
16964
|
-
".
|
|
16965
|
-
"
|
|
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
|
|
17178
|
-
const
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
26216
|
+
const detected = [
|
|
26007
26217
|
...detectSecretsInFiles(files, opts),
|
|
26008
26218
|
...detectPatternsInFiles(files, opts),
|
|
26009
26219
|
...scaToCanonical(input.sca ?? [])
|
|
26010
26220
|
];
|
|
26011
|
-
|
|
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.
|
|
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": {
|