OSM Manager
OSM Manager
/resolve/osm-manager, admin only. Three tabs, because there are three
distinct jobs, and two of them write to a public database under our name.
Everything OSM receives from us is published and reviewed in public. Their reporting guidelines name the failure modes that get a report rejected:
lack evidence or proof · report legitimate packages as malicious without substantiation · provide vague descriptions · submit duplicates without database verification · exaggerate severity levels
Four of those five are decided before the request is sent. That is why the
validation in vdb-site/api/internal/osm is not defensive plumbing but the
quality gate itself, and why it runs on every report regardless of whether a
human or the mapper produced it.
Tab 1 — OSM Records
CVEMetadata.source = 'osm' left-joined to OsmThreat, paginated, with
filters read from the data rather than hardcoded.
The join is left on purpose. OsmThreat is written by submission and by the
threat feed, so an advisory can exist with no threat row and a threat row can
outlive its advisory. Both of those are what a reviewer is looking for, and an
inner join would hide both. The linked filter selects either side.
Tab 2 — Lookup
GET /functions/v1/check-malicious, answered beside whatever our own corpus
holds for the same resource.
Both answers come back in one response because the useful information is the combination, not either half:
| OSM | Us | Conclusion | Action |
|---|---|---|---|
| yes | no | they hold it, we do not | record their threat against one of our advisories |
| yes | yes | both hold it | reconcile; nothing to submit |
| no | yes | we hold it, they do not | a submission candidate |
| no | no | neither | a negative answer, not a clean bill of health |
The conclusion is named server-side (suggestedAction) so the lookup tab and
the submit tab cannot derive it differently.
Our side matches on OsmThreat identity and on MalwareIoc.value. The
second is what makes the tab useful: a package our own scanners flagged has an
IOC row and no threat row at all, and that combination is exactly the candidate
the third tab exists for.
Tab 3 — Submit
Our malicious-package records that OSM does not already hold, each shown as the exact body that would be sent, with every validation problem against it.
Scope defaults to our own detections. Of the ~254k records with a malicious
indicator and no OSM threat, ~227k carry cveSource = 'oss-malicious-packages'
— the OpenSSF feed, a public third-party corpus we ingest. Relaying somebody
else’s dataset in bulk is not research; it is noise on a public database with
our name on it. osm is excluded from every scope, since submitting OSM’s own
data back to OSM is duplication by construction.
Within the remaining sources, CVEMetadata.isMaliciousPackage is the gate.
A registry slug is not one: pypi and go are shared with the PyPA and
Go-vulndb advisory importers, so filtering on source alone would offer ordinary
advisories as malware reports.
Order of operations on submit
- Validate against the OSM requirements. A blocking problem stops here, before any network call.
- Check for duplicates with
check-malicious. A hit stops here and returns what OSM already holds. This cannot be skipped — the request field exists only so the name is documented, and passing it is a 400. - Submit
POST /functions/v1/submit-threat-report. - Record the result graph:
OsmThreat+OsmThreatIoc+OsmThreatActor, idempotent on every table.
Step 4 failing after step 3 succeeded is reported as its own outcome, not as a generic error. The report is published at that point, and a reviewer who retried would submit it twice.
What the mapper does differently from osm-submitter
The desktop osm-submitter has a
review queue over the same tables. Three of its mappings produce reports OSM
would have to correct or reject, and the Go mapper deliberately does not
reproduce them:
| Field | osm-submitter | Here | Why |
|---|---|---|---|
resource_identifier (package) | npm/left-pad | left-pad | The documented format is the registry package name; the registry has its own field. A slash-prefixed identifier matches nothing on their side. |
registry | falls back to CVEMetadata.source | only the four documented values, else omitted | Otherwise we publish registry="vulnetix" or registry="ghsa". The field is optional, so an unmappable ecosystem is carried in the tags and the description instead — the information survives and the enum stays valid. |
payload_description | raw IOC values | defanged | OSM asks for hxxps:// and [.]. A live C2 URL on a public page is a link a reader can click. |
version_info accepts comma-separated versions only, so entries carrying a
range operator are dropped rather than sent, and Validate refuses the field
if one survives.
Configuration
| Variable | Where | Purpose |
|---|---|---|
OSM_API_TOKEN | prod/vdb-manager/api-keys, wired into the vdb-site-api task definition | Bearer token. Server-side only; the console calls our endpoints and never sees it. |
OSM_API_BASE | unset in production | Points the client at a mock for testing. |
The token is shared with the processors rather than copied into a second secret, so the OSM Manager and the ingest pipeline can never authenticate as different reporters.
GET /admin/osm/config reports whether a token is present — never the token —
so a form that cannot submit says so up front instead of failing on the button.
Endpoints
All under /api/site/v1/admin/osm, all behind RequireVulnetixAdmin.
| Method | Path | Purpose |
|---|---|---|
| GET | /config | Token presence and the vocabularies the forms offer |
| GET | /facets | Distinct filter values, read from the data |
| GET | /records | The review list |
| GET | /records/{cveId} | One record with its IOCs and actors |
| POST | /records | Record an OSM threat against one of our advisories |
| POST | /check | Proxy check-malicious, plus our own matches |
| GET | /candidates | Submission queue, each with its report and problems |
| POST | /submit | Validate, dedupe, submit, record |
/check is a POST for a read because the resource identifier can be a full
URL; putting live indicators in our own query strings invites truncation and
logging.
Tests
internal/osm/osm_test.go— the contract: identifier formats per report type, the registry enum, defanging (including idempotency and not mangling filenames), version normalisation, and the rejection of vague descriptions.internal/handler/osm_manager_sql_test.go(-tags=integration) — every query against a real Postgres, in every scope and filter shape. An empty candidate queue looks exactly like “nothing to submit”, which is the one wrong answer nobody would investigate.