diff --git a/defects/drone-0001/patch/drone-0001.patch b/defects/drone-0001/patch/drone-0001.patch new file mode 100644 index 000000000..f472fef3d --- /dev/null +++ b/defects/drone-0001/patch/drone-0001.patch @@ -0,0 +1,74 @@ +--- a/pubsub/inmem.go ++++ b/pubsub/inmem.go +@@ -14,7 +14,6 @@ import ( + "context" + "errors" + "sync" + "time" + + "github.com/rs/zerolog/log" +- "golang.org/x/exp/slices" + ) + +@@ -91,7 +90,8 @@ func (r *InMemory) Publish(ctx context.Context, topic string, payload []byte, op + topic = formatTopic(pubConfig.app, pubConfig.namespace, topic) + wg := sync.WaitGroup{} + for _, sub := range r.registry { +- if slices.Contains(sub.topics, topic) && !sub.isClosed() { ++ if sub.hasTopic(topic) && !sub.isClosed() { + wg.Add(1) + go func(subscriber *inMemorySubscriber) { + defer wg.Done() +@@ -128,9 +128,9 @@ func (s *inMemorySubscriber) Subscribe(_ context.Context, topics ...string) erro + defer s.mutex.RUnlock() + topics = s.formatTopics(topics...) + for _, ch := range topics { +- if slices.Contains(s.topics, ch) { +- continue +- } +- s.topics = append(s.topics, ch) ++ if _, ok := s.topicSet[ch]; !ok { ++ s.topicSet[ch] = struct{}{} ++ s.topics = append(s.topics, ch) ++ } + } + return nil + } +@@ -140,9 +140,9 @@ func (s *inMemorySubscriber) Unsubscribe(_ context.Context, topics ...string) er + defer s.mutex.RUnlock() + topics = s.formatTopics(topics...) + for _, ch := range topics { +- if slices.Contains(s.topics, ch) { +- s.topics[i] = s.topics[len(s.topics)-1] +- s.topics = s.topics[:len(s.topics)-1] +- } ++ if _, ok := s.topicSet[ch]; ok { ++ delete(s.topicSet, ch) ++ // rebuild slice from set ++ s.topics = s.topics[:0] ++ for t := range s.topicSet { ++ s.topics = append(s.topics, t) ++ } ++ } + } + return nil + } + ++func (s *inMemorySubscriber) hasTopic(topic string) bool { ++ s.mutex.RLock() ++ defer s.mutex.RUnlock() ++ _, ok := s.topicSet[topic] ++ return ok ++} + + // struct change: add topicSet map alongside topics slice + type inMemorySubscriber struct { + config *SubscribeConfig + handler func([]byte) error + channel chan []byte + once sync.Once + mutex sync.RWMutex + topics []string ++ topicSet map[string]struct{} + closed bool + } diff --git a/defects/drone-0001/test/Drone0001Test.java b/defects/drone-0001/test/Drone0001Test.java new file mode 100644 index 000000000..52f4b5476 --- /dev/null +++ b/defects/drone-0001/test/Drone0001Test.java @@ -0,0 +1,179 @@ +import java.util.*; + +/** + * Test for drone-0001: CWE-407 O(S*T) linear topic scan in InMemory pubsub Publish. + * + * Location: pubsub/inmem.go InMemory.Publish() and inMemorySubscriber.Subscribe() + * + * Defect: for each subscriber (S), slices.Contains(sub.topics, topic) performs + * an O(T) linear scan over topics. With S subscribers each holding T topics, one + * Publish call costs O(S*T). Subscribe() and Unsubscribe() also use + * slices.Contains inside their own loops. + * + * In a busy Drone CI instance running many concurrent SSE streams and + * pipeline-event notifications this creates quadratic work per event published. + * + * Fix: maintain a map[string]struct{} topicSet alongside the topics slice. + * hasTopic() becomes O(1) map lookup. Publish drops from O(S*T) to O(S). + * + * Speedup at S=500 subscribers, T=20 topics: 500*20=10000 ops vs 500 ops = 20x. + * + * Compile and run (no build tool required): + * javac defects/drone-0001/test/Drone0001Test.java -d /tmp/drone-0001 + * java -cp /tmp/drone-0001 Drone0001Test + */ +public class Drone0001Test { + + private static int passed = 0; + private static int failed = 0; + + // --- Defective subscriber: slice-based topic membership --- + + static class SubscriberDefective { + final List topics = new ArrayList<>(); + + void addTopic(String t) { + if (!topics.contains(t)) topics.add(t); // O(T) scan + } + + boolean hasTopic(String t) { + return topics.contains(t); // O(T) scan — defect site + } + } + + // --- Fixed subscriber: map-based topic membership --- + + static class SubscriberFixed { + final Set topicSet = new HashSet<>(); + + void addTopic(String t) { + topicSet.add(t); // O(1) + } + + boolean hasTopic(String t) { + return topicSet.contains(t); // O(1) — fix + } + } + + /** + * Counts total element comparisons when Publish scans S subscribers, + * each with T topics, and the publish topic is NOT present (worst case). + */ + static long publishCostDefective(int S, int T, String publishTopic) { + List subs = new ArrayList<>(); + for (int s = 0; s < S; s++) { + SubscriberDefective sub = new SubscriberDefective(); + for (int t = 0; t < T; t++) sub.addTopic("t" + s + "." + t); + subs.add(sub); + } + long ops = 0; + for (SubscriberDefective sub : subs) { + for (int i = 0; i < sub.topics.size(); i++) { + ops++; + if (sub.topics.get(i).equals(publishTopic)) break; + } + } + return ops; + } + + static long publishCostFixed(int S, int T, String publishTopic) { + List subs = new ArrayList<>(); + for (int s = 0; s < S; s++) { + SubscriberFixed sub = new SubscriberFixed(); + for (int t = 0; t < T; t++) sub.addTopic("t" + s + "." + t); + subs.add(sub); + } + // one O(1) hash lookup per subscriber + long ops = 0; + for (SubscriberFixed sub : subs) { + sub.hasTopic(publishTopic); + ops++; + } + return ops; + } + + // --- Tests --- + + static void testDefectiveQuadraticCost() { + long cost100 = publishCostDefective(100, 20, "absent"); + long cost200 = publishCostDefective(200, 20, "absent"); + // doubling S doubles ops (O(S*T) with fixed T) + check("defective cost at S=100,T=20 is exactly 2000", + cost100 == 100L * 20); + check("defective cost doubles when S doubles (O(S*T))", + cost200 == cost100 * 2); + } + + static void testFixedLinearCost() { + long cost100 = publishCostFixed(100, 20, "absent"); + long cost200 = publishCostFixed(200, 20, "absent"); + check("fixed cost at S=100 is exactly 100 (one lookup per subscriber)", + cost100 == 100); + check("fixed cost at S=200 is exactly 200", + cost200 == 200); + } + + static void testSpeedupRatioAtScale() { + int S = 500, T = 20; + long defCost = publishCostDefective(S, T, "none"); + long fixCost = publishCostFixed(S, T, "none"); + double ratio = (double) defCost / fixCost; + check("speedup at S=500,T=20 is at least 15x (expect ~20x)", + ratio >= 15.0); + System.out.printf(" drone-0001 publish cost: defective=%d fixed=%d ratio=%.1fx%n", + defCost, fixCost, ratio); + } + + static void testFixedSubscriberDeduplicates() { + SubscriberFixed sub = new SubscriberFixed(); + sub.addTopic("events:pipeline"); + sub.addTopic("events:pipeline"); // duplicate + sub.addTopic("events:repo"); + check("set-based subscriber deduplicates topics", + sub.topicSet.size() == 2); + } + + static void testFixedHasTopicCorrect() { + SubscriberFixed sub = new SubscriberFixed(); + sub.addTopic("events:pipeline:exec"); + check("hasTopic returns true for subscribed topic", + sub.hasTopic("events:pipeline:exec")); + check("hasTopic returns false for unsubscribed topic", + !sub.hasTopic("events:repo:push")); + } + + static void testDefectiveHasTopicCorrect() { + SubscriberDefective sub = new SubscriberDefective(); + sub.addTopic("events:pipeline:exec"); + check("defective hasTopic returns true for subscribed topic", + sub.hasTopic("events:pipeline:exec")); + check("defective hasTopic returns false for unsubscribed topic", + !sub.hasTopic("events:repo:push")); + } + + // --- Harness --- + + static void check(String desc, boolean cond) { + if (cond) { + System.out.println(" PASS: " + desc); + passed++; + } else { + System.out.println(" FAIL: " + desc); + failed++; + } + } + + public static void main(String[] args) { + System.out.println("=== Drone0001Test (CWE-407 pubsub Publish O(S*T) topic scan) ===\n"); + + testDefectiveQuadraticCost(); + testFixedLinearCost(); + testSpeedupRatioAtScale(); + testFixedSubscriberDeduplicates(); + testFixedHasTopicCorrect(); + testDefectiveHasTopicCorrect(); + + System.out.println("\n--- " + passed + " passed, " + failed + " failed ---"); + if (failed > 0) System.exit(1); + } +} diff --git a/defects/drone-0002/patch/drone-0002.patch b/defects/drone-0002/patch/drone-0002.patch new file mode 100644 index 000000000..e31109605 --- /dev/null +++ b/defects/drone-0002/patch/drone-0002.patch @@ -0,0 +1,81 @@ +--- a/cache/ttl_cache.go ++++ b/cache/ttl_cache.go +@@ -14,6 +14,7 @@ import ( + "context" + "fmt" + "slices" ++ "sync" + "sync/atomic" + "time" + +@@ -25,6 +26,7 @@ type TTLCache[K comparable, V any] struct { + mx sync.RWMutex + cache map[K]cacheEntry[V] + purgeStop chan struct{} ++ group singleflightGroup[K, V] + getter Getter[K, V] + maxAge time.Duration + countHit atomic.Int64 +@@ -200,13 +202,27 @@ func (c *TTLCache[K, V]) Get(ctx context.Context, key K) (V, error) { + item, ok := c.fetch(key, now) + if ok { + return item, nil + } + +- item, err := c.getter.Find(ctx, key) ++ // Use singleflight to prevent thundering herd: concurrent goroutines that ++ // miss the cache for the same key share a single backend fetch instead of ++ // each issuing their own database call. ++ item, err, _ := c.group.Do(key, func() (V, error) { ++ return c.getter.Find(ctx, key) ++ }) + if err != nil { + return nothing, fmt.Errorf("cache: failed to find one: %w", err) + } + + c.mx.Lock() + c.cache[key] = cacheEntry[V]{ + added: now, + data: item, + } + c.mx.Unlock() + + return item, nil + } + ++// singleflightGroup is a minimal generic singleflight implementation. ++type singleflightGroup[K comparable, V any] struct { ++ mu sync.Mutex ++ m map[K]*call[V] ++} ++ ++type call[V any] struct { ++ wg sync.WaitGroup ++ val V ++ err error ++} ++ ++func (g *singleflightGroup[K, V]) Do(key K, fn func() (V, error)) (V, error, bool) { ++ g.mu.Lock() ++ if g.m == nil { ++ g.m = make(map[K]*call[V]) ++ } ++ if c, ok := g.m[key]; ok { ++ g.mu.Unlock() ++ c.wg.Wait() ++ return c.val, c.err, true ++ } ++ c := &call[V]{} ++ c.wg.Add(1) ++ g.m[key] = c ++ g.mu.Unlock() ++ ++ c.val, c.err = fn() ++ c.wg.Done() ++ ++ g.mu.Lock() ++ delete(g.m, key) ++ g.mu.Unlock() ++ ++ return c.val, c.err, false ++} diff --git a/defects/drone-0002/test/Drone0002Test.java b/defects/drone-0002/test/Drone0002Test.java new file mode 100644 index 000000000..403782da4 --- /dev/null +++ b/defects/drone-0002/test/Drone0002Test.java @@ -0,0 +1,200 @@ +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.*; + +/** + * Test for drone-0002: MOAD-0005 Thundering Herd in cache/ttl_cache.go TTLCache.Get(). + * + * Location: cache/ttl_cache.go TTLCache.Get() + * + * Defect pattern (CWE-362 cache stampede): + * 1. fetch(key) — acquires RLock, returns cache miss + * 2. getter.Find(ctx, key) — called with NO lock held + * 3. mx.Lock(); cache[key] = item; mx.Unlock() + * + * Under concurrent load: C goroutines all see a cache miss simultaneously for + * the same key, all call getter.Find() independently, all issue C database + * round-trips for the same record. A thundering herd of DB queries results. + * + * Fix: wrap getter.Find() in a singleflight group keyed by the cache key. + * Only the first goroutine issues the DB call; all others share its result. + * Backend calls drop from C to 1 for any C concurrent misses on the same key. + * + * Compile and run (no build tool required): + * javac defects/drone-0002/test/Drone0002Test.java -d /tmp/drone-0002 + * java -cp /tmp/drone-0002 Drone0002Test + */ +public class Drone0002Test { + + private static int passed = 0; + private static int failed = 0; + + // --- Fake backend store with call counter --- + + static class FakeStore { + final AtomicInteger callCount = new AtomicInteger(0); + final long latencyMs; + + FakeStore(long latencyMs) { this.latencyMs = latencyMs; } + + String find(String key) throws InterruptedException { + callCount.incrementAndGet(); + Thread.sleep(latencyMs); + return "value-for-" + key; + } + } + + // --- Defective cache: no singleflight --- + + static class CacheDefective { + final Map cache = new ConcurrentHashMap<>(); + final FakeStore store; + + CacheDefective(FakeStore s) { this.store = s; } + + String get(String key) throws Exception { + String v = cache.get(key); + if (v != null) return v; + // thundering herd: multiple threads all reach here simultaneously + v = store.find(key); + cache.put(key, v); + return v; + } + } + + // --- Fixed cache: singleflight coalesces concurrent misses --- + + static class CacheFixed { + final Map cache = new ConcurrentHashMap<>(); + final FakeStore store; + final ConcurrentHashMap> inflight = new ConcurrentHashMap<>(); + + CacheFixed(FakeStore s) { this.store = s; } + + String get(String key) throws Exception { + String v = cache.get(key); + if (v != null) return v; + + CompletableFuture mine = new CompletableFuture<>(); + CompletableFuture existing = inflight.putIfAbsent(key, mine); + if (existing != null) return existing.get(); // wait for in-flight call + + try { + v = store.find(key); + cache.put(key, v); + mine.complete(v); + return v; + } catch (Exception e) { + mine.completeExceptionally(e); + throw e; + } finally { + inflight.remove(key); + } + } + } + + // --- Helpers --- + + static int countBackendCalls(boolean useFixed, int concurrent, long latencyMs) + throws Exception { + FakeStore store = new FakeStore(latencyMs); + Object cache = useFixed ? new CacheFixed(store) : new CacheDefective(store); + + CountDownLatch ready = new CountDownLatch(concurrent); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(concurrent); + ExecutorService pool = Executors.newFixedThreadPool(concurrent); + + for (int i = 0; i < concurrent; i++) { + pool.submit(() -> { + ready.countDown(); + try { + start.await(); + if (useFixed) ((CacheFixed) cache).get("mykey"); + else ((CacheDefective) cache).get("mykey"); + } catch (Exception ignored) { + } finally { done.countDown(); } + }); + } + + ready.await(5, TimeUnit.SECONDS); // all threads waiting at start gate + start.countDown(); // release simultaneously + done.await(10, TimeUnit.SECONDS); + pool.shutdown(); + return store.callCount.get(); + } + + // --- Tests --- + + static void testDefectiveTriggersManyBackendCalls() throws Exception { + int concurrent = 20; + int calls = countBackendCalls(false, concurrent, 30); + check("defective cache triggers >1 backend calls (thundering herd confirmed)", + calls > 1); + System.out.printf(" drone-0002 defective backend calls at C=%d: %d (expect ~%d)%n", + concurrent, calls, concurrent); + } + + static void testFixedTriggersExactlyOneBackendCall() throws Exception { + int concurrent = 20; + int calls = countBackendCalls(true, concurrent, 30); + check("fixed cache triggers exactly 1 backend call (singleflight working)", + calls == 1); + System.out.printf(" drone-0002 fixed backend calls at C=%d: %d (expect 1)%n", + concurrent, calls); + } + + static void testFixedReturnCorrectValue() throws Exception { + FakeStore store = new FakeStore(5); + CacheFixed cache = new CacheFixed(store); + String result = cache.get("mykey"); + check("fixed cache returns correct value", "value-for-mykey".equals(result)); + } + + static void testFixedSecondCallHitsCache() throws Exception { + FakeStore store = new FakeStore(0); + CacheFixed cache = new CacheFixed(store); + cache.get("k1"); + int afterFirst = store.callCount.get(); + cache.get("k1"); // second call — must hit cache + int afterSecond = store.callCount.get(); + check("second Get on same key hits cache (no backend call)", + afterFirst == afterSecond); + } + + static void testSpeedupRatioAtConcurrency20() throws Exception { + int concurrent = 20; + int defCalls = countBackendCalls(false, concurrent, 15); + int fixCalls = countBackendCalls(true, concurrent, 15); + double ratio = (double) defCalls / Math.max(1, fixCalls); + check("fixed version reduces backend calls by at least 10x at C=20", + ratio >= 10.0); + System.out.printf(" drone-0002 thundering herd ratio at C=%d: %.1fx fewer backend calls%n", + concurrent, ratio); + } + + // --- Harness --- + + static void check(String desc, boolean cond) { + if (cond) { + System.out.println(" PASS: " + desc); + passed++; + } else { + System.out.println(" FAIL: " + desc); + failed++; + } + } + + public static void main(String[] args) throws Exception { + System.out.println("=== Drone0002Test (MOAD-0005 Thundering Herd in TTLCache.Get) ===\n"); + + testDefectiveTriggersManyBackendCalls(); + testFixedTriggersExactlyOneBackendCall(); + testFixedReturnCorrectValue(); + testFixedSecondCallHitsCache(); + testSpeedupRatioAtConcurrency20(); + + System.out.println("\n--- " + passed + " passed, " + failed + " failed ---"); + if (failed > 0) System.exit(1); + } +} diff --git a/defects/drone/SCAN.md b/defects/drone/SCAN.md new file mode 100644 index 000000000..396adf5a1 --- /dev/null +++ b/defects/drone/SCAN.md @@ -0,0 +1,27 @@ +## Drone CI (Harness Gitness) 5-MOAD Scan — 2026-03-31 + +Target: https://github.com/harness/drone (depth=1) + +### MOAD-0001 (CWE-407) — 1 DEFECT FOUND +See drone-0001. pubsub/inmem.go Publish() calls slices.Contains(sub.topics, topic) +for every subscriber — O(S*T) per Publish call. + +### MOAD-0002 (Intertangle) — CLEAN +Manager struct aggregates stores via dependency injection; each store interface is +independently swappable. No shared mutable global state coupling pipeline execution +to unrelated subsystems. Wire-based DI separates concerns cleanly. + +### MOAD-0003 (Leaked Context) — CLEAN +app/api/request/context.go uses context.Context (standard Go pattern for +per-request identity). No goroutine-local or thread-local identity leakage found. +Pattern matches Kubernetes apiserver context design. + +### MOAD-0004 (CWE-312) — CLEAN +Searched all log calls for token/secret/password/webhook literals. No credential +values passed to zerolog log lines. Secret service logs error conditions only, +never values. Webhook tokens are stored encrypted in DB, not logged. + +### MOAD-0005 (Thundering Herd) — 1 DEFECT FOUND +See drone-0002. cache/ttl_cache.go TTLCache.Get() has classic cache stampede: +fetch (RLock miss) then getter.Find() with no lock, then write. Multiple goroutines +can all miss and all call DB simultaneously. diff --git a/defects/esp-idf-0001/patch/esp-idf-0001.patch b/defects/esp-idf-0001/patch/esp-idf-0001.patch new file mode 100644 index 000000000..330da9d7a --- /dev/null +++ b/defects/esp-idf-0001/patch/esp-idf-0001.patch @@ -0,0 +1,9 @@ +--- a/components/esp_wifi/src/smartconfig.c ++++ b/components/esp_wifi/src/smartconfig.c +@@ -32,7 +32,6 @@ static void handler_got_ssid_passwd(void *arg, esp_event_base_t base, int32_t e + memcpy(password, evt->password, sizeof(evt->password)); + memcpy(cellphone_ip, evt->cellphone_ip, sizeof(evt->cellphone_ip)); + + ESP_LOGD(TAG, "SSID:%s", ssid); +- ESP_LOGD(TAG, "PASSWORD:%s", password); + ESP_LOGD(TAG, "Phone ip: %d.%d.%d.%d", cellphone_ip[0], cellphone_ip[1], cellphone_ip[2], cellphone_ip[3]); diff --git a/defects/esp-idf-0001/test/EspIdf0001Test.java b/defects/esp-idf-0001/test/EspIdf0001Test.java new file mode 100644 index 000000000..e19329873 --- /dev/null +++ b/defects/esp-idf-0001/test/EspIdf0001Test.java @@ -0,0 +1,131 @@ +import java.util.ArrayList; +import java.util.List; + +/** + * Test for esp-idf-0001: CWE-312 WiFi password logged verbatim via ESP_LOGD + * in components/esp_wifi/src/smartconfig.c handler_got_ssid_passwd(). + * + * Pattern: + * ESP_LOGD(TAG, "PASSWORD:%s", password) + * + * When debug logging is enabled the WiFi PSK (pre-shared key) is emitted + * verbatim to serial/JTAG output. Any listener on those interfaces obtains + * the plaintext credential without any further effort. + * + * Fix: remove the ESP_LOGD("PASSWORD:%s", ...) line. SSID logging (non-secret) + * is acceptable; PSK logging is not. + * + * Compile and run (no build tool required): + * javac defects/esp-idf-0001/test/EspIdf0001Test.java -d /tmp/esp-idf-0001 + * java -cp /tmp/esp-idf-0001 EspIdf0001Test + */ +public class EspIdf0001Test { + + private static int passed = 0; + private static int failed = 0; + + // --- Simulated log collector --- + + static class LogCollector { + private final List lines = new ArrayList<>(); + + void logd(String tag, String fmt, Object... args) { + lines.add(String.format(fmt, args)); + } + + boolean containsText(String text) { + for (String line : lines) { + if (line.contains(text)) return true; + } + return false; + } + + int size() { return lines.size(); } + } + + // --- Defective: logs password in DEBUG output --- + + static void handleGotSsidPasswdDefective(String ssid, String password, LogCollector log) { + log.logd("smartconfig", "SSID:%s", ssid); + log.logd("smartconfig", "PASSWORD:%s", password); // CWE-312 site + log.logd("smartconfig", "Phone ip: 192.168.1.1"); + } + + // --- Fixed: password line removed --- + + static void handleGotSsidPasswdFixed(String ssid, String password, LogCollector log) { + log.logd("smartconfig", "SSID:%s", ssid); + // PASSWORD log line removed — fix for esp-idf-0001 + log.logd("smartconfig", "Phone ip: 192.168.1.1"); + } + + // --- Tests --- + + static void testDefectiveLogsPassword() { + LogCollector log = new LogCollector(); + String password = "MySecretWiFiPSK123"; + handleGotSsidPasswdDefective("MyNet", password, log); + check("defective must log the password (confirms CWE-312 site is present)", + log.containsText(password)); + } + + static void testFixedDoesNotLogPassword() { + LogCollector log = new LogCollector(); + String password = "MySecretWiFiPSK123"; + handleGotSsidPasswdFixed("MyNet", password, log); + check("fixed must NOT log the WiFi PSK in any log line", + !log.containsText(password)); + } + + static void testFixedStillLogsSsid() { + LogCollector log = new LogCollector(); + handleGotSsidPasswdFixed("HomeNetwork_5GHz", "secret", log); + check("SSID (non-sensitive) must still appear in logs after fix", + log.containsText("HomeNetwork_5GHz")); + } + + static void testFixedHasFewerLines() { + LogCollector defLog = new LogCollector(); + LogCollector fixLog = new LogCollector(); + handleGotSsidPasswdDefective("net", "pw", defLog); + handleGotSsidPasswdFixed("net", "pw", fixLog); + check("fixed version produces fewer log lines than defective", + fixLog.size() < defLog.size()); + } + + static void testPasswordNotLeakedAcrossMultipleCalls() { + // Simulate multiple smartconfig events — none should leak password + String[] passwords = {"PSK-alpha", "PSK-beta", "PSK-gamma"}; + for (String pw : passwords) { + LogCollector log = new LogCollector(); + handleGotSsidPasswdFixed("net", pw, log); + check("password '" + pw + "' must not appear in any log line", + !log.containsText(pw)); + } + } + + // --- Harness --- + + static void check(String desc, boolean cond) { + if (cond) { + System.out.println(" PASS: " + desc); + passed++; + } else { + System.out.println(" FAIL: " + desc); + failed++; + } + } + + public static void main(String[] args) { + System.out.println("=== EspIdf0001Test (CWE-312 WiFi PSK logged) ===\n"); + + testDefectiveLogsPassword(); + testFixedDoesNotLogPassword(); + testFixedStillLogsSsid(); + testFixedHasFewerLines(); + testPasswordNotLeakedAcrossMultipleCalls(); + + System.out.println("\n--- " + passed + " passed, " + failed + " failed ---"); + if (failed > 0) System.exit(1); + } +} diff --git a/defects/esp-idf-0002/patch/esp-idf-0002.patch b/defects/esp-idf-0002/patch/esp-idf-0002.patch new file mode 100644 index 000000000..e87082b20 --- /dev/null +++ b/defects/esp-idf-0002/patch/esp-idf-0002.patch @@ -0,0 +1,8 @@ +--- a/components/esp_http_client/lib/http_auth.c ++++ b/components/esp_http_client/lib/http_auth.c +@@ -155,7 +155,6 @@ char *http_auth_digest(const char *username, const char *password, esp_http_auth + if (digest_func(ha1, "%s:%s:%s", username, auth_data->realm, password) <= 0) { + goto _digest_exit; + } + +- ESP_LOGD(TAG, "%s %s %s %s", "Digest", username, auth_data->realm, password); diff --git a/defects/esp-idf-0002/test/EspIdf0002Test.java b/defects/esp-idf-0002/test/EspIdf0002Test.java new file mode 100644 index 000000000..e90d53807 --- /dev/null +++ b/defects/esp-idf-0002/test/EspIdf0002Test.java @@ -0,0 +1,146 @@ +import java.util.ArrayList; +import java.util.List; + +/** + * Test for esp-idf-0002: CWE-312 HTTP Digest auth password logged verbatim + * in components/esp_http_client/lib/http_auth.c http_auth_digest(). + * + * Pattern: + * ESP_LOGD(TAG, "%s %s %s %s", "Digest", username, auth_data->realm, password) + * + * During HTTP Digest authentication setup the plaintext password is emitted + * to serial/JTAG when debug logging is active. Unlike Basic Auth the password + * should never leave the chip except as part of the one-way digest hash. + * + * Fix: remove the offending ESP_LOGD line. Logging the digest hash result + * (ha1/response) would be safe; logging the raw password is not. + * + * Compile and run (no build tool required): + * javac defects/esp-idf-0002/test/EspIdf0002Test.java -d /tmp/esp-idf-0002 + * java -cp /tmp/esp-idf-0002 EspIdf0002Test + */ +public class EspIdf0002Test { + + private static int passed = 0; + private static int failed = 0; + + // --- Simulated log collector --- + + static class LogCollector { + private final List lines = new ArrayList<>(); + + void logd(String tag, String fmt, Object... args) { + lines.add(String.format(fmt, args)); + } + + boolean containsText(String text) { + for (String line : lines) { + if (line.contains(text)) return true; + } + return false; + } + + int size() { return lines.size(); } + } + + /** Trivial stand-in for MD5 hex — actual hash value irrelevant for this test. */ + static String mockMd5(String input) { + return Integer.toHexString(input.hashCode() & 0x7fffffff); + } + + // --- Defective: logs username + realm + password --- + + static String computeDigestDefective( + String username, String realm, String password, LogCollector log) { + String ha1 = mockMd5(username + ":" + realm + ":" + password); + log.logd("HTTP_AUTH", "%s %s %s %s", "Digest", username, realm, password); // CWE-312 + String ha2 = mockMd5("GET:/api/data"); + return mockMd5(ha1 + ":nonce123:" + ha2); + } + + // --- Fixed: no password in any log statement --- + + static String computeDigestFixed( + String username, String realm, String password, LogCollector log) { + String ha1 = mockMd5(username + ":" + realm + ":" + password); + // ESP_LOGD with password removed — fix for esp-idf-0002 + String ha2 = mockMd5("GET:/api/data"); + return mockMd5(ha1 + ":nonce123:" + ha2); + } + + // --- Tests --- + + static void testDefectiveLogsPassword() { + LogCollector log = new LogCollector(); + computeDigestDefective("alice", "testrealm@host.com", "hunter2", log); + check("defective must log the password (confirms CWE-312 site is present)", + log.containsText("hunter2")); + } + + static void testFixedDoesNotLogPassword() { + LogCollector log = new LogCollector(); + computeDigestFixed("alice", "testrealm@host.com", "hunter2", log); + check("fixed must NOT log the HTTP auth password", + !log.containsText("hunter2")); + } + + static void testDefectiveAlsoExposesUsername() { + LogCollector log = new LogCollector(); + computeDigestDefective("alice", "testrealm@host.com", "hunter2", log); + // The combined log line is the real risk (username + realm + password together) + check("defective log line includes username (combined exposure risk)", + log.containsText("alice")); + } + + static void testFixedProducesNoLogLines() { + LogCollector log = new LogCollector(); + computeDigestFixed("alice", "testrealm@host.com", "hunter2", log); + check("fixed digest computation emits no log lines", + log.size() == 0); + } + + static void testBothReturnSameDigest() { + LogCollector log1 = new LogCollector(); + LogCollector log2 = new LogCollector(); + String d1 = computeDigestDefective("bob", "realm", "s3cr3t", log1); + String d2 = computeDigestFixed("bob", "realm", "s3cr3t", log2); + check("fixed and defective produce identical digest response (fix is behaviour-neutral)", + d1.equals(d2)); + } + + static void testPasswordNotLeakedForMultipleRealms() { + String[] realms = {"api.example.com", "admin.local", "iot-gateway"}; + for (String realm : realms) { + LogCollector log = new LogCollector(); + computeDigestFixed("user", realm, "TopSecretPW", log); + check("password not logged for realm '" + realm + "'", + !log.containsText("TopSecretPW")); + } + } + + // --- Harness --- + + static void check(String desc, boolean cond) { + if (cond) { + System.out.println(" PASS: " + desc); + passed++; + } else { + System.out.println(" FAIL: " + desc); + failed++; + } + } + + public static void main(String[] args) { + System.out.println("=== EspIdf0002Test (CWE-312 HTTP auth password logged) ===\n"); + + testDefectiveLogsPassword(); + testFixedDoesNotLogPassword(); + testDefectiveAlsoExposesUsername(); + testFixedProducesNoLogLines(); + testBothReturnSameDigest(); + testPasswordNotLeakedForMultipleRealms(); + + System.out.println("\n--- " + passed + " passed, " + failed + " failed ---"); + if (failed > 0) System.exit(1); + } +} diff --git a/defects/esp-idf/SCAN.md b/defects/esp-idf/SCAN.md new file mode 100644 index 000000000..66f1478e0 --- /dev/null +++ b/defects/esp-idf/SCAN.md @@ -0,0 +1,28 @@ +## ESP-IDF 5-MOAD Scan — 2026-03-31 + +Target: https://github.com/espressif/esp-idf (depth=1) + +### MOAD-0001 (CWE-407) — CLEAN +WiFi core (esp_wifi) is a precompiled binary blob (no C source for station list). +Bluetooth GATT utils (gatt_utils.c) iterates fixed-size arrays bounded by +GATT_MAX_APPS (max ~10 simultaneous GATT apps) and GATT_MAX_BG_CONN_DEV — not +O(N^2) at IoT scale; array sizes are compile-time constants, not user-controlled. +NVS page scan iterates ENTRY_COUNT (126 entries per page) — bounded, not unbounded. +No O(N^2) hot-path defect found. + +### MOAD-0002 (Intertangle) — CLEAN +Components are cleanly decoupled: esp_wifi, bt, nvs_flash, esp-tls, esp_http_client +each have their own init/deinit. No shared mutable god object found coupling +independent subsystems. + +### MOAD-0003 (Leaked Context) — CLEAN +ESP-IDF is C-based, uses FreeRTOS tasks. No thread-local storage API misuse found. +pvTaskGetThreadLocalStoragePointer usage in components is scoped to task setup, +not request-identity leakage. + +### MOAD-0004 (CWE-312) — 2 DEFECTS FOUND +See esp-idf-0001 and esp-idf-0002. + +### MOAD-0005 (Thundering Herd) — CLEAN +NVS uses NVSHandleLocked with Lock RAII guard on every read/write operation. +WiFi event loop is single-threaded event dispatch. No concurrent cache stampede found.