polin-guard 0.1.0
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/LICENSE +21 -0
- package/README.md +157 -0
- package/bin/cli.js +114 -0
- package/package.json +42 -0
- package/src/patterns.js +92 -0
- package/src/scan.js +190 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Valentin Shyaka
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# polin-guard
|
|
2
|
+
|
|
3
|
+
**Block obfuscated build/commit-time code-injection payloads before they ever enter your repo.**
|
|
4
|
+
|
|
5
|
+
`polin-guard` is a tiny, **zero-dependency** scanner that catches the family of
|
|
6
|
+
malicious JavaScript "stagers" that hide a payload on a single, space-padded line
|
|
7
|
+
inside an otherwise normal config or entry file — e.g. `tailwind.config.js`,
|
|
8
|
+
`ecosystem.config.js`, `.eslintrc.js`, `postcss.config.js`, or `src/index.ts`.
|
|
9
|
+
|
|
10
|
+
These payloads typically:
|
|
11
|
+
|
|
12
|
+
- decode strings at runtime with a character-shuffle cipher,
|
|
13
|
+
- re-expose Node's `require`/`module` as globals, and
|
|
14
|
+
- execute a second stage through a `Function()` constructor —
|
|
15
|
+
|
|
16
|
+
…all of which runs **automatically at build/dev/CI time** with full Node.js
|
|
17
|
+
access to your environment variables, SSH keys, and tokens. Because the malicious
|
|
18
|
+
code sits hundreds of spaces to the right of legitimate code, it is trivially
|
|
19
|
+
missed in review. `polin-guard` makes it impossible to miss.
|
|
20
|
+
|
|
21
|
+
> Built after a real incident in which this exact payload was committed across
|
|
22
|
+
> multiple repositories. The detection rules are tuned for **high precision** —
|
|
23
|
+
> a `CRITICAL` finding should be safe to block a commit on.
|
|
24
|
+
|
|
25
|
+
## What it detects
|
|
26
|
+
|
|
27
|
+
| Rule | Severity | What it catches |
|
|
28
|
+
|------|----------|-----------------|
|
|
29
|
+
| `global-bang-key` | critical | `global['!']=…` stager marker |
|
|
30
|
+
| `global-underscore-handle` | critical | `global[_$_…]=…` obfuscated handle |
|
|
31
|
+
| `require-reexposed` | critical | `…]=require; … typeof module` capability escalation |
|
|
32
|
+
| `char-shuffle-cipher` | critical | `String.fromCharCode(127)` cipher delimiter |
|
|
33
|
+
| `escape-density` | critical | a line with ≥25 `\xNN`/`\uNNNN` escapes (obfuscated blob) |
|
|
34
|
+
| `iife-constructor` | critical | immediately-invoked `Function()` on a long line |
|
|
35
|
+
| `oversized-line` | critical* | a source line > 1000 chars **with** exec/require tokens (the concealment trick) |
|
|
36
|
+
| `oversized-line` | warning | a long line with no exec tokens (review) |
|
|
37
|
+
| `eval` / `atob` / `child_process` | warning | weaker indicators |
|
|
38
|
+
|
|
39
|
+
\* A long line **without** exec tokens is only a warning, to keep false positives near zero.
|
|
40
|
+
|
|
41
|
+
Lockfiles, minified bundles, source maps, and `node_modules` are excluded automatically.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npm install --save-dev polin-guard
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or run it without installing:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
npx polin-guard --all
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
No Node? Use the standalone script — copy `scan-injection.sh` into your repo.
|
|
56
|
+
|
|
57
|
+
## Usage
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
polin-guard --staged # scan staged content (use in pre-commit; also covers `git commit --amend`)
|
|
61
|
+
polin-guard --all # scan every tracked file
|
|
62
|
+
polin-guard --ci # same as --all, for CI
|
|
63
|
+
polin-guard path/to/file.js ... # scan specific files (no git required)
|
|
64
|
+
polin-guard --strict # treat warnings as blocking too
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Exit code `1` means a blocking finding was detected.
|
|
68
|
+
|
|
69
|
+
### As a pre-commit hook (husky)
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm install --save-dev polin-guard husky
|
|
73
|
+
npx husky init
|
|
74
|
+
# add the scan to the hook (runs on commit AND amend):
|
|
75
|
+
echo 'npx --no-install polin-guard --staged' > .husky/pre-commit
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
A ready-made hook is included at `.husky/pre-commit` in this package.
|
|
79
|
+
|
|
80
|
+
### As a pre-commit hook (pre-commit.com framework)
|
|
81
|
+
|
|
82
|
+
See `examples/pre-commit-config.yaml`. It calls the standalone `scan-injection.sh`,
|
|
83
|
+
so it needs no Node.
|
|
84
|
+
|
|
85
|
+
### In CI (the bypass-proof backstop)
|
|
86
|
+
|
|
87
|
+
A local hook can be skipped with `git commit --no-verify` or sidestepped by a
|
|
88
|
+
force-push from a compromised machine. Add the server-side scan so history is
|
|
89
|
+
always re-checked:
|
|
90
|
+
|
|
91
|
+
Copy `examples/github-action.yml` to `.github/workflows/polin-guard.yml`.
|
|
92
|
+
|
|
93
|
+
## Configuration
|
|
94
|
+
|
|
95
|
+
Optional `.polinguardrc.json` in your repo root:
|
|
96
|
+
|
|
97
|
+
```json
|
|
98
|
+
{
|
|
99
|
+
"maxLineLength": 1000,
|
|
100
|
+
"maxEscapes": 25,
|
|
101
|
+
"excludeDirs": ["node_modules", "dist", "build", "vendor"],
|
|
102
|
+
"includeExtensions": [".js", ".cjs", ".mjs", ".jsx", ".ts", ".tsx", ".vue", ".json", ".bat", ".cmd", ".ps1", ".sh"]
|
|
103
|
+
}
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Acknowledging a verified false positive
|
|
107
|
+
|
|
108
|
+
If a line is genuinely legitimate (a real minified-in-source blob, say):
|
|
109
|
+
|
|
110
|
+
- put `// polinguard-allow-line` on the same line, **or**
|
|
111
|
+
- put `// polinguard-allow-next-line` on the line above it, **or**
|
|
112
|
+
- raise `maxLineLength` / exclude the path in `.polinguardrc.json`.
|
|
113
|
+
|
|
114
|
+
Never use `git commit --no-verify` to push past a finding you haven't understood.
|
|
115
|
+
|
|
116
|
+
## Audit an existing repo / whole org
|
|
117
|
+
|
|
118
|
+
One-off scan of a checked-out repo:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npx polin-guard --all
|
|
122
|
+
# or, without Node:
|
|
123
|
+
git grep -nI '.\{1000,\}' # flag any suspiciously long line
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Scan every branch of every repo in a GitHub org:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
for r in $(gh repo list YOUR_ORG --limit 200 --json name --jq '.[].name'); do
|
|
130
|
+
git clone --quiet "https://github.com/YOUR_ORG/$r.git" "/tmp/scan/$r" || continue
|
|
131
|
+
( cd "/tmp/scan/$r"
|
|
132
|
+
for b in $(git branch -r | grep -v HEAD | sed 's# *origin/##'); do
|
|
133
|
+
git checkout -q "$b" 2>/dev/null || continue
|
|
134
|
+
npx --yes polin-guard --all || echo "FOUND in $r @ $b"
|
|
135
|
+
done )
|
|
136
|
+
done
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## How it works
|
|
140
|
+
|
|
141
|
+
Pure Node, no dependencies (so the security tool itself adds no supply-chain risk).
|
|
142
|
+
For each candidate file it reads the staged blob (`git show :file`) or the working
|
|
143
|
+
copy, then analyzes every line for the signatures and concealment patterns above.
|
|
144
|
+
It exits non-zero on any `critical` finding so a hook or CI step fails the build.
|
|
145
|
+
|
|
146
|
+
## Development
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npm test # runs the zero-dependency test suite (clean + inert-malicious fixtures)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
The `test/fixtures/malicious.config.js` fixture contains the detection *signatures*
|
|
153
|
+
but performs no real decode/exec — it exists only to prove the detector fires.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { run } = require('../src/scan');
|
|
5
|
+
|
|
6
|
+
const HELP = `polin-guard — block obfuscated code-injection payloads before they are committed
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
polin-guard [options] [paths...]
|
|
10
|
+
|
|
11
|
+
Modes:
|
|
12
|
+
--staged Scan files staged for commit (default; use in pre-commit hooks).
|
|
13
|
+
--all Scan all git-tracked files.
|
|
14
|
+
--ci Alias for --all (use in CI).
|
|
15
|
+
[paths...] Scan specific files/globs (no git required).
|
|
16
|
+
|
|
17
|
+
Options:
|
|
18
|
+
--strict Treat warnings as blocking (exit non-zero on warnings too).
|
|
19
|
+
--quiet Only print on findings.
|
|
20
|
+
--no-color Disable ANSI colors.
|
|
21
|
+
-h, --help Show this help.
|
|
22
|
+
-v, --version Show version.
|
|
23
|
+
|
|
24
|
+
Exit codes:
|
|
25
|
+
0 clean (or only warnings without --strict)
|
|
26
|
+
1 blocking finding(s) detected
|
|
27
|
+
2 usage / runtime error
|
|
28
|
+
|
|
29
|
+
Docs & allowlisting: see README. Add a config via .polinguardrc.json.`;
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const o = { mode: 'staged', paths: [], strict: false, quiet: false, color: true };
|
|
33
|
+
for (const a of argv) {
|
|
34
|
+
if (a === '--staged') o.mode = 'staged';
|
|
35
|
+
else if (a === '--all' || a === '--ci') o.mode = 'all';
|
|
36
|
+
else if (a === '--strict') o.strict = true;
|
|
37
|
+
else if (a === '--quiet') o.quiet = true;
|
|
38
|
+
else if (a === '--no-color') o.color = false;
|
|
39
|
+
else if (a === '-h' || a === '--help') o.help = true;
|
|
40
|
+
else if (a === '-v' || a === '--version') o.version = true;
|
|
41
|
+
else if (a.startsWith('-')) { o.unknown = a; }
|
|
42
|
+
else { o.paths.push(a); o.mode = 'paths'; }
|
|
43
|
+
}
|
|
44
|
+
return o;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function paint(s, code, enabled) {
|
|
48
|
+
const ESC = String.fromCharCode(27);
|
|
49
|
+
return enabled ? ESC + "[" + code + "m" + s + ESC + "[0m" : s;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function main() {
|
|
53
|
+
const o = parseArgs(process.argv.slice(2));
|
|
54
|
+
const c = o.color && process.stdout.isTTY;
|
|
55
|
+
|
|
56
|
+
if (o.help) { process.stdout.write(HELP + '\n'); return 0; }
|
|
57
|
+
if (o.version) {
|
|
58
|
+
try { process.stdout.write(require('../package.json').version + '\n'); } catch { process.stdout.write('0.0.0\n'); }
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
if (o.unknown) { process.stderr.write(`polin-guard: unknown option ${o.unknown}\n`); return 2; }
|
|
62
|
+
|
|
63
|
+
let res;
|
|
64
|
+
try {
|
|
65
|
+
res = run({ mode: o.mode, paths: o.paths, strict: o.strict });
|
|
66
|
+
} catch (e) {
|
|
67
|
+
process.stderr.write(`polin-guard: ${e.message}\n`);
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (res.findings.length === 0) {
|
|
72
|
+
if (!o.quiet) {
|
|
73
|
+
process.stdout.write(paint('✓ polin-guard: no injection indicators found', '32', c) +
|
|
74
|
+
` (${res.filesScanned} file${res.filesScanned === 1 ? '' : 's'} scanned)\n`);
|
|
75
|
+
}
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const header = res.blocking
|
|
80
|
+
? paint('✖ polin-guard: potential code injection detected', '1;31', c)
|
|
81
|
+
: paint('⚠ polin-guard: review-worthy findings', '33', c);
|
|
82
|
+
process.stderr.write(`\n${header}\n\n`);
|
|
83
|
+
|
|
84
|
+
// Group findings by file.
|
|
85
|
+
const byFile = new Map();
|
|
86
|
+
for (const f of res.findings) {
|
|
87
|
+
if (!byFile.has(f.file)) byFile.set(f.file, []);
|
|
88
|
+
byFile.get(f.file).push(f);
|
|
89
|
+
}
|
|
90
|
+
for (const [file, items] of byFile) {
|
|
91
|
+
process.stderr.write(paint(file, '36', c) + '\n');
|
|
92
|
+
for (const it of items) {
|
|
93
|
+
const tag = it.severity === 'critical'
|
|
94
|
+
? paint('CRITICAL', '1;31', c)
|
|
95
|
+
: paint('warning ', '33', c);
|
|
96
|
+
process.stderr.write(` ${tag} ${file}:${it.line} [${it.ruleId}]\n ${it.message}\n`);
|
|
97
|
+
}
|
|
98
|
+
process.stderr.write('\n');
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (res.blocking) {
|
|
102
|
+
process.stderr.write(
|
|
103
|
+
'Commit blocked. If this is a genuine attack, do NOT commit — investigate the file.\n' +
|
|
104
|
+
'If this is a verified false positive, acknowledge the line with a\n' +
|
|
105
|
+
`"// polinguard-allow-line" comment, an "polinguard-allow-next-line" comment above it,\n` +
|
|
106
|
+
'or adjust .polinguardrc.json. (Bypass for one commit: git commit --no-verify.)\n'
|
|
107
|
+
);
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
process.stderr.write('Warnings only — not blocking. Use --strict to block on warnings.\n');
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
process.exit(main());
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "polin-guard",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Block obfuscated build/commit-time code-injection payloads (hidden long-line JS stagers) before they enter your repo. Zero dependencies. Works as a pre-commit hook, in CI, or standalone.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"polin-guard": "bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "src/scan.js",
|
|
9
|
+
"type": "commonjs",
|
|
10
|
+
"files": [
|
|
11
|
+
"bin/",
|
|
12
|
+
"src/",
|
|
13
|
+
"README.md",
|
|
14
|
+
"LICENSE"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node test/run.js",
|
|
18
|
+
"scan": "node bin/cli.js --all",
|
|
19
|
+
"scan:staged": "node bin/cli.js --staged"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"security",
|
|
23
|
+
"supply-chain",
|
|
24
|
+
"malware",
|
|
25
|
+
"pre-commit",
|
|
26
|
+
"husky",
|
|
27
|
+
"git-hook",
|
|
28
|
+
"obfuscation",
|
|
29
|
+
"static-analysis",
|
|
30
|
+
"injection",
|
|
31
|
+
"stager"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=14"
|
|
35
|
+
},
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"dependencies": {},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/Valentin-Shyaka/polin-guard.git"
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/patterns.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Detection rules for polin-guard.
|
|
5
|
+
*
|
|
6
|
+
* These target the family of obfuscated build/commit-time JavaScript "stagers"
|
|
7
|
+
* that hide a payload on a single, heavily space-padded line inside an otherwise
|
|
8
|
+
* legitimate config or entry file (e.g. tailwind.config.js, ecosystem.config.js,
|
|
9
|
+
* .eslintrc.js, postcss.config.js, src/index.ts). The payload typically:
|
|
10
|
+
* - decodes strings at runtime via a character-shuffle cipher,
|
|
11
|
+
* - re-exposes Node's `require`/`module` as globals, and
|
|
12
|
+
* - runs a second stage through a Function() constructor.
|
|
13
|
+
*
|
|
14
|
+
* The goal is HIGH precision: a "critical" finding should almost never be a
|
|
15
|
+
* false positive, so it is safe to BLOCK a commit on it.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
// Default thresholds (override via .polinguardrc.json).
|
|
19
|
+
const DEFAULTS = {
|
|
20
|
+
maxLineLength: 1000, // a single source line longer than this is suspicious
|
|
21
|
+
maxEscapes: 25, // count of \xNN / \uNNNN escapes on one line => obfuscated blob
|
|
22
|
+
// Files / directories that legitimately contain long or generated lines.
|
|
23
|
+
excludeDirs: [
|
|
24
|
+
'node_modules', '.git', 'dist', 'build', 'out', 'coverage',
|
|
25
|
+
'.next', '.nuxt', '.output', '.turbo', '.cache', 'vendor', '__snapshots__',
|
|
26
|
+
],
|
|
27
|
+
excludeFilePatterns: [
|
|
28
|
+
/\.min\.(js|css|mjs|cjs)$/i,
|
|
29
|
+
/\.map$/i,
|
|
30
|
+
/(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?)$/i,
|
|
31
|
+
/\.snap$/i,
|
|
32
|
+
],
|
|
33
|
+
// Only these extensions are scanned. Covers JS/TS, Vue, configs, and the
|
|
34
|
+
// Windows batch / shell droppers seen alongside the JS stager.
|
|
35
|
+
includeExtensions: [
|
|
36
|
+
'.js', '.cjs', '.mjs', '.jsx', '.ts', '.tsx', '.vue',
|
|
37
|
+
'.json', '.bat', '.cmd', '.ps1', '.sh',
|
|
38
|
+
],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Near-unique signatures of the known stager. Each match is CRITICAL on its own.
|
|
42
|
+
const SIGNATURES = [
|
|
43
|
+
{
|
|
44
|
+
id: 'global-bang-key',
|
|
45
|
+
re: /global\s*\[\s*['"`]!['"`]\s*\]/,
|
|
46
|
+
message: "Assigns to global['!'] — a known obfuscated-stager marker.",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
id: 'global-underscore-handle',
|
|
50
|
+
re: /global\s*\[\s*_\$_/,
|
|
51
|
+
message: 'Assigns to global[_$_…] — obfuscated stager variable handle.',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'require-reexposed',
|
|
55
|
+
re: /\]\s*=\s*require\s*;[\s\S]{0,60}typeof\s+module/,
|
|
56
|
+
message: 'Re-exposes require()/module as globals — capability-escalation pattern.',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
id: 'char-shuffle-cipher',
|
|
60
|
+
// String.fromCharCode(127) used as a sentinel/delimiter in the shuffle cipher.
|
|
61
|
+
// Legitimate uses are virtually always inside excluded node_modules (e.g. websocket).
|
|
62
|
+
re: /String\.fromCharCode\(\s*127\s*\)/,
|
|
63
|
+
message: 'Uses fromCharCode(127) cipher delimiter — stager string-decoder pattern.',
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
// Immediately-invoked Function() / this[...] constructor: a second-stage exec sink.
|
|
68
|
+
const IIFE_CONSTRUCTOR =
|
|
69
|
+
/(?:\bFunction\b|this\s*\[[^\]]+\]|global\s*\[[^\]]+\])\s*\([^)]*\)\s*\(/;
|
|
70
|
+
|
|
71
|
+
// Tokens that turn an over-long line from "suspicious" into "critical".
|
|
72
|
+
const EXEC_TOKENS =
|
|
73
|
+
/\b(require|eval|atob|unescape|child_process|execSync|spawnSync|Function)\b|process\s*\.\s*env|global\s*\[|String\.fromCharCode/;
|
|
74
|
+
|
|
75
|
+
// Standalone weaker indicators (reported as warnings, never block on their own).
|
|
76
|
+
const SOFT_INDICATORS = [
|
|
77
|
+
{ id: 'eval-call', re: /\beval\s*\(/, message: 'Contains eval().' },
|
|
78
|
+
{ id: 'atob-call', re: /\batob\s*\(/, message: 'Contains atob() (base64 decode).' },
|
|
79
|
+
{
|
|
80
|
+
id: 'child-process-in-config',
|
|
81
|
+
re: /require\(\s*['"`]child_process['"`]\s*\)/,
|
|
82
|
+
message: "Loads child_process.",
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
module.exports = {
|
|
87
|
+
DEFAULTS,
|
|
88
|
+
SIGNATURES,
|
|
89
|
+
IIFE_CONSTRUCTOR,
|
|
90
|
+
EXEC_TOKENS,
|
|
91
|
+
SOFT_INDICATORS,
|
|
92
|
+
};
|
package/src/scan.js
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { execFileSync } = require('child_process');
|
|
6
|
+
const {
|
|
7
|
+
DEFAULTS,
|
|
8
|
+
SIGNATURES,
|
|
9
|
+
IIFE_CONSTRUCTOR,
|
|
10
|
+
EXEC_TOKENS,
|
|
11
|
+
SOFT_INDICATORS,
|
|
12
|
+
} = require('./patterns');
|
|
13
|
+
|
|
14
|
+
const ALLOW_LINE_MARKER = 'polinguard-allow-next-line';
|
|
15
|
+
const ALLOW_INLINE_MARKER = 'polinguard-allow-line';
|
|
16
|
+
|
|
17
|
+
/** Load optional config file from the repo root or cwd. */
|
|
18
|
+
function loadConfig(cwd) {
|
|
19
|
+
const candidates = ['.polinguardrc.json', '.polinguard.json'];
|
|
20
|
+
for (const name of candidates) {
|
|
21
|
+
const p = path.join(cwd, name);
|
|
22
|
+
try {
|
|
23
|
+
if (fs.existsSync(p)) {
|
|
24
|
+
const user = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
25
|
+
return { ...DEFAULTS, ...user };
|
|
26
|
+
}
|
|
27
|
+
} catch (e) {
|
|
28
|
+
process.stderr.write(`polin-guard: ignoring invalid ${name}: ${e.message}\n`);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return { ...DEFAULTS };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function git(args, cwd) {
|
|
35
|
+
return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 1024 * 1024 * 64 });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Files staged for commit (added/copied/modified/renamed). */
|
|
39
|
+
function getStagedFiles(cwd) {
|
|
40
|
+
const out = git(['diff', '--cached', '--name-only', '--diff-filter=ACMR'], cwd);
|
|
41
|
+
return out.split('\n').filter(Boolean);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** All tracked files (for --all / --ci). */
|
|
45
|
+
function getTrackedFiles(cwd) {
|
|
46
|
+
return git(['ls-files'], cwd).split('\n').filter(Boolean);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read the *staged* blob content (what will actually be committed). */
|
|
50
|
+
function readStaged(file, cwd) {
|
|
51
|
+
try {
|
|
52
|
+
return git(['show', `:${file}`], cwd);
|
|
53
|
+
} catch (e) {
|
|
54
|
+
return null; // deleted or unreadable
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isExcluded(file, cfg) {
|
|
59
|
+
const parts = file.split(/[\\/]/);
|
|
60
|
+
if (parts.some((p) => cfg.excludeDirs.includes(p))) return true;
|
|
61
|
+
if (cfg.excludeFilePatterns.some((re) => re.test(file))) return true;
|
|
62
|
+
const ext = path.extname(file).toLowerCase();
|
|
63
|
+
if (!cfg.includeExtensions.includes(ext)) return true;
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function countEscapes(line) {
|
|
68
|
+
const m = line.match(/\\x[0-9a-fA-F]{2}|\\u[0-9a-fA-F]{4}/g);
|
|
69
|
+
return m ? m.length : 0;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Analyze a single line. Returns array of findings for that line. */
|
|
73
|
+
function analyzeLine(line, cfg) {
|
|
74
|
+
const out = [];
|
|
75
|
+
|
|
76
|
+
// 1) Near-unique stager signatures -> always critical.
|
|
77
|
+
for (const sig of SIGNATURES) {
|
|
78
|
+
if (sig.re.test(line)) {
|
|
79
|
+
out.push({ ruleId: sig.id, severity: 'critical', message: sig.message });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 2) Dense escape-sequence blob -> obfuscated payload.
|
|
84
|
+
const esc = countEscapes(line);
|
|
85
|
+
if (esc >= cfg.maxEscapes) {
|
|
86
|
+
out.push({
|
|
87
|
+
ruleId: 'escape-density',
|
|
88
|
+
severity: 'critical',
|
|
89
|
+
message: `High escape-sequence density (${esc} \\x/\\u escapes) — obfuscated blob.`,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3) Immediately-invoked Function() constructor on a long line -> exec sink.
|
|
94
|
+
if (IIFE_CONSTRUCTOR.test(line) && line.length > 200) {
|
|
95
|
+
out.push({
|
|
96
|
+
ruleId: 'iife-constructor',
|
|
97
|
+
severity: 'critical',
|
|
98
|
+
message: 'Immediately-invoked Function()/dynamic constructor on a long line — second-stage exec sink.',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// 4) Oversized line: critical if it also carries exec/require tokens, else a warning.
|
|
103
|
+
if (line.length > cfg.maxLineLength) {
|
|
104
|
+
const exec = EXEC_TOKENS.test(line);
|
|
105
|
+
out.push({
|
|
106
|
+
ruleId: 'oversized-line',
|
|
107
|
+
severity: exec ? 'critical' : 'warning',
|
|
108
|
+
message: `Line length ${line.length} exceeds limit (${cfg.maxLineLength})` +
|
|
109
|
+
(exec ? ' and contains exec/require tokens — classic hidden-payload concealment.' : ' — review for hidden content.'),
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 5) Soft indicators (warnings only).
|
|
114
|
+
for (const ind of SOFT_INDICATORS) {
|
|
115
|
+
if (ind.re.test(line)) {
|
|
116
|
+
out.push({ ruleId: ind.id, severity: 'warning', message: ind.message });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Scan one file's content (string). */
|
|
124
|
+
function scanContent(file, content, cfg) {
|
|
125
|
+
const findings = [];
|
|
126
|
+
const lines = content.split(/\r?\n/);
|
|
127
|
+
for (let i = 0; i < lines.length; i++) {
|
|
128
|
+
const line = lines[i];
|
|
129
|
+
// Inline allow markers let a maintainer acknowledge a known-good long line.
|
|
130
|
+
if (line.includes(ALLOW_INLINE_MARKER)) continue;
|
|
131
|
+
if (i > 0 && lines[i - 1].includes(ALLOW_LINE_MARKER)) continue;
|
|
132
|
+
|
|
133
|
+
const lineFindings = analyzeLine(line, cfg);
|
|
134
|
+
for (const f of lineFindings) {
|
|
135
|
+
findings.push({ ...f, file, line: i + 1 });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return findings;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run a scan.
|
|
143
|
+
* @param {object} opts
|
|
144
|
+
* @param {'staged'|'all'|'paths'} opts.mode
|
|
145
|
+
* @param {string[]} [opts.paths] explicit paths when mode === 'paths'
|
|
146
|
+
* @param {string} [opts.cwd]
|
|
147
|
+
* @param {boolean} [opts.strict] treat warnings as blocking too
|
|
148
|
+
*/
|
|
149
|
+
function run(opts = {}) {
|
|
150
|
+
const cwd = opts.cwd || process.cwd();
|
|
151
|
+
const cfg = loadConfig(cwd);
|
|
152
|
+
const mode = opts.mode || 'staged';
|
|
153
|
+
|
|
154
|
+
let files = [];
|
|
155
|
+
let readFile;
|
|
156
|
+
|
|
157
|
+
if (mode === 'paths') {
|
|
158
|
+
files = opts.paths || [];
|
|
159
|
+
readFile = (f) => {
|
|
160
|
+
try { return fs.readFileSync(path.resolve(cwd, f), 'utf8'); } catch { return null; }
|
|
161
|
+
};
|
|
162
|
+
} else if (mode === 'all') {
|
|
163
|
+
files = getTrackedFiles(cwd);
|
|
164
|
+
readFile = (f) => {
|
|
165
|
+
try { return fs.readFileSync(path.resolve(cwd, f), 'utf8'); } catch { return null; }
|
|
166
|
+
};
|
|
167
|
+
} else {
|
|
168
|
+
// staged (default): scan the exact content that will be committed.
|
|
169
|
+
files = getStagedFiles(cwd);
|
|
170
|
+
readFile = (f) => readStaged(f, cwd);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const scanned = [];
|
|
174
|
+
const findings = [];
|
|
175
|
+
for (const file of files) {
|
|
176
|
+
if (isExcluded(file, cfg)) continue;
|
|
177
|
+
const content = readFile(file);
|
|
178
|
+
if (content == null) continue;
|
|
179
|
+
scanned.push(file);
|
|
180
|
+
findings.push(...scanContent(file, content, cfg));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const critical = findings.filter((f) => f.severity === 'critical');
|
|
184
|
+
const warnings = findings.filter((f) => f.severity === 'warning');
|
|
185
|
+
const blocking = opts.strict ? findings.length > 0 : critical.length > 0;
|
|
186
|
+
|
|
187
|
+
return { filesScanned: scanned.length, findings, critical, warnings, blocking };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
module.exports = { run, scanContent, analyzeLine, loadConfig };
|