Skip to content

Commit 5f40e1a

Browse files
authored
Scan Phantom Gyp npm build files (#6)
1 parent bda1524 commit 5f40e1a

9 files changed

Lines changed: 89 additions & 10 deletions

File tree

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.1.17
1+
0.1.18

detections_shaihulud_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package main
22

33
import (
4+
"archive/tar"
45
"archive/zip"
6+
"bytes"
7+
"compress/gzip"
58
"os"
69
"path/filepath"
710
"strings"
@@ -590,6 +593,61 @@ func TestCIWorkflowPathIsContentScanned(t *testing.T) {
590593
}
591594
}
592595

596+
func TestBindingGypPackageLoaderIsContentScanned(t *testing.T) {
597+
if !textCandidate("package/binding.gyp") {
598+
t.Fatal("binding.gyp should be treated as text for archive IOC scanning")
599+
}
600+
if got := classifyScanFile("repo/node_modules/pkg/binding.gyp", 2048, nil); got != scanContent {
601+
t.Fatalf("node_modules binding.gyp: classifyScanFile = %v, want scanContent", got)
602+
}
603+
if got := classifyScanFile("repo/native-addon/binding.gyp", 2048, nil); got != scanMetadataOnly {
604+
t.Fatalf("source-tree binding.gyp should remain metadata-only in project profile: got %v", got)
605+
}
606+
}
607+
608+
func TestArchiveInspectionFindsPhantomGypBindingGyp(t *testing.T) {
609+
var archive bytes.Buffer
610+
gz := gzip.NewWriter(&archive)
611+
tw := tar.NewWriter(gz)
612+
payload := []byte(`{
613+
"targets": [
614+
{
615+
"target_name": "Setup",
616+
"type": "none",
617+
"sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
618+
}
619+
]
620+
}`)
621+
header := &tar.Header{
622+
Name: "package/binding.gyp",
623+
Mode: 0o644,
624+
Size: int64(len(payload)),
625+
}
626+
if err := tw.WriteHeader(header); err != nil {
627+
t.Fatal(err)
628+
}
629+
if _, err := tw.Write(payload); err != nil {
630+
t.Fatal(err)
631+
}
632+
if err := tw.Close(); err != nil {
633+
t.Fatal(err)
634+
}
635+
if err := gz.Close(); err != nil {
636+
t.Fatal(err)
637+
}
638+
639+
detection := NewMiniShaiHuludDetectionWithRemote(phantomGypRemotePack())
640+
var findings []Finding
641+
detection.ScanFile(FileContext{
642+
Path: filepath.Join(t.TempDir(), "pkg-1.0.0.tgz"),
643+
Base: "pkg-1.0.0.tgz",
644+
Data: archive.Bytes(),
645+
}, func(finding Finding) {
646+
findings = append(findings, finding)
647+
})
648+
assertSeverityContains(t, dedupeFindings(findings), "critical", "ioc-string", "Phantom Gyp install-time node execution: 100% match")
649+
}
650+
593651
func miasmaRemotePack() *RemoteDetectionPack {
594652
return &RemoteDetectionPack{
595653
ID: "miasma-2026-06",
@@ -628,6 +686,27 @@ func miasmaRemotePack() *RemoteDetectionPack {
628686
}
629687
}
630688

689+
func phantomGypRemotePack() *RemoteDetectionPack {
690+
return &RemoteDetectionPack{
691+
ID: "phantom-gyp-2026-06",
692+
Campaign: "Miasma Phantom Gyp npm compromise June 2026",
693+
CompositeIOCs: []RemoteCompositeIOC{
694+
{
695+
Label: "Phantom Gyp install-time node execution",
696+
Severity: "critical",
697+
MinMatches: 3,
698+
Signals: []RemoteIOC{
699+
{Label: "gyp targets block", Pattern: `(?i)"targets"\s*:`},
700+
{Label: "setup target", Pattern: `(?i)"target_name"\s*:\s*"Setup"`},
701+
{Label: "node index.js command substitution", Pattern: `(?i)<!\(\s*node\s+index\.js`},
702+
{Label: "silent execution", Pattern: `(?i)>\s*/dev/null\s+2>&1`},
703+
{Label: "stub source fallback", Pattern: `(?i)echo\s+stub\.c`},
704+
},
705+
},
706+
},
707+
}
708+
}
709+
631710
func scanFixture(t *testing.T, fixture string) []Finding {
632711
t.Helper()
633712

docs/DETECTIONS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ Product and developer copy should describe findings as exposure or triage eviden
8080

8181
Use the remote detection pack when possible. Change engine code only when a detection requires new parsing, archive handling, package inventory support, or composite behavior that cannot be expressed as data.
8282

83-
Pack IOC regexes only run against files the engine content-scans. Covered ecosystems include npm, PyPI, Composer, and crates (`Cargo.toml`/`Cargo.lock` map to the `crates` ecosystem). Crates build scripts (`build.rs`), AI-agent instruction files (`.cursorrules`, `CLAUDE.md`, `AGENTS.md`), repo-open AI/editor execution config (`.claude/settings.json`, `.gemini/settings.json`, `.cursor/rules/*`, `.vscode/tasks.json`, `.github/setup.js`/`.mjs`), and GitHub Actions workflow files (`.github/workflows/*.yml`/`.yaml`) are content-scanned so payload, prompt-injection, and malicious-publish-workflow IOCs can match. Adding a new ecosystem or file class requires an engine change (`manifestEcosystem`, `normalizePackageEcosystem`, `textCandidate`, `isAlwaysScanBase`, `isRepoOpenExecutionPath`, `isCIWorkflowPath`).
83+
Pack IOC regexes only run against files the engine content-scans. Covered ecosystems include npm, PyPI, Composer, and crates (`Cargo.toml`/`Cargo.lock` map to the `crates` ecosystem). Crates build scripts (`build.rs`), AI-agent instruction files (`.cursorrules`, `CLAUDE.md`, `AGENTS.md`), repo-open AI/editor execution config (`.claude/settings.json`, `.gemini/settings.json`, `.cursor/rules/*`, `.vscode/tasks.json`, `.github/setup.js`/`.mjs`), npm package-cache native-build configs (`binding.gyp`), and GitHub Actions workflow files (`.github/workflows/*.yml`/`.yaml`) are content-scanned so payload, prompt-injection, install-time execution, and malicious-publish-workflow IOCs can match. Adding a new ecosystem or file class requires an engine change (`manifestEcosystem`, `normalizePackageEcosystem`, `textCandidate`, `isAlwaysScanBase`, `isRepoOpenExecutionPath`, `isCIWorkflowPath`).
8484

8585
Add tests in the engine repo for parser/engine behavior. Add pack-specific fixtures/tests in the detection pack repo when the scanner already supports the needed rule type.
8686

docs/SCANNING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Always scan:
4646
- startup/token-sensitive paths
4747
- remote incident filenames
4848

49-
Project profile scans only dependency files likely to be loaders, such as setup/install/runtime/router/token filenames. Arbitrary dependency source files stay metadata-only unless deep scan is selected.
49+
Project profile scans only dependency files likely to be loaders, such as setup/install/runtime/router/token filenames, Python `.pth` files, and npm native-build `binding.gyp` files in package-cache paths. Arbitrary dependency source files stay metadata-only unless deep scan is selected.
5050

5151
## Cache Semantics
5252

engine.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ func isPackageCachePath(slash string) bool {
481481
}
482482

483483
func isDependencyLoaderCandidate(base string) bool {
484-
if base == "_index.js" || strings.HasSuffix(base, ".pth") {
484+
if base == "_index.js" || base == "binding.gyp" || strings.HasSuffix(base, ".pth") {
485485
return true
486486
}
487487
return strings.Contains(base, "install") ||
@@ -724,7 +724,7 @@ func packageFromNodeModulesPath(location string) string {
724724

725725
func textCandidate(path string) bool {
726726
lower := strings.ToLower(path)
727-
for _, suffix := range []string{".json", ".lock", ".yaml", ".yml", ".txt", ".log", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".py", ".rs", ".toml", ".ini", ".cfg", ".conf", ".plist", ".service", ".pth", ".md", ".mdc"} {
727+
for _, suffix := range []string{".json", ".lock", ".yaml", ".yml", ".txt", ".log", ".js", ".mjs", ".cjs", ".ts", ".tsx", ".py", ".rs", ".toml", ".ini", ".cfg", ".conf", ".plist", ".service", ".pth", ".gyp", ".md", ".mdc"} {
728728
if strings.HasSuffix(lower, suffix) {
729729
return true
730730
}

frontend/package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "spice-frontend",
33
"private": true,
4-
"version": "0.1.17",
4+
"version": "0.1.18",
55
"type": "module",
66
"scripts": {
77
"dev": "vite --host 127.0.0.1",

scan_index.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ type indexedFile struct {
5151
LastScannedAt time.Time
5252
}
5353

54-
const scanEngineVersion = "2026-05-13-remote-rules-v2"
54+
const scanEngineVersion = "2026-06-20-phantom-gyp"
5555
const inventoryFTSVersion = "2026-05-16-inventory-fts-v1"
5656

5757
const createPackageInventoryFTS = `CREATE VIRTUAL TABLE IF NOT EXISTS package_inventory_fts USING fts5(

wails.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"info": {
1414
"companyName": "Turen Labs, Inc",
1515
"productName": "Spice",
16-
"productVersion": "0.1.17",
16+
"productVersion": "0.1.18",
1717
"copyright": "Copyright (c) 2026 Turen Labs, Inc. All rights reserved.",
1818
"comments": "Spice local incident-pack scanner."
1919
}

0 commit comments

Comments
 (0)