Skip to content

Commit 2b8a3f3

Browse files
authored
feat: supply chain defense phase 1 - credential coverage, dep rug-pull, aguara v0.10.0 (#83)
* chore: bump aguara to v0.10.0 New decoders (URL/Unicode/HTML/hex), NLP for JSON/YAML, aggregate RiskScore, proximity weighting, dynamic confidence, configurable dedup, cross-file toxicflow, library-mode rug-pull. * feat: expand TC-002 credential coverage and add TC-011 persistence detection TC-002: 6 new patterns covering shell history, git credentials, package manager auth (.npmrc, .pypirc), CI/CD configs, crypto wallets, and SSL/TLS private keys. Closes all credential file gaps exposed by the litellm supply chain attack. TC-011: new critical rule detecting persistence mechanisms - systemd user services, sysmon backdoor path, crontab entries, shell profile modifications. Based on litellm malware persistence patterns. * feat: dependency manifest rug-pull detection on gateway startup Hashes requirements.txt, package-lock.json, go.sum, and other dependency manifests on gateway startup. Compares to stored hashes from previous run. Warns when manifests change between runs. Catches the exact litellm scenario: version bump with malicious payload detected before the MCP server starts. Config: gateway.dep_check (bool), servers.*.working_dir (string). Storage: ~/.oktsec/dep-hashes.json (0600 permissions). 11 tests.
1 parent e88e9e3 commit 2b8a3f3

7 files changed

Lines changed: 419 additions & 22 deletions

File tree

go.mod

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,14 @@ require (
77
github.com/charmbracelet/bubbletea v1.3.10
88
github.com/charmbracelet/lipgloss v1.1.0
99
github.com/fatih/color v1.18.0
10-
github.com/garagon/aguara v0.9.1
10+
github.com/garagon/aguara v0.10.0
1111
github.com/google/uuid v1.6.0
1212
github.com/jackc/pgx/v5 v5.8.0
1313
github.com/modelcontextprotocol/go-sdk v1.4.1
1414
github.com/prometheus/client_golang v1.23.2
1515
github.com/spf13/cobra v1.10.2
1616
github.com/stretchr/testify v1.11.1
1717
golang.org/x/term v0.41.0
18-
golang.org/x/text v0.35.0
1918
gopkg.in/yaml.v3 v3.0.1
2019
modernc.org/sqlite v1.46.1
2120
)
@@ -68,6 +67,7 @@ require (
6867
golang.org/x/oauth2 v0.34.0 // indirect
6968
golang.org/x/sync v0.20.0 // indirect
7069
golang.org/x/sys v0.42.0 // indirect
70+
golang.org/x/text v0.35.0 // indirect
7171
google.golang.org/protobuf v1.36.8 // indirect
7272
modernc.org/libc v1.67.6 // indirect
7373
modernc.org/mathutil v1.7.1 // indirect

go.sum

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
3535
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
3636
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
3737
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
38-
github.com/garagon/aguara v0.3.1 h1:6nhFRFazQKth3XIsBLG+4AELWB4EYW++XqU1FJfQCOA=
39-
github.com/garagon/aguara v0.3.1/go.mod h1:8SfNIdQxmX8tapfX/FWje818ZxKf3uRuS1jVe7FU96I=
40-
github.com/garagon/aguara v0.9.0 h1:yoO/PDnFwV6zAB0PdX1XamszKUn5nX+/iempRamMXkY=
41-
github.com/garagon/aguara v0.9.0/go.mod h1:njU1wgwlHIKnH1mmPua7ifNbzkRERnMyNndLBmMWk4A=
42-
github.com/garagon/aguara v0.9.1 h1:IzcG1BBAUnq7aZVjypQIz59gshvBDzuQWed54l4S2B0=
43-
github.com/garagon/aguara v0.9.1/go.mod h1:njU1wgwlHIKnH1mmPua7ifNbzkRERnMyNndLBmMWk4A=
38+
github.com/garagon/aguara v0.10.0 h1:2s8Opc/rBx7xsojymrn4Qbh23d9Br0HU6SaSmV5fc+4=
39+
github.com/garagon/aguara v0.10.0/go.mod h1:njU1wgwlHIKnH1mmPua7ifNbzkRERnMyNndLBmMWk4A=
4440
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
4541
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
4642
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -139,13 +135,10 @@ go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
139135
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
140136
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
141137
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
142-
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
143-
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
144138
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
139+
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
145140
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
146141
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
147-
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
148-
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
149142
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
150143
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
151144
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -155,13 +148,10 @@ golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
155148
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
156149
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
157150
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
158-
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
159-
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
160151
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
161152
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
162-
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
163-
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
164153
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
154+
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
165155
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
166156
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
167157
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

internal/config/config.go

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -319,16 +319,18 @@ type GatewayConfig struct {
319319
Bind string `yaml:"bind"` // default 127.0.0.1
320320
EndpointPath string `yaml:"endpoint_path"` // default /mcp
321321
ScanResponses bool `yaml:"scan_responses"` // scan backend responses
322+
DepCheck bool `yaml:"dep_check,omitempty"` // hash dependency manifests on startup
322323
}
323324

324325
// MCPServerConfig defines a backend MCP server to proxy through the gateway.
325326
type MCPServerConfig struct {
326-
Transport string `yaml:"transport"` // "stdio" or "http"
327-
Command string `yaml:"command,omitempty"` // for stdio
328-
Args []string `yaml:"args,omitempty"`
329-
URL string `yaml:"url,omitempty"` // for http
330-
Headers map[string]string `yaml:"headers,omitempty"`
331-
Env map[string]string `yaml:"env,omitempty"` // env vars for stdio
327+
Transport string `yaml:"transport"` // "stdio" or "http"
328+
Command string `yaml:"command,omitempty"` // for stdio
329+
Args []string `yaml:"args,omitempty"`
330+
URL string `yaml:"url,omitempty"` // for http
331+
Headers map[string]string `yaml:"headers,omitempty"`
332+
Env map[string]string `yaml:"env,omitempty"` // env vars for stdio
333+
WorkingDir string `yaml:"working_dir,omitempty"` // working directory for dep_check
332334
}
333335

334336
// CategoryWebhook binds a rule category to default notification channels.

internal/gateway/dephash.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
package gateway
2+
3+
import (
4+
"crypto/sha256"
5+
"encoding/hex"
6+
"encoding/json"
7+
"os"
8+
"path/filepath"
9+
)
10+
11+
// depManifests lists dependency manifest filenames to look for.
12+
var depManifests = []string{
13+
"requirements.txt",
14+
"Pipfile.lock",
15+
"package.json",
16+
"package-lock.json",
17+
"yarn.lock",
18+
"pnpm-lock.yaml",
19+
"go.sum",
20+
"go.mod",
21+
}
22+
23+
// DepChange describes a dependency manifest that changed between runs.
24+
type DepChange struct {
25+
ServerName string
26+
File string
27+
OldHash string // empty on first run
28+
NewHash string
29+
}
30+
31+
// depHashStore manages SHA-256 hashes of MCP server dependency manifests.
32+
// Hashes are persisted to a JSON file between runs.
33+
type depHashStore struct {
34+
path string // e.g. ~/.oktsec/dep-hashes.json
35+
hashes map[string]map[string]string // server name -> filename -> sha256 hex
36+
}
37+
38+
// newDepHashStore loads existing hashes from the JSON file, or creates an empty store.
39+
func newDepHashStore(storePath string) *depHashStore {
40+
s := &depHashStore{
41+
path: storePath,
42+
hashes: make(map[string]map[string]string),
43+
}
44+
data, err := os.ReadFile(storePath)
45+
if err != nil {
46+
// File doesn't exist or can't be read — start fresh.
47+
return s
48+
}
49+
_ = json.Unmarshal(data, &s.hashes)
50+
if s.hashes == nil {
51+
s.hashes = make(map[string]map[string]string)
52+
}
53+
return s
54+
}
55+
56+
// Check hashes dependency manifests in workingDir and compares them to stored
57+
// values. Returns a DepChange for each file that is new or changed.
58+
func (s *depHashStore) Check(serverName, workingDir string) []DepChange {
59+
old := s.hashes[serverName]
60+
if old == nil {
61+
old = make(map[string]string)
62+
}
63+
64+
current := make(map[string]string)
65+
var changes []DepChange
66+
67+
for _, name := range depManifests {
68+
p := filepath.Join(workingDir, name)
69+
data, err := os.ReadFile(p)
70+
if err != nil {
71+
continue // file doesn't exist or unreadable — skip
72+
}
73+
sum := sha256.Sum256(data)
74+
h := hex.EncodeToString(sum[:])
75+
current[name] = h
76+
77+
if oldH, seen := old[name]; !seen {
78+
// First time seeing this file.
79+
changes = append(changes, DepChange{
80+
ServerName: serverName,
81+
File: name,
82+
NewHash: h,
83+
})
84+
} else if oldH != h {
85+
// File changed since last run.
86+
changes = append(changes, DepChange{
87+
ServerName: serverName,
88+
File: name,
89+
OldHash: oldH,
90+
NewHash: h,
91+
})
92+
}
93+
}
94+
95+
// Store the current hashes for this server.
96+
s.hashes[serverName] = current
97+
return changes
98+
}
99+
100+
// Save persists the hash store to its JSON file with 0600 permissions.
101+
// Creates the parent directory if it doesn't exist.
102+
func (s *depHashStore) Save() error {
103+
dir := filepath.Dir(s.path)
104+
if err := os.MkdirAll(dir, 0o700); err != nil {
105+
return err
106+
}
107+
data, err := json.MarshalIndent(s.hashes, "", " ")
108+
if err != nil {
109+
return err
110+
}
111+
return os.WriteFile(s.path, data, 0o600)
112+
}
113+
114+
// defaultDepHashPath returns ~/.oktsec/dep-hashes.json.
115+
func defaultDepHashPath() string {
116+
home, err := os.UserHomeDir()
117+
if err != nil {
118+
home = "."
119+
}
120+
return filepath.Join(home, ".oktsec", "dep-hashes.json")
121+
}

0 commit comments

Comments
 (0)