Skip to content

Commit 94c2cdb

Browse files
Add Another Article and Add Custom IOCs with source tracking
1 parent e7c3273 commit 94c2cdb

1 file changed

Lines changed: 189 additions & 7 deletions

File tree

src/App.jsx

Lines changed: 189 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)