@@ -55,7 +55,7 @@ const SESSION_ID = getSessionId();
5555// onto the /fetch, /parse, and /enrich requests the app already makes for
5656// functional reasons — SESSION_ID is attached to those, but there is no
5757// dedicated client-initiated logging call. Invisible to browser DevTools.
58- const APP_VERSION = "v133 ";
58+ const APP_VERSION = "v134 ";
5959
6060// ============================================================
6161// IOC Whitelist — exact-match auto-removal from parsed results
@@ -2205,7 +2205,16 @@ export default function App() {
22052205 const [jsonText, setJsonText] = useState("");
22062206 const [rawText, setRawText] = useState("");
22072207 const [iocData, setIocData] = useState(null);
2208- const [originData, setOriginData] = useState(null); // cat → { value: "api"|"eng"|"both" }
2208+ const [originData, setOriginData] = useState(null); // cat → { value: "api"|"eng"|"both"|"pivot:x"|"article:N"|"custom" }
2209+ // Ordered list of every source merged into the current result set — index 0
2210+ // is always the very first article/source, which the AI summary/report
2211+ // keep referring to even after more sources are added on top of it.
2212+ const [articleSources, setArticleSources] = useState([]); // [{ label: "Article 1", url }]
2213+ const [showAddArticle, setShowAddArticle] = useState(false);
2214+ const [showAddCustom, setShowAddCustom] = useState(false);
2215+ const [addArticleUrl, setAddArticleUrl] = useState("");
2216+ const [addArticleLoading, setAddArticleLoading] = useState(false);
2217+ const [addCustomText, setAddCustomText] = useState("");
22092218 const [registryDetails, setRegistryDetails] = useState([]); // [{ key, valueName?, valueType?, data? }]
22102219 const [meta, setMeta] = useState(null); // { title, description, url, tags[] }
22112220 const [aiSummary, setAiSummary] = useState(null); // { headline, summary, recommendations[] }
@@ -4699,6 +4708,44 @@ export default function App() {
46994708 setAiScanState("idle"); setAiScanCounts(null); setAiScanError("");
47004709 setRetryCount(0); setCooldown(0); setRawArticle(""); setArticleClean(""); setDefangAll(false);
47014710 setReferences([]); setMergedHashes({}); setShowMerged(false);
4711+ setArticleSources([]); setShowAddArticle(false); setShowAddCustom(false); setAddArticleUrl(""); setAddCustomText("");
4712+ };
4713+
4714+ // Merges newly-parsed IOC data into the existing result set instead of
4715+ // replacing it — used by "Add Another Article" and "Add Custom IOCs" so
4716+ // scanning a second source doesn't wipe out the first. Only genuinely NEW
4717+ // values get tagged with sourceLabel for provenance; a value already
4718+ // present keeps whatever origin it already had.
4719+ const mergeIntoResults = (newData, newOrigin, sourceLabel, newDetails = []) => {
4720+ const { data: wd, refs: wr } = applyWhitelistAndRefs(newData || {});
4721+ const normKey = (cat, v) => {
4722+ let n = String(v).trim().replace(/\/+$/, "");
4723+ if (cat === "URL" || cat === "DOMAIN") n = n.replace(/^https?:\/\//i, "").replace(/\/+$/, "");
4724+ return n.toLowerCase();
4725+ };
4726+ setIocData((prev) => {
4727+ const merged = { ...(prev || {}) };
4728+ Object.entries(wd).forEach(([cat, arr]) => {
4729+ const existing = merged[cat] || [];
4730+ const existingNorm = new Set(existing.map((v) => normKey(cat, v)));
4731+ const additions = arr.filter((v) => !existingNorm.has(normKey(cat, v)));
4732+ if (additions.length) merged[cat] = [...existing, ...additions];
4733+ });
4734+ const ordered = {};
4735+ ORDER.forEach((k) => { if (merged[k]?.length) ordered[k] = merged[k]; });
4736+ Object.keys(merged).forEach((k) => { if (!ordered[k] && merged[k]?.length) ordered[k] = merged[k]; });
4737+ return ordered;
4738+ });
4739+ setOriginData((prev) => {
4740+ const next = { ...(prev || {}) };
4741+ Object.entries(newOrigin || {}).forEach(([cat, valMap]) => {
4742+ if (!next[cat]) next[cat] = {};
4743+ Object.keys(valMap).forEach((v) => { if (next[cat][v] === undefined) next[cat][v] = sourceLabel; });
4744+ });
4745+ return next;
4746+ });
4747+ if (wr?.length) setReferences((prev) => [...(prev || []), ...wr]);
4748+ if (newDetails?.length) setRegistryDetails((prev) => [...(prev || []), ...newDetails]);
47024749 };
47034750
47044751 const goHome = () => {
@@ -4721,14 +4768,20 @@ export default function App() {
47214768 // stale if read in the same tick as a just-fired setUrl(). Guarded with a
47224769 // strict string check so this can never accidentally receive a DOM event
47234770 // object from a bare `onClick={runFetch}` handler.
4724- const runFetch = async (overrideUrl) => {
4725- resetResults();
4771+ // opts.merge: true when called from "Add Another Article" — skips the
4772+ // reset, merges the new source's IOCs into the existing set instead of
4773+ // replacing them, and never touches the primary URL input or the
4774+ // AI-summary article text (which always stays pinned to the first source).
4775+ const runFetch = async (overrideUrl, opts = {}) => {
4776+ const merge = !!opts.merge;
4777+ if (merge) setAddArticleLoading(true);
4778+ else resetResults();
47264779 setLoading(true);
47274780 // Auto-prepend https:// if scheme missing
47284781 let fetchUrl = (typeof overrideUrl === "string" ? overrideUrl : url).trim();
47294782 if (fetchUrl && !/^https?:\/\//i.test(fetchUrl)) {
47304783 fetchUrl = "https://" + fetchUrl;
4731- setUrl(fetchUrl);
4784+ if (!merge) setUrl(fetchUrl);
47324785 }
47334786
47344787 const apiP = fetch(`${WORKER_BASE}/parse`, {
@@ -4845,8 +4898,9 @@ export default function App() {
48454898 pRes.status === "rejected" ? (pRes.reason?.message || "page fetch failed") : "no page IOCs",
48464899 ].join("; ");
48474900 setError(`Could not fetch this URL (${why}). The site may use anti-scraping protection or require JavaScript. Download the page manually (Save As → PDF/HTML) and use the Upload File tab.`);
4848- setMode("upload");
4901+ if (!merge) setMode("upload");
48494902 setLoading(false);
4903+ if (merge) setAddArticleLoading(false);
48504904 return;
48514905 }
48524906 }
@@ -4876,13 +4930,35 @@ export default function App() {
48764930 usedDetails = engDetails;
48774931 }
48784932
4933+ if (merge) {
4934+ // Additional article — merge in, tag new IOCs by article number, and
4935+ // never touch the primary source's meta/article text (AI summary/
4936+ // report always refer to the first article regardless of how many
4937+ // more get added on top). articleSources.length is captured from this
4938+ // call's own closure, stable for the whole async function.
4939+ const label = `Article ${articleSources.length + 1}`;
4940+ const tagged = {};
4941+ Object.entries(origin).forEach(([c, valMap]) => {
4942+ tagged[c] = {};
4943+ Object.keys(valMap).forEach((v) => { tagged[c][v] = `article:${label}`; });
4944+ });
4945+ mergeIntoResults(data, tagged, `article:${label}`, usedDetails);
4946+ setArticleSources((prev) => [...prev, { label, url: fetchUrl }]);
4947+ setAddArticleLoading(false);
4948+ setShowAddArticle(false);
4949+ setAddArticleUrl("");
4950+ setLoading(false);
4951+ return;
4952+ }
4953+
48794954 setRegistryDetails(usedDetails);
48804955 { const { data: wd, refs: wr } = applyWhitelistAndRefs(data); setIocData(wd); setReferences(wr); }
48814956 setOriginData(origin);
48824957 setMeta(apiMeta);
48834958 setSourceUrl(fetchUrl);
48844959 if (articleText) setRawArticle(articleText);
48854960 if (articleBody) setArticleClean(articleBody);
4961+ setArticleSources([{ label: "Article 1", url: fetchUrl }]);
48864962 setLoading(false);
48874963 const _iocCount = Object.values(data || {}).reduce((s, arr) => s + (Array.isArray(arr) ? arr.length : 0), 0);
48884964 };
@@ -5007,6 +5083,24 @@ export default function App() {
50075083 setSourceUrl("(raw paste)");
50085084 };
50095085
5086+ // "Add Custom IOCs" — same local extraction engine as Paste IOCs, but
5087+ // merges into the existing result set instead of replacing it. Runs even
5088+ // when there are no results yet, so it doubles as a quick-start option.
5089+ const addCustomIocs = () => {
5090+ const ex = extractIocs(addCustomText);
5091+ if (!Object.keys(ex.data).length) {
5092+ setError("No recognizable IOCs found in the pasted text.");
5093+ return;
5094+ }
5095+ const origin = {};
5096+ Object.entries(ex.data).forEach(([c, arr]) => { origin[c] = {}; arr.forEach((v) => { origin[c][v] = "custom"; }); });
5097+ mergeIntoResults(ex.data, origin, "custom", ex.registryDetails);
5098+ if (!sourceUrl) setSourceUrl("(custom IOCs)");
5099+ setAddCustomText("");
5100+ setShowAddCustom(false);
5101+ setError("");
5102+ };
5103+
50105104
50115105 // Enrichment row builder — extracts structured data from enrichCache for export
50125106 const enrichRow = (cat, value) => {
@@ -6863,7 +6957,7 @@ export default function App() {
68636957 </div>
68646958 </div>
68656959
6866- {total > 0 && (
6960+ {total > 0 && (<>
68676961 <div className="flex items-center gap-3 mb-4 py-3 flex-wrap" style={{ borderBottom: "1px solid rgba(120,160,180,0.08)" }}>
68686962 <span className="text-3xl font-medium tabular-nums" style={{ color: "#00ff9c", letterSpacing: "-1px" }}>{total}</span>
68696963 <span className="text-[10px] uppercase" style={{ color: "#5d7382", letterSpacing: "1.5px" }}>indicators</span>
@@ -6891,6 +6985,27 @@ export default function App() {
68916985 }}>
68926986 <ShieldOff size={15} /> {defangAll ? "Defanged" : "Defang"}
68936987 </button>
6988+ <button onClick={() => { setShowAddArticle((v) => !v); setShowAddCustom(false); }}
6989+ title="Fetch another article and merge its IOCs into these results, without losing what's already here"
6990+ className="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-semibold"
6991+ style={{
6992+ color: showAddArticle ? "#04111a" : "#d99a4e",
6993+ backgroundColor: showAddArticle ? "#d99a4e" : "rgba(217,154,78,0.14)",
6994+ border: `1px solid rgba(217,154,78,${showAddArticle ? "1" : "0.55"})`,
6995+ }}>
6996+ <Globe size={15} /> Add Article
6997+ </button>
6998+ <button onClick={() => { setShowAddCustom((v) => !v); setShowAddArticle(false); }}
6999+ title="Paste additional IOCs and merge them into these results"
7000+ className="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-semibold"
7001+ style={{
7002+ color: showAddCustom ? "#04111a" : "#7c9cff",
7003+ backgroundColor: showAddCustom ? "#7c9cff" : "rgba(124,156,255,0.14)",
7004+ border: `1px solid rgba(124,156,255,${showAddCustom ? "1" : "0.55"})`,
7005+ }}>
7006+ <Wand2 size={15} /> Add IOCs
7007+ </button>
7008+ <div className="shrink-0" style={{ width: "1px", height: "28px", background: "rgba(120,160,180,0.15)" }}></div>
68947009 <GButton onClick={exportAllCSV} disabled={!total} color="#00ff9c" icon={<Download size={15} />}>CSV</GButton>
68957010 <GButton onClick={exportAllXLSX} disabled={!total} color="#00e5ff" icon={<Download size={15} />}>XLSX</GButton>
68967011 <button onClick={() => setReportOpen(true)} disabled={!total}
@@ -6908,7 +7023,59 @@ export default function App() {
69087023 </button>
69097024 </div>
69107025 </div>
7026+
7027+ {articleSources.length > 1 && (
7028+ <div className="flex items-center gap-1.5 flex-wrap mb-3 -mt-2 text-[10px]" style={{ color: "#5d7382" }}>
7029+ <span className="uppercase" style={{ letterSpacing: "1px" }}>Sources:</span>
7030+ {articleSources.map((s, i) => (
7031+ <span key={i} className="rounded-full px-2 py-0.5" title={s.url}
7032+ style={{ color: i === 0 ? "#d99a4e" : "#7c9cff", backgroundColor: i === 0 ? "rgba(217,154,78,0.10)" : "rgba(124,156,255,0.10)", border: `1px solid ${i === 0 ? "rgba(217,154,78,0.3)" : "rgba(124,156,255,0.3)"}` }}>
7033+ {s.label}{i === 0 ? " (AI summary source)" : ""}
7034+ </span>
7035+ ))}
7036+ </div>
7037+ )}
7038+
7039+ {showAddArticle && (
7040+ <div className="rounded-xl p-4 mb-4 flex flex-col gap-2" style={{ background: "rgba(217,154,78,0.06)", border: "1px solid rgba(217,154,78,0.3)" }}>
7041+ <div className="text-xs font-bold flex items-center gap-1.5" style={{ color: "#d99a4e" }}><Globe size={14} /> Add Another Article</div>
7042+ <div className="text-[11px]" style={{ color: "#8aa0ad" }}>Fetches and merges a second article's IOCs into the current results. The AI summary keeps referring to {articleSources[0]?.label || "Article 1"}.</div>
7043+ <div className="flex flex-col sm:flex-row gap-2">
7044+ <input
7045+ value={addArticleUrl}
7046+ onChange={(e) => setAddArticleUrl(e.target.value)}
7047+ onKeyDown={(e) => e.key === "Enter" && addArticleUrl && !addArticleLoading && runFetch(addArticleUrl, { merge: true })}
7048+ placeholder="https://another-threat-report.example/article"
7049+ className="flex-1 rounded-lg px-3 py-2.5 text-sm outline-none"
7050+ style={{ backgroundColor: "rgba(0,0,0,0.45)", border: "1px solid rgba(120,160,180,0.22)", color: "#dff" }}
7051+ />
7052+ <GButton onClick={() => runFetch(addArticleUrl, { merge: true })} disabled={!addArticleUrl || addArticleLoading} color="#d99a4e" solid
7053+ icon={addArticleLoading ? <Loader2 size={16} className="animate-spin" /> : <Search size={16} />}>
7054+ {addArticleLoading ? "Fetching…" : "Fetch & Merge"}
7055+ </GButton>
7056+ </div>
7057+ </div>
7058+ )}
7059+
7060+ {showAddCustom && (
7061+ <div className="rounded-xl p-4 mb-4 flex flex-col gap-2" style={{ background: "rgba(124,156,255,0.06)", border: "1px solid rgba(124,156,255,0.3)" }}>
7062+ <div className="text-xs font-bold flex items-center gap-1.5" style={{ color: "#7c9cff" }}><Wand2 size={14} /> Add Custom IOCs</div>
7063+ <div className="text-[11px]" style={{ color: "#8aa0ad" }}>Paste any text — same engine as Paste IOCs — and merge what it finds into the current results instead of replacing them.</div>
7064+ <textarea
7065+ value={addCustomText}
7066+ onChange={(e) => setAddCustomText(e.target.value)}
7067+ placeholder="Paste extra IOCs — IPs, domains, hashes, URLs, defanged or not…"
7068+ rows={4}
7069+ className="w-full rounded-lg px-3 py-2.5 text-sm outline-none resize-y"
7070+ style={{ backgroundColor: "rgba(0,0,0,0.45)", border: "1px solid rgba(120,160,180,0.22)", color: "#dff" }}
7071+ />
7072+ <div className="flex items-center gap-2">
7073+ <GButton onClick={addCustomIocs} disabled={!addCustomText.trim()} color="#7c9cff" solid icon={<Wand2 size={16} />}>Add to Results</GButton>
7074+ {addCustomText && <GButton onClick={() => setAddCustomText("")} color="#94a3b8" icon={<Trash2 size={15} />}>Clear</GButton>}
7075+ </div>
7076+ </div>
69117077 )}
7078+ </>)}
69127079
69137080 <div className="rounded-xl p-4 mb-5" style={panel}>
69147081 <div className="flex flex-wrap mb-3" style={{ borderBottom: "1px solid rgba(217,154,78,0.16)" }}>
@@ -7632,6 +7799,21 @@ export default function App() {
76327799 Pivot: {originData[cat][arr[i]].slice(6)}
76337800 </span>
76347801 )}
7802+ {/* Source provenance — only shown once more than one source has been
7803+ merged in (Add Another Article / Add Custom IOCs); a single-source
7804+ session has nothing ambiguous to label. */}
7805+ {articleSources.length > 1 && originData?.[cat]?.[arr[i]]?.startsWith?.("article:") && (
7806+ <span className="ml-1.5 text-[9px] rounded px-1 py-0.5 align-middle" title="Which merged-in article this indicator came from"
7807+ style={{ color: "#7c9cff", backgroundColor: "rgba(124,156,255,0.12)", border: "1px solid rgba(124,156,255,0.3)" }}>
7808+ 📰 {originData[cat][arr[i]].slice(8)}
7809+ </span>
7810+ )}
7811+ {originData?.[cat]?.[arr[i]] === "custom" && (
7812+ <span className="ml-1.5 text-[9px] rounded px-1 py-0.5 align-middle" title="Manually added via Add Custom IOCs"
7813+ style={{ color: "#a3e635", backgroundColor: "rgba(163,230,53,0.12)", border: "1px solid rgba(163,230,53,0.3)" }}>
7814+ ✍️ Custom
7815+ </span>
7816+ )}
76357817 {enr?.data?.domainReg?.state === "deleted" && (
76367818 <span className="ml-1.5 text-[9px] rounded px-1 py-0.5 align-middle font-bold"
76377819 style={{ color: "#ff4d6d", backgroundColor: "rgba(255,77,109,0.15)", border: "1px solid rgba(255,77,109,0.3)" }}>
0 commit comments