Repository navigation
Releases: mimecast/src2sink
Release list
src2sink 3.1.0
A drop-in upgrade. SCHEMA_VERSION stays at 2, DETECTION_VERSION at
13 and DERIVATION_VERSION at 5 — no rescan, no re-derive, and an existing
metabase is reused as-is. Two things in it: aggregation stops re-reading the same
bytes, and --promote-api-clients stops taking a reviewer's word for what it can
check itself.
⚠️ Upgrading
--promote-api-clients now refuses candidates it previously accepted. If you
promote a batch that was already reviewed by hand, expect refusals on stderr and
a lower merged count — that is the feature working, not a regression. A candidate
is refused when its target_repo is empty or names no repo in the metabase, or
when it names a client library rather than the service that receives the
calls. In the first fleet review 50 of 191 candidates failed one of those two
by hand. Nothing else about the flow changes, refusals name the candidate and the
reason, and one refusal does not block the rest of the batch.
Changed
-
Aggregation reads the metabase once instead of fourteen times (
OI-41).
Every aggregator parsed the whole metabase for itself — measured at 2.2 GB per
parse on a 746-repo fleet, so ~31 GB of JSON decoded to read the same bytes
over and over, and that repetition was 67% of aggregation in an A/B. A
shared streamed pass now offers each record to every collector in turn.full parses : 14 -> 3 collect_service_edges : 3 -> 1 aggregation : 2.12s -> 0.90s (2.4x, on the test fixture) peak RSS : 148 MB -> 150 MBThe memory line is the point. Memoising the load buys the same seconds and
costs a held copy of the fleet — measured at +118 MB on a 29 MB metabase,
which at 2.2 GB isOI-15's ceiling reached through the fix, on a host already
observed swapping. Collectors retain their reductions and never the records, so
time is one parse and memory is one record. Output is unchanged: all 27
generated artefacts are pinned byte-for-byte.The 2.4x is measured on the test fixture; the fleet figure is not yet
confirmed. Three parses remain, and deliberately — they are separate phases
with a data dependency, and collapsing them would mean holding records across
the whole aggregation. A ratchet fails the build if a new aggregator adds one
back. -
--promote-api-clientsenforces the two mechanical review gates (OI-42).
docs/api-clients-json.md§4 lists five
disqualifying gates; two of them are pure computation the tool already performs
at discovery time, andpromotemerged on trust and re-checked neither. A
binding that slipped past a reviewer became authoritative and misdirected every
edge it matched. The reviewer keeps the judgement calls; the tool now does the
arithmetic. The document's post-conditions are asserted as tests rather than
left as a checklist for a person.
Fixed
promoteno longer discards the rest ofapi-clients.json(OI-42). It
rewrote the file as{"bindings": [...]}, dropping every other top-level key —
including the_commentcarrying the "never commit — internal topology"
notice on a gitignored, sensitivity-marked file. Silently, and the file still
looked correct.- Every duplicate of a binding key is refreshed (
OI-42). Bindings were
indexed with a dict comprehension over a list that may hold duplicates, so only
the last copy of a key was updated. Earlier copies kept their old values —
still present, still loaded, and now disagreeing with their twin about the same
binding.
src2sink 3.0.0
src2sink can now say which entry points reach which sinks, with evidence.
That is the capability the tool is named for, and until this release it did not
have it: it found sources, it found sinks, and nothing connected them.
⚠️ Upgrading
A full rescan is required. DETECTION_VERSION moves 10 → 11, so records
built by an earlier version are not reused. SCHEMA_VERSION stays at 2 — the
record structure is unchanged and an existing metabase still parses — but two
things about its contents changed, and one of them will break a consumer that
does not expect it.
Breaking: call-site observations now come in two shapes. Observation was
widened from sink-shaped names to every call, because the middle of a layered
path was recorded nowhere. Recording the full SQL-evidence set for every call
measured at 3.4x the nodes and 4.7x the record size on a real repository, so an
ordinary call now records only symbol, receiver, arguments and its
enclosing scope. raw, receiver_is_database, library_hint,
file_sql_evidence and parameterised are absent on ordinary calls and
present only on sink-shaped ones. Read them with .get(), not []; absent means
"nothing vouched for this call being SQL, because nothing looked". See
SCHEMA.md.
Records grow ~1.6x in nodes and ~1.7x in bytes. That is the measured cost of
the widening after two mitigations: dropping raw from ordinary calls, and
pruning calls that name nothing declared in the repository — 77% of observed
calls, being get, append, len and the rest of the standard library, which
an intra-repo path cannot pass through by definition.
Added
-
Source-to-sink paths inside a service (
OI-17). The headline. A new
tainted-pathfinding states that a value from an entry point reaches a sink,
and cites every hop with itsfile:line, its resolution tier, and the argument
that carried the value. Reachability alone would report every endpoint as
reaching every sink its service can touch; a hop carrying no tainted value is
pruned, so a decoy calling a static query does not appear at all rather
than appearing with a low score.Path confidence is the minimum hop, never a product, with length recorded
separately and the weakest link named — a reader can act on "8 hops, weakest
link is theB→Cbinding" and nobody can act on0.058. Depth is unbounded:
measured on a 2,000-service fleet, capping at three hops finds 25% of what
depth eight finds. There is no confidence floor, because for an indicator a
floor converts cheap false positives into expensive, invisible false negatives. -
Tiered call resolution (
OI-17, step 3).stockService.process(...)now
resolves to a specific declaration: T1 from a declared field type (high),
T2 an interface expanded to its implementations (medium, and explicitly
ambiguous when there is more than one), T3 a name unique in the repo
(low); not unique, dropped. The tier is recorded on every edge. T2 is why the
answer can never be "unreachable" — a constructor-injected interface field is
the standard Spring shape, so stopping at the declared type would report a
confident dead end for most of a JVM fleet. -
The first
intra-repoedges.FlowEdgehas advertised the kind since the
schema was written and nothing ever emitted one. -
Call arguments are recorded, which is what makes a path evidence rather
than reachability. -
A persisted fleet index (
OI-15).metabase/index.sqlite3, built during
aggregation, holds the four things a trace consults, keyed by target repo. A
trace now answers from indexed lookups and never loads the fleet — peak
memory is a function of what arrives at the target, not of fleet size.
Previously a trace read every record in the metabase to answer a question about
one repo, which at 34 GB on disk needs roughly 222 GB resident merely to be
held. Staleness is checked on every read; a stale, missing or corrupt index
falls back to loading rather than serving a wrong answer.
Fixed
-
The producer scan read the whole fleet once per binding (
OI-30). Reported
from the field at 70 minutes — the slowest part of a scan bar fleet-wide
traces. The binding loop was on the outside, so a 34 GB checkout was read from
disk once per binding and the only thing that differed between passes was which
regex ran over text already in memory. Now the fleet is walked once and matched
against every binding while resident: N× fewer file reads, plus a further 2×
from building the indices once per run instead of twice. -
Discovered bindings named the client library instead of the service
(OI-40). When a client library is published from its own repository, the
repo declaring the coordinate is the library — so 42 of 191 candidates
pointed at a library rather than the service it calls, while 0 of 99
hand-authored bindings do. The records looked entirely correct, which is why
nothing rejected them. A target with no inbound endpoints is now corrected to
the single sibling service that has them, the substitution is shown to the
reviewer, and a target that cannot be corrected is flagged rather than dropped
— because "no endpoints" is also what a detection gap looks like. -
A gate against silent detection failures (
OI-36). The principle that a
detection path must not fail to empty without saying so has been written down
since 1.1.0 — and was then violated in every release since, including three
Kotlin gaps that left a JVM fleet reporting clean while detecting nothing. It
is now enforced: a handler that discards an exception must emit a signal or be
listed with a reason. 59 were found, 15 justified, and 44 frozen as debt that
can only shrink. No behaviour change; what it prevents is the next one. -
A versioned
api/latest/directory contributed nothing to the metabase
(OI-39). The test-path predicate's camelCase branch ran under
IGNORECASE, so any path segment ending in "test" was treated as a test
directory —latest,protest,contest,attest,greatest. Extraction
returns nothing for a test path, so a repository laid out that way produced no
endpoints, no sinks and no dependencies, with no note and no count. The same
predicate was too narrow in the other direction: test files living beside
their code (routes.spec.ts,handler_test.go,test_views.py) were
extracted as though they shipped. Both fixed;DETECTION_VERSION12 → 13. -
Any
.get("…")in JavaScript was an inbound HTTP endpoint (OI-37). The
Express pattern began at the dot, so it matched a verb-named call on any
receiver — and JavaScript is the language where.get(key)is ubiquitous for
reasons unrelated to HTTP. Over a 746-repo estate that produced 10,245
inbound endpoints of which 20 were real, all atconfidence: "high":
reactive-form field access, Cypress selectors, cache writes, and 605 outbound
client calls recorded as doors into the service. The receiver is now required.
Confidence is derived from how anchored a pattern is rather than hardcoded, and
rawnow contains the receiver so a reviewer can audit a match without
re-reading the source.DETECTION_VERSION11 → 12. -
The trace index never described the batch that had just run (
OI-38). Only
the build wrote it, and the build runs before any trace exists — so its
catalogue-coverage figure was always one run stale, and on a first run no index
appeared at all. The batch now writes it too. -
Discovery's two passes could never agree, so
discovery_method: "both"was
unreachable (OI-33). The supply-side pass named a target by the directory
that declares the client coordinate — inside the repo for a multi-module
build — while the demand-side pass used the repo id. An exact-string lookup
could not bridge them, so the strongest signal the design produces occurred
zero times in 226 candidates. Targets are now normalised to the longest known
repo id, which also restores thepathsandservice_aliasesthe bad key was
suppressing. Reviewer decisions on already-triaged candidates are preserved
across the key change, and a coordinate that resolves to nothing now says so
instead of arriving as an ordinary weak candidate. -
Api-client discovery rescanned the whole fleet once per class (
OI-35).
The demand-side pass asked "which repos contain a file calledStockClient"
by walking every node of every record, once per target and per class — so
node visits grew about 15x for every doubling of the repository count. The
question is corpus-wide and target-independent, so it is now answered from an
index built in one pass: 576x fewer node visits at 48 repos, and the
saving keeps growing with the fleet. -
The checkout was walked 25 times per run to find a handful of files
(OI-31).Path.rglob(name)traverses the whole tree and filters by name,
so four filenames cost four traversals — and no phase shared a walk with any
other. Now one traversal serves everything: 25 → 3, and → 1 when the
CLI names every pattern up front. -
--discover-api-clientswas silently ignored outside a full scan
(OI-31). Combined with--graphs-onlyor--aggregate-onlyit printed
Done.and wrote no candidates file — the flag accepted, doing nothing, with
no clue why. Discovery reads records and the checkout, both of which those
modes have. -
Three Kotlin gaps, all silent since 2.1.0. Each made Kotlin produce a
clean-looking result rather than a wrong one, which is the failure mode
OI-13exists to prevent, and each passed its original parity test because
those tests compared the easy half of a record:- interfaces were never recognised as interfaces — Kotlin has no
`inter...
- interfaces were never recognised as interfaces — Kotlin has no
src2sink 2.1.0
Observation and classification, separated. 2.0.0 was about findings that were
wrong; this release is mostly about making a wrong finding cheap to correct
— plus one defect reported from the field that made Maven dependency data empty
for most real repositories.
Why a minor bump. Two new node families and new record fields. Nothing is
removed and no field changes meaning, so a 2.0.0 consumer keeps working;
docs/releasing.md makes a new family a minor bump.
Before you upgrade: the first build after installing rescans every
repository — DETECTION_VERSION moved from 1 to 6. If you have not yet run the
rescan 2.0.0 required, this replaces it rather than adding to it.
The one to read if you read nothing else: namespaced POMs — which is to say
every POM an IDE or archetype emits — parsed to zero dependencies in 2.0.0 and
earlier, silently. If you have Maven repositories, your dependency data was
empty and nothing said so.
Added
-
call-siteobservation nodes. The extractor now records every call
carrying a sink-shaped name, with the inputs a classifier needs, whether or
not any family claims it. Previously a call that failed the evidence gate was
discarded, which is why changing what a library means required re-extracting
the fleet.kindisreference: an observation asserts nothing about danger,
and the absence of a correspondingsqlnode says the classifier declined it,
not that the code is safe.
DETECTION_VERSIONbumps, so the first build after upgrading rescans
every repository. Measured at ~7% more nodes on the fixture corpus, roughly
one observation persqlnode. -
File-scoped SQL evidence no longer overrules a receiver (
OI-26). The
three evidence signals were OR'd together, so the weakest — a fact about other
code in the same file — decided once satisfied.httpClient.execute(r)became
a SQL execution sink because a JDBC query sat in the same class, and since
execution sinks feed the raw-code-payload linker, it could fabricate an
injection endpoint that never existed. Evidence is now ordered by how local it
is: file evidence rescues an unknown receiver, never one recognised as
another kind of boundary.ps,pstmtandcstmtjoined the receiver
vocabulary so the tightening does not withdraw realPreparedStatementsinks. -
Namespaced POMs parsed to zero dependencies (
OI-18). Reported from the
field against 2.0.0. Namespace declarations were stripped with a regex, which
left prefix uses behind — the standard Maven root element carries
xsi:schemaLocation, so removingxmlns:xsi="..."madexsi:an unbound
prefix and the document failed to parse.except ParseError: return []
swallowed it. Every POM an IDE or archetype emits was affected, so Maven
dependency data was empty for most real repositories and nothing said so.
Namespaces are now matched rather than stripped. -
Maven dependency versions are resolved offline (
OI-18).${property}
strings and empty inherited versions were recorded as though they were
versions. Resolution is now tiered —literal,property,parent-in-repo,
parent-in-fleet,unresolved— with the tier recorded, and works without
mvn, a registry, or downloading anything: an internal parent POM lives in a
repository already cloned.<dependencyManagement>no longer emits the BOM as
a dependency in its own right. -
Go and Python dependencies are parsed, and lockfiles are read (
OI-19).
Dependency parsing covered Java and npm against nine ecosystems recognised for
identity, sodependencies_internal: []on a Go repo meant "not implemented"
and looked identical to "no internal dependencies".go.modstates exact
versions outright;uv.lock,poetry.lockandpackage-lock.jsonhold the
resolved answer a manifest range does not. Dependencies now carry
version_kind—resolved,rangeorunresolved— because^1.4.2names a
set rather than a version. A repo whose ecosystem is recognised but unparsed
now says so in its notes instead of reporting an empty list.
Changed
- Findings are now derived from observations, in a separate pass. Extraction
records what it saw;src2sink/derive.pydecides what it means. The two are
versioned apart:derivation_versiongoverns the findings, and changing a
classification rule now re-derives from existing records — no source, no
parsing, no fleet rescan — whiledetection_versionstill governs what is
observed. A newsql-field-markerobservation carries the input the
raw-code-payloadlink previously held only in memory. - SQL classification now reads observations, not source. The rules are
unchanged and thesqlnodes emitted are byte-identical; what changed is the
input.classify_sql_from_observationsconsumescall-siterecords and never
touches the source or the AST, so correcting a classification —OI-26, or any
entry in the boundary catalogue — becomes a change to one function over stored
data rather than a reason to re-extract the fleet.
Fixed
- Identical but meaningless paths matched at
highconfidence (OI-24).
path_templates_matchreturned on string equality before asking whether
either side named a destination, so two repos both exposing a bare/v1— or
/api, or/service— produced a high-confidence cross-repo edge. That is the
defectOI-1was raised to remove, surviving through the one path that never
reached the guardOI-1added. - Path placeholders and operation verbs counted as destinations (
OI-25).
/{id}matched/{name}, and/searchmatched/search, both athigh.
Placeholders are now filtered like/api. Operation verbs are deliberately
not filtered —/v1/queryis a real route of a real query service — but a
match resting entirely on verbs is capped atlow.
Behaviour change:/orders/createand/orders/deleteno longer match
each other. They are different endpoints, and conflating them was never
intended.
src2sink 2.0.0
Detection correctness. Where 1.1.0 recovered callers that were missing, this work
is mostly about output that was wrong — findings the tool stated confidently
and should not have. Issues are tracked as OI-n in
docs/issues/src2sink-open-issues.md;
each is reproduced by a named regression test.
Why a major bump. SCHEMA_VERSION is unchanged at 2 and existing
metabases still load, but detail.parameterised — a documented field — changed
from a boolean to a posture string. Anything reading it as a boolean breaks, and
docs/releasing.md makes a change in a documented field's
meaning a major bump regardless of whether the file still parses.
Before you upgrade: the first build after installing this version rescans
every repository, because records now record which detector produced them and
none of the existing ones do (OI-16). Budget for a full fleet scan once.
Fixed
-
The
sqlfamily matched on method name alone (OI-7).execute,query
andupdateare ordinary method names, and the receiver — available on every
grammar's AST node — was read and discarded. SohttpClient.execute(request),
messageDigest.update(data)andcall.execute()were catalogued as
unparameterised SQL execution sinks athighconfidence. Worse, an execution
sink feedslink_raw_code_payload_endpoints, so an HTTP proxy carrying a field
namedsqlmanufactured araw-code-payloadfinding — a fabricated
injection endpoint that sends an analyst to audit code that was never
vulnerable. Asqlnode now requires positive evidence: a database receiver, a
library name in the call text, or file-level SQL evidence. -
Version prefixes outranked real route names (
OI-1). Confidence graded by
which structural rule fired rather than how much meaning matched, so a repo
exposing a bare/v1beat the service actually exposing/stock— and the
correct candidate was discarded, not merely ranked lower. Segments naming a
version or a layer are now dropped before comparison, and equal-confidence
candidates are ranked by specificity. -
SQL assembled by formatting produced no node at all (
OI-8).
String.format,.formatted,MessageFormat.format, Python%and.format
were uncovered. Two adjacent holes mattered more: the concatenation patterns
excluded both quote characters, so"… WHERE ref = '" + ref + "'"— the
canonical injection shape — could not be spanned; and the template pattern
required interpolation before the SQL keyword, so"SELECT … ${id}"rarely
fired. -
parameterisedclaimed a safety property it could not establish (OI-10).
A placeholder does not undo a concatenation in the same statement, and the
value was computed from any SQL-shaped literal anywhere in the file — so an
unrelated safe constant certified an injectable call site while the scan
simultaneously reported that statement as asqlsource. -
A base-query constant hid the concatenation appended to it (
OI-11). In
SAFE + " AND ref = '" + ref + "'"the keyword lives in the constant and the
concatenated fragments carry none, so nothing matched: no finding was emitted
and the call site was reported safe. Constants are now resolved through a
per-file symbol table, the same mechanism already used for constant-mediated
URLs. -
Tracing rebuilt the whole fleet service-call graph for every target
(OI-14). The graph is fleet-wide and target-independent, so a batch of N
traces built the identical graph N times and then filtered it N times. It is
now built once per run: 10 traces over a 200-repo fleet fell from 22.05s to
0.04s. Separately, the graph build itself re-normalised every candidate route
string on every comparison — 4.5M calls for 150 repos — which memoisation cuts
by 4.2x (400 repos: 38.28s to 9.15s). Reports are unchanged. -
A detection fix never reached a repository that had not changed
(OI-16). The incremental scan skipped a repo whose git sha matched the sha
in its existing record — keying the cache on what was scanned, but not on what
scanned it. So every fix in this release reached only the repositories that
happened to commit afterwards, and nothing said so, because a record did not
record which detector produced it. Records now carry adetection_versionand
are rebuilt when it differs. The first build after upgrading rescans the
whole fleet, since no existing record carries the field; after that, findings
from superseded detectors disappear. A new gate fails the build when an
extractor changes without a version bump.
Changed
detail.parameterisedis now a posture, not a boolean. Values are
parameterised,mixed,raw,staticorunknown.mixed— a placeholder
in a statement that is also concatenated — is the case the boolean could not
express.SCHEMA_VERSIONis unchanged at2and existing metabases still
load: a pre-2.0.0true/falseis reported asunknownrather than
translated, because that boolean was produced by the heuristic this release
removes.sqlandraw-code-payloadcounts fall. This is withdrawn false
positives, not lost coverage — expect the drop on any dashboard tracking them.- Service-call edges resolved through a bare
/v1or/apidisappear, and
edges that previously rankedlowthrough a version prefix are nowmedium.
trace --pathfiltering is deliberately unchanged: filtering and routing are
different questions and now use different predicates. detail.receiveradded tosqlsink nodes.
Added
- A mutation gate (
make mutation,scripts/mutation_check.py, in theci
chain). Coverage says the tests ran; this says they would notice. It
reintroduces each catalogued defect in a sandboxed copy of the tree and
requires the suite to fail. On its first runs it found two vacuous tests, two
pieces of dead logic, and one untested branch — see the commit history.
src2sink 1.0.3
No functional changes. Hardens how releases are built and attested.
Added
-
SLSA Build L3. Provenance is now generated and signed inside
slsa-github-generator's reusable workflow, whose steps this repository cannot
modify and whose signing identity the build steps never see. That isolation is
what separates L3 from L2: at L2 the identity that signs lives in the same job
that runs the build, so a compromised build step could produce a tampered
artefact with authentic-looking provenance. The provenance is attached to the
GitHub release asmultiple.intoto.jsonland verified with:slsa-verifier verify-artifact src2sink-1.0.3-py3-none-any.whl \ --provenance-path multiple.intoto.jsonl \ --source-uri github.com/mimecast/src2sink \ --source-tag v1.0.3
The L2 attestations from 1.0.2 continue alongside it, so
gh attestation verifyand the PyPI PEP 740 route both still work — three ways to check the
same artefact, aimed at different tools. -
Publishing now runs only after provenance generation succeeds, so a provenance
failure stops a release before the irreversible upload rather than after it. -
A
workflow_dispatchtrigger on the release workflow that builds and generates
provenance without publishing, for exercising changes to the release path
without spending a version number — and a monthly schedule that runs the same
rehearsal unattended, so breakage in the upstream generator surfaces before a
release depends on it.
Fixed
-
Build provenance no longer attests
dist/.gitignore.subject-path: dist/*
matched the file uv writes there, so 1.0.2's provenance lists it as a third
subject alongside the wheel and sdist. Cosmetic — the real artefacts were
attested correctly either way — but a provenance statement claiming a
.gitignorewas a build output undermines confidence in the rest of it. The
attested subjects are now the two distributions and nothing else:$ gh attestation verify src2sink-1.0.3-py3-none-any.whl --repo mimecast/src2sink # subjects: src2sink-1.0.3-py3-none-any.whl, src2sink-1.0.3.tar.gz
src2sink 1.0.2
No functional changes. Adds build provenance to the release process.
Added
-
Signed build provenance — SLSA Build L2. Every published artefact now
carries provenance generated and signed by the build platform, in two forms:
a GitHub attestation, and a PEP 740 attestation stored alongside the files on
PyPI so it is available to anyone who installed from there rather than from
the GitHub release. Verify with:gh attestation verify src2sink-1.0.2-py3-none-any.whl --repo mimecast/src2sink python -m pypi_attestations verify pypi --repo mimecast/src2sink src2sink-1.0.2-*.whlProvenance ties an artefact to the source commit, tag, and workflow run that
produced it, so a file claiming to be src2sink can be checked rather than
trusted. 1.0.0 and 1.0.1 have none — they predate this.This is Build L2, not L3: the identity that signs belongs to the same job
that runs the build, so it is not beyond the reach of a compromised build step.
docs/slsa.mdsets out what closes that gap.
Changed
- The publish step uses
pypa/gh-action-pypi-publishinstead ofuv publish.
uv uploads PEP 740 attestations that already exist but does not generate them;
the PyPA action does both. Publishing is still tokenless Trusted Publishing,
and the PyPI publisher configuration is unchanged.
src2sink 1.0.1
No functional changes. The analyser, its output schema, and the CLI are
byte-for-byte what 1.0.0 shipped. There is nothing here a user of 1.0.0 needs;
the version exists to carry the release automation and documentation below.
Added
- This changelog, and a release procedure (
docs/releasing.md)
covering versioning, the gate run, tagging, building, and recovery when a bad
version reaches PyPI. - Automated publishing via PyPI Trusted Publishing (OIDC). A
v*tag now
builds from the tagged tree, verifies the tag matches the packaged version,
publishes to PyPI, and attaches the same artefacts to the GitHub release —
with no API token stored anywhere in the repository. - A link to this changelog from the README.
Fixed
- CI cache save race. Every cached job derived the same
uvcache key and
started at once, so they raced for the save reservation and the losers
annotated each run with "Unable to reserve cache". One job now writes the
cache and the rest restore only;srtm, which installs nothing, opts out
entirely (the input defaults toauto, which had quietly opted it in).
Verified on a green run with zero annotations.
src2sink 1.0.0
Yanked on PyPI, 2026-07-29 — "Superseded by 1.0.1". Not a defect: 1.0.1 is
functionally identical, and this release is sound. Existing== 1.0.0pins
keep resolving as before; new installs resolve to 1.0.1 regardless.
First public release. src2sink builds a source-to-sink metabase: a structured,
human-readable knowledge base of an entire source-code estate, designed to be
loaded as context for LLM-assisted SAST so taint can be followed across
repositories — a SQL fragment built in one service and executed in another, an
internal library that silently forwards to a JDBC sink, PII entering at an
ingress and surfacing in a log three repos away.
Extraction
- Tree-sitter extractors for Java, Kotlin, Python, Go, JavaScript, and
TypeScript, plus configuration-file extraction (Springapplication.yml/
.properties, and friends) for facts that never appear in code. - Output schema v2 (
SCHEMA_VERSION = 2): a flow graph ofFlowNode
(source/propagator/sink/store, with afamily, a
pii_classification, adata_class, and a confidence rating) joined by
FlowEdgeat intra-file, intra-repo, and cross-repo scope. - Build-system and framework detection across Maven, Gradle, npm/yarn/pnpm, pip,
Poetry, Pipenv, Go modules, and Cargo. Declared dependencies are parsed from
pom.xml,build.gradle*, andpackage.json; component identity (which
repo publishes a given coordinate) additionally resolvespyproject.toml,
setup.cfg,Cargo.toml,composer.json,go.mod,*.csproj/*.fsproj/
*.vbproj, and*.gemspec. An internal-vs-external coordinate classifier
runs off your own namespace patterns.
Cross-repo analysis
- Taint catalogues — SQL sources and execution sinks, file sinks, outbound
HTTP sinks, PII sources and sinks, crypto operations, raw-code-payload
endpoints, and security-relevant configuration. - Graphs — service-call graph (HTTP out ↔ HTTP in), queue producer/consumer
graph, data-store graph, payload-endpoint producers, PII lifecycle, and
cross-repo phone-number flows. - OpenAPI discovery — specs found in the estate are matched to services and
folded into the service-call edges. - Phase 3 models — per-repo auth cards, crypto-agility cards, a PII lifecycle
model, and a GDPR Article 30 ROPA projection. - Bidirectional tracing —
src2sink-tracefollows a target repo or endpoint
upstream to its producers and downstream to its sinks;src2sink-trace-batch
does it for every raw-payload endpoint discovered.
Command-line tools
src2sink-build (extract + aggregate), src2sink-trace, src2sink-trace-batch,
src2sink-curate (internal-library taint tables), and src2sink-baseline
(fleet baseline). Builds are incremental by git SHA — unchanged repos are
skipped, cross-repo aggregation always re-runs so the estate stays consistent.
Built to scan hostile input
Scanned repositories are treated as untrusted, and the outputs are treated as
prompt material for an LLM. Every control below is traced to a test through the
SRTM:
- Execution bulkhead — each repo is analysed in its own process with a
wall-clock budget, so a pathological parse or catastrophic regex kills one
repo, not the run. - Path containment — crafted
.git/HEADsymrefs and escaping symlinks cannot
read outside the repo. - Hardened XML — manifests are parsed with
defusedxml; entity-expansion
payloads do not expand. - Size, file-count, and line-length caps, plus a content pre-screen that
skips binary and minified/obfuscated files before a parser sees them. Every
skip is recorded, never silent. - Untrusted-output neutralisation — extracted content is escaped and fenced
in Markdown and JSONL so a comment in a scanned repo cannot issue instructions
to a downstream LLM. - Literal-PII redaction in code snippets, and log hygiene that reports the
exception type and repo id rather than paths or content. - Run provenance — every build writes a secret-free
run-manifest.json
(tool version, per-repo SHAs, counts, UTC timestamps) for reproducibility and
Article 30 records.
Operational guidance — data classification, least-privilege CI, retention and
erasure — is in docs/operations-security.md;
the risk register is in docs/threat-model.md.
Quality gates
CI runs six gates on every push and pull request, and weekly:
| Gate | Enforces |
|---|---|
test |
302 tests, 84% coverage overall, 90% on the security-critical modules |
srtm |
every requirement in the SRTM still has an implementing test or a documented audit |
mypy (strict) |
mypy --strict, clean across the package and scripts |
bandit |
Python SAST, clean |
pip-audit |
no known advisories against the locked dependency set |
opengrep |
pattern SAST with a pinned ruleset, clean at ERROR severity |
Dependencies are hash-pinned in a committed uv.lock and installed with
uv sync --locked. Known false positives are annotated inline with a stated
reason rather than suppressed wholesale.
Fixed before release
SKIP_DIRSwas matched against each manifest's absolute path, so a repos
root under any colliding path segment —/tmp/repos,~/build/repos— indexed
nothing at all. Silently: no error, no note, every coordinate resolving to "not
found" and every source-map lookup coming back empty. The first CI run caught
it, because GitHub runners put pytest's temporary directories under/tmp;
reproducible locally withTMPDIR=/tmp.SKIP_DIRSnow applies only to
segments below the scan root — the absolute prefix is the operator's
filesystem layout, not part of the scanned tree. No published version is
affected: the fix predates this release.
Requirements
Python 3.14+. Install with pip install src2sink or uv add src2sink.
Known limitations
- Detection is heuristic and rated (
high/medium/low); treatlowas
investigate, not confirmed. - Scala files are counted in the language breakdown and get the language-agnostic
regex passes, but there is no tree-sitter grammar for them yet, so no AST-level
extraction. - The metabase is a concentrated map of weaknesses and personal-data locations.
Store it access-controlled and encrypted at rest — see the operations guide.