Threat-Actor Attribution
For every malicious package the VDB ingests, a shared engine resolves the malware author — not just the victim package — and links operators across accounts via reused public keys.
Malicious-package advisories arrive two ways: the 16 registry scanners detect malware first-hand by running the malware-detection engine over a package’s source, and five advisory-aggregation sources import packages others have already flagged. After either kind commits an advisory, an inline pass resolves the responsible threat actor from the package/repo identity plus upstream registry, GitHub, and Docker Hub lookups, then records the actor, its harvested keys, and the source→actor edge. A package’s declared repository is deliberately distrusted: in dependency-confusion and typosquat attacks it points at a legitimate victim org, never the attacker.
The detected/extracted indicators of compromise — malicious domains, IPs and C2/exfil URLs — are retained (MalwareIoc, and host infrastructure in MalwareHost) and re-published as the public known-bad STIX feed, which the registry scanners then match future packages against.
Advisory-aggregation sources
Beyond the registry scanners, five aggregation sources store packages others have flagged and feed the engine. Each owns a distinct detection rule:
| Processor | source | Malware detection rule |
|---|---|---|
osm-json | osm | Every OSM record is malicious |
oss-malware-git | oss-malicious-packages | MAL- advisory files |
gemnasium-git | gitlab | CWE-506, or “malware”/“malicious” in title/description |
ghsa-rss | github | “Malware in” title, CWE-506, or MAL- alias |
snyk-fetch | snyk | vuln.Malicious, or title == "Malicious Package" |
The general-purpose OSV pipelines (osv-json and the OSV-shaped git sources) skip attribution — they ingest advisories of all kinds and do not assume maliciousness.
Attribution model
The engine resolves an attribution form from the shape of the report, then assigns a confidence:
| Form | Basis | Confidence |
|---|---|---|
| Repository report | Repo owner | High |
| Container (Docker Hub) | Namespace owner | High |
| Whole-package, non-hijack | Registry maintainer | High if tagged typosquat / dependency-confusion, else medium |
| Go module | Repo owner derived from the module path | High |
| Commit hash present | Commit author | High |
| Hijack (compromised account) | Maintainer is the victim — not attributed | — |
Hijack victims are excluded
When a report describes a compromised-account hijack, the package maintainer is the victim, not the attacker. The engine records actorAttributionStatus = hijack-victim-excluded and emits no actor edge. Attributing the victim would poison the actor graph with innocent maintainers.
The impersonation trap
For non-Go packages, the repository a package declares (in its manifest, homepage, or metadata) is very often the target of a dependency-confusion or typosquat attack — a legitimate org’s repo such as microsoft, named precisely to look trustworthy. The engine never attributes the declared repo as the actor. Instead it captures it as a claimedRepo* record (URL, owner, platform) and an attribution status, so the trap is visible without contaminating the actor graph.
Go modules are the exception: a Go module’s identity is its repository path, so the repo owner is a legitimate attribution basis rather than a claimed-repo trap.
Cross-account key linkage
When the resolved actor is a GitHub identity, the engine harvests that account’s public keys:
- SSH authentication keys and SSH signing keys — stored with their OpenSSH SHA256 fingerprint.
- GPG keys — stored with their key id.
Each key becomes a ThreatActorKey row. A fingerprint observed across multiple accounts is the strongest available signal that those accounts are the same operator — distinct GitHub handles that authenticate or sign with the same private key collapse into one operator behind the scenes.
Schema
All columns are camelCase; all timestamps are BigInt milliseconds (time.Now().UnixMilli()).
Shared actor tables
These two tables are generic and shared across every source.
ThreatActor
| Column | Notes |
|---|---|
uuid | Primary key |
platform | e.g. github, npm, pypi, dockerhub |
identifier | Account/namespace identifier on that platform |
actorType | Resolved actor classification |
displayName, email, profileUrl, company, location | Identity profile (best-effort) |
rawJSON | Full upstream identity payload |
threatCount | Number of attributed threats |
firstSeen, lastSeen | BigInt ms |
@@unique(platform, identifier) — one row per identity per platform.
ThreatActorKey
| Column | Notes |
|---|---|
threatActorUuid | FK → ThreatActor |
keyType | ssh-auth, ssh-signing, or gpg |
githubKeyId | Upstream GitHub key id |
publicKey | Raw public key material |
fingerprint | OpenSSH SHA256 fingerprint (SSH) or key id (GPG) — the cross-account join column |
title, emails | Key metadata |
Source→actor edges (exactly one per record)
A given (cveId, source) is attributed through exactly one edge table — there is no double-write. OSM uses OsmThreatActor; every other malware source uses MalwareThreatActor.
OsmThreatActor (OSM only)
| Column | Notes |
|---|---|
osmThreatUuid | FK → OsmThreat |
threatActorUuid | FK → ThreatActor |
cveId | The minted OSM-YYYY-N id |
The impersonation-trap and hijack-status fields live on the OSM record itself: OsmThreat.actorAttributionStatus and OsmThreat.claimedRepoUrl / claimedRepoOwner / claimedRepoPlatform.
MalwareThreatActor (every non-OSM malware source)
| Column | Notes |
|---|---|
cveId, cveSource | The attributed advisory |
threatActorUuid | FK → ThreatActor |
attributionForm | Resolved form (repository, container, whole-package, go-module, commit) |
attributionBasis | Why this actor was chosen |
confidence | high / medium |
commitHash | Set when the form is commit-based |
repoUrl | Attributed repository, where applicable |
MalwareAttribution (every non-OSM malware source)
Carries the trap and status for non-OSM sources, mirroring the columns OSM keeps on OsmThreat:
| Column | Notes |
|---|---|
cveId, cveSource | The advisory |
status | e.g. hijack-victim-excluded |
claimedRepoUrl, claimedRepoOwner, claimedRepoPlatform | Impersonation-trap target — never attributed |
Threat-intel / IOC tables
The indicators extracted from each malicious package, and the malicious host
infrastructure they point at, are retained for re-publication as the public
known-bad STIX feed — which the registry scanners then
match future packages against. These tables are shared across every non-OSM
malware source (OSM keeps its own OsmThreatIoc, below).
MalwareIoc
| Column | Notes |
|---|---|
cveId, cveSource | The advisory the indicator was extracted from |
iocType | IOC type (see the list below) |
value | The indicator itself — domain, URL, IP, hash, install command, … |
ecosystem | Package ecosystem (nullable) |
truncated | Whether the stored value was truncated |
references | JSON array — now also carrying the matched file/line plus STIX provenance for known-bad-feed matches |
@@id(cveId, cveSource, iocType, value) — one row per distinct indicator per advisory.
IOC types: domain, url, ipv4, ipv6, file-hash, install-command,
exfil-endpoint, email, wallet, package-name, typosquat,
ownership-change. Hash formats sha256 / sha1 / md5 / sha512 / b2 are
stored as file-hash variants.
Malicious C2 / exfil host infrastructure — the domains and IPs packages phone
home to — is kept separately from per-advisory IOCs (in MalwareHost /
MalwareHostLink) so the same host can be tied to many advisories.
malscan-stix-processor publishes MalwareHost as the STIX
dns feed.
MalwareHost
One row per distinct malicious host, carrying the STIX indicator it is published as.
| Column | Notes |
|---|---|
uuid | Primary key |
value | The malicious host — a domain name or an ipv4 / ipv6 literal |
hostType | MalwareHostType enum: domain, ipv4, or ipv6 |
stixIndicatorId, stixPattern | The STIX indicator id and pattern this host is published as |
validFrom, lastSeen | BigInt ms |
maxSeverity | Highest severity observed across linked advisories |
truncated | Whether the stored value was truncated |
MalwareHostLink
Each edge ties a MalwareHost to the advisory/IOC observation that surfaced it, so one host can be attributed to many packages.
| Column | Notes |
|---|---|
malwareHostUuid | FK → MalwareHost |
source | MalwareHostSource enum (which pipeline observed it) |
cveId, cveSource | The advisory the host was tied to (or osmThreatUuid for OSM) |
iocType | The IOC type that produced the host (domain / ipv4 / ipv6) |
ecosystem, severity | Per-observation ecosystem and severity |
firstSeen | BigInt ms |
linkKey | Dedup key — ON CONFLICT ("linkKey") DO NOTHING |
OSM retention tables
OSM additionally retains its full upstream payload and parsed IOCs (OSM-only):
| Table | Purpose | Key columns |
|---|---|---|
OsmThreat | Full OSM API record retention | osmThreatUuid, cveId, packageName, registry, severity, rawJSON, actorAttributionStatus, claimedRepo*, firstSeen, lastSeen |
OsmThreatIoc | Parsed indicators of compromise | osmThreatUuid (FK), iocType, value, truncated |
Execution
Attribution runs in two places:
- Inline post-commit pass — each of the five malware processors runs attribution immediately after committing an advisory. It is first-time-only: a record that already carries a
MalwareAttribution(or, for OSM, anOsmThreatattribution) row is never re-enriched. - One-time backfill —
cmd/malware-actor-backfill(just go-malware-actor-backfill) selects every record whereisMaliciousPackage = trueANDsource <> 'osm'across all sources and attributes the historical backlog through the same engine.
v2 API exposure (Pro-gated)
Attribution is surfaced through two vdb-api v2 endpoints, gated by ProFilter:
GET /v2/malware/{ecosystem}/{package}GET /v2/vuln/{identifier}/threat-actors
Both return advisories and counts to all callers. Paid (Pro+) callers additionally receive:
threatActors— including each actor’s harvested key fingerprintsiocs— parsed indicators of compromiseimpersonationTrap— the capturedclaimedRepo*target
Community responses null those three fields and list them under _pro_data so callers can see what an upgrade unlocks.