Package analysis
What this module is
Malwagon Python package analysis takes one package requirement typed as text and installs it inside a disposable Linux virtual machine under a syscall tracer. It returns a report of what the install itself started, wrote, contacted and left on disk. The requirement string is the sample: no distribution file is uploaded, none is fetched onto the analysis host, and Python packaging is the whole scope of the module.
Where this capability came from
Package analysis reached Malwagon as a technology transfer from extuno.com. What crossed over was the method: treat installing as the event worth recording, and take a requirement string as sample enough to record it with. The module that runs that method here is this platform's own, from its specifier grammar and its disposable Linux guest to its tracing, its separation of install noise from the package's own code, its side-effect class names and the install object those names travel in, so a package report you read on this platform was produced by that build, on hardware this platform operates.
Installing a Python package runs code before you import a line of it
A source distribution carries a build script the installer has to execute to produce something installable, and a modern build backend is a program the installer imports and calls. Both run on the machine doing the install, with that machine's privileges, at the moment the requirement is resolved. No line of your application has to call the library for that code to have run; it ran because you asked for the package. That window is the whole subject of this module. The trace attaches to the install command as it launches and is released when that command exits, so the record Malwagon returns covers exactly the interval in which code nobody called gets to run.
The machine that normally executes it is rarely a workstation someone is watching. It is a build agent in the middle of a pipeline, or a developer's laptop a minute after a branch checkout, and what is within reach from there at that instant is the job's own credentials in the process environment, the token that can push to the source repository, the signing and publishing material for whatever that job releases, the tree that was just checked out and the internal network the runner sits on. The disposable guest stands in for that machine, and the run is read for the moves that would count on it: a process the build did not need, a write landing outside the environment the install was given, a connection opened by something that is not the installer, and reads of the shadow password file or of SSH private key material, which are recorded as credential access. An install-time script starts in a position most droppers never reach, and it gets there without a user opening anything.
The record a traced install leaves
The argument above is only worth as much as the evidence Malwagon can put behind it, and the evidence is the record a traced install leaves. A clean install has one shape. The installer resolves a name, fetches from the index, unpacks into the environment it was handed and exits; the process tree holds the installer and the build children it needed; the connections go to the index and to operating-system name resolution; the writes land under the install target. Every one of those things happens when you install an empty module, which is the point: on its own the shape says nothing about the package, so the module subtracts it.
An install carrying code of its own breaks that shape in ways the trace can name: a process appears that building a package did not call for, or something that is not the installer opens a connection, or a write lands outside the environment the install was given, or a boot or login surface is touched while the install is still running. The report states which of those happened and names the path, the command line or the endpoint behind each one, so a finding can be read back to the file it came from.
examplelib==1.4.0
install_time_execution false
side_effects none
process tree the installer and the build children it needed
network the index, and name resolution for it
files under the install target
examplelib-extras==0.9.2
install_time_execution true
side_effects suspicious_process_spawn, network_during_install,
persistence_during_install
process tree a shell started under the build step
network a host that is not the index, opened by that shell
files a write to a login startup file, path named
Run it at these three moments
- At adoption
- Analyse the exact requirement string the build will resolve, before the first install lands on a build agent or a laptop.
- On every version bump
- Analyse it again. A reading six months old describes the release that was read, and the interval between a maintainer account changing hands and a new release appearing is short.
- On every transitive change
- Analyse it again when the resolved set moves under you. A dependency nobody named is installed by the same run, with the same privileges as the one that was named.
Gate the pipeline on the run's install-time execution flag, not on a person remembering to look.
Pinning is worth doing and it does not answer this question. A pin fixes which release of the package you named; it says nothing about what that release does while it installs, and it does not reach the dependencies resolved alongside it in the same run. Re-analysing a pinned string is where that shows, because the target line comes back identical and the install object under it does not have to: a run whose install-time execution flag was false can come back true with side-effect classes named beside it, or come back true with the same classes over a command line the earlier run's spawned processes did not hold, a remote endpoint the earlier connections never reached, a SHA-256 on a dropped file that was not there before. Pinning and analysis fit together in one direction only: the pin is what makes an analysed version the version that actually lands, so the pin is the mechanism and the analysis is the answer. A lockfile full of exact versions nobody has ever detonated is a repeatable install of unknown code.
Why does the same requirement install different code next month?
Because a requirement is usually a range, and a range is a question the resolver answers on the
day the build runs. >=1.4 asks for whatever is newest and compatible at that
moment, and a ~=2.0 constraint accepts any compatible release in a series that is
still being published into. Even an exact pin on the package you named leaves its dependencies
free unless every one of them is pinned too, and those are resolved in the same run, from the
same index, by the same process, with the same privileges. In the install record a resolver-driven
change is not a subtle thing to spot. It arrives as a process in the traced tree the previous
run's tree did not hold, or a name in the DNS section nothing resolved last time, or a write
landing outside the install target where the earlier run wrote only under it; and because a
dependency's install behaviour is captured as part of the same run rather than split out from
the package you named, a change well down the resolved set still surfaces as a change in the
record filed under the name you submitted. The requirement text is stable. What it resolves to
is not.
So the code you analysed last month and the code your build installs this morning can differ under one name, with nothing in your repository changed to record it. The report is where it does get recorded: it names the target exactly as it was submitted, the installer's exit status, whether the install ran code of its own and the side-effect classes when it did, and it keeps the conditions the run happened under beside them: the guest image, whether the machine came from a pre-warmed snapshot or a cold boot, and how long the install ran. Two readings of one string can then be set against each other instead of both being separately true. Reproducibility is a property of the resolved set, not of the requirement file, which is why the useful unit of analysis is the string as it resolves today. It is also why the recommendation above is keyed to the resolved set moving, not to a calendar.
A name is what an analyst has in hand
An advisory names a release. It gives you a distribution name and a version, and the move it invites is the one worth not making: fetch the artifact onto a machine you care about, unpack it and look. A name is what an analyst has, so a name is what this module takes as the sample.
The sample is a name, and the grammar is the first gate
- target
- One package requirement typed as text, up to 200 characters. There is no file field on this form at all. The string itself is stored as the sample and is what the scan is filed under.
- guest
- A disposable Linux image, fixed server-side. No operating-system selector, no work path, no manual launch mode, no memory-dump options. The form renders none of them.
- egress
- Required, and structural. The guest has to reach the index for there to be anything to observe, so a plan without egress is refused at validation, before a credit moves.
- run
- A fixed install window, with no duration control on the form, and one credit per scan. Dependencies are resolved and installed inside it, not stubbed.
- layers
- Static and dynamic start selected, AI starts off, at least one is required. Over REST and MCP the dynamic layer is forced on and the AI layer forced off.
- output
- The run is recorded with
detonation_modeset topip_install, and carries one install-time execution flag with its side effects named.
The field is validated against one grammar before anything is stored, and that grammar is an allowlist: a name, optional extras, at most one version specifier, anchored at both ends. Everything else is refused with a message that names the shape the field wants, so the reply tells a submitter what to type next. The point of writing it as an allowlist is that a requirement string is the one piece of caller text on this module that ends up as an argument, and an allowlist admits only the shapes named in it, so a syntax nobody has considered has to be added on purpose before it is accepted.
| Part | What is accepted | Bound | Accepted |
|---|---|---|---|
| Distribution name | A letter or a digit first, then letters, digits, dot, underscore and hyphen. A leading dot or hyphen is not a name. | 1-99 chars | examplelib |
| Extras | One pair of square brackets holding letters, digits, comma, dot, underscore and hyphen. No spaces inside them. | 1-60 chars | examplelib[extra] |
| Version specifier | One of ==, >=, <=, ~=,
!=, >, <, followed by letters, digits,
dot, underscore, asterisk, plus, exclamation and hyphen. One specifier, not two. |
1-40 chars | ==1.4.0, >=1.2, ~=2.0, ==1.4.* |
| The whole field | Name, then optional extras, then the optional specifier, and nothing else on either side of it. | 200 chars | examplelib[extra]==1.4.0 |
examplelib accepted
examplelib[extra]==1.4.0 accepted
otherlib>=1.2 accepted
pkg~=2.0 accepted
pkg==1.4.* accepted
-r requirements.txt refused a requirements file is not a requirement
https://host/pkg.whl refused URLs
git+https://host/repo.git refused version-control references
./local/pkg refused paths, relative or absolute
pkg>=1.0,<2.0 refused more than one constraint on one name
pkg; python_version<'3.9' refused environment markers
.hidden refused a leading dot or hyphen
examplelib\n refused the pattern is anchored at end of string,
not end of line
Egress here is a property of the plan, not a checkbox
This module is paid plans only, and the refusal happens during form validation, not at the end of a queued scan that could never have worked. There is no isolated version to fall back to: an install with no route to the index produces a timeout and nothing else, which is not a cheaper answer, it is no answer. The second gate is further down, at the point the machine is created and the network it attaches to is chosen, so a hand-built submission that talks its way past the first one still does not land on a routed network.
Where does the install actually happen?
Not on this host. Nothing about the named distribution is downloaded, unpacked or installed here: the host validates a string, creates a scan and starts a machine. Inside that machine the installer resolves the requirement, fetches it and installs it. That is how the install becomes observable: the event is watched while it happens, and nobody is left holding an artifact and a guess. Installing is the event under analysis, so the install has to happen somewhere, and the somewhere is a machine that exists for this one run and is discarded afterwards.
That machine is a full virtual machine under a hypervisor, on hardware Malwagon operates. It is restored from a live memory snapshot of a booted Linux system, so the install begins against a system that is already up and has never held anyone else's run, and it is given a disposable disk of its own for the duration. The recording is taken from outside it, which is the subject of the diagram below and of the paragraph after it.
The install goes into an environment built for that run, so the package's own installed files stay separable from anything it writes elsewhere on the machine. Dependencies are resolved and installed for real, and that is deliberate: a clean package with a poisoned dependency is a genuine supply-chain shape, and a stubbed tree would put it outside the observed run. The whole install process tree is traced at the syscall level for the length of the run, with kernel event tracing where the guest supports it and process tracing where it does not, and the raw trace is normalised host-side into typed process, file, network, DNS and signal records.
Attribution is a property of how the evidence was collected, fixed as each record is written and never reconstructed afterwards. Each recorded file operation, process and connection carries the process id that produced it and is scoped to the traced install tree, so "what the install did" is not inferred by subtracting machine-wide activity from a baseline. Read the process graph and you are reading parents and children, not a busy Linux box.
Two of the recording layers are agentless: they do not run inside the guest at all. The packet capture is taken at the hypervisor link and the screen is captured from the framebuffer, both outside the guest's own operating system, so an install script that kills, unhooks or blinds the in-guest tracer sitting next to it removes one source and leaves those two recording. The in-guest trace is what supplies the call-level detail those two do not carry, and it is a second source, never the only one. Beside it sits a small but decisive record: the guest declares that it launched, with the pid and the full install command line. That is what separates a report with nothing in it from a run that did not reach the install, which is the failure mode that quietly turns "we never ran it" into "it did nothing".
| Layer | On this module | Why |
|---|---|---|
| Dynamic | Runs | The traced install is the method. Turn it off and the module has almost nothing left to say. |
| Packet capture | Outside | Full capture at the hypervisor link, downloadable, and analysed for signature alerts, TLS client fingerprints, SNI hosts, HTTP metadata, cleartext protocols, DNS, beaconing, domain-generation likeness and fast flux. |
| Screen recording | Outside | Framebuffer frames for the run, plus a live view of the guest while the install is happening. |
| Threat intelligence | Runs | Over the indicators the run derived: URL, address and domain feed lookups plus an offline known-bad hash check, with this platform's own baseline destinations excluded so a stale listing on ordinary infrastructure does not manufacture a finding. |
| Static | Partial | On a name submission there is no distribution file on the platform, so the static layer sees the reference text only. The full archive reading belongs to an uploaded sdist or wheel, which is the section below. |
| AI narrative | Optional | Off by default, and built from derived data only: the install summary, the side-effect flags, the process, file and network summaries and the metadata. The package itself is not sent to it. Forced off on the REST and MCP paths. |
| Memory forensics | Not run | There is no RAM capture or introspection pass on this path. An install-time stage that stays resident leaves its syscalls behind, not a memory image. |
| Document handling | Not run | Nothing in a package run is a document, and the module does not pretend otherwise. |
Install noise is subtracted, not reported at you
Tracing an install captures everything, and everything includes the network connections, the file writes and the spawned processes that come with installing any package at all, up to and including one that contains no code. A signal that fires on every input is not a signal. The work is separating the activity that installing anything produces from the activity attributable to this package's own install-time code.
What survives the subtraction is classified into four named classes, and those names travel intact into the report, the API response and the MCP result. The network class shows how the separation is done: the question is asked of the process that opened the socket, not of the address it reached, because the installer talking to the index is the operation itself, and flagging that fires on every package including the ones that do nothing.
| Class | What it means |
|---|---|
| suspicious_process_spawn | Something in the traced install tree started a process that building a package does not need. |
| file_write_outside_install | A file was created or written somewhere that is not the environment the install was given. |
| network_during_install | A process in the tree that is not the installer opened a connection, or a name was resolved that is not the index. |
| persistence_during_install | A create, write, modify or rename touched one of the persistence surfaces below. |
A written file is called a payload only when the run earned that word. A payload-shaped name is not enough on its own, because benign installs write scripts and shared objects constantly, so the strong claim needs corroboration from the run itself. Every written file is recorded either way, with path, size where known and a SHA-256; what changes is whether the report calls one a payload, and a file recorded without that label is still there to be looked up.
| Surface | What a write there buys the author | Technique |
|---|---|---|
| Cron locations | A command that keeps running long after the build job has finished and been forgotten. | T1053.003 |
| Systemd unit directories | A service or timer that starts on boot, under a name that reads like infrastructure. | T1543.002 |
| rc.local | A command run late in boot on images that still honour the file. | T1543.002 |
| Shell startup files | Code that runs the next time anyone opens a shell as that account, including the next interactive session on a developer machine. | T1546.004 |
| Dynamic-linker preload file | A library loaded into every dynamically linked process that starts afterwards. | T1574.006 |
| Desktop autostart entries | A program started at session login on a workstation. | T1547 |
| SSH authorized keys | Interactive access as that account, with no further code needed. | T1098.004 |
Alongside the install-specific classes, the run is read for the abuse and evasion primitives that mean the same thing wherever they appear: process tracing and injection through the debugging interface, anonymous in-memory file creation for fileless execution, kernel module loading, setuid and setgid transitions, deliberate process renaming, and eBPF program loading. Reads of the shadow password file and of SSH private key material are recorded as credential access. Sigma rules are matched over the derived process-creation, file and network events, and the techniques of matched rules are folded into the same technique list, so a rule hit and an observed primitive land in the same list, with no second panel quietly disagreeing with the first.
| Observed in the install tree | Tactic | Technique |
|---|---|---|
| A process is started | Execution | T1059 |
| Native API use inside the traced tree | Execution | T1106 |
| Another process is traced or injected into | Defense Evasion | T1055.008 |
| An anonymous in-memory file is created and run | Defense Evasion | T1620 |
| A file is deleted after being written | Defense Evasion | T1070.004 |
| Permissions are changed on a file | Defense Evasion | T1222.002 |
| A process name is deliberately changed | Defense Evasion | T1036 |
| A kernel module is loaded | Persistence | T1547.006 |
| A setuid or setgid transition happens | Privilege Escalation | T1548.001 |
| The shadow file or an SSH private key is read | Credential Access | T1552.001 |
| A remote endpoint is contacted | Command and Control | T1071 |
| The install executed code of its own | Initial Access | T1195.001 |
The packet capture gets the same treatment every other module's capture gets, because the hypervisor does not care what the guest was doing when it produced the frames: signature alerting, TLS client fingerprinting with JA3 and JA4, SNI extraction, HTTP metadata, cleartext protocol detection, DNS, and the timing analyses for beaconing, domain-generation likeness and fast flux. Baseline destinations that every guest of this kind reaches are filtered out of the intelligence layer, and the count of filtered rows is recorded, so a quiet network section is distinguishable from a network section that was quietly emptied. Endpoints and process command lines are then read for configuration and endpoint extraction.
Indicators are derived and stored from the run itself: addresses, domains, URLs, dropped-file hashes and TLS fingerprints, with loopback, private and link-local ranges excluded and platform infrastructure scrubbed. That scrub exists for a specific reason worth being blunt about. A platform that feeds its own sandbox traffic into its own reputation data poisons itself, and then confidently reports its own infrastructure as an indicator of compromise. Indicators are defanged on display and exportable from the scan.
An answer a build can act on
The output of a package run is not meant to be read by a person every time. It is meant to be read once by a person and then continuously by a pipeline, which is why the install result is a small typed object: the target as submitted, whether it came from a name or a copied file, the installer's exit status and a success flag, one install-time execution boolean, and the side effects by name. That object is in the report, in the REST response and in the MCP result, through the same allowlist that decides what is allowed to leave in a machine-readable report at all.
submit {"module": "package", "target": "examplelib[extra]==1.4.0"}
report "detonation_mode": "pip_install",
"pip_install": {
"target": "examplelib[extra]==1.4.0",
"from_file": false,
"success": true,
"install_time_execution": false,
"side_effects": []
}
gate install_time_execution == false -> the install ran no code of its own
install_time_execution == true -> read side_effects, then the run
Every run also records the conditions it happened under, and those belong in the same breath as the verdict: which guest image was used, how long the run was, whether the machine came from a pre-warmed snapshot or a cold boot, how long it took before the install actually started, the total elapsed time, and whether the guest reported finishing. Anything the guest noticed about its own degraded state is kept separate from what the sample did, so a slow restore is not read as package behaviour.
Each section of the report works to a budget, and the report says when a section reached one. A long trace is summarised, not reproduced, and a truncated section is marked as truncated where it is displayed, so a short table reads as a short table and not as a quiet run. The figures themselves stay unpublished: a budget a submitter can see is a budget a deliberately noisy submission can be written against.
| Section | What a row holds |
|---|---|
| Syscall sequence | The ordered calls made inside the traced install tree. |
| File operations | The path, the pid behind it, a timestamp, and the destination when the operation was a rename. |
| Spawned processes | The process, its parent and its full command line. |
| Network connections | Each connection, plus the unique remote endpoints behind them. |
| DNS queries | Names the guest saw resolved, merged with the ones the capture recorded. |
| Indicators | Addresses, domains, URLs and dropped-file hashes, defanged on display and exportable. |
| Syscall count summary | Call names ordered by how often the run made them. |
| Dropped files | Path, size where known, and a SHA-256. |
| Persistence surfaces touched | The surface category and the path that touched it. |
| Side effects in the install object | The class names, carried into the API response and the MCP result. |
What if you already have the distribution file?
Sometimes the artifact is in your hand: a wheel pulled from an internal mirror, an sdist attached to a ticket, a build output you want read before it is published. That goes in as a file submission, on the file module's form, and the platform types it by content, not by extension. A gzip tar carrying setup.py, PKG-INFO, setup.cfg or pyproject.toml is recognised as a source distribution; a zip carrying a metadata entry or a dist-info directory is recognised as a wheel. Either way the distribution gets a static package reading, and choosing the Linux image additionally detonates it as a traced install of the file you uploaded.
Nothing in that static reading executes. The archive is walked with bounds on it, nothing is written to disk, and setup.py is parsed as an abstract syntax tree, so a file whose whole purpose is to be executed by an installer can be read without ever executing it. Every bundled module in the distribution is inventoried the same way, for dangerous call categories, imports, network indicators and locally decoded blobs, across ten categories: dynamic code execution, dynamic import, process execution, network, encoding and decoding, decompression, serialised-code execution, deserialisation, reflection and native memory access. The distribution name is compared offline against a local corpus of common names for typosquatting, with no lookup leaving the platform.
top-level call: urlopen (line 14) top-level try block (line 22) cmdclass override (custom install/build command) process_execution: subprocess.Popen (line 31) network: socket.create_connection (line 44) dynamic_code_execution: exec (line 47)
| Analysis | Name submission | Uploaded sdist or wheel |
|---|---|---|
| Traced install in a disposable guest | Yes the module is this run | Yes on the Linux image, installing the uploaded file |
| Archive walk and file inventory | No no distribution is held here | Yes nothing executed, nothing written to disk |
| setup.py install-time evidence | No | Yes parsed as code, not run |
| Metadata, version and declared dependencies | No | Yes from the metadata entry or the project file |
| Typosquat comparison | No | Yes computed offline, with the near name and the edit distance |
| File reputation on the artifact | No there is no artifact to look up | Yes |
| Dropped-file hashing, indicators and intelligence from the run | Yes | Yes |
What this module does not do
- Paid plans only, with no isolated fallback
- Community and anonymous scanning cannot run this module at all. The refusal happens during validation, before a credit is spent, because the guest has to reach the index and an install with nowhere to fetch from is not a cheaper analysis.
- Python packaging only
- Other language ecosystems and operating-system packages are not accepted. The grammar, the install and the side-effect classes are shaped around one ecosystem, and a thin veneer over the rest would be worse than not offering them.
- One requirement per scan
- No requirements file, no version-control or URL or local-path installs, no environment markers, no two constraints on one name. A lockfile is one submission per line.
- The install is a Linux install
- There is no Windows package run on this module, so install-time code that only fires on Windows will not show itself here.
- No memory forensics on this path
- No RAM capture and no introspection pass, so an install-time stage that unpacks itself in memory and never touches disk is described by its syscalls and its network, not by a memory image.
- Dropped files are identified, not retrieved
- Path, size and SHA-256 are recorded. The bytes are not brought back from the guest, so there is no download of what the install wrote and no automatic second-stage analysis of it. A hash you can look up is what you get.
- No decrypted HTTPS content here
- Encrypted destinations appear as addresses, SNI hostnames and TLS client fingerprints, not as plaintext request bodies. What was carried inside the session is not part of this module's answer.
- The install window is fixed
- The form offers no duration control, so behaviour that is deliberately delayed well past the install falls outside the run. Time-bombed install code is a real technique and this is a real gap.
- Nothing imports the package afterwards
- This module answers one question: does installing this run code, and what does that code do. It is not a test of what the library does when your application later calls it, and a package whose payload is in a function nobody called during the install is not exercised.
- A name submission has no artifact to read
- Without a distribution file on the platform there is no archive walk, no setup.py evidence, no metadata, no dependency list and no typosquat check on a name-only scan, and no file reputation lookup either. That reading belongs to an uploaded sdist or wheel. The value on a name submission comes from the run.
- Attribution needs the code to be its own process
- Separating the package's own code from the installer doing its job rests on process identity: every recorded operation is tied to the process that produced it, and that is the line the report draws. Where process identity does not separate two actors, the report does not pretend that it did. A dependency is one such case: its install behaviour is captured as part of the same run, without being split out from the package you named.
- The answer is about one release at one moment
- What the index served when the run happened is what was analysed. A later release under the same name can behave differently, which is exactly why the recommendation above is to run this on every version bump; a yanked or deleted release cannot be analysed at all.
- A failed install is a short report
- Name does not exist, build error, resolver conflict: the report states the installer's exit status and there is little behaviour to describe. That is an account of the run, not a verdict on the package.
- Rule coverage is process, file and network shaped
- There is no registry-equivalent surface on this platform to match rules against, so detection content written around registry keys has nothing to fire on here.
- Very noisy installs are summarised
- A long trace is truncated and the document says so. Every stored table works to a budget of its own and reports when it reached it, so what you are reading is either the whole section or a section marked as cut.
Running one, and where to put it
Console, REST and MCP
Type the requirement into the console exactly as your build would resolve it, leave static
and dynamic selected, and add the AI layer if you want the narrative. Through REST, set the
module to package and send the same string as the target; through MCP, the
submit tool takes it in the same shape and validates it against the same grammar. One
credit, one disposable machine, one report.
Read the install-time execution flag before anything else. False means the install ran no code of its own. True means read the named side effects, then the run behind them. A pipeline that can call an API can gate on that one field, and a person stays out of the loop until there is something for a person to look at.
Where to read next depends on which half of this you want. The file module takes over the moment you are holding the sdist or the wheel rather than the name for it. The guest an install runs in, how it is restored and how it is discarded, is written once for every module on the platform page. That the requirement string is what gets stored as the sample, and that hashes and derived indicators are all that ever leave Malwagon, is the boundary set out in full on the security page. And the submit-then-poll shape behind the install object above, on both surfaces that return it, is on the API and MCP page.