Skip to content

Commit 097ef6e

Browse files
committed
Update topology, loop, llm, models, and pebbledb sources
Incorporate updated versions of internal/llm/client.go, pkg/analysis/topology/obfuscation.go (+ internal/external tests), pkg/analysis/loop/scev.go (+ test), pkg/models/constants.go, and pkg/storage/pebbledb/store.go. go build ./... and go test ./... pass.
1 parent 44d5287 commit 097ef6e

8 files changed

Lines changed: 292 additions & 23 deletions

File tree

internal/llm/client.go

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"strings"
1818
"sync"
1919
"time"
20+
"unicode"
2021
"unicode/utf8"
2122

2223
"github.com/BlackVectorOps/semantic_firewall/v4/pkg/models"
@@ -184,8 +185,11 @@ func executeOpenAIRaw(ctx context.Context, sysPrompt, userMsg, apiKey, model, ap
184185
userItem.Content = json.RawMessage(userJSON)
185186

186187
reqBody := models.OpenAIResponsesRequest{
187-
Model: model,
188-
Store: true,
188+
Model: model,
189+
// Do not retain payloads server-side. Audited commit messages and diffs
190+
// can originate from private/proprietary repositories; persisting them on
191+
// the provider is an unnecessary data-exposure surface for a security tool.
192+
Store: false,
189193
Items: []models.OpenAIItem{sysItem, userItem},
190194
ResponseFormat: &models.OpenAIRespFmt{Type: "json_object"},
191195
}
@@ -411,8 +415,45 @@ func validateOutput(res models.LLMResult) error {
411415
return fmt.Errorf("invalid verdict type '%s'", res.Verdict)
412416
}
413417

414-
forbiddenPhrases := []string{"ignore previous", "system prompt"}
415-
lowerEv := strings.ToLower(res.Evidence)
418+
// Structural bounds: the evidence field is a plain-string summary. An
419+
// over-long or control-char-laden value is itself a signal that the model
420+
// was steered off-protocol, independent of any specific phrase.
421+
if utf8.RuneCountInString(res.Evidence) > models.MaxEvidenceRunes {
422+
return fmt.Errorf("evidence exceeds maximum length (%d runes)", models.MaxEvidenceRunes)
423+
}
424+
for _, r := range res.Evidence {
425+
// Allow common whitespace; reject other control characters that have no
426+
// place in a plain-text summary and are frequently used to smuggle
427+
// delimiters or terminal escapes past naive substring checks.
428+
if r == '\n' || r == '\r' || r == '\t' {
429+
continue
430+
}
431+
if r < 0x20 || r == 0x7f {
432+
return fmt.Errorf("evidence contains disallowed control character U+%04X", r)
433+
}
434+
}
435+
436+
// Normalize before matching so spacing/case tricks do not defeat the check.
437+
// This is a defense-in-depth backstop, not the primary control: collapse
438+
// runs of whitespace and lowercase, then scan for known injection markers.
439+
lowerEv := collapseWhitespace(strings.ToLower(res.Evidence))
440+
forbiddenPhrases := []string{
441+
"ignore previous",
442+
"ignore prior",
443+
"ignore the above",
444+
"ignore all previous",
445+
"disregard previous",
446+
"disregard prior",
447+
"disregard the above",
448+
"system prompt",
449+
"system message",
450+
"developer message",
451+
"you are now",
452+
"new instructions",
453+
"override instructions",
454+
"begin data",
455+
"end data",
456+
}
416457
for _, phrase := range forbiddenPhrases {
417458
if strings.Contains(lowerEv, phrase) {
418459
return fmt.Errorf("unsafe content: '%s'", phrase)
@@ -421,6 +462,28 @@ func validateOutput(res models.LLMResult) error {
421462
return nil
422463
}
423464

465+
// collapseWhitespace lowercases nothing (caller does that) but reduces every
466+
// run of Unicode whitespace to a single ASCII space, defeating the common
467+
// "i g n o r e" / "ignore\u00a0previous" spacing evasions against the phrase
468+
// denylist above.
469+
func collapseWhitespace(s string) string {
470+
var b strings.Builder
471+
b.Grow(len(s))
472+
prevSpace := false
473+
for _, r := range s {
474+
if unicode.IsSpace(r) {
475+
if !prevSpace {
476+
b.WriteByte(' ')
477+
prevSpace = true
478+
}
479+
continue
480+
}
481+
b.WriteRune(r)
482+
prevSpace = false
483+
}
484+
return b.String()
485+
}
486+
424487
func parseLLMJSON(content string) (models.LLMResult, error) {
425488
cleanContent := cleanJSONMarkdown(content)
426489
var result models.LLMResult
@@ -476,3 +539,5 @@ func (t *testProxyTransport) RoundTrip(req *http.Request) (*http.Response, error
476539
}
477540
return rt.RoundTrip(req)
478541
}
542+
543+

pkg/analysis/loop/scev.go

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -457,27 +457,22 @@ func deriveTripCount(loop *Loop) {
457457
return
458458
}
459459

460-
var isUpCounting, ivOnLeft bool
460+
var isUpCounting bool
461461
var isInclusive, isNEQ bool
462462

463463
switch binOp.Op {
464464
case token.LSS:
465465
isUpCounting = true
466-
ivOnLeft = true
467466
case token.LEQ:
468467
isUpCounting = true
469-
ivOnLeft = true
470468
isInclusive = true
471469
case token.GTR:
472470
isUpCounting = false
473-
ivOnLeft = true
474471
case token.GEQ:
475472
isUpCounting = false
476-
ivOnLeft = true
477473
isInclusive = true
478474
case token.NEQ:
479475
isNEQ = true
480-
ivOnLeft = true
481476
default:
482477
loop.TripCount = &SCEVUnknown{Value: nil}
483478
return
@@ -497,7 +492,11 @@ func deriveTripCount(loop *Loop) {
497492
} else if found := findIV(binOp.Y); found != nil {
498493
iv = found
499494
limit = binOp.X
500-
ivOnLeft = !ivOnLeft
495+
// IV is on the right-hand side (e.g. "limit < i"). Normalize to the
496+
// canonical "iv OP limit" form by reversing the comparison direction.
497+
// For symmetric NEQ this is a no-op. Strictness/inclusivity is preserved
498+
// by the swap (e.g. "limit < i" == "i > limit": both strict), so only the
499+
// counting direction needs to flip.
501500
if !isNEQ {
502501
isUpCounting = !isUpCounting
503502
}
@@ -523,21 +522,26 @@ func deriveTripCount(loop *Loop) {
523522
// Determine if Dead (TripCount 0) or Divergent (Unknown)
524523
isDead := false
525524
if isUpCounting {
526-
// Condition: i < limit. Loop runs if Start < Limit.
527-
if startC.Cmp(limitC) >= 0 {
528-
// Condition is false immediately.
525+
// Condition: i < limit (or i <= limit when inclusive).
526+
// Loop runs if Start < Limit, or Start == Limit when inclusive.
527+
cmp := startC.Cmp(limitC)
528+
if cmp > 0 || (cmp == 0 && !isInclusive) {
529+
// Condition is false on entry. For "<" equality is dead;
530+
// for "<=" equality runs exactly once, so not dead.
529531
isDead = true
530532
} else if stepC.Sign() <= 0 {
531-
// Start < Limit, but step is negative (or zero). Diverges.
533+
// Start <= Limit, but step is negative (or zero). Diverges.
532534
loop.TripCount = &SCEVUnknown{Value: nil}
533535
return
534536
}
535537
} else {
536-
// Condition: i > limit. Loop runs if Start > Limit.
537-
if startC.Cmp(limitC) <= 0 {
538+
// Condition: i > limit (or i >= limit when inclusive).
539+
// Loop runs if Start > Limit, or Start == Limit when inclusive.
540+
cmp := startC.Cmp(limitC)
541+
if cmp < 0 || (cmp == 0 && !isInclusive) {
538542
isDead = true
539543
} else if stepC.Sign() >= 0 {
540-
// Start > Limit, but step is positive. Diverges.
544+
// Start >= Limit, but step is positive. Diverges.
541545
loop.TripCount = &SCEVUnknown{Value: nil}
542546
return
543547
}
@@ -812,3 +816,5 @@ func negateSCEV(s SCEV) SCEV {
812816
return &SCEVGenericExpr{Op: token.MUL, X: s, Y: &SCEVConstant{Value: big.NewInt(-1)}}
813817
}
814818
}
819+
820+

pkg/analysis/loop/scev_test.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,36 @@ func TestTripCountLogic(t *testing.T) {
137137
funcName: "count",
138138
expected: -1, // Unknown
139139
},
140+
{
141+
name: "Inclusive equality up (runs once)",
142+
src: `package main
143+
func count() {
144+
// Start=5, Limit=5, "i <= 5": runs exactly once, not dead.
145+
for i := 5; i <= 5; i++ { }
146+
}`,
147+
funcName: "count",
148+
expected: 1,
149+
},
150+
{
151+
name: "Inclusive equality down (runs once)",
152+
src: `package main
153+
func count() {
154+
// Start=5, Limit=5, "i >= 5": runs exactly once, not dead.
155+
for i := 5; i >= 5; i-- { }
156+
}`,
157+
funcName: "count",
158+
expected: 1,
159+
},
160+
{
161+
name: "Exclusive equality up (dead)",
162+
src: `package main
163+
func count() {
164+
// Start=5, Limit=5, "i < 5": false on entry, dead.
165+
for i := 5; i < 5; i++ { }
166+
}`,
167+
funcName: "count",
168+
expected: 0,
169+
},
140170
}
141171

142172
for _, tc := range tests {
@@ -247,3 +277,5 @@ func TestFloatExclusion(t *testing.T) {
247277
t.Errorf("Expected 0 IVs for float loop, got %d", len(l.Inductions))
248278
}
249279
}
280+
281+

pkg/analysis/topology/obfuscation.go

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,10 @@ func AnalyzeObfuscation(t *FunctionTopology) ObfuscationProfile {
185185
}
186186

187187
// 4. Indirect / reflective call ratio from the call signatures the topology
188-
// already collected. extractCallSignature tags these "dynamic:" / "reflect:".
188+
// already collected. extractCallSignature tags these "dynamic:" / "invoke:";
189+
// reflection invocation emits the dot-form "reflect.Call" (see
190+
// isIndirectCallSig for the verified form set and why "reflect:" alone was
191+
// insufficient).
189192
totalCalls, indirectCalls := 0, 0
190193
for sig, n := range t.CallSignatures {
191194
totalCalls += n
@@ -398,12 +401,57 @@ func structuralConstOperands(instr ssa.Instruction) map[*ssa.Value]bool {
398401
// "go:invoke:T.M", "defer:invoke:T.M") are NOT matched — only the bare
399402
// "invoke:" prefix and "go:dynamic:"/"defer:dynamic:" are. Pinned by
400403
// TestIsIndirectCallSig; closing the gap should update that test deliberately.
404+
// isIndirectCallSig reports whether a call signature represents indirect or
405+
// reflective dispatch -- the calls whose target is not statically a named
406+
// function and which therefore evade call-target detection.
407+
//
408+
// SIGNATURE FORMS ARE EMPIRICALLY VERIFIED against topology.ExtractTopology on
409+
// real SSA built with ssa.InstantiateGenerics (the mode both ir/builder.go and
410+
// scan.go use). Do not infer forms from the emitter source alone -- the
411+
// optimizing SSA build resolves some calls differently than NaiveForm would.
412+
//
413+
// - "dynamic:" raw function-pointer call. VERIFIED.
414+
// - "invoke:" interface method dispatch. VERIFIED.
415+
// - "go:dynamic:" dynamic call spawned in a goroutine.
416+
// - "defer:dynamic:" dynamic call in a defer.
417+
// - reflect invocation: reflect.Value.Call and friends. VERIFIED to emit the
418+
// DOT-form "reflect.Call" / "reflect.MethodByName" via extractFunctionSig
419+
// (<pkg>.<func>), NOT the colon-form "reflect:Call". The colon-form branch
420+
// in extractCallSignature is UNREACHABLE under InstantiateGenerics (verified
421+
// by exhausting method-value / method-expression / stored-value shapes), so
422+
// matching only "reflect:" silently MISSED all reflection dispatch and
423+
// under-counted IndirectCallRatio to zero for reflection-hidden capabilities
424+
// -- precisely the evasion technique the signal exists to catch. Both forms
425+
// are now matched; the colon-form is retained for loader-independence.
426+
//
427+
// Only the reflect *invocation* entry points count as indirect dispatch.
428+
// reflect.TypeOf / reflect.ValueOf and similar introspection helpers also emit
429+
// "reflect.<Func>" but are ordinary direct calls, not dynamic dispatch, so they
430+
// are matched by name, not by a blanket "reflect." prefix (which would also
431+
// capture a user package named reflect).
401432
func isIndirectCallSig(sig string) bool {
402433
return hasPrefix(sig, "dynamic:") ||
403-
hasPrefix(sig, "reflect:") ||
434+
hasPrefix(sig, "reflect:") || // colon-form: unreachable under this loader, kept defensively
404435
hasPrefix(sig, "invoke:") ||
405436
hasPrefix(sig, "go:dynamic:") ||
406-
hasPrefix(sig, "defer:dynamic:")
437+
hasPrefix(sig, "defer:dynamic:") ||
438+
isReflectInvokeSig(sig)
439+
}
440+
441+
// reflectInvokeMethods are the reflect.Value methods that perform DYNAMIC
442+
// INVOCATION of an underlying callable -- the reflection equivalent of an
443+
// indirect call. Introspection methods (Kind, Type, Field, Len, ...) are
444+
// deliberately excluded: they do not dispatch a call. Emitted dot-form is
445+
// "reflect.<Method>".
446+
var reflectInvokeMethods = map[string]bool{
447+
"reflect.Call": true,
448+
"reflect.CallSlice": true,
449+
"reflect.MethodByName": true, // resolves a method dynamically; its result is invoked
450+
"reflect.Method": true,
451+
}
452+
453+
func isReflectInvokeSig(sig string) bool {
454+
return reflectInvokeMethods[sig]
407455
}
408456

409457
// flatteningScore estimates control-flow-flattening from the dispatcher's
@@ -548,3 +596,5 @@ func totalStringLen(ss []string) int {
548596
func hasPrefix(s, prefix string) bool {
549597
return len(s) >= len(prefix) && s[:len(prefix)] == prefix
550598
}
599+
600+

pkg/analysis/topology/obfuscation_internal_test.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,38 @@ func TestHasPrefix(t *testing.T) {
8080

8181
func TestIsIndirectCallSig(t *testing.T) {
8282
t.Parallel()
83-
indirect := []string{"dynamic:func()", "reflect:Call", "invoke:T.M", "go:dynamic:x", "defer:dynamic:x"}
83+
indirect := []string{
84+
"dynamic:func()",
85+
"invoke:T.M",
86+
"go:dynamic:x",
87+
"defer:dynamic:x",
88+
// Reflection invocation: the DOT-form is what ExtractTopology actually
89+
// emits under InstantiateGenerics (verified against real SSA). Matching
90+
// only the colon-form previously let all reflection dispatch escape the
91+
// IndirectCallRatio, under-counting obfuscation for reflection-hidden
92+
// capabilities. Both forms must match.
93+
"reflect.Call",
94+
"reflect.CallSlice",
95+
"reflect.MethodByName",
96+
"reflect:Call", // colon-form: unreachable under this loader, matched defensively
97+
}
8498
for _, s := range indirect {
8599
if !isIndirectCallSig(s) {
86100
t.Errorf("isIndirectCallSig(%q)=false, want true", s)
87101
}
88102
}
89-
direct := []string{"net.Dial", "builtin:println", "closure:func()", "", "call:unknown"}
103+
direct := []string{
104+
"net.Dial",
105+
"builtin:println",
106+
"closure:func()",
107+
"",
108+
"call:unknown",
109+
// reflect introspection helpers emit "reflect.<Func>" too but are
110+
// ordinary direct calls, NOT dynamic dispatch -- they must NOT count.
111+
"reflect.TypeOf",
112+
"reflect.ValueOf",
113+
"reflect.DeepEqual",
114+
}
90115
for _, s := range direct {
91116
if isIndirectCallSig(s) {
92117
t.Errorf("isIndirectCallSig(%q)=true, want false", s)
@@ -272,3 +297,5 @@ func FuzzMaxWindowEntropy(f *testing.F) {
272297
}
273298
})
274299
}
300+
301+

0 commit comments

Comments
 (0)