@sphior/scan 0.1.6 → 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 +56 -4
- package/dist/server.cjs +56 -4
- 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
|
@@ -15274,6 +15274,24 @@ var PATTERN_RULES = [
|
|
|
15274
15274
|
exts: JS,
|
|
15275
15275
|
pattern: /createHash\s*\(\s*['"`](?:md5|sha1)['"`]\s*\)/gi
|
|
15276
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
|
+
},
|
|
15277
15295
|
// ── Python ──
|
|
15278
15296
|
{
|
|
15279
15297
|
id: "pattern.py.requests_verify_false",
|
|
@@ -16614,7 +16632,39 @@ function collectTainted(scope, seed) {
|
|
|
16614
16632
|
}
|
|
16615
16633
|
return tainted;
|
|
16616
16634
|
}
|
|
16617
|
-
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()) {
|
|
16618
16668
|
const hits = [];
|
|
16619
16669
|
const hit = (ruleId, node) => {
|
|
16620
16670
|
hits.push({ ruleId, node });
|
|
@@ -16633,6 +16683,7 @@ function scanSinks(scope, tainted) {
|
|
|
16633
16683
|
else if (memberPath(callee) === "document.write" && exprIsTainted(arg0, tainted)) hit("taint.js.xss", n);
|
|
16634
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);
|
|
16635
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);
|
|
16636
16687
|
else if (REDIRECT_CALLS.has(memberPath(callee) ?? "") && exprIsTainted(arg0, tainted)) hit("taint.js.open_redirect", n);
|
|
16637
16688
|
else if (!!method && ["send", "write", "end"].includes(method) && !!objPath && (objPath === "res" || objPath === "response") && exprIsTainted(arg0, tainted)) hit("taint.js.xss", n);
|
|
16638
16689
|
else if (!!method && MONGO_QUERY_METHODS.has(method) && exprIsTainted(arg0, tainted)) hit("taint.js.nosql_injection", n);
|
|
@@ -16712,6 +16763,7 @@ function detectTaintFromAst(ast, path, text) {
|
|
|
16712
16763
|
fingerprint
|
|
16713
16764
|
});
|
|
16714
16765
|
};
|
|
16766
|
+
const fsNames = collectFsImportedNames(ast);
|
|
16715
16767
|
const functions = collectLocalFunctions(ast);
|
|
16716
16768
|
const paramSinks = /* @__PURE__ */ new Map();
|
|
16717
16769
|
const hitKey2 = (h) => `${h.ruleId}@${h.node.start ?? -1}`;
|
|
@@ -16723,7 +16775,7 @@ function detectTaintFromAst(ast, path, text) {
|
|
|
16723
16775
|
fn.params.forEach((p, i2) => {
|
|
16724
16776
|
if (!p) return;
|
|
16725
16777
|
const tainted = collectTainted(fn.scope, [p]);
|
|
16726
|
-
const hits = scanSinks(fn.scope, tainted);
|
|
16778
|
+
const hits = scanSinks(fn.scope, tainted, fsNames);
|
|
16727
16779
|
walkScope(fn.scope, (n) => {
|
|
16728
16780
|
if (n.type !== "CallExpression") return;
|
|
16729
16781
|
const callee = calleeName(n);
|
|
@@ -16760,7 +16812,7 @@ function detectTaintFromAst(ast, path, text) {
|
|
|
16760
16812
|
});
|
|
16761
16813
|
for (const scope of scopes) {
|
|
16762
16814
|
const tainted = collectTainted(scope, []);
|
|
16763
|
-
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);
|
|
16764
16816
|
walkScope(scope, (n) => {
|
|
16765
16817
|
if (n.type !== "CallExpression") return;
|
|
16766
16818
|
const name2 = calleeName(n);
|
|
@@ -16962,7 +17014,7 @@ function scaToCanonical(input) {
|
|
|
16962
17014
|
|
|
16963
17015
|
// src/engine/version.ts
|
|
16964
17016
|
var ENGINE_VERSION = "0.1.0";
|
|
16965
|
-
var RULESET_VERSION = "2026.10.
|
|
17017
|
+
var RULESET_VERSION = "2026.10.23-24";
|
|
16966
17018
|
|
|
16967
17019
|
// src/engine/suppress.ts
|
|
16968
17020
|
var EMPTY_CONFIG = { excludes: [], scoped: [], problems: [] };
|
package/dist/server.cjs
CHANGED
|
@@ -24351,6 +24351,24 @@ var PATTERN_RULES = [
|
|
|
24351
24351
|
exts: JS,
|
|
24352
24352
|
pattern: /createHash\s*\(\s*['"`](?:md5|sha1)['"`]\s*\)/gi
|
|
24353
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
|
+
},
|
|
24354
24372
|
// ── Python ──
|
|
24355
24373
|
{
|
|
24356
24374
|
id: "pattern.py.requests_verify_false",
|
|
@@ -25691,7 +25709,39 @@ function collectTainted(scope, seed) {
|
|
|
25691
25709
|
}
|
|
25692
25710
|
return tainted;
|
|
25693
25711
|
}
|
|
25694
|
-
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()) {
|
|
25695
25745
|
const hits = [];
|
|
25696
25746
|
const hit = (ruleId, node) => {
|
|
25697
25747
|
hits.push({ ruleId, node });
|
|
@@ -25710,6 +25760,7 @@ function scanSinks(scope, tainted) {
|
|
|
25710
25760
|
else if (memberPath(callee) === "document.write" && exprIsTainted(arg0, tainted)) hit("taint.js.xss", n);
|
|
25711
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);
|
|
25712
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);
|
|
25713
25764
|
else if (REDIRECT_CALLS.has(memberPath(callee) ?? "") && exprIsTainted(arg0, tainted)) hit("taint.js.open_redirect", n);
|
|
25714
25765
|
else if (!!method && ["send", "write", "end"].includes(method) && !!objPath && (objPath === "res" || objPath === "response") && exprIsTainted(arg0, tainted)) hit("taint.js.xss", n);
|
|
25715
25766
|
else if (!!method && MONGO_QUERY_METHODS.has(method) && exprIsTainted(arg0, tainted)) hit("taint.js.nosql_injection", n);
|
|
@@ -25789,6 +25840,7 @@ function detectTaintFromAst(ast, path, text) {
|
|
|
25789
25840
|
fingerprint
|
|
25790
25841
|
});
|
|
25791
25842
|
};
|
|
25843
|
+
const fsNames = collectFsImportedNames(ast);
|
|
25792
25844
|
const functions = collectLocalFunctions(ast);
|
|
25793
25845
|
const paramSinks = /* @__PURE__ */ new Map();
|
|
25794
25846
|
const hitKey2 = (h) => `${h.ruleId}@${h.node.start ?? -1}`;
|
|
@@ -25800,7 +25852,7 @@ function detectTaintFromAst(ast, path, text) {
|
|
|
25800
25852
|
fn.params.forEach((p, i2) => {
|
|
25801
25853
|
if (!p) return;
|
|
25802
25854
|
const tainted = collectTainted(fn.scope, [p]);
|
|
25803
|
-
const hits = scanSinks(fn.scope, tainted);
|
|
25855
|
+
const hits = scanSinks(fn.scope, tainted, fsNames);
|
|
25804
25856
|
walkScope(fn.scope, (n) => {
|
|
25805
25857
|
if (n.type !== "CallExpression") return;
|
|
25806
25858
|
const callee = calleeName(n);
|
|
@@ -25837,7 +25889,7 @@ function detectTaintFromAst(ast, path, text) {
|
|
|
25837
25889
|
});
|
|
25838
25890
|
for (const scope of scopes) {
|
|
25839
25891
|
const tainted = collectTainted(scope, []);
|
|
25840
|
-
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);
|
|
25841
25893
|
walkScope(scope, (n) => {
|
|
25842
25894
|
if (n.type !== "CallExpression") return;
|
|
25843
25895
|
const name2 = calleeName(n);
|
|
@@ -26039,7 +26091,7 @@ function scaToCanonical(input) {
|
|
|
26039
26091
|
|
|
26040
26092
|
// src/engine/version.ts
|
|
26041
26093
|
var ENGINE_VERSION = "0.1.0";
|
|
26042
|
-
var RULESET_VERSION = "2026.10.
|
|
26094
|
+
var RULESET_VERSION = "2026.10.23-24";
|
|
26043
26095
|
|
|
26044
26096
|
// src/engine/suppress.ts
|
|
26045
26097
|
var SPHIORIGNORE_FILE = ".sphiorignore";
|
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": {
|