<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Nartan Naik]]></title><description><![CDATA[Nartan Naik]]></description><link>https://nrtnnaik.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 11:22:18 GMT</lastBuildDate><atom:link href="https://nrtnnaik.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[What Is Serialization? A Descent Into Memory (and the Climb Back Out)]]></title><description><![CDATA[Why can’t a program just send its memory? Learn serialization in C++ through the stack, heap, pointers, byte order and RFC 4253, with code you can run]]></description><link>https://nrtnnaik.hashnode.dev/what-is-serialization-a-descent-into-memory-and-the-climb-back-out</link><guid isPermaLink="true">https://nrtnnaik.hashnode.dev/what-is-serialization-a-descent-into-memory-and-the-climb-back-out</guid><category><![CDATA[serialization]]></category><category><![CDATA[#cpp #guide]]></category><category><![CDATA[byte-order]]></category><category><![CDATA[endianness]]></category><category><![CDATA[stack and heap]]></category><category><![CDATA[networking]]></category><category><![CDATA[pcapplusplus]]></category><category><![CDATA[open source]]></category><category><![CDATA[ssh]]></category><category><![CDATA[rfc-4253]]></category><dc:creator><![CDATA[Nartan Naik]]></dc:creator><pubDate>Sat, 07 Nov 2026 07:03:19 GMT</pubDate><content:encoded><![CDATA[<h1>What Is Serialization? A Descent Into Memory (and the Climb Back Out)</h1>
<p><em>Part 1 of my series on contributing structured serialization to the SSH layer of PcapPlusPlus.</em></p>
<blockquote>
<p><strong>How to read this post.</strong> Think of the call stack from your DSA class. Every time we ask a new question, we <strong>push</strong> a frame and dive deeper. Every time we find an answer, we <strong>pop</strong> back up to the question that sent us down. By the end, the stack is empty and the main question is answered. Watch for the markers: ⬇ means we are diving, ⬆ means we are returning.</p>
</blockquote>
<hr />
<h2>Level 0: The main question</h2>
<p><strong>What is serialization?</strong></p>
<p>Here is the one-sentence definition, which we will earn properly by the end:</p>
<blockquote>
<p><strong>Serialization</strong> is converting an in-memory data structure into a flat, self-contained format that can be stored, transmitted, or inspected outside the running program. <strong>Deserialization</strong> is the reverse: rebuilding the structure from that format.</p>
</blockquote>
<p>That sentence hides at least four unanswered questions. <em>Flat</em> compared to what? <em>Self-contained</em> from what? What is a <em>format</em>? And why can't a program just hand over its memory?</p>
<p>Let's push our first frame.</p>
<hr />
<h2>⬇ Level 1: Why can't we just copy the memory?</h2>
<p>Here is some data in a C++ program:</p>
<pre><code class="language-cpp">struct Person
{
    std::string name;
    int age;
};

Person p{"Bruce Wayne of Gotham City, heir to Wayne Enterprises", 35};
</code></pre>
<p><code>p</code> is an object sitting in RAM. The tempting idea: "It's just bytes. I'll copy <code>sizeof(Person)</code> bytes and send them to another program."</p>
<p>It fails. To see why, run this experiment and look at the addresses:</p>
<pre><code class="language-cpp">std::cout &lt;&lt; "sizeof(Person)         = " &lt;&lt; sizeof(Person) &lt;&lt; " bytes\n";
std::cout &lt;&lt; "name length            = " &lt;&lt; p.name.size() &lt;&lt; " characters\n";
std::cout &lt;&lt; "address of p (stack)   = " &lt;&lt; static_cast&lt;const void*&gt;(&amp;p) &lt;&lt; "\n";
std::cout &lt;&lt; "address of the text    = " &lt;&lt; static_cast&lt;const void*&gt;(p.name.data()) &lt;&lt; "\n";
</code></pre>
<p><strong>My real output:</strong></p>
<pre><code class="language-text">=== Experiment 1: where does the data live? ===
sizeof(Person)         = 28 bytes
name length            = 53 characters
address of p (stack)   = 0x61fecc
address of the text    = 0x939ad0
</code></pre>
<p>Notice that <code>sizeof(Person)</code> is a small, fixed number, yet the name has 53 characters. The characters cannot be inside the object. And look at the two addresses: they are in completely different regions of memory. The object <code>p</code> holds the text <strong>somewhere else</strong>.</p>
<blockquote>
<p><strong>A surprise from running it on two machines.</strong> On my Windows build <code>sizeof(Person)</code> printed <strong>28</strong>. The same program on a 64-bit Linux machine printed <strong>40</strong>. The code did not change, so the <em>layout</em> of the same struct did. I checked my compiler with <code>g++ -dumpmachine</code> and it reported <code>mingw32</code>, a 32-bit target (note the short six-digit addresses). With this standard library, a pointer is 4 bytes there and a <code>std::string</code> is 24 bytes, so the struct is 24 + 4 = 28. On a 64-bit build a pointer is 8 bytes, a <code>std::string</code> is 32 bytes, and the compiler pads the struct to a multiple of 8, giving 40. The lesson: <strong>the raw bytes of an object depend on the compiler and the machine</strong>, so you cannot ship them to another system and expect them to mean the same thing.</p>
</blockquote>
<p>Where does the text live, then? That is a question for the next level.</p>
<h3>⬇⬇ Level 2: Where does the data live? Stack vs heap</h3>
<p>A running program uses two main regions of RAM.</p>
<table>
<thead>
<tr>
<th></th>
<th><strong>Stack</strong></th>
<th><strong>Heap</strong></th>
</tr>
</thead>
<tbody><tr>
<td>Managed</td>
<td>automatically</td>
<td>manually (<code>new</code>/<code>malloc</code>), or by owner objects like <code>std::string</code> and <code>std::vector</code></td>
</tr>
<tr>
<td>Created</td>
<td>when a function is called</td>
<td>when your code asks for it at runtime</td>
</tr>
<tr>
<td>Destroyed</td>
<td>when the function returns</td>
<td>when freed (or when the owning object is destroyed)</td>
</tr>
<tr>
<td>Size</td>
<td>known at compile time</td>
<td>decided at runtime, can be large</td>
</tr>
<tr>
<td>Speed</td>
<td>very fast</td>
<td>slower</td>
</tr>
</tbody></table>
<p>The stack is the <strong>same LIFO stack</strong> you know from DSA, applied to memory: each function call pushes a <em>stack frame</em> holding its local variables, and returning pops it. That is why local variables vanish when a function ends.</p>
<p>The heap is for two situations: the size is unknown until runtime (a string could be 5 or 5 million characters), or the data must outlive the function that made it.</p>
<p>Both live in the same RAM, as two regions of one building with two floors. And a warning about names: this "heap" has <strong>nothing to do</strong> with the min-heap or max-heap data structure. It is an unfortunate shared word.</p>
<p>So a <code>std::string</code> is a small object on the stack holding a pointer, a length and a capacity. The actual characters live in a heap block it manages.</p>
<blockquote>
<p><strong>A footnote worth a footnote.</strong> Short strings may be stored <em>inside</em> the string object itself (the "small string optimization"). That is why the demo uses a deliberately long name: it forces the text onto the heap so we can see the effect.</p>
</blockquote>
<h3>⬆ Back at Level 1: so what does <code>p</code> actually contain?</h3>
<p>It contains a <strong>pointer</strong>: an address. Which raises the next question.</p>
<h3>⬇⬇ Level 2: Why is an address useless to anyone else?</h3>
<p>Every process gets its own <strong>virtual address space</strong>. An address such as <code>0x5569db7512b0</code> is a number meaningful only inside the process that owns it, like a house number that exists only in one town. Copy the bytes of <code>p</code> into another program and you copy the <em>house number</em>, not the house. The other process finds something unrelated at that address, or nothing, and reading it may crash. This is a <strong>shallow copy</strong>: it duplicates the pointer but not the thing pointed to.</p>
<p>Beyond pointers, raw object bytes also depend on the compiler, the CPU, and the standard library: <code>sizeof(std::string)</code>, padding between members, and byte order can all differ between machines.</p>
<h3>⬆ Back at Level 1: the way out</h3>
<p>If memory can't leave the process, we have to build something that can. We <strong>follow the pointers, collect the real data, and write it down in a flat form</strong> that has no addresses and no assumptions about layout. Here is the smallest possible serializer:</p>
<pre><code class="language-cpp">std::string toJson(const Person&amp; p)
{
    return "{\"name\": \"" + p.name + "\", \"age\": " + std::to_string(p.age) + "}";
}
</code></pre>
<p><strong>My real output:</strong></p>
<pre><code class="language-text">=== Experiment 2: serialize to a flat text format ===
{"name": "Bruce Wayne of Gotham City, heir to Wayne Enterprises", "age": 35}
</code></pre>
<p>That text contains the real characters and the real number. It can be saved to a file, sent over a network, or read by a Python or JavaScript program. That is serialization.</p>
<blockquote>
<p><strong>Honest caveat.</strong> My <code>toJson</code> is a toy. A real one must <strong>escape</strong> special characters: a name containing a quote (<code>"</code>) would produce broken JSON. Real serializers handle that for you, which is one reason we use a framework instead of string concatenation.</p>
</blockquote>
<p>Level 1 is answered. But a skeptic asks: <em>"Do we really need text? Can't we send raw bytes when the data is simple?"</em> Yes, and that opens the deepest part of the dive.</p>
<hr />
<h2>⬇ Level 1: When are raw bytes fine?</h2>
<p>Raw binary is compact and fast. It works when two conditions hold:</p>
<ol>
<li>The data contains <strong>no pointers</strong>, only plain values such as fixed-size integers (<code>uint32_t</code> is always exactly 4 bytes).</li>
<li>Both sides <strong>agree on the exact layout</strong>, including byte order.</li>
</ol>
<p>This clears up a common confusion: <strong>JSON versus raw binary is not "serialization versus no serialization."</strong> Both are serialization. They are just different <em>formats</em>.</p>
<p>And here a new question appears, because bytes alone say nothing.</p>
<h3>⬇⬇ Level 2: What does a byte sequence <em>mean</em>?</h3>
<p>Look at these four bytes: <code>00 00 01 2C</code>.</p>
<ul>
<li>If both sides agree "this is a 4-byte unsigned integer", it means <strong>300</strong>.</li>
<li>If they agree "these are four separate flags", it means something entirely different.</li>
</ul>
<p>Raw bytes have <strong>no meaning on their own</strong>. Meaning comes from an agreed rule, and that rule is the <strong>format</strong>.</p>
<p>How does 300 become <code>00 00 01 2C</code>? One byte only holds 0 to 255, so we split the number in base 256 (like decimal digits, but with 256 symbols):</p>
<ul>
<li>300 = 1 × 256 + 44</li>
<li>The two "digits" are 1 and 44, which in hexadecimal are <code>01</code> and <code>2C</code></li>
<li>Padded to four bytes: <code>00 00 01 2C</code></li>
</ul>
<p>The receiver never sees "300". It sees four bytes and applies the agreed rule <strong>in reverse</strong>. Notice that the rule included something subtle: <em>which byte comes first?</em></p>
<h3>⬇⬇⬇ Level 3: Byte order (endianness)</h3>
<p>In the decimal number 4,205, the <strong>4</strong> is the most significant digit (it changes the value most) and the <strong>5</strong> is the least significant. Bytes work the same way: in <code>00 00 01 2C</code>, the byte <code>01</code> carries more weight than <code>2C</code>.</p>
<p><strong>Endianness is simply the decision of which byte to store first.</strong></p>
<table>
<thead>
<tr>
<th>Order</th>
<th>Stores first</th>
<th>300 looks like</th>
<th>Where</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Big-endian</strong></td>
<td>most significant byte</td>
<td><code>00 00 01 2C</code></td>
<td>network protocols ("network byte order")</td>
</tr>
<tr>
<td><strong>Little-endian</strong></td>
<td>least significant byte</td>
<td><code>2C 01 00 00</code></td>
<td>most PCs and phones (x86, ARM)</td>
</tr>
</tbody></table>
<p>Why do both exist? Largely <strong>history</strong>: hardware designers chose differently and neither side won. Big-endian reads naturally in a hex dump; little-endian suits some arithmetic hardware. The early Internet standardized on big-endian.</p>
<p>Let's see it on a real machine:</p>
<pre><code class="language-cpp">std::uint32_t n = 300;
const unsigned char* mem = reinterpret_cast&lt;const unsigned char*&gt;(&amp;n);

for (int i = 0; i &lt; 4; ++i)
    std::printf("%02x ", mem[i]);          // bytes as MY machine stores them

unsigned char wire[4];
wire[0] = static_cast&lt;unsigned char&gt;((n &gt;&gt; 24) &amp; 0xFF);
wire[1] = static_cast&lt;unsigned char&gt;((n &gt;&gt; 16) &amp; 0xFF);
wire[2] = static_cast&lt;unsigned char&gt;((n &gt;&gt; 8)  &amp; 0xFF);
wire[3] = static_cast&lt;unsigned char&gt;( n        &amp; 0xFF);

for (int i = 0; i &lt; 4; ++i)
    std::printf("%02x ", wire[i]);         // bytes in big-endian (network) order
</code></pre>
<p><strong>My real output:</strong></p>
<pre><code class="language-text">=== Experiment 3: byte order of 300 ===
bytes in MY machine's memory: 2c 01 00 00 
bytes in big-endian (network) order: 00 00 01 2c 
</code></pre>
<p>The first line is my machine's own <em>little-endian</em> order, and the second is the <em>big-endian</em> network order. Same number, two byte sequences.</p>
<p><strong>Why does this matter if it never affects my own PC?</strong> Inside one machine, every instruction uses the same order and you never see the bytes. Byte order matters only when <strong>bytes cross a boundary</strong>: a network, a file, another machine. A little-endian machine that reads <code>00 00 01 2C</code> straight into an integer sees <code>2C 01 00 00</code> in its own order, which is 738,262,272 instead of 300. That is why networking code converts ("network to host", <code>ntohl</code> and friends), and why documentation for every field in a protocol library should state its byte order.</p>
<p><em>(JSON text has no byte-order problem for numbers: the digits <code>3</code>, <code>0</code>, <code>0</code> are written in reading order. Byte order is a binary-format concern.)</em></p>
<h3>⬆⬆ Back at Level 2: two things settled</h3>
<p>Bytes mean what the format says, and the format includes the byte order. Which brings us to the question that has been waiting since Level 1.</p>
<h3>⬇⬇ Level 2: How do two programs agree on a format?</h3>
<p><strong>They don't negotiate at runtime. They agree in advance, on paper.</strong> The agreement is a <strong>specification</strong>, and both programs are written to follow it.</p>
<p>For the Internet, many specifications live in documents called <strong>RFCs</strong>: <strong>Request for Comments</strong>. For SSH, the transport protocol is defined in <strong>RFC 4253</strong>, <em>The Secure Shell (SSH) Transport Layer Protocol</em>. The OpenSSH developers wrote their sender to follow it; the PcapPlusPlus developers wrote their reader to follow it. They never needed to speak to each other. If someone disagrees with the document, the symptom is garbage values or crashes.</p>
<p>SSH's basic data types are defined in a sibling document, <strong>RFC 4251</strong> (<em>The Secure Shell (SSH) Protocol Architecture</em>, section 5). There, a <code>uint32</code> is defined as a 32-bit unsigned integer stored as four bytes in order of decreasing significance, which it calls network byte order: <strong>big-endian</strong>. A <code>string</code> is a <code>uint32</code> length followed by that many bytes, and a <code>name-list</code> is a string of comma-separated names. Specifications are often split across several RFCs like this.</p>
<h3>⬇⬇⬇ Level 3: So what do SSH bytes look like?</h3>
<p>RFC 4253 defines, among other things:</p>
<ul>
<li><strong>The identification string.</strong> A text line at the start of a connection, in the form <code>SSH-protoversion-softwareversion</code>, optionally followed by a comment, and ended by carriage return and line feed.</li>
<li><strong>The binary packet.</strong> After that, messages travel as: a 4-byte <strong>packet length</strong> (<code>uint32</code>), a 1-byte <strong>padding length</strong>, the <strong>payload</strong>, random <strong>padding</strong>, and a <strong>MAC</strong> whose size depends on the negotiated algorithm (it is empty at the start, before any MAC has been agreed). Since <code>uint32</code> is big-endian by RFC 4251, so is the packet length.</li>
<li><strong>Key exchange.</strong> The handshake in which the two sides agree on encryption algorithms. After keys are set up, the payload is encrypted and becomes opaque to anyone watching the wire.</li>
</ul>
<p>Here is the insight that connects everything: <strong>those SSH wire bytes are already serialized.</strong> The sender took its in-memory data and wrote it into this exact binary layout. That was serialization. Anyone reading the capture must then <em>deserialize</em> it, which is what a parsing library does.</p>
<h3>⬆⬆⬆ Back to Level 1: the stack is shrinking</h3>
<p>Raw bytes are fine, but only because a format (written in a specification) gives them meaning. Serialization is not <em>opposed</em> to sending bytes. Sending bytes <strong>is</strong> serialization, whenever the bytes follow an agreed format.</p>
<hr />
<h2>Side quests: three things that sound similar but aren't</h2>
<p>While learning this, you will meet other packet topics. They are neighbours, not synonyms:</p>
<table>
<thead>
<tr>
<th>Topic</th>
<th>The question it answers</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Serialization / byte order</strong></td>
<td><em>How do we represent the data so the other side can read it?</em></td>
</tr>
<tr>
<td><strong>Checksum</strong> (and SSH's cryptographic cousin, the <strong>MAC</strong>)</td>
<td><em>Did the data arrive unchanged?</em></td>
</tr>
<tr>
<td><strong>Packet loss</strong></td>
<td><em>Did the data arrive at all?</em></td>
</tr>
</tbody></table>
<p>Data can fail in any one of these ways while the other two are fine: perfect format but corrupted in transit; intact but never delivered; delivered but read in the wrong byte order. (A checksum is also just another <strong>field</strong> in a header, with its own size and byte order, so it is itself serialized.)</p>
<hr />
<h2>⬆ Back to Level 0: the stack is empty, the answer stands</h2>
<p>Now the definition from the top of this post should read very differently:</p>
<blockquote>
<p><strong>Serialization</strong> converts an in-memory data structure (full of pointers and machine-specific layout) into a <strong>flat, self-contained format</strong> (no addresses, agreed meaning, agreed byte order) that can be stored, transmitted, or inspected outside the running program.</p>
</blockquote>
<p>Four words to keep:</p>
<ul>
<li><strong>Serializer</strong>: the code that writes the format (<code>toJson</code> above).</li>
<li><strong>Format</strong>: the agreed rules for the output (JSON, XML, YAML, or a binary layout like SSH's).</li>
<li><strong>Byte order</strong>: one rule <em>inside</em> a binary format.</li>
<li><strong>Round trip</strong>: serialize, then deserialize, and you get back the same data.</li>
</ul>
<hr />
<h2>Why any of this matters for PcapPlusPlus</h2>
<p><a href="https://github.com/seladb/PcapPlusPlus">PcapPlusPlus</a> is a C++ library for capturing, parsing and crafting network packets. For protocols like SSH it acts as an <strong>observer</strong>: it reads the already-serialized bytes from the wire and decodes them. It is the <em>parsing</em> (deserialization) half.</p>
<p>Its maintainers have added a generic serialization framework (PR #2259) so that any layer's decoded fields can be exported in a structured format. The framework adds an <code>ISerializer</code> interface, scopes for nested objects and arrays, and a JSON serializer. Each protocol layer then <strong>declares its fields once</strong>, and the serializer decides the output format. The roll-out is tracked in <a href="https://github.com/seladb/PcapPlusPlus/issues/2278">Issue #2278</a>, where the SSH layer is one of the layers still waiting for support. That is the piece I am working toward.</p>
<p>To contribute it properly, I need to understand three more things, and each gets its own post:</p>
<ol>
<li><strong>How PcapPlusPlus represents a packet</strong>: layers as views over a raw buffer.</li>
<li><strong>The serialization framework itself</strong>: fields, descriptors, and the serialize function.</li>
<li><strong>The SSH layer's fields</strong>: what is honestly available to export, and what is hidden by encryption.</li>
</ol>
<p><strong>Next up: Part 2, "A Packet Is a Stack of Views."</strong></p>
<hr />
<h2>Try it yourself</h2>
<p>The three experiments above live in one small file. Build and run:</p>
<pre><code class="language-bash">g++ -std=c++17 -Wall -Wextra -o serialization_demo serialization_demo.cpp
./serialization_demo
</code></pre>
<p>Your addresses and your <code>sizeof(Person)</code> in Experiment 1 may differ from mine, and that is itself the lesson: addresses belong to the process, and layouts belong to the compiler and machine, not to the data.</p>
]]></content:encoded></item><item><title><![CDATA[Building CyberCube: An AI-Powered SOC Platform from Packets to Alerts]]></title><description><![CDATA[How I built CyberCube, an AI-powered SOC platform that parses PCAPs, flags threats with Random Forest, and explains alerts with SHAP and MITRE ATT&CK.]]></description><link>https://nrtnnaik.hashnode.dev/building-cybercube-soc-platform</link><guid isPermaLink="true">https://nrtnnaik.hashnode.dev/building-cybercube-soc-platform</guid><category><![CDATA[cybersecurity]]></category><category><![CDATA[Machine Learning]]></category><category><![CDATA[Python]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[React]]></category><dc:creator><![CDATA[Nartan Naik]]></dc:creator><pubDate>Mon, 02 Nov 2026 12:21:46 GMT</pubDate><content:encoded><![CDATA[<p>Most security tools tell you something is wrong. A good one also tells you why, and what to do next. CyberCube is a security operations (SOC) platform I built to do all three: it reads network traffic, flags threats with a machine-learning model, and explains each alert well enough for an analyst to act on it.</p>
<h2>Where it started</h2>
<p>CyberCube began as our major project. We could have built another full-stack app, but we wanted to try something different, so we moved into cybersecurity as an experiment. This post covers what we built and what its numbers do and do not mean.</p>
<h2>From packets to alerts</h2>
<p>Traffic can enter the platform in two ways:</p>
<ul>
<li><strong>PCAP upload.</strong> You drag a packet-capture file into the web app. A parser I wrote unpacks the binary data field by field, reading the Ethernet, IP and TCP headers by hand. It then groups packets into two-way flows and runs the model on each one.</li>
<li><strong>Live capture.</strong> The sniffer tries to capture packets directly from the machine's network interface. That needs administrator privileges. When they are missing, it falls back to generated traffic so the live view keeps working. That fallback is meant for demos, not as real capture.</li>
</ul>
<p>Detected threats appear on a React dashboard through a WebSocket connection (<code>/ws/threats</code>), so new alerts arrive without refreshing the page. The dashboard includes alert tables and a global threat map that plots where attackers appear to come from.</p>
<h2>The dataset, and an honest caveat</h2>
<p>Training a detection model needs labelled traffic. I generated a dataset of 12,000 records with 22 features, designed to follow the CIC-IDS2017 schema, and included attacker locations and MITRE ATT&amp;CK labels. <strong>The data is synthetic.</strong> That means the accuracy numbers below show the pipeline works from end to end, but they are not a claim about performance on real-world traffic.</p>
<h2>Choosing a model</h2>
<p>I needed a classifier that is accurate, fast, and explainable, because analysts will only act on alerts they trust. I benchmarked four on an 80/20 split of the data (2,400 test samples):</p>
<ul>
<li><strong>Gradient Boosting</strong> matched the accuracy but was about 1.8 times slower, roughly 30 microseconds per prediction against 17.</li>
<li><strong>The neural network</strong> was faster but less accurate, and hard to explain.</li>
<li><strong>Logistic Regression</strong> was the fastest but the least accurate.</li>
<li><strong>Random Forest</strong> gave the best balance.</li>
</ul>
<p>I deployed Random Forest: about <strong>99.5% accuracy</strong>, <strong>99.37% macro-F1</strong>, around <strong>17 microseconds</strong> per prediction, and explanations that show why each flow was flagged.</p>
<h2>Explaining every alert</h2>
<p>A label like "DDoS, 98% confidence" is not enough for an analyst, so each alert comes with more:</p>
<ul>
<li><strong>SHAP waterfall charts.</strong> SHAP shows which features of a flow, such as the timing between packets or the count of TCP flags, pushed the model toward "attack" or "benign".</li>
<li><strong>MITRE ATT&amp;CK mapping.</strong> Each threat type is tagged with a standard technique ID. For example, DDoS maps to T1498, port scans to T1046, brute force to T1110, web attacks to T1190 and botnet traffic to T1071.</li>
<li><strong>Plain-language summaries.</strong> The Gemini API turns an alert into a short root-cause explanation, sent through a backend proxy so the key stays on the server.</li>
</ul>
<h2>From detection to response</h2>
<p>CyberCube also helps you act:</p>
<ul>
<li><strong>Firewall rules.</strong> One click generates commands to block an attacker's IP: <code>netsh advfirewall</code> for Windows, <code>iptables</code> for Linux, and rules for Suricata and Snort.</li>
<li><strong>PDF incident reports.</strong> A report contains the severity, attacker location, MITRE technique, all 22 flow metrics, the SHAP-based root cause, and a space for the analyst's signature.</li>
</ul>
<h2>Multi-tenancy and deployment</h2>
<p>Several organizations can use the same platform without seeing each other's data. Users sign in with Google OAuth, and every threat record carries a tenant key derived from the user's email using HMAC. Session tokens are signed and expire after 12 hours, and the WebSocket only delivers alerts that match the connected user's key.</p>
<p>The backend is FastAPI with SQLAlchemy, using SQLite by default and PostgreSQL (Neon) in production. A script injects sampled flows from the dataset into the system on a schedule, run by GitHub Actions, which keeps the dashboard populated. The app is deployed on Render and Vercel.</p>
<h2>What I would improve next</h2>
<ul>
<li>Train and test on real CIC-IDS2017 data and compare it with the synthetic results.</li>
<li>Test the live sniffer on real network traffic instead of relying on the demo fallback.</li>
<li>Add more attack types beyond the five currently mapped.</li>
<li>Write automated tests for the PCAP parser and the prediction pipeline.</li>
</ul>
<h2>Takeaways</h2>
<p>A detection model is only useful if an analyst can trust it and act on its output. The explanations, the standard threat labels and the ready-made firewall rules were as much of the project as the classifier itself. And being clear about what synthetic data can and cannot prove makes the results more believable.</p>
<ul>
<li>Live app: <a href="https://ai-powered-cyber-threat-detection-soc.onrender.com">ai-powered-cyber-threat-detection-soc.onrender.com</a></li>
<li>Code: <a href="https://github.com/NartanNaik/CyberCube">github.com/NartanNaik/CyberCube</a></li>
<li>Stack: Python, Scikit-learn, FastAPI, React, PostgreSQL, SHAP, Gemini API</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[Cutting LLM Input by 98%: How I Built Unpack]]></title><description><![CDATA[How I built Unpack, an AI tool that turns codebases into architecture docs, and a custom scanner that cut LLM input from 6.45M to ~125K tokens.]]></description><link>https://nrtnnaik.hashnode.dev/how-i-built-unpack</link><guid isPermaLink="true">https://nrtnnaik.hashnode.dev/how-i-built-unpack</guid><category><![CDATA[llm]]></category><category><![CDATA[Python]]></category><category><![CDATA[FastAPI]]></category><category><![CDATA[React]]></category><category><![CDATA[supabase]]></category><dc:creator><![CDATA[Nartan Naik]]></dc:creator><pubDate>Mon, 02 Nov 2026 12:18:09 GMT</pubDate><content:encoded><![CDATA[<p>Every time I wanted an AI's help with one of my projects, I had to explain the project from scratch: the file structure, how the pieces connect, what I had changed since last time. It took a lot of time, and the explanation was never fully accurate. Unpack is the tool I built to stop doing that. You give it a codebase, and it streams back architecture documentation.</p>
<h2>The problem: a codebase does not fit in a prompt</h2>
<p>The obvious fix is to send the whole repository to an AI model. That fails quickly. On a large raw repository, the input came to about 6.45 million tokens, far beyond any model's context window. My early attempts with a big project like CyberCube ended with an AI telling me it was too huge and to try a smaller one.</p>
<p>That is not a useful answer for a tool whose job is to understand big projects. So the real problem became deciding what the model should never see.</p>
<h2>The scanner: cutting 98% of the noise</h2>
<p>Most of a repository is not worth reading. Build artifacts, binaries, lockfiles and generated files add size and no understanding. I wrote a custom scanner in Python that:</p>
<ul>
<li>Skips ignored directories, file names and extensions (build output, binaries, lockfiles).</li>
<li>Enforces size caps on individual files, on the raw upload and on the final text sent to the model.</li>
<li>Ranks files by importance so the most useful code is kept when the cap is reached.</li>
</ul>
<table>
<thead>
<tr>
<th></th>
<th>Tokens</th>
</tr>
</thead>
<tbody><tr>
<td>Raw repository</td>
<td>6.45M</td>
</tr>
<tr>
<td>After scanning</td>
<td>~125K</td>
</tr>
</tbody></table>
<p>That is a 98% reduction in model input, and it removes the context-window failures.</p>
<h2>Three ways to bring in code</h2>
<ul>
<li><strong>Folder picker.</strong> The browser reads the folder directly. The files are filtered on the client side first, so less data is uploaded, and then zipped with JSZip.</li>
<li><strong>ZIP upload.</strong> The archive is opened in the browser, filtered, and zipped again.</li>
<li><strong>GitHub URL.</strong> The backend fetches a public repository, up to a size limit, and processes it the same way.</li>
</ul>
<h2>Four depths of analysis</h2>
<p>Not every question needs a 20-page report, so Unpack offers four analysis depths. Each uses its own prompt and token budget, and the backend picks a model to match:</p>
<ol>
<li><strong>Quick Summary</strong>: a short overview of what the project is.</li>
<li><strong>Standard Architecture</strong>: the main components and how they connect.</li>
<li><strong>Deep Dive</strong>: everything: stack, data flow, schema, file breakdown.</li>
<li><strong>Diagnose</strong>: a full audit that also lists security and technical-debt issues.</li>
</ol>
<h2>Streaming the answer</h2>
<p>A detailed analysis takes a while to generate, so the backend streams it back as newline-delimited JSON events instead of making you wait for one big response. There are four event types: <code>delta</code> carries new text, <code>ping</code> shows the model is still working, <code>done</code> returns the final report, and <code>error</code> reports failures. On the frontend, a small XMLHttpRequest helper tracks both the upload and the streaming response, so the user sees live progress.</p>
<h2>Accounts and cost control</h2>
<p>Users sign in with Google or GitHub through Supabase Auth. The first time someone logs in, the backend validates their session, creates an OpenRouter key for that user capped at $0, and stores it in Supabase along with a SHA-256 hash of the key. The key is tied to the user's email, so each person gets their own.</p>
<p>A $0 cap means free models only, so the app cannot produce a surprise bill, and one person's usage cannot use up another's allowance.</p>
<p>Users can also choose between two modes: <strong>Temp</strong>, where the analysis is not saved, and <strong>Persistent</strong>, where it is stored in a history table in Supabase so they can come back to it.</p>
<h2>Exporting in the browser</h2>
<p>Reports can be downloaded as PDF, DOCX or PPTX. All three are generated entirely in the browser, with jsPDF, docx and pptxgenjs, so nothing goes through the server. Every export has the same branding: a purple and gold watermark, an UNPACK footer and page numbers.</p>
<p>Nothing in this project was dramatically hard, but the PDF was the fiddliest, and generating the watermark for all three formats took more time than I expected.</p>
<h2>Does it work?</h2>
<p>Yes, and I can prove it with my own projects. The architecture notes I keep for CreatorLens AI and CyberCube were generated by Unpack. They include the system architecture, tech stack, database schema, features, file breakdown and a list of technical-debt issues, and that is exactly what I built it to produce.</p>
<h2>What I would improve next</h2>
<ul>
<li>Add rate limiting to the key-provisioning and analysis endpoints.</li>
<li>Redact secrets such as API keys from source files before they reach the model.</li>
<li>Add automated tests, starting with the scanner's filtering rules.</li>
<li>Validate uploaded archives more strictly, beyond checking their size.</li>
</ul>
<h2>Takeaways</h2>
<p>With LLM apps, deciding what not to send matters as much as the prompt. Filtering the input removed 98% of the tokens and made large repositories workable, and it came from understanding what a model actually needs to see to describe a project.</p>
<ul>
<li>Live app: <a href="https://unpack-tau.vercel.app">unpack-tau.vercel.app</a></li>
<li>Code: <a href="https://github.com/NartanNaik/Unpack">github.com/NartanNaik/Unpack</a></li>
<li>Stack: React, FastAPI, Python, Supabase, OpenRouter</li>
</ul>
]]></content:encoded></item><item><title><![CDATA[From Copy - Pasting Usernames to One - Click Imports: Building CreatorLens AI]]></title><description><![CDATA[How I built CreatorLens AI, a Flutter, React and Firestore app that imports Instagram creators in bulk, and got past Vercel's 10-second limit.]]></description><link>https://nrtnnaik.hashnode.dev/building-creatorlens-ai</link><guid isPermaLink="true">https://nrtnnaik.hashnode.dev/building-creatorlens-ai</guid><category><![CDATA[serverless]]></category><category><![CDATA[Vercel]]></category><category><![CDATA[Firebase]]></category><category><![CDATA[Flutter]]></category><category><![CDATA[Node.js]]></category><dc:creator><![CDATA[Nartan Naik]]></dc:creator><pubDate>Mon, 02 Nov 2026 12:13:12 GMT</pubDate><content:encoded><![CDATA[<p>I wanted to store a lot of Instagram creators in a database, along with details I cared about, like their nationality and other attributes. The first version of the workflow was slow, and I spent the next few months making it faster. CreatorLens AI is the result: a platform to ingest, enrich and curate creator profiles, now managing 2,377 of them.</p>
<h2>Step one: pasting usernames by hand</h2>
<p>I started by pasting each creator's username into the app one at a time. That worked for ten profiles and became painful at a hundred.</p>
<p>So I added <strong>mass upload</strong>: collect usernames in a list, then add them all at once. It was faster, but I was still filling in every attribute myself, and that took even longer than collecting the names.</p>
<h2>Step two: letting AI fill in the details</h2>
<p>I connected the Gemini API so I no longer had to type attributes like nationality by hand. The AI reads the categories I have defined and fills them in for each profile. The part I like most is that if I add a new column, the AI fills that one in too, so the schema is not hard-coded.</p>
<h2>Step three: a mobile app with a share button</h2>
<p>The workflow was still slow because I had to open the web app and enter things. So I built a <strong>Flutter mobile app</strong> with a built-in share target. When I find a creator's reel, I share it to my app the same way I would send a reel to a friend, and it lands in the website's queue. The app signs in with Google, stores tokens securely on the phone, and sends the URL to the REST API with a bearer token.</p>
<p>It was better, but each share still took more than 5 seconds from tapping share until the app closed itself.</p>
<h2>Step four: the Garbage Eater</h2>
<p>While working on the app I had been saving reels, and I realised those saved reels were already a list of everything I wanted to add. So I used a scraper to export all my saved reels as a JSON file, then built a feature to paste that JSON in and import everything at once. I named it the <strong>Garbage Eater</strong>, because I throw a big messy file at it and it sorts everything out.</p>
<p>Now my whole job is to save a reel and keep scrolling. Everything else happens afterwards, with each reel taking under a second in my own use.</p>
<p>CreatorLens now has four ways to add profiles:</p>
<table>
<thead>
<tr>
<th>Method</th>
<th>How it works</th>
</tr>
</thead>
<tbody><tr>
<td>Manual entry</td>
<td>Add one profile by hand</td>
</tr>
<tr>
<td>Mass upload</td>
<td>Paste a list of usernames</td>
</tr>
<tr>
<td>Mobile share</td>
<td>Share a reel from Instagram to the Flutter app</td>
</tr>
<tr>
<td>Garbage Eater</td>
<td>Import a JSON export of saved reels</td>
</tr>
</tbody></table>
<h2>The staging pipeline</h2>
<p>Raw URLs do not go straight into the main database. They pass through a <strong>staging queue</strong>, which is a two-step pipeline:</p>
<ol>
<li><strong>Queue.</strong> A shared reel (URL, username, thumbnail, caption) is saved in a staging collection and the request returns immediately.</li>
<li><strong>Process.</strong> A separate request enriches that item: it fetches metadata such as followers, likes, comments and views, and runs the AI step. After that, I approve the item and it moves into the main profiles collection.</li>
</ol>
<p>Splitting it this way means no single request has to do everything, and it lets me review items before they become real profiles.</p>
<h2>Hitting Vercel's 10-second limit</h2>
<p>I first hit Vercel's 10-second function limit with the Garbage Eater. Small files were fine, but a large JSON file took too long to process in one request, and the request was cut off.</p>
<p>The fix was to break the work into batches. The importer now sends items in <strong>chunks of at most 50 per request</strong>, and the staging pipeline processes them separately, so each request stays well inside the limit. The batches are throttled as well, so a big import does not flood the database.</p>
<h2>Protecting a free-tier database</h2>
<p>I run on Firestore's free tier, so every read and write counts against a daily quota. Three things keep usage down:</p>
<ul>
<li><strong>A Bloom filter</strong> is a small in-memory structure that answers "have I seen this username before?" without touching the database. It can say "probably yes" by mistake, but it never says "no" for something it has seen, which makes it a safe quick check. Because it skips a database read, it is faster and saves quota.</li>
<li><strong>Per-route rate limiting</strong> caps how often each group of endpoints can be called. For example, mass upload allows 60 requests a minute, bulk actions 30, and the video refresh job only 5.</li>
<li><strong>Chunked, batched writes</strong> group many writes into transactions instead of sending one write per profile.</li>
</ul>
<p>The Bloom filter has a limit I should be honest about: it lives in memory on each running server instance and is not loaded from the database. After a restart, or on another instance, it starts empty, so it reduces duplicates and database reads but does not guarantee zero duplicates.</p>
<h2>A security detail worth sharing: column names</h2>
<p>Because users can create custom columns, those column names end up in database queries. Firestore treats a dot in a field name as a path into nested data, so a malicious name could write to places it should not reach. A validation step now allows only letters, numbers, underscores and hyphens in column names, and I added middleware that blocks NoSQL and dot-notation injection before any input reaches a query.</p>
<p>The rest of the API follows the same careful approach:</p>
<ul>
<li>Short-lived JWTs with refresh-token rotation, so a leaked token expires quickly.</li>
<li>AES-256-GCM encryption for sensitive fields.</li>
<li>Google OAuth sign-in with per-user data isolation, so no one can see another user's profiles.</li>
</ul>
<h2>What I would improve next</h2>
<ul>
<li>Share duplicate checks across server instances, so deduplication survives restarts.</li>
<li>Add automated tests. The project currently has none, and the importer and staging logic would benefit most.</li>
<li>Stream-parse very large JSON files instead of loading them whole.</li>
<li>Move long-running enrichment to a proper background job queue.</li>
</ul>
<h2>Takeaways</h2>
<p>Most of this project was removing the slowest step, one step at a time: first typing, then pasting, then AI, then the phone, and finally the importer. A serverless time limit does not have to block you if you split the work into small, separate requests.</p>
<ul>
<li>Live app: <a href="https://createrlens.vercel.app">createrlens.vercel.app</a></li>
<li>Code: <a href="https://github.com/NartanNaik/CreaterLens">github.com/NartanNaik/CreaterLens</a></li>
<li>Stack: React, Node.js, Express, Firebase Firestore, Flutter, Vercel, Gemini API</li>
</ul>
]]></content:encoded></item></channel></rss>