From a234d39b8be54a437282114c7ed8e03e805de981 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 30 Jul 2026 12:20:50 +0200 Subject: [PATCH 1/5] feat(skills): Add enactment --- .../appsec-agent/sections/setup.md | 4 +- .../appsec-agent/sections/state-resumption.md | 1 + .../discover-universal-boundaries/main.md.j2 | 30 + .../sections/constraints.md | 7 + .../sections/input.md | 7 + .../sections/output.md | 11 + .../sections/preamble.md | 7 + .../sections/tracking.md | 5 + .../sections/workflow.md | 84 +++ skills-templates/enactment-agent/main.md.j2 | 30 + skills-templates/enactment-agent/scripts | 1 + .../enactment-agent/sections/dispatching.md | 24 + .../sections/key-constraints.md | 6 + .../enactment-agent/sections/preamble.md | 7 + .../enactment-agent/sections/setup.md | 40 ++ .../sections/state-resumption.md | 34 + .../enactment-agent/sections/workflow.md | 39 ++ .../references/boundaries.md.j2 | 45 ++ .../references/crossref.md.j2 | 43 ++ .../orchestrate-stage/sections/workflow.md | 2 + skills-templates/shared/scripts/_common.py | 9 + skills-templates/shared/scripts/generate.py | 55 +- skills-templates/shared/scripts/get_status.py | 115 +++- .../shared/tracking/boundary-spec.md | 28 + .../shared/tracking/reference-finding.md | 21 + skills/appsec-agent/SKILL.md | 5 +- skills/appsec-agent/scripts/_common.py | 9 + skills/appsec-agent/scripts/generate.py | 55 +- skills/appsec-agent/scripts/get_status.py | 115 +++- skills/discover-universal-boundaries/SKILL.md | 192 ++++++ skills/enactment-agent/SKILL.md | 174 +++++ skills/enactment-agent/scripts/_common.py | 191 ++++++ skills/enactment-agent/scripts/generate.py | 612 ++++++++++++++++++ skills/enactment-agent/scripts/get_status.py | 470 ++++++++++++++ skills/orchestrate-stage/SKILL.md | 2 + .../references/boundaries.md | 112 ++++ .../orchestrate-stage/references/crossref.md | 63 ++ skills/orchestrate-stage/scripts/_common.py | 9 + skills/orchestrate-stage/scripts/generate.py | 55 +- .../orchestrate-stage/scripts/get_status.py | 115 +++- 40 files changed, 2758 insertions(+), 76 deletions(-) create mode 100644 skills-templates/discover-universal-boundaries/main.md.j2 create mode 100644 skills-templates/discover-universal-boundaries/sections/constraints.md create mode 100644 skills-templates/discover-universal-boundaries/sections/input.md create mode 100644 skills-templates/discover-universal-boundaries/sections/output.md create mode 100644 skills-templates/discover-universal-boundaries/sections/preamble.md create mode 100644 skills-templates/discover-universal-boundaries/sections/tracking.md create mode 100644 skills-templates/discover-universal-boundaries/sections/workflow.md create mode 100644 skills-templates/enactment-agent/main.md.j2 create mode 120000 skills-templates/enactment-agent/scripts create mode 100644 skills-templates/enactment-agent/sections/dispatching.md create mode 100644 skills-templates/enactment-agent/sections/key-constraints.md create mode 100644 skills-templates/enactment-agent/sections/preamble.md create mode 100644 skills-templates/enactment-agent/sections/setup.md create mode 100644 skills-templates/enactment-agent/sections/state-resumption.md create mode 100644 skills-templates/enactment-agent/sections/workflow.md create mode 100644 skills-templates/orchestrate-stage/references/boundaries.md.j2 create mode 100644 skills-templates/orchestrate-stage/references/crossref.md.j2 create mode 100644 skills-templates/shared/tracking/boundary-spec.md create mode 100644 skills-templates/shared/tracking/reference-finding.md create mode 100644 skills/discover-universal-boundaries/SKILL.md create mode 100644 skills/enactment-agent/SKILL.md create mode 100644 skills/enactment-agent/scripts/_common.py create mode 100644 skills/enactment-agent/scripts/generate.py create mode 100644 skills/enactment-agent/scripts/get_status.py create mode 100644 skills/orchestrate-stage/references/boundaries.md create mode 100644 skills/orchestrate-stage/references/crossref.md diff --git a/skills-templates/appsec-agent/sections/setup.md b/skills-templates/appsec-agent/sections/setup.md index bbe498665..5a51ee615 100644 --- a/skills-templates/appsec-agent/sections/setup.md +++ b/skills-templates/appsec-agent/sections/setup.md @@ -17,7 +17,7 @@ Read the project's build files to fix the target language — Maven/Gradle → j ### 4. Choose the workflow -Ask the user for both levels together: +Ask the user for both knobs together: 1. Scan level — `lite` · `normal` · `deep` - lite — build + scan (expected, when there are already existing artifacts) @@ -36,4 +36,4 @@ Seed the run state and the working tree with the chosen levels and language: uv run /scripts/generate.py init --scan-level --triage-level --language ``` -It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree. +It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree. If the user wants a supplied finding set reproduced rather than the project searched for vulnerabilities, that is the enactment pipeline — stop here and load `enactment-agent` instead. diff --git a/skills-templates/appsec-agent/sections/state-resumption.md b/skills-templates/appsec-agent/sections/state-resumption.md index 2b3701834..7b5414109 100644 --- a/skills-templates/appsec-agent/sections/state-resumption.md +++ b/skills-templates/appsec-agent/sections/state-resumption.md @@ -18,6 +18,7 @@ The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` deriv `state.yaml` shape: ```yaml +mode: discovery scan_level: deep triage_level: dynamic language: java diff --git a/skills-templates/discover-universal-boundaries/main.md.j2 b/skills-templates/discover-universal-boundaries/main.md.j2 new file mode 100644 index 000000000..a2f1e0eb4 --- /dev/null +++ b/skills-templates/discover-universal-boundaries/main.md.j2 @@ -0,0 +1,30 @@ +--- +name: discover-universal-boundaries +description: Generalize a family of known finding traces into one reusable source and one reusable sink boundary, saturated against the whole family. Use before rule authoring when reproducing supplied findings, consolidating finding-specific rules, or replacing an incidental source such as a map access or an arbitrary method call +license: Apache-2.0 +metadata: + author: opentaint + version: "{{ version }}" +--- + +{% include "sections/preamble.md" %} + +## Inputs + +{% include "sections/input.md" %} + +## Workflow + +{% include "sections/workflow.md" %} + +## Output + +{% include "sections/output.md" %} + +## Tracking + +{% include "sections/tracking.md" %} + +## Constraints + +{% include "sections/constraints.md" %} diff --git a/skills-templates/discover-universal-boundaries/sections/constraints.md b/skills-templates/discover-universal-boundaries/sections/constraints.md new file mode 100644 index 000000000..33fbd8c22 --- /dev/null +++ b/skills-templates/discover-universal-boundaries/sections/constraints.md @@ -0,0 +1,7 @@ +- Work only the assigned finding ids, your family's spec, and the reference `family` field on a split +- Universal does not mean untyped or unconstrained — generalize the boundary, then recover precision with context restrictions and sanitizers, never by re-narrowing the boundary to the findings +- Never propose all methods, all map values, or all getters as a source, and never a `pattern-either` with one branch per finding +- Never mark the spec saturated on a round that widened, broke, or split anything +- Never propose a sanitizer that would suppress a trace the reference set says is real, and never record a validator as a sanitizer without the invariant it enforces +- Record a poor taint fit as an explicit pseudo-boundary; never drop the finding +- Don't create or recommend approximations here — note the opaque carriers and stop diff --git a/skills-templates/discover-universal-boundaries/sections/input.md b/skills-templates/discover-universal-boundaries/sections/input.md new file mode 100644 index 000000000..f1ebc55a7 --- /dev/null +++ b/skills-templates/discover-universal-boundaries/sections/input.md @@ -0,0 +1,7 @@ +{% include "shared/inputs-preamble.md" %} + +{% include "shared/inputs/project-root.md" %} +{% include "shared/inputs/language.md" %} +- `findings` (required) — path to the supplied finding manifest or report the reference set was normalized from +- `finding-ids` (required) — the reference finding ids assigned to this family. Their normalized files are `.opentaint/tracking/reference/.yaml` +- `family` (required) — kebab-case name of the family; also the name of its spec and of the rule units seeded from it diff --git a/skills-templates/discover-universal-boundaries/sections/output.md b/skills-templates/discover-universal-boundaries/sections/output.md new file mode 100644 index 000000000..810530794 --- /dev/null +++ b/skills-templates/discover-universal-boundaries/sections/output.md @@ -0,0 +1,11 @@ +Return every spec path written (one per family or subfamily) and, in a few lines each: + +- the universal source, and which more incidental candidates you rejected; +- the universal sink, and the vulnerability-class scope it stays inside; +- how many saturation rounds it took and what the last round changed; +- the assigned finding ids by factorization status — `covered`, `needs-restriction`, `unfactored`; +- any family split, with the ids that moved and the reference files you rewrote; +- the sanitizers, negative patterns, and context restrictions listed separately from the boundaries; and +- unresolved boundary evidence, as the `open_questions` entries. + +Never paste file contents. diff --git a/skills-templates/discover-universal-boundaries/sections/preamble.md b/skills-templates/discover-universal-boundaries/sections/preamble.md new file mode 100644 index 000000000..a55f99eaa --- /dev/null +++ b/skills-templates/discover-universal-boundaries/sections/preamble.md @@ -0,0 +1,7 @@ +# Discover universal boundaries + +Generalize one family of known finding traces into rule-ready boundaries: a single source and a single sink that every finding in the family factors through. + +The goal is not the broadest syntax that happens to match every trace. It is the most primitive semantic boundary that stays inside the vulnerability class — general enough that the family needs one rule per side, specific enough that the rule still means something. Precision is recovered afterwards with context restrictions and sanitizers, listed separately, never by narrowing the boundary back down to the findings you started from. + +A boundary is only universal once it has been saturated: widened, re-checked against the whole family, and left unchanged by a full round. diff --git a/skills-templates/discover-universal-boundaries/sections/tracking.md b/skills-templates/discover-universal-boundaries/sections/tracking.md new file mode 100644 index 000000000..7ff626490 --- /dev/null +++ b/skills-templates/discover-universal-boundaries/sections/tracking.md @@ -0,0 +1,5 @@ +This skill writes the boundary spec for its family, and — only when a split moves a finding — the `family` field on the reference files it moved. Leave `stages.units_seeded` pending: seeding the rule units is the calling stage's step. Touch no other reference field, no rule unit, and no rule. + +{% include "shared/tracking/boundary-spec.md" %} + +{% include "shared/tracking/reference-finding.md" %} diff --git a/skills-templates/discover-universal-boundaries/sections/workflow.md b/skills-templates/discover-universal-boundaries/sections/workflow.md new file mode 100644 index 000000000..8920c491b --- /dev/null +++ b/skills-templates/discover-universal-boundaries/sections/workflow.md @@ -0,0 +1,84 @@ +### 1. Reconstruct every trace + +For each assigned finding, read its reference file and then the project source it points at, and record: + +1. the attacker or untrusted authority; +2. the first project-visible ingress; +3. the transformations and trust-domain crossings on the way; +4. the validators, guards, and authorization decisions it passes; +5. the primitive security-relevant effect at the end; and +6. the vulnerable invariant that is absent or defeated. + +Read enough surrounding source to tell the real boundary from incidental syntax. A getter, collection lookup, DTO accessor, or service method is usually propagation or context, not ingress. + +### 2. Propose the source + +Move backward from the finding-specific expressions until you reach the earliest reusable trust-boundary value the family shares. Prefer, in order when applicable: + +- request body, parameter, path, query, header, cookie, or multipart value; +- message, frame, packet, event, webhook, or callback payload; +- deserialized external object; +- persisted attacker-controlled record at a second-order re-entry boundary; +- tenant or user-controlled configuration at activation or read-back; +- environment or runtime configuration entering a security decision; or +- an explicit structural pseudo-source standing for attacker-selected identity or control state. + +Reject a candidate that is merely `$MAP.get(...)` after untrusted data has already entered, a `$METAVAR.method(...)` with no boundary type/signature/annotation/enclosing entrypoint, a ubiquitous getter that would taint trusted objects just as readily, or an internal carrier that ordinary propagation or a later approximation should handle. + +Express narrow usage conditions separately, as typed patterns, annotations, `pattern-inside`, and `...`. Never bake incidental access syntax into the source. + +### 3. Propose the sink + +Move forward from the finding-specific service calls to the most primitive operation that realizes the vulnerability, while staying specific to its class. Prefer boundaries such as: + +- network connect, request, send, or download for SSRF; +- process, script, expression, template, query, or deserialization execution for injection; +- path resolution plus filesystem read/write for traversal; +- privileged object read or disclosure for IDOR and data exposure; +- privileged object mutation or durable state commit for integrity and authorization failures; +- authentication or authorization decision for control bypass; +- redirect or token release for redirect and OAuth issues; +- signature verification or unsigned callback state commit for callback integrity; +- secret activation, credential construction, or outbound use for secret exposure; and +- logger argument consumption for log injection. + +Don't stop at a controller-to-service call when the primitive effect is analyzable deeper in the project or a reusable library sink can express it. Use a structural sink only when the vulnerability *is* the missing control at that boundary, or when deeper propagation is genuinely unavailable. + +### 4. Saturate + +A boundary proposed from a few traces is a guess until it survives the whole family. Loop until a full round changes nothing. + +Each round: + +1. Factor every assigned finding — not only the new ones — through the current boundaries: + + ```text + universal source -> finding-specific context -> propagation -> universal sink + ``` + +2. For each finding that does not factor, generalize the offending side by exactly one step toward a more primitive boundary — never by adding a second alternative that merely spells out that finding's syntax. A `pattern-either` listing one branch per finding is the failure this skill exists to prevent. +3. Re-check the findings that already factored. A widening that breaks an earlier factorization is a widening too far: back it out and split instead. +4. Challenge the widened boundary in both directions — if the sink now admits a different vulnerability class, narrow it back to the primitive effect or add a class-specific context restriction; if the source now admits trusted values, record what separates them as a context restriction rather than shrinking the boundary. +5. Record the round: what changed, and which factorization statuses moved. + +The family is saturated when a full round widened nothing, broke nothing, and left every assigned finding either `covered` or `needs-restriction` with a named restriction. Stop and split — or record the finding `unfactored` with the evidence in `open_questions` — rather than looping a fourth time on the same finding. + +When the only boundary the whole family shares is arbitrary syntax, the family was wrong: split it. Each subfamily gets its own spec named `-` and its own saturation loop, every assigned finding lands in exactly one subfamily, and each moved finding's reference file has its `family` rewritten to the subfamily that now owns it. + +Keep independently triggerable paths distinct in the factorization even when they share both boundaries. + +### 5. Identify the precision controls + +List these separately from the positive boundaries — the spec records them, and they must never be folded into the boundary patterns: + +- sanitizers that actually enforce the relevant invariant; +- `pattern-not` exclusions for safe expression forms; +- `pattern-not-inside` exclusions for safe guarded regions; +- `pattern-inside` contexts that constrain a universal boundary to the intended entrypoint, type, tenant, or vulnerability family; and +- validators that are *not* security-relevant, recorded explicitly so nothing later mistakes them for sanitizers. + +Real sanitization looks like resolved-IP private-range rejection for SSRF, canonical-path containment for traversal, strict identifier ownership checks for IDOR, signature verification for callbacks, and CR/LF neutralization for log injection. Presence, length, parsing, or `@Valid` alone normally sanitizes none of these. + +### 6. Write the specification + +Write one spec per family or subfamily. `candidate_patterns` must be concrete enough for `create-rule` to test — a fully-qualified method with its signature, or the annotation and type that identify the boundary. Record opaque carriers under `approximation_candidates`, and stop there: no approximation is created or recommended until a rule-first scan proves a trace stops at one. diff --git a/skills-templates/enactment-agent/main.md.j2 b/skills-templates/enactment-agent/main.md.j2 new file mode 100644 index 000000000..a78eb7b46 --- /dev/null +++ b/skills-templates/enactment-agent/main.md.j2 @@ -0,0 +1,30 @@ +--- +name: enactment-agent +description: Reproduce a supplied finding set as verified OpenTaint rules, owning the long project build and scans and delegating each other pipeline stage. Use when the user supplies findings, a scanner report, or source-to-sink traces to reproduce, validate, or convert into reusable rules +license: Apache-2.0 +metadata: + author: opentaint + version: "{{ version }}" +--- + +{% include "sections/preamble.md" %} + +## Setup + +{% include "sections/setup.md" %} + +## Workflow + +{% include "sections/workflow.md" %} + +## Dispatching + +{% include "sections/dispatching.md" %} + +## State and resumption + +{% include "sections/state-resumption.md" %} + +## Key constraints + +{% include "sections/key-constraints.md" %} diff --git a/skills-templates/enactment-agent/scripts b/skills-templates/enactment-agent/scripts new file mode 120000 index 000000000..aca7e2caf --- /dev/null +++ b/skills-templates/enactment-agent/scripts @@ -0,0 +1 @@ +../shared/scripts \ No newline at end of file diff --git a/skills-templates/enactment-agent/sections/dispatching.md b/skills-templates/enactment-agent/sections/dispatching.md new file mode 100644 index 000000000..b791656ad --- /dev/null +++ b/skills-templates/enactment-agent/sections/dispatching.md @@ -0,0 +1,24 @@ +Dispatch exactly one stage-orchestrator subagent for each stage invocation: + +``` +Invoke the Skill orchestrate-stage first, then follow its instructions precisely +Inputs: + stage: +``` + +A subagent inherits the project-root working directory, so omit `project-root`. + +Stage context: + +- `boundaries` — normalize the supplied findings into the reference set, generalize each family into a saturated source and sink boundary, and seed the rule units from it +- `sources` — author the seeded source units' rules and wire the joins +- `sinks` — author the seeded sink units' rules and wire the joins +- `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier +- `crossref` — judge each supplied finding against the latest scan and refresh the coverage manifest +- `triage` — classify the latest findings and refresh the vulnerability report +- `poc` — reproduce confirmed findings and add the outcomes to the report +- `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure + +Keep each agent id until the next scan validates its artifacts. On a stage-owned error, resume that agent with `stage: escalation`, the exact error, and the artifact path/id. If its thread is unavailable, start a re-entrant `orchestrate-stage` agent with that diagnosis. + +Dispatch each subagent fresh, don't fork context into it. Then wait for it natively, don't monitor or poll every minute. If the harness forces a wait timeout, set it to ~1h and re-wait when it returns. diff --git a/skills-templates/enactment-agent/sections/key-constraints.md b/skills-templates/enactment-agent/sections/key-constraints.md new file mode 100644 index 000000000..8f0d45e70 --- /dev/null +++ b/skills-templates/enactment-agent/sections/key-constraints.md @@ -0,0 +1,6 @@ +- read pipeline state through `/scripts/get_status.py`, not by hand — don't re-derive it with glob/grep/`python3 -c`/yaml scans over `.opentaint/tracking`, `results`, or the `*.yaml`, nor open finding/unit/reference/SARIF files just to review progress. If its output doesn't settle the question, re-run it with `--full` before opening any file +- don't author or edit stage-owned artifacts or tracking; MAIN writes only `model_commit`, `build_jdk`, and `max_memory` in `state.yaml` +- keep one generated project model for the run; never hand-edit or replace it mid-analysis — fix the build and rebuild before starting a new run +- source and sink rules come before approximation work; an approximation never compensates for a boundary that was never authored +- coverage is counted by unique finding identity, never by rule id or raw SARIF result count, and a result counts as a reproduction only when its trace carries the finding's own attack path +- never drop a supplied finding as unsuitable for taint analysis diff --git a/skills-templates/enactment-agent/sections/preamble.md b/skills-templates/enactment-agent/sections/preamble.md new file mode 100644 index 000000000..f0c26ea72 --- /dev/null +++ b/skills-templates/enactment-agent/sections/preamble.md @@ -0,0 +1,7 @@ +# Enactment Agent + +Reproduce a supplied set of findings with OpenTaint. Every finding ends the run either matched by a verified OpenTaint result whose trace carries the finding's own identity, or recorded with the exact rule, modeling, or engine limitation that stopped it. Keep the long project build and every full-project scan in this main session; delegate each bounded stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. + +This is the enactment pipeline — the same machine as `appsec-agent`, and the same `.opentaint/` tree, differing in how the source and sink rules are produced. There, they come from discovering the project's dependency attack surface; here, from generalizing the supplied findings into reusable boundaries, so both sides exist before the first scan and that scan is rule-first. Use `appsec-agent` instead when the goal is to find unknown vulnerabilities. + +No finding is dropped for being a poor fit for taint analysis. Authorization, integrity, configuration, hard-coded-secret, and structural-control findings are modeled as explicit pseudo-taint boundaries. diff --git a/skills-templates/enactment-agent/sections/setup.md b/skills-templates/enactment-agent/sections/setup.md new file mode 100644 index 000000000..9041b7dfc --- /dev/null +++ b/skills-templates/enactment-agent/sections/setup.md @@ -0,0 +1,40 @@ +### 1. Confirm the toolchain + +Confirm `opentaint` is on PATH with `opentaint -v`. If it's missing, don't proceed silently — tell the user and offer the install command for their platform, run an install only on explicit confirmation: + +- macOS / Linux, in order: `brew install --cask seqra/tap/opentaint` · `npm install -g @seqra/opentaint` +- Windows: `npm install -g @seqra/opentaint` + +After installing, run `opentaint health` to confirm everything's resolved. + +### 2. Confirm agent nesting + +This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. + +### 3. Locate the findings + +The supplied findings are this run's input and the only thing it is measured against. Ask the user for their path when it isn't already given — a manifest, SARIF, scanner report, or a directory of finding documents. If the user has only described the findings in conversation, write them to a file first and use that; the pipeline resumes from disk, not from this thread. + +### 4. Determine the language + +Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. + +### 5. Choose the workflow + +Ask the user for the triage level: + +1. Triage level — `static` · `dynamic` + - static — classify findings from the model, no running app + - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option + +There is no scan-level question here: reproducing a finding set always needs the full rule and approximation toolbox, so enactment is always deep. + +### 6. Bootstrap + +Seed the run state and the working tree: + +```bash +uv run /scripts/generate.py init --mode enactment --triage-level --language --findings +``` + +It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. It refuses to convert an existing discovery run — that tracking has no reference set behind it, so enactment starts in its own project tree. diff --git a/skills-templates/enactment-agent/sections/state-resumption.md b/skills-templates/enactment-agent/sections/state-resumption.md new file mode 100644 index 000000000..83158812c --- /dev/null +++ b/skills-templates/enactment-agent/sections/state-resumption.md @@ -0,0 +1,34 @@ +Use this ownership map to route work and scan errors: + +``` +.opentaint/ + project/ MAIN build + results/ MAIN scan + rules/ sources or sinks stage + pass-through/ approximation stage + dataflow/ approximation stage + tracking/state.yaml MAIN run knobs + tracking/reference/ boundaries stage writes, crossref stage judges + tracking/boundaries/ boundaries stage + tracking/ stage agents, leaves, and join scripts otherwise + enactment.md crossref stage + vulnerabilities.md triage / PoC stage + issues/ escalation stage +``` + +The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan. + +`state.yaml` shape: + +```yaml +mode: enactment +scan_level: deep +triage_level: static +language: java +findings: reports/pentest-2026-07.md +model_commit: 0123456789abcdef0123456789abcdef01234567 +build_jdk: null +max_memory: null +``` + +`mode` is what selects this pipeline; `findings` is the supplied set the whole run is measured against. Both are written at bootstrap and never edited afterwards — pointing an in-flight run at a different finding file strands its reference set. diff --git a/skills-templates/enactment-agent/sections/workflow.md b/skills-templates/enactment-agent/sections/workflow.md new file mode 100644 index 000000000..27faee3d5 --- /dev/null +++ b/skills-templates/enactment-agent/sections/workflow.md @@ -0,0 +1,39 @@ +The run is one fixed pipeline. Use `uv run /scripts/get_status.py` to choose the next action: + +``` +build → MAIN: build +reference_set / boundaries → stage subagent: boundaries +source_rules → stage subagent: sources +sink_rules → stage subagent: sinks +scan → MAIN: scan +approximations → stage subagent: approx-round, then MAIN: rescan; repeat +triage → stage subagent: triage +poc → stage subagent: poc +crossref → stage subagent: crossref +``` + +Both rule sides are authored before the first scan on purpose: the scan that follows is the one that proves the boundaries, and its results are what later stages are allowed to react to. Never let an approximation stand in for a missing source or sink rule. + +The cross-reference closes the run rather than steering it: it judges what the finished rule set, its approximations, and its verdicts actually reproduced. + +### Build in MAIN + +When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. + +Record the returned `build_jdk` in `.opentaint/tracking/state.yaml`. Record `model_commit` as the full HEAD only when no source file is uncommitted, otherwise set it to null. Build non-convergence blocks the run because no later phase can proceed without the model. Keep one untouched model for the entire enactment run. + +### Scan in MAIN + +When status reports `scan`, or a stage returns with a rescan pending, load and follow the `run-scan` skill in this main session. Start the scan with the harness's main-session background-command facility, keep the engine's self-timeout, add a 1200-second outer backstop, and wait for the process completion event. + +A valid `.opentaint/results/report.sarif` means the scan completed, including exit 254 after an engine timeout. Record `max_memory: 16G` when the scan had to bump memory and reuse it on later scans. If no SARIF exists after the allowed retry/backstop, follow the repair path below for a malformed rule/approximation; otherwise dispatch `orchestrate-stage` with `stage: escalation` and the scan `setup` to write the scan-wide resource issue, then stop. + +When a scan or later stage reports a malformed approximation, unloadable created rule, ineffective join, or a created rule's false positive/negative, route the exact diagnosis and artifact path/id to the responsible stage agent per Dispatching, then scan again in MAIN. + +After every build, scan, or stage return, run `uv run /scripts/get_status.py` once to choose the next action. Use `--full` at run start, on resume, or when the brief output does not settle the question. + +### Iterating to coverage + +The pipeline loops by design, and status is what closes it. A rescan makes every cross-reference pending again; a cross-reference that finds a trace stopped at an opaque carrier sends the run back to an approximation round; one that blames a rule sends it back to the stage that authored it, so status returns there before closing again. Follow status through each loop rather than declaring the run finished early — an earlier phase reading `IN_PROGRESS` after a later one ran is the loop working, not a regression. + +The run is complete when status reports `run complete` — every supplied finding reproduced or recorded with its blocker, and `.opentaint/enactment.md` current. Report the coverage manifest, keeping raw SARIF results, validated findings, and unique vulnerability identities as separate counts. diff --git a/skills-templates/orchestrate-stage/references/boundaries.md.j2 b/skills-templates/orchestrate-stage/references/boundaries.md.j2 new file mode 100644 index 000000000..f3ab8694a --- /dev/null +++ b/skills-templates/orchestrate-stage/references/boundaries.md.j2 @@ -0,0 +1,45 @@ +# Reference set + universal boundaries + +Turn the supplied findings into a normalized reference set, generalize each finding family into one saturated source and sink boundary, and seed the source and sink units those boundaries imply. Enactment mode only — it replaces dependency discovery, and everything it writes feeds the ordinary rule-authoring stages. + +## Normalize the reference set + +`state.yaml` names the supplied findings under `findings` — a manifest, SARIF, report, or directory of finding documents. Write one `.opentaint/tracking/reference/.yaml` per supplied finding. + +{% include "shared/tracking/reference-finding.md" %} + +Give each finding a stable id of its own — the supplied one when it has one. Preserve separately triggerable attack paths as separate findings even when they share a sink. Never drop a finding for being a poor fit for taint analysis: an authorization, integrity, configuration, hard-coded-secret, or structural-control finding gets a reference file like any other, and becomes an explicit pseudo-boundary later. + +Then group the findings into families and set `family` on each. Partition by vulnerability class or by a cohesive finding family — never by file batches or arbitrary count. A family is the set of findings you expect to share one source and one sink. + +Fan out this normalization when the supplied set is large: one leaf per slice of the supplied report, each writing its own reference files. Assign the families yourself once every file exists, since that decision needs the whole set. + +## Discover the boundaries + +Fan out discover-universal-boundaries, one leaf per family. + +Inputs each: +- `language` +- `findings` — the path `state.yaml` names +- `finding-ids` — the ids assigned to this family +- `family` + +Expect back — `.opentaint/tracking/boundaries/.yaml` with `saturation.status: saturated`, one `factorization` entry per assigned finding, and the controls listed separately from the positive boundaries. A leaf that splits its family writes one spec per subfamily and rewrites `family` on each reference finding it moved, so every finding still points at the spec that owns it. + +{% include "shared/tracking/boundary-spec.md" %} + +A spec returning with an `unfactored` finding is not a failure to retry blindly — read its `open_questions`, and either re-dispatch the leaf with the finding split out as its own family or accept the pseudo-boundary it proposes. + +## Seed the rule units + +For each saturated spec, write its `candidate_patterns` into the family's source and sink units, then set `stages.units_seeded: done` on the spec. The unit file name is the family, so the two sides and the spec stay tied together. + +{% include "shared/tracking/source-unit.md" %} + +{% include "shared/tracking/sink-unit.md" %} + +Copy `method`, `signature`, and `note` from the spec's `candidate_patterns` (plus `vuln_class` on the sink side), leave `rule_id: null` and the `stages` pending, and fill `dependencies` with the dependency each pattern's package comes from — empty when the boundary is a project member, as a structural pseudo-boundary usually is. Do not carry `context_restrictions`, `sanitizers`, or `negative_patterns` into the units: the boundary is the positive pattern, and the controls stay listed in the spec. + +## Stage gate + +`get_status.py` drives `reference_set` then `boundaries`, naming findings without a family, families without a spec, unsaturated specs, unfactored findings, and unseeded specs. Finish when both are `DONE`, or when the next step it reports is source rules. diff --git a/skills-templates/orchestrate-stage/references/crossref.md.j2 b/skills-templates/orchestrate-stage/references/crossref.md.j2 new file mode 100644 index 000000000..781ce95ab --- /dev/null +++ b/skills-templates/orchestrate-stage/references/crossref.md.j2 @@ -0,0 +1,43 @@ +# Cross-reference — scan results against the reference set + +Judge, per supplied finding, whether the latest scan reproduced it, and assemble the coverage manifest. Enactment mode only, and the stage that closes the run: every rule, approximation, and verdict is already in place, so what the scan shows now is what the run delivered. Nothing else may set a reference finding's `status`. + +It is also the stage that decides what the run still owes. A judgement here can send the pipeline back — an unmodeled carrier to an approximation round, a rule-caused miss to the stage that authored the rule — and status will report that earlier phase as current again. That is the loop working; re-enter this stage after the rescan rather than closing on stale results. + +## Match by identity, never by rule id + +For each reference finding `get_status.py` lists as pending, compare the scan's results against its recorded identity: the source the trace enters at, the propagation it goes through, the sink it reaches, and the location. A result counts as a reproduction only when its trace carries the same attack path. A rule firing somewhere, a matching vulnerability class, or a matching file is not a match. + +Record the outcome on the reference file: + +{% include "shared/tracking/reference-finding.md" %} + +- reproduced — set `status: reproduced` and put the matching SARIF result hashes in `matched_hashes` +- overmatch — the trace reproduces, but the same rule also fires on flows that are not this finding: still `reproduced`, and note the overmatch in `notes` +- stops at an opaque carrier — `status: unreproduced`, `cause: approximation`, and the exact carrier(s) in `blocked_at`. Get the carrier from `.opentaint/results/dropped-external-methods.yaml` or a localized reachability trace, not from a guess +- no source or sink fact on the expected path — `status: unreproduced`, `cause: rule`. Say in `notes` which side is missing and why, and report it to MAIN: the stage that authored the rule fixes it and the run comes back here +- reproducible only by a modeling limit the engine cannot express — `status: unreproduced`, `cause: engine`, one-line `blocker`, and `blocked_at` cleared + +Set `crossref: done` on every file you judge. A later rescan makes them pending again, which is the loop working: judge them against the new results rather than trusting the old verdict. + +Independently, note scan results that carry no reference finding — those are OpenTaint-exclusive and go to triage like any other finding. Do not count them as reproductions. + +## Close the blocked carriers + +When `get_status.py` reports traces stopping at unmodeled carriers, that work belongs to an approximation round, not here: report the carriers to MAIN, which runs the round and the rescan and re-enters this stage. Only after the rescan proves a carrier still breaks the path does it become an `engine` cause with a `blocker`. + +## Write the coverage manifest + +Once no reference finding is pending or blocked, rewrite `.opentaint/enactment.md` from the current reference files: + +- the finding-level coverage table — one row per supplied finding: id, vulnerability class, family, `status`, and the one-clause reason for an unreproduced one +- the three counts kept apart: raw SARIF results, validated findings, unique vulnerability identities +- reproduced, OpenTaint-exclusive, and reference-exclusive findings as separate sections, exclusives only when triage validated them +- the reusable artifacts the run produced: source rules, sink rules, joins, approximations +- one blocker line per non-reproduced finding, naming the rule, modeling, or engine limitation that remains + +Reflect only current state — rewrite the manifest, don't append to it. + +## Stage gate + +`get_status.py` names pending reference findings, blocked carriers, then the manifest. Finish when `crossref` is `DONE`. Report reproduced/unreproduced totals and every blocker. diff --git a/skills-templates/orchestrate-stage/sections/workflow.md b/skills-templates/orchestrate-stage/sections/workflow.md index a2ca715e8..6b0253760 100644 --- a/skills-templates/orchestrate-stage/sections/workflow.md +++ b/skills-templates/orchestrate-stage/sections/workflow.md @@ -7,6 +7,8 @@ sinks → /references/sinks.md triage → /references/triage.md poc → /references/poc.md escalation → /references/escalation.md +boundaries → /references/boundaries.md (enactment mode) +crossref → /references/crossref.md (enactment mode) ``` Run the bundled script to get the setup overview before proceeding to the reference's instructions: diff --git a/skills-templates/shared/scripts/_common.py b/skills-templates/shared/scripts/_common.py index 092f216dd..8c7f0d5dd 100644 --- a/skills-templates/shared/scripts/_common.py +++ b/skills-templates/shared/scripts/_common.py @@ -6,6 +6,7 @@ scripts from the project root. """ import glob +import re import subprocess from pathlib import Path @@ -20,6 +21,8 @@ SINKS_TR = RULES_TR / "sinks" JOINS_TR = RULES_TR / "joins" FINDINGS_TR = TRACKING / "findings" +REFERENCE_TR = TRACKING / "reference" # enactment mode: the supplied findings, normalized +BOUNDARIES_TR = TRACKING / "boundaries" # enactment mode: per-family boundary specs RESULTS = ROOT / "results" DROPPED = RESULTS / "dropped-external-methods.yaml" SARIF = RESULTS / "report.sarif" @@ -176,6 +179,12 @@ def build_done_keys(): return keys +# ---- finding files ---- + +RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) +VERDICT_RE = re.compile(r'^verdict:\s*(.+?)\s*$', re.M) + + def ledger_verdicted_keys(): """method+signature keys already verdicted in classification.yaml (source ∪ safe).""" doc = load_yaml(RULES_TR / "classification.yaml", {}) or {} diff --git a/skills-templates/shared/scripts/generate.py b/skills-templates/shared/scripts/generate.py index 8f98987bc..b9de7adc3 100644 --- a/skills-templates/shared/scripts/generate.py +++ b/skills-templates/shared/scripts/generate.py @@ -26,12 +26,11 @@ import yaml -from _common import (APPROX, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, - SOURCES_TR, TRACKING, class_of, classified_keys, - dropped_entries, dump_yaml, fqn_base, git_head, - ledger_verdicted_keys, load_yaml, member_key, package_of, - strip_quotes) +from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, + PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, + SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, + dropped_entries, dump_yaml, fqn_base, git_head, ledger_verdicted_keys, + load_yaml, member_key, package_of, strip_quotes) ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch @@ -48,17 +47,32 @@ # them up front gives every stage a place to land and makes the empty tree self-describing. INIT_DIRS = [TRACKING, APPROX, SOURCES_TR, SINKS_TR, JOINS_TR, FINDINGS_TR, RESULTS, RULES, PASS_THROUGH, DATAFLOW] +ENACTMENT_DIRS = [REFERENCE_TR, BOUNDARIES_TR] # enactment mode only STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves def cmd_init(args): - for d in INIT_DIRS: + enactment = args.mode == "enactment" + if enactment and not args.findings: + raise SystemExit("init --mode enactment requires --findings ") + if not enactment and not args.scan_level: + raise SystemExit("init --mode discovery requires --scan-level") + # enactment reproduces a supplied finding set, which always needs the full rule + approximation + # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. + scan_level = "deep" if enactment else args.scan_level + + for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) state_path = TRACKING / "state.yaml" prior = load_yaml(state_path, {}) or {} resume = bool(prior) - state = {"scan_level": args.scan_level, "triage_level": args.triage_level, + if resume and prior.get("mode", "discovery") != args.mode: + raise SystemExit(f"{state_path} is a {prior.get('mode', 'discovery')} run — a mode switch " + "would strand its tracking; start the other mode in a fresh project tree") + state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} + if enactment: + state["findings"] = args.findings or prior.get("findings") for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") @@ -67,13 +81,18 @@ def cmd_init(args): hist_path = TRACKING / "history.yaml" runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] if not resume: - runs.append({"commit": git_head(), "type": f"{args.scan_level}/{args.triage_level}"}) + runs.append({"commit": git_head(), + "type": f"{args.mode}/{scan_level}/{args.triage_level}" + + ("" if args.controls == "on" else "/no-controls")}) hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") - mode = "resumed (derived knobs preserved)" if resume else "fresh" - print(f"init {mode}: scan_level={state['scan_level']} triage_level={state['triage_level']} " - f"language={state['language']}") - print(f"seeded {len(INIT_DIRS)} directories under .opentaint/") + how = "resumed (derived knobs preserved)" if resume else "fresh" + print(f"init {how}: mode={args.mode} scan_level={scan_level} " + f"triage_level={state['triage_level']} language={state['language']}") + if enactment: + print(f"findings={state['findings']}") + print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " + "directories under .opentaint/") print("next: uv run scripts/get_status.py --full") return 0 @@ -420,10 +439,8 @@ def cmd_merge_skipped(args): _FP_PREFERENCE = ("vulnerabilitySourceSinkHash", "vulnerabilityWithTraceHash") -RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) HASHES_RE = re.compile(r'^sarif_hashes:\s*\[(.*)\]\s*$', re.M) HASHES_BLOCK_RE = re.compile(r'^sarif_hashes:\s*\n((?:[ \t]+-[^\n]*\n?)+)', re.M) -VERDICT_RE = re.compile(r'^verdict:\s*(.+?)\s*$', re.M) def docker_name(seed, taken): @@ -561,9 +578,14 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--scan-level", required=True, choices=["lite", "normal", "deep"]) + i.add_argument("--mode", default="discovery", choices=["discovery", "enactment"], + help="discovery: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--scan-level", choices=["lite", "normal", "deep"], + help="discovery mode only; enactment is always deep") i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) i.add_argument("--language", default=None, help="target language, determined by the orchestrator") + i.add_argument("--findings", default=None, + help="enactment mode: path to the supplied finding manifest/report/directory") i.set_defaults(func=cmd_init) p = sub.add_parser("partition", help="split classification work into per-agent plans") @@ -581,6 +603,7 @@ def main(): f = sub.add_parser("findings", help="seed per-rule finding files from results/report.sarif") f.set_defaults(func=cmd_findings) + args = ap.parse_args() return args.func(args) diff --git a/skills-templates/shared/scripts/get_status.py b/skills-templates/shared/scripts/get_status.py index aca3b06b6..776d20179 100644 --- a/skills-templates/shared/scripts/get_status.py +++ b/skills-templates/shared/scripts/get_status.py @@ -22,19 +22,21 @@ import sys from pathlib import Path -from _common import (APPROX, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, - SOURCES_TR, TRACKING, build_done_keys, classified_keys, - dropped_entries, git_head, load_yaml, member_key, - modeled_entries, skipped_keys) +from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, + PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, + SOURCES_TR, TRACKING, build_done_keys, classified_keys, dropped_entries, + git_head, load_yaml, member_key, modeled_entries, skipped_keys, + strip_quotes) STATE = load_yaml(TRACKING / "state.yaml", {}) or {} +MODE = STATE.get("mode") or "discovery" SCAN_LEVEL = STATE.get("scan_level") TRIAGE_LEVEL = STATE.get("triage_level") DISCOVER_PLANS = RULES_TR / "plans" APPROX_PLANS = APPROX / "plans" VULN = ROOT / "vulnerabilities.md" +ENACTMENT = ROOT / "enactment.md" GLOBAL_CAP = 10 @@ -49,6 +51,12 @@ def load_units(d): if Path(d).is_dir() else [] +def load_docs(d): + """(path, doc) for every tracking file in a directory — reference, boundary, control.""" + return [(p, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ + if Path(d).is_dir() else [] + + def load_joins(): return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(JOINS_TR.glob("*.yaml"))] \ if JOINS_TR.is_dir() else [] @@ -280,7 +288,76 @@ def ph_poc(): return True, [], None -PHASES = [ +# ---- enactment-mode phases ---- + +def ph_reference_set(): + docs = load_docs(REFERENCE_TR) + if not docs: + src = STATE.get("findings") or "state.yaml findings unset" + return False, [f"normalize the supplied findings ({src}) into " + ".opentaint/tracking/reference/.yaml"], None + missing = sorted(p.stem for p, d in docs if not strip_quotes(d.get("family", ""))) + if missing: + return False, ["reference findings not assigned to a boundary family:"] \ + + [f" {m}" for m in missing], None + return True, [], None + + +def families(): + return sorted({strip_quotes(d.get("family", "")) for _, d in load_docs(REFERENCE_TR) + if strip_quotes(d.get("family", ""))}) + + +def ph_boundaries(): + specs = {p.stem: d for p, d in load_docs(BOUNDARIES_TR)} + fams = families() + missing = [f for f in fams if f not in specs] + if missing: + return False, ["dispatch discover-universal-boundaries, one per family:"] \ + + [f" {f}" for f in missing], None + # a split renames the family on its reference findings, so every spec here owns its findings + unsaturated = [f for f in fams + if str((specs[f].get("saturation") or {}).get("status", "")).strip() + != "saturated"] + if unsaturated: + return False, ["boundary specs not saturated:"] + [f" {f}" for f in unsaturated], None + unfactored = sorted(p.stem for p, d in load_docs(REFERENCE_TR) + if p.stem not in (specs.get(strip_quotes(d.get("family", "")), {}) + .get("factorization") or {})) + if unfactored: + return False, ["reference findings with no factorization in their spec:"] \ + + [f" {r}" for r in unfactored], None + unseeded = [f for f in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] + if unseeded: + return False, ["seed the source and sink units from these specs' candidate_patterns:"] \ + + [f" {f}" for f in unseeded], None + return True, [], None + + +def ph_crossref(): + if not SARIF.is_file(): + return False, ["dispatch run-scan"], None + docs = load_docs(REFERENCE_TR) + scanned = SARIF.stat().st_mtime + pend = [p for p, d in docs + if str(d.get("crossref", "pending")).strip() != "done" or p.stat().st_mtime < scanned] + if pend: + return False, [f"cross-reference the scan against {len(pend)} reference finding(s):"] \ + + [f" {p}" for p in pend], None + blocked = sorted({str(m) for _, d in docs for m in (d.get("blocked_at") or [])}) + if blocked: + return False, ["expected traces stop at unmodeled carriers:"] + [f" {m}" for m in blocked] \ + + ["model them in an approximation round, rescan, then cross-reference again"], None + rep = sum(1 for _, d in docs if str(d.get("status", "")).strip() == "reproduced") + stale = newest_mtime([p for p, _ in docs]) > (ENACTMENT.stat().st_mtime + if ENACTMENT.is_file() else 0) + if not ENACTMENT.is_file() or stale: + return False, [f"rewrite .opentaint/enactment.md coverage manifest " + f"({rep}/{len(docs)} reproduced)"], None + return True, [], None + + +DISCOVERY_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), @@ -291,6 +368,25 @@ def ph_poc(): ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), ] +# enactment reproduces a supplied finding set: the reference set and its saturated boundaries +# replace dependency discovery, and both rule sides are authored before the first scan so that +# scan is rule-first. The cross-reference closes the run — it judges what the finished rule set, +# its approximations, its verdicts and its controls actually reproduced. +ENACTMENT_PHASES = [ + ("build", ph_build, lambda: True), + ("reference_set", ph_reference_set, lambda: True), + ("boundaries", ph_boundaries, lambda: True), + ("source_rules", ph_source_rules, lambda: True), + ("sink_rules", ph_sink_rules, lambda: True), + ("scan", ph_scan, lambda: True), + ("approximations", ph_approximations, lambda: True), + ("triage", ph_triage, lambda: True), + ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), + ("crossref", ph_crossref, lambda: True), +] + +PHASES = ENACTMENT_PHASES if MODE == "enactment" else DISCOVERY_PHASES + # ---- caps ---- @@ -329,8 +425,11 @@ def evaluate(): def cmd_full(): commit = short(STATE.get("model_commit")) or "none" - print(f"scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} language={STATE.get('language')} " - f"commit={commit} cap={GLOBAL_CAP} (heavy {heavy_cap()})") + print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " + f"language={STATE.get('language')} commit={commit} " + f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") + if MODE == "enactment": + print(f"findings={STATE.get('findings')}") rows, current = evaluate() # a phase downstream of the current stage that vacuously satisfies its own check is not # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. diff --git a/skills-templates/shared/tracking/boundary-spec.md b/skills-templates/shared/tracking/boundary-spec.md new file mode 100644 index 000000000..db55b8a5e --- /dev/null +++ b/skills-templates/shared/tracking/boundary-spec.md @@ -0,0 +1,28 @@ +`.opentaint/tracking/boundaries/.yaml` — one boundary family: the single source and single sink every finding in the family factors through, plus the controls that recover precision. `candidate_patterns` are concrete enough for `create-rule` to test, and seed the family's source and sink units verbatim. `factorization` carries one entry per assigned reference finding, `status` one of `covered` (factors through both boundaries as-is), `needs-restriction` (factors only under a named `context_restrictions` entry), or `unfactored` (does not factor — explain in `open_questions` and split or add a pseudo-boundary). `saturation` records the widen-and-recheck rounds and only reads `saturated` once a full round changed neither the boundaries nor any factorization. `approximation_candidates` are opaque carriers noted for later — never acted on before a scan proves the trace stops there. Keep it clear from comments + +```yaml +family: ssrf +findings: [DSC-014, DSC-021] +source: + semantic_boundary: external request value entering a controller + candidate_patterns: + - { method: org.springframework.web.bind.annotation.RequestBody, signature: null, note: annotated controller parameter } + context_restrictions: [] +sink: + semantic_boundary: outbound HTTP request with a caller-supplied URL + candidate_patterns: + - { method: org.springframework.web.client.RestTemplate#getForObject, signature: "(Ljava/lang/String;Ljava/lang/Class;[Ljava/lang/Object;)Ljava/lang/Object;", vuln_class: ssrf, note: egress with attacker URL } + context_restrictions: [] +sanitizers: [] +negative_patterns: [] +factorization: + DSC-014: { source: request body callbackUrl, sink: RestTemplate#getForObject, status: covered } + DSC-021: { source: request param targetUrl, sink: RestTemplate#getForObject, status: needs-restriction, restriction: admin-only controller } +approximation_candidates: [] +open_questions: [] +saturation: + rounds: 3 + status: saturated +stages: + units_seeded: done +``` diff --git a/skills-templates/shared/tracking/reference-finding.md b/skills-templates/shared/tracking/reference-finding.md new file mode 100644 index 000000000..330f224e1 --- /dev/null +++ b/skills-templates/shared/tracking/reference-finding.md @@ -0,0 +1,21 @@ +`.opentaint/tracking/reference/.yaml` — one supplied finding, normalized to a stable identity and carried through boundary discovery and the cross-reference. The file is named for the finding's own id. `family` ties it to its boundary spec and is rewritten when a family splits. `status` is `pending` until the cross-reference judges it, then `reproduced` or `unreproduced`; `cause` explains an `unreproduced` one so the pipeline knows who owns it — `rule` (a boundary, restriction, or sanitizer is wrong), `approximation` (an opaque carrier breaks the path), or `engine` (a modeling limit, paired with `blocker`). `blocked_at` lists the carriers still to model and is cleared once they are modeled or judged terminal. `matched_hashes` are the SARIF result hashes whose trace carries this finding's identity — never a rule-id match alone. Keep it clear from comments + +```yaml +id: DSC-014 +vuln_class: ssrf +family: ssrf +source: request body field `callbackUrl` on POST /api/webhook/register +propagation: WebhookReqVO -> WebhookDO -> WebhookService#dispatch +sink: RestTemplate#getForObject in WebhookService#dispatch +expected_location: yudao-module-infra/.../WebhookService.java:88 +guards: URL parsed with new URI(...), no private-range rejection +status: reproduced +cause: null +blocker: null +blocked_at: [] +matched_hashes: [a1b2c3d4e5f6a7b8] +crossref: done +notes: > + crossref: join ssrf-webhook-ext fired at WebhookService#dispatch:88 with the trace entering at + the registration body — same attack path as the reference finding +``` diff --git a/skills/appsec-agent/SKILL.md b/skills/appsec-agent/SKILL.md index 72143a40b..4f31494f0 100644 --- a/skills/appsec-agent/SKILL.md +++ b/skills/appsec-agent/SKILL.md @@ -34,7 +34,7 @@ Read the project's build files to fix the target language — Maven/Gradle → j ### 4. Choose the workflow -Ask the user for both levels together: +Ask the user for both knobs together: 1. Scan level — `lite` · `normal` · `deep` - lite — build + scan (expected, when there are already existing artifacts) @@ -53,7 +53,7 @@ Seed the run state and the working tree with the chosen levels and language: uv run /scripts/generate.py init --scan-level --triage-level --language ``` -It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree. +It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree. If the user wants a supplied finding set reproduced rather than the project searched for vulnerabilities, that is the enactment pipeline — stop here and load `enactment-agent` instead. ## Workflow @@ -132,6 +132,7 @@ The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` deriv `state.yaml` shape: ```yaml +mode: discovery scan_level: deep triage_level: dynamic language: java diff --git a/skills/appsec-agent/scripts/_common.py b/skills/appsec-agent/scripts/_common.py index 092f216dd..8c7f0d5dd 100644 --- a/skills/appsec-agent/scripts/_common.py +++ b/skills/appsec-agent/scripts/_common.py @@ -6,6 +6,7 @@ scripts from the project root. """ import glob +import re import subprocess from pathlib import Path @@ -20,6 +21,8 @@ SINKS_TR = RULES_TR / "sinks" JOINS_TR = RULES_TR / "joins" FINDINGS_TR = TRACKING / "findings" +REFERENCE_TR = TRACKING / "reference" # enactment mode: the supplied findings, normalized +BOUNDARIES_TR = TRACKING / "boundaries" # enactment mode: per-family boundary specs RESULTS = ROOT / "results" DROPPED = RESULTS / "dropped-external-methods.yaml" SARIF = RESULTS / "report.sarif" @@ -176,6 +179,12 @@ def build_done_keys(): return keys +# ---- finding files ---- + +RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) +VERDICT_RE = re.compile(r'^verdict:\s*(.+?)\s*$', re.M) + + def ledger_verdicted_keys(): """method+signature keys already verdicted in classification.yaml (source ∪ safe).""" doc = load_yaml(RULES_TR / "classification.yaml", {}) or {} diff --git a/skills/appsec-agent/scripts/generate.py b/skills/appsec-agent/scripts/generate.py index 8f98987bc..b9de7adc3 100644 --- a/skills/appsec-agent/scripts/generate.py +++ b/skills/appsec-agent/scripts/generate.py @@ -26,12 +26,11 @@ import yaml -from _common import (APPROX, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, - SOURCES_TR, TRACKING, class_of, classified_keys, - dropped_entries, dump_yaml, fqn_base, git_head, - ledger_verdicted_keys, load_yaml, member_key, package_of, - strip_quotes) +from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, + PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, + SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, + dropped_entries, dump_yaml, fqn_base, git_head, ledger_verdicted_keys, + load_yaml, member_key, package_of, strip_quotes) ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch @@ -48,17 +47,32 @@ # them up front gives every stage a place to land and makes the empty tree self-describing. INIT_DIRS = [TRACKING, APPROX, SOURCES_TR, SINKS_TR, JOINS_TR, FINDINGS_TR, RESULTS, RULES, PASS_THROUGH, DATAFLOW] +ENACTMENT_DIRS = [REFERENCE_TR, BOUNDARIES_TR] # enactment mode only STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves def cmd_init(args): - for d in INIT_DIRS: + enactment = args.mode == "enactment" + if enactment and not args.findings: + raise SystemExit("init --mode enactment requires --findings ") + if not enactment and not args.scan_level: + raise SystemExit("init --mode discovery requires --scan-level") + # enactment reproduces a supplied finding set, which always needs the full rule + approximation + # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. + scan_level = "deep" if enactment else args.scan_level + + for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) state_path = TRACKING / "state.yaml" prior = load_yaml(state_path, {}) or {} resume = bool(prior) - state = {"scan_level": args.scan_level, "triage_level": args.triage_level, + if resume and prior.get("mode", "discovery") != args.mode: + raise SystemExit(f"{state_path} is a {prior.get('mode', 'discovery')} run — a mode switch " + "would strand its tracking; start the other mode in a fresh project tree") + state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} + if enactment: + state["findings"] = args.findings or prior.get("findings") for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") @@ -67,13 +81,18 @@ def cmd_init(args): hist_path = TRACKING / "history.yaml" runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] if not resume: - runs.append({"commit": git_head(), "type": f"{args.scan_level}/{args.triage_level}"}) + runs.append({"commit": git_head(), + "type": f"{args.mode}/{scan_level}/{args.triage_level}" + + ("" if args.controls == "on" else "/no-controls")}) hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") - mode = "resumed (derived knobs preserved)" if resume else "fresh" - print(f"init {mode}: scan_level={state['scan_level']} triage_level={state['triage_level']} " - f"language={state['language']}") - print(f"seeded {len(INIT_DIRS)} directories under .opentaint/") + how = "resumed (derived knobs preserved)" if resume else "fresh" + print(f"init {how}: mode={args.mode} scan_level={scan_level} " + f"triage_level={state['triage_level']} language={state['language']}") + if enactment: + print(f"findings={state['findings']}") + print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " + "directories under .opentaint/") print("next: uv run scripts/get_status.py --full") return 0 @@ -420,10 +439,8 @@ def cmd_merge_skipped(args): _FP_PREFERENCE = ("vulnerabilitySourceSinkHash", "vulnerabilityWithTraceHash") -RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) HASHES_RE = re.compile(r'^sarif_hashes:\s*\[(.*)\]\s*$', re.M) HASHES_BLOCK_RE = re.compile(r'^sarif_hashes:\s*\n((?:[ \t]+-[^\n]*\n?)+)', re.M) -VERDICT_RE = re.compile(r'^verdict:\s*(.+?)\s*$', re.M) def docker_name(seed, taken): @@ -561,9 +578,14 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--scan-level", required=True, choices=["lite", "normal", "deep"]) + i.add_argument("--mode", default="discovery", choices=["discovery", "enactment"], + help="discovery: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--scan-level", choices=["lite", "normal", "deep"], + help="discovery mode only; enactment is always deep") i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) i.add_argument("--language", default=None, help="target language, determined by the orchestrator") + i.add_argument("--findings", default=None, + help="enactment mode: path to the supplied finding manifest/report/directory") i.set_defaults(func=cmd_init) p = sub.add_parser("partition", help="split classification work into per-agent plans") @@ -581,6 +603,7 @@ def main(): f = sub.add_parser("findings", help="seed per-rule finding files from results/report.sarif") f.set_defaults(func=cmd_findings) + args = ap.parse_args() return args.func(args) diff --git a/skills/appsec-agent/scripts/get_status.py b/skills/appsec-agent/scripts/get_status.py index aca3b06b6..776d20179 100644 --- a/skills/appsec-agent/scripts/get_status.py +++ b/skills/appsec-agent/scripts/get_status.py @@ -22,19 +22,21 @@ import sys from pathlib import Path -from _common import (APPROX, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, - SOURCES_TR, TRACKING, build_done_keys, classified_keys, - dropped_entries, git_head, load_yaml, member_key, - modeled_entries, skipped_keys) +from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, + PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, + SOURCES_TR, TRACKING, build_done_keys, classified_keys, dropped_entries, + git_head, load_yaml, member_key, modeled_entries, skipped_keys, + strip_quotes) STATE = load_yaml(TRACKING / "state.yaml", {}) or {} +MODE = STATE.get("mode") or "discovery" SCAN_LEVEL = STATE.get("scan_level") TRIAGE_LEVEL = STATE.get("triage_level") DISCOVER_PLANS = RULES_TR / "plans" APPROX_PLANS = APPROX / "plans" VULN = ROOT / "vulnerabilities.md" +ENACTMENT = ROOT / "enactment.md" GLOBAL_CAP = 10 @@ -49,6 +51,12 @@ def load_units(d): if Path(d).is_dir() else [] +def load_docs(d): + """(path, doc) for every tracking file in a directory — reference, boundary, control.""" + return [(p, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ + if Path(d).is_dir() else [] + + def load_joins(): return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(JOINS_TR.glob("*.yaml"))] \ if JOINS_TR.is_dir() else [] @@ -280,7 +288,76 @@ def ph_poc(): return True, [], None -PHASES = [ +# ---- enactment-mode phases ---- + +def ph_reference_set(): + docs = load_docs(REFERENCE_TR) + if not docs: + src = STATE.get("findings") or "state.yaml findings unset" + return False, [f"normalize the supplied findings ({src}) into " + ".opentaint/tracking/reference/.yaml"], None + missing = sorted(p.stem for p, d in docs if not strip_quotes(d.get("family", ""))) + if missing: + return False, ["reference findings not assigned to a boundary family:"] \ + + [f" {m}" for m in missing], None + return True, [], None + + +def families(): + return sorted({strip_quotes(d.get("family", "")) for _, d in load_docs(REFERENCE_TR) + if strip_quotes(d.get("family", ""))}) + + +def ph_boundaries(): + specs = {p.stem: d for p, d in load_docs(BOUNDARIES_TR)} + fams = families() + missing = [f for f in fams if f not in specs] + if missing: + return False, ["dispatch discover-universal-boundaries, one per family:"] \ + + [f" {f}" for f in missing], None + # a split renames the family on its reference findings, so every spec here owns its findings + unsaturated = [f for f in fams + if str((specs[f].get("saturation") or {}).get("status", "")).strip() + != "saturated"] + if unsaturated: + return False, ["boundary specs not saturated:"] + [f" {f}" for f in unsaturated], None + unfactored = sorted(p.stem for p, d in load_docs(REFERENCE_TR) + if p.stem not in (specs.get(strip_quotes(d.get("family", "")), {}) + .get("factorization") or {})) + if unfactored: + return False, ["reference findings with no factorization in their spec:"] \ + + [f" {r}" for r in unfactored], None + unseeded = [f for f in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] + if unseeded: + return False, ["seed the source and sink units from these specs' candidate_patterns:"] \ + + [f" {f}" for f in unseeded], None + return True, [], None + + +def ph_crossref(): + if not SARIF.is_file(): + return False, ["dispatch run-scan"], None + docs = load_docs(REFERENCE_TR) + scanned = SARIF.stat().st_mtime + pend = [p for p, d in docs + if str(d.get("crossref", "pending")).strip() != "done" or p.stat().st_mtime < scanned] + if pend: + return False, [f"cross-reference the scan against {len(pend)} reference finding(s):"] \ + + [f" {p}" for p in pend], None + blocked = sorted({str(m) for _, d in docs for m in (d.get("blocked_at") or [])}) + if blocked: + return False, ["expected traces stop at unmodeled carriers:"] + [f" {m}" for m in blocked] \ + + ["model them in an approximation round, rescan, then cross-reference again"], None + rep = sum(1 for _, d in docs if str(d.get("status", "")).strip() == "reproduced") + stale = newest_mtime([p for p, _ in docs]) > (ENACTMENT.stat().st_mtime + if ENACTMENT.is_file() else 0) + if not ENACTMENT.is_file() or stale: + return False, [f"rewrite .opentaint/enactment.md coverage manifest " + f"({rep}/{len(docs)} reproduced)"], None + return True, [], None + + +DISCOVERY_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), @@ -291,6 +368,25 @@ def ph_poc(): ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), ] +# enactment reproduces a supplied finding set: the reference set and its saturated boundaries +# replace dependency discovery, and both rule sides are authored before the first scan so that +# scan is rule-first. The cross-reference closes the run — it judges what the finished rule set, +# its approximations, its verdicts and its controls actually reproduced. +ENACTMENT_PHASES = [ + ("build", ph_build, lambda: True), + ("reference_set", ph_reference_set, lambda: True), + ("boundaries", ph_boundaries, lambda: True), + ("source_rules", ph_source_rules, lambda: True), + ("sink_rules", ph_sink_rules, lambda: True), + ("scan", ph_scan, lambda: True), + ("approximations", ph_approximations, lambda: True), + ("triage", ph_triage, lambda: True), + ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), + ("crossref", ph_crossref, lambda: True), +] + +PHASES = ENACTMENT_PHASES if MODE == "enactment" else DISCOVERY_PHASES + # ---- caps ---- @@ -329,8 +425,11 @@ def evaluate(): def cmd_full(): commit = short(STATE.get("model_commit")) or "none" - print(f"scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} language={STATE.get('language')} " - f"commit={commit} cap={GLOBAL_CAP} (heavy {heavy_cap()})") + print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " + f"language={STATE.get('language')} commit={commit} " + f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") + if MODE == "enactment": + print(f"findings={STATE.get('findings')}") rows, current = evaluate() # a phase downstream of the current stage that vacuously satisfies its own check is not # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. diff --git a/skills/discover-universal-boundaries/SKILL.md b/skills/discover-universal-boundaries/SKILL.md new file mode 100644 index 000000000..e2bb8f9d9 --- /dev/null +++ b/skills/discover-universal-boundaries/SKILL.md @@ -0,0 +1,192 @@ +--- +name: discover-universal-boundaries +description: Generalize a family of known finding traces into one reusable source and one reusable sink boundary, saturated against the whole family. Use before rule authoring when reproducing supplied findings, consolidating finding-specific rules, or replacing an incidental source such as a map access or an arbitrary method call +license: Apache-2.0 +metadata: + author: opentaint + version: "0.3.0" +--- + +# Discover universal boundaries + +Generalize one family of known finding traces into rule-ready boundaries: a single source and a single sink that every finding in the family factors through. + +The goal is not the broadest syntax that happens to match every trace. It is the most primitive semantic boundary that stays inside the vulnerability class — general enough that the family needs one rule per side, specific enough that the rule still means something. Precision is recovered afterwards with context restrictions and sanitizers, listed separately, never by narrowing the boundary back down to the findings you started from. + +A boundary is only universal once it has been saturated: widened, re-checked against the whole family, and left unchanged by a full round. + +## Inputs + +Provided by the caller, fall back to the default value when omitted. Ask back only when a required input is missing and has no sensible default + +- `project-root` (optional) — root of the target project. Opentaint keeps all analysis artifacts under the fixed `/.opentaint/` directory, so every `.opentaint/...` path below resolves there. Default: current directory +- `language` (required) — target language for this project and language-specific instructions +- `findings` (required) — path to the supplied finding manifest or report the reference set was normalized from +- `finding-ids` (required) — the reference finding ids assigned to this family. Their normalized files are `.opentaint/tracking/reference/.yaml` +- `family` (required) — kebab-case name of the family; also the name of its spec and of the rule units seeded from it + +## Workflow + +### 1. Reconstruct every trace + +For each assigned finding, read its reference file and then the project source it points at, and record: + +1. the attacker or untrusted authority; +2. the first project-visible ingress; +3. the transformations and trust-domain crossings on the way; +4. the validators, guards, and authorization decisions it passes; +5. the primitive security-relevant effect at the end; and +6. the vulnerable invariant that is absent or defeated. + +Read enough surrounding source to tell the real boundary from incidental syntax. A getter, collection lookup, DTO accessor, or service method is usually propagation or context, not ingress. + +### 2. Propose the source + +Move backward from the finding-specific expressions until you reach the earliest reusable trust-boundary value the family shares. Prefer, in order when applicable: + +- request body, parameter, path, query, header, cookie, or multipart value; +- message, frame, packet, event, webhook, or callback payload; +- deserialized external object; +- persisted attacker-controlled record at a second-order re-entry boundary; +- tenant or user-controlled configuration at activation or read-back; +- environment or runtime configuration entering a security decision; or +- an explicit structural pseudo-source standing for attacker-selected identity or control state. + +Reject a candidate that is merely `$MAP.get(...)` after untrusted data has already entered, a `$METAVAR.method(...)` with no boundary type/signature/annotation/enclosing entrypoint, a ubiquitous getter that would taint trusted objects just as readily, or an internal carrier that ordinary propagation or a later approximation should handle. + +Express narrow usage conditions separately, as typed patterns, annotations, `pattern-inside`, and `...`. Never bake incidental access syntax into the source. + +### 3. Propose the sink + +Move forward from the finding-specific service calls to the most primitive operation that realizes the vulnerability, while staying specific to its class. Prefer boundaries such as: + +- network connect, request, send, or download for SSRF; +- process, script, expression, template, query, or deserialization execution for injection; +- path resolution plus filesystem read/write for traversal; +- privileged object read or disclosure for IDOR and data exposure; +- privileged object mutation or durable state commit for integrity and authorization failures; +- authentication or authorization decision for control bypass; +- redirect or token release for redirect and OAuth issues; +- signature verification or unsigned callback state commit for callback integrity; +- secret activation, credential construction, or outbound use for secret exposure; and +- logger argument consumption for log injection. + +Don't stop at a controller-to-service call when the primitive effect is analyzable deeper in the project or a reusable library sink can express it. Use a structural sink only when the vulnerability *is* the missing control at that boundary, or when deeper propagation is genuinely unavailable. + +### 4. Saturate + +A boundary proposed from a few traces is a guess until it survives the whole family. Loop until a full round changes nothing. + +Each round: + +1. Factor every assigned finding — not only the new ones — through the current boundaries: + + ```text + universal source -> finding-specific context -> propagation -> universal sink + ``` + +2. For each finding that does not factor, generalize the offending side by exactly one step toward a more primitive boundary — never by adding a second alternative that merely spells out that finding's syntax. A `pattern-either` listing one branch per finding is the failure this skill exists to prevent. +3. Re-check the findings that already factored. A widening that breaks an earlier factorization is a widening too far: back it out and split instead. +4. Challenge the widened boundary in both directions — if the sink now admits a different vulnerability class, narrow it back to the primitive effect or add a class-specific context restriction; if the source now admits trusted values, record what separates them as a context restriction rather than shrinking the boundary. +5. Record the round: what changed, and which factorization statuses moved. + +The family is saturated when a full round widened nothing, broke nothing, and left every assigned finding either `covered` or `needs-restriction` with a named restriction. Stop and split — or record the finding `unfactored` with the evidence in `open_questions` — rather than looping a fourth time on the same finding. + +When the only boundary the whole family shares is arbitrary syntax, the family was wrong: split it. Each subfamily gets its own spec named `-` and its own saturation loop, every assigned finding lands in exactly one subfamily, and each moved finding's reference file has its `family` rewritten to the subfamily that now owns it. + +Keep independently triggerable paths distinct in the factorization even when they share both boundaries. + +### 5. Identify the precision controls + +List these separately from the positive boundaries — the spec records them, and they must never be folded into the boundary patterns: + +- sanitizers that actually enforce the relevant invariant; +- `pattern-not` exclusions for safe expression forms; +- `pattern-not-inside` exclusions for safe guarded regions; +- `pattern-inside` contexts that constrain a universal boundary to the intended entrypoint, type, tenant, or vulnerability family; and +- validators that are *not* security-relevant, recorded explicitly so nothing later mistakes them for sanitizers. + +Real sanitization looks like resolved-IP private-range rejection for SSRF, canonical-path containment for traversal, strict identifier ownership checks for IDOR, signature verification for callbacks, and CR/LF neutralization for log injection. Presence, length, parsing, or `@Valid` alone normally sanitizes none of these. + +### 6. Write the specification + +Write one spec per family or subfamily. `candidate_patterns` must be concrete enough for `create-rule` to test — a fully-qualified method with its signature, or the annotation and type that identify the boundary. Record opaque carriers under `approximation_candidates`, and stop there: no approximation is created or recommended until a rule-first scan proves a trace stops at one. + +## Output + +Return every spec path written (one per family or subfamily) and, in a few lines each: + +- the universal source, and which more incidental candidates you rejected; +- the universal sink, and the vulnerability-class scope it stays inside; +- how many saturation rounds it took and what the last round changed; +- the assigned finding ids by factorization status — `covered`, `needs-restriction`, `unfactored`; +- any family split, with the ids that moved and the reference files you rewrote; +- the sanitizers, negative patterns, and context restrictions listed separately from the boundaries; and +- unresolved boundary evidence, as the `open_questions` entries. + +Never paste file contents. + +## Tracking + +This skill writes the boundary spec for its family, and — only when a split moves a finding — the `family` field on the reference files it moved. Leave `stages.units_seeded` pending: seeding the rule units is the calling stage's step. Touch no other reference field, no rule unit, and no rule. + +`.opentaint/tracking/boundaries/.yaml` — one boundary family: the single source and single sink every finding in the family factors through, plus the controls that recover precision. `candidate_patterns` are concrete enough for `create-rule` to test, and seed the family's source and sink units verbatim. `factorization` carries one entry per assigned reference finding, `status` one of `covered` (factors through both boundaries as-is), `needs-restriction` (factors only under a named `context_restrictions` entry), or `unfactored` (does not factor — explain in `open_questions` and split or add a pseudo-boundary). `saturation` records the widen-and-recheck rounds and only reads `saturated` once a full round changed neither the boundaries nor any factorization. `approximation_candidates` are opaque carriers noted for later — never acted on before a scan proves the trace stops there. Keep it clear from comments + +```yaml +family: ssrf +findings: [DSC-014, DSC-021] +source: + semantic_boundary: external request value entering a controller + candidate_patterns: + - { method: org.springframework.web.bind.annotation.RequestBody, signature: null, note: annotated controller parameter } + context_restrictions: [] +sink: + semantic_boundary: outbound HTTP request with a caller-supplied URL + candidate_patterns: + - { method: org.springframework.web.client.RestTemplate#getForObject, signature: "(Ljava/lang/String;Ljava/lang/Class;[Ljava/lang/Object;)Ljava/lang/Object;", vuln_class: ssrf, note: egress with attacker URL } + context_restrictions: [] +sanitizers: [] +negative_patterns: [] +factorization: + DSC-014: { source: request body callbackUrl, sink: RestTemplate#getForObject, status: covered } + DSC-021: { source: request param targetUrl, sink: RestTemplate#getForObject, status: needs-restriction, restriction: admin-only controller } +approximation_candidates: [] +open_questions: [] +saturation: + rounds: 3 + status: saturated +stages: + units_seeded: done +``` + +`.opentaint/tracking/reference/.yaml` — one supplied finding, normalized to a stable identity and carried through boundary discovery and the cross-reference. The file is named for the finding's own id. `family` ties it to its boundary spec and is rewritten when a family splits. `status` is `pending` until the cross-reference judges it, then `reproduced` or `unreproduced`; `cause` explains an `unreproduced` one so the pipeline knows who owns it — `rule` (a boundary, restriction, or sanitizer is wrong), `approximation` (an opaque carrier breaks the path), or `engine` (a modeling limit, paired with `blocker`). `blocked_at` lists the carriers still to model and is cleared once they are modeled or judged terminal. `matched_hashes` are the SARIF result hashes whose trace carries this finding's identity — never a rule-id match alone. Keep it clear from comments + +```yaml +id: DSC-014 +vuln_class: ssrf +family: ssrf +source: request body field `callbackUrl` on POST /api/webhook/register +propagation: WebhookReqVO -> WebhookDO -> WebhookService#dispatch +sink: RestTemplate#getForObject in WebhookService#dispatch +expected_location: yudao-module-infra/.../WebhookService.java:88 +guards: URL parsed with new URI(...), no private-range rejection +status: reproduced +cause: null +blocker: null +blocked_at: [] +matched_hashes: [a1b2c3d4e5f6a7b8] +crossref: done +notes: > + crossref: join ssrf-webhook-ext fired at WebhookService#dispatch:88 with the trace entering at + the registration body — same attack path as the reference finding +``` + +## Constraints + +- Work only the assigned finding ids, your family's spec, and the reference `family` field on a split +- Universal does not mean untyped or unconstrained — generalize the boundary, then recover precision with context restrictions and sanitizers, never by re-narrowing the boundary to the findings +- Never propose all methods, all map values, or all getters as a source, and never a `pattern-either` with one branch per finding +- Never mark the spec saturated on a round that widened, broke, or split anything +- Never propose a sanitizer that would suppress a trace the reference set says is real, and never record a validator as a sanitizer without the invariant it enforces +- Record a poor taint fit as an explicit pseudo-boundary; never drop the finding +- Don't create or recommend approximations here — note the opaque carriers and stop diff --git a/skills/enactment-agent/SKILL.md b/skills/enactment-agent/SKILL.md new file mode 100644 index 000000000..fde921bc0 --- /dev/null +++ b/skills/enactment-agent/SKILL.md @@ -0,0 +1,174 @@ +--- +name: enactment-agent +description: Reproduce a supplied finding set as verified OpenTaint rules, owning the long project build and scans and delegating each other pipeline stage. Use when the user supplies findings, a scanner report, or source-to-sink traces to reproduce, validate, or convert into reusable rules +license: Apache-2.0 +metadata: + author: opentaint + version: "0.3.0" +--- + +# Enactment Agent + +Reproduce a supplied set of findings with OpenTaint. Every finding ends the run either matched by a verified OpenTaint result whose trace carries the finding's own identity, or recorded with the exact rule, modeling, or engine limitation that stopped it. Keep the long project build and every full-project scan in this main session; delegate each bounded stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. + +This is the enactment pipeline — the same machine as `appsec-agent`, and the same `.opentaint/` tree, differing in how the source and sink rules are produced. There, they come from discovering the project's dependency attack surface; here, from generalizing the supplied findings into reusable boundaries, so both sides exist before the first scan and that scan is rule-first. Use `appsec-agent` instead when the goal is to find unknown vulnerabilities. + +No finding is dropped for being a poor fit for taint analysis. Authorization, integrity, configuration, hard-coded-secret, and structural-control findings are modeled as explicit pseudo-taint boundaries. + +## Setup + +### 1. Confirm the toolchain + +Confirm `opentaint` is on PATH with `opentaint -v`. If it's missing, don't proceed silently — tell the user and offer the install command for their platform, run an install only on explicit confirmation: + +- macOS / Linux, in order: `brew install --cask seqra/tap/opentaint` · `npm install -g @seqra/opentaint` +- Windows: `npm install -g @seqra/opentaint` + +After installing, run `opentaint health` to confirm everything's resolved. + +### 2. Confirm agent nesting + +This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. + +### 3. Locate the findings + +The supplied findings are this run's input and the only thing it is measured against. Ask the user for their path when it isn't already given — a manifest, SARIF, scanner report, or a directory of finding documents. If the user has only described the findings in conversation, write them to a file first and use that; the pipeline resumes from disk, not from this thread. + +### 4. Determine the language + +Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. + +### 5. Choose the workflow + +Ask the user for the triage level: + +1. Triage level — `static` · `dynamic` + - static — classify findings from the model, no running app + - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option + +There is no scan-level question here: reproducing a finding set always needs the full rule and approximation toolbox, so enactment is always deep. + +### 6. Bootstrap + +Seed the run state and the working tree: + +```bash +uv run /scripts/generate.py init --mode enactment --triage-level --language --findings +``` + +It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. It refuses to convert an existing discovery run — that tracking has no reference set behind it, so enactment starts in its own project tree. + +## Workflow + +The run is one fixed pipeline. Use `uv run /scripts/get_status.py` to choose the next action: + +``` +build → MAIN: build +reference_set / boundaries → stage subagent: boundaries +source_rules → stage subagent: sources +sink_rules → stage subagent: sinks +scan → MAIN: scan +approximations → stage subagent: approx-round, then MAIN: rescan; repeat +triage → stage subagent: triage +poc → stage subagent: poc +crossref → stage subagent: crossref +``` + +Both rule sides are authored before the first scan on purpose: the scan that follows is the one that proves the boundaries, and its results are what later stages are allowed to react to. Never let an approximation stand in for a missing source or sink rule. + +The cross-reference closes the run rather than steering it: it judges what the finished rule set, its approximations, and its verdicts actually reproduced. + +### Build in MAIN + +When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. + +Record the returned `build_jdk` in `.opentaint/tracking/state.yaml`. Record `model_commit` as the full HEAD only when no source file is uncommitted, otherwise set it to null. Build non-convergence blocks the run because no later phase can proceed without the model. Keep one untouched model for the entire enactment run. + +### Scan in MAIN + +When status reports `scan`, or a stage returns with a rescan pending, load and follow the `run-scan` skill in this main session. Start the scan with the harness's main-session background-command facility, keep the engine's self-timeout, add a 1200-second outer backstop, and wait for the process completion event. + +A valid `.opentaint/results/report.sarif` means the scan completed, including exit 254 after an engine timeout. Record `max_memory: 16G` when the scan had to bump memory and reuse it on later scans. If no SARIF exists after the allowed retry/backstop, follow the repair path below for a malformed rule/approximation; otherwise dispatch `orchestrate-stage` with `stage: escalation` and the scan `setup` to write the scan-wide resource issue, then stop. + +When a scan or later stage reports a malformed approximation, unloadable created rule, ineffective join, or a created rule's false positive/negative, route the exact diagnosis and artifact path/id to the responsible stage agent per Dispatching, then scan again in MAIN. + +After every build, scan, or stage return, run `uv run /scripts/get_status.py` once to choose the next action. Use `--full` at run start, on resume, or when the brief output does not settle the question. + +### Iterating to coverage + +The pipeline loops by design, and status is what closes it. A rescan makes every cross-reference pending again; a cross-reference that finds a trace stopped at an opaque carrier sends the run back to an approximation round; one that blames a rule sends it back to the stage that authored it, so status returns there before closing again. Follow status through each loop rather than declaring the run finished early — an earlier phase reading `IN_PROGRESS` after a later one ran is the loop working, not a regression. + +The run is complete when status reports `run complete` — every supplied finding reproduced or recorded with its blocker, and `.opentaint/enactment.md` current. Report the coverage manifest, keeping raw SARIF results, validated findings, and unique vulnerability identities as separate counts. + +## Dispatching + +Dispatch exactly one stage-orchestrator subagent for each stage invocation: + +``` +Invoke the Skill orchestrate-stage first, then follow its instructions precisely +Inputs: + stage: +``` + +A subagent inherits the project-root working directory, so omit `project-root`. + +Stage context: + +- `boundaries` — normalize the supplied findings into the reference set, generalize each family into a saturated source and sink boundary, and seed the rule units from it +- `sources` — author the seeded source units' rules and wire the joins +- `sinks` — author the seeded sink units' rules and wire the joins +- `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier +- `crossref` — judge each supplied finding against the latest scan and refresh the coverage manifest +- `triage` — classify the latest findings and refresh the vulnerability report +- `poc` — reproduce confirmed findings and add the outcomes to the report +- `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure + +Keep each agent id until the next scan validates its artifacts. On a stage-owned error, resume that agent with `stage: escalation`, the exact error, and the artifact path/id. If its thread is unavailable, start a re-entrant `orchestrate-stage` agent with that diagnosis. + +Dispatch each subagent fresh, don't fork context into it. Then wait for it natively, don't monitor or poll every minute. If the harness forces a wait timeout, set it to ~1h and re-wait when it returns. + +## State and resumption + +Use this ownership map to route work and scan errors: + +``` +.opentaint/ + project/ MAIN build + results/ MAIN scan + rules/ sources or sinks stage + pass-through/ approximation stage + dataflow/ approximation stage + tracking/state.yaml MAIN run knobs + tracking/reference/ boundaries stage writes, crossref stage judges + tracking/boundaries/ boundaries stage + tracking/ stage agents, leaves, and join scripts otherwise + enactment.md crossref stage + vulnerabilities.md triage / PoC stage + issues/ escalation stage +``` + +The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan. + +`state.yaml` shape: + +```yaml +mode: enactment +scan_level: deep +triage_level: static +language: java +findings: reports/pentest-2026-07.md +model_commit: 0123456789abcdef0123456789abcdef01234567 +build_jdk: null +max_memory: null +``` + +`mode` is what selects this pipeline; `findings` is the supplied set the whole run is measured against. Both are written at bootstrap and never edited afterwards — pointing an in-flight run at a different finding file strands its reference set. + +## Key constraints + +- read pipeline state through `/scripts/get_status.py`, not by hand — don't re-derive it with glob/grep/`python3 -c`/yaml scans over `.opentaint/tracking`, `results`, or the `*.yaml`, nor open finding/unit/reference/SARIF files just to review progress. If its output doesn't settle the question, re-run it with `--full` before opening any file +- don't author or edit stage-owned artifacts or tracking; MAIN writes only `model_commit`, `build_jdk`, and `max_memory` in `state.yaml` +- keep one generated project model for the run; never hand-edit or replace it mid-analysis — fix the build and rebuild before starting a new run +- source and sink rules come before approximation work; an approximation never compensates for a boundary that was never authored +- coverage is counted by unique finding identity, never by rule id or raw SARIF result count, and a result counts as a reproduction only when its trace carries the finding's own attack path +- never drop a supplied finding as unsuitable for taint analysis diff --git a/skills/enactment-agent/scripts/_common.py b/skills/enactment-agent/scripts/_common.py new file mode 100644 index 000000000..8c7f0d5dd --- /dev/null +++ b/skills/enactment-agent/scripts/_common.py @@ -0,0 +1,191 @@ +"""Shared helpers for the appsec-agent orchestrator scripts. + +Not a runnable script — imported by the PEP723 entry points (get_status.py, generate.py), +which carry the pyyaml dependency. Every path resolves under the fixed +/.opentaint/ tree relative to the current directory, so run the entry +scripts from the project root. +""" +import glob +import re +import subprocess +from pathlib import Path + +import yaml + +ROOT = Path(".opentaint") +MODEL = ROOT / "project" +TRACKING = ROOT / "tracking" +APPROX = TRACKING / "approximations" +RULES_TR = TRACKING / "rules" +SOURCES_TR = RULES_TR / "sources" +SINKS_TR = RULES_TR / "sinks" +JOINS_TR = RULES_TR / "joins" +FINDINGS_TR = TRACKING / "findings" +REFERENCE_TR = TRACKING / "reference" # enactment mode: the supplied findings, normalized +BOUNDARIES_TR = TRACKING / "boundaries" # enactment mode: per-family boundary specs +RESULTS = ROOT / "results" +DROPPED = RESULTS / "dropped-external-methods.yaml" +SARIF = RESULTS / "report.sarif" +RULES = ROOT / "rules" +PASS_THROUGH = ROOT / "pass-through" +DATAFLOW = ROOT / "dataflow" + + +# ---- yaml io ---- + +def load_yaml(path, default=None): + p = Path(path) + if not p.is_file(): + return default + try: + return yaml.safe_load(p.read_text(encoding="utf-8")) or default + except yaml.YAMLError as e: + raise SystemExit(f"{p}: invalid YAML — {e}\n" + " a JVM signature containing '[' must be quoted in flow style " + "(signature: \"([BLjava/lang/String;)V\")") + + +def dump_yaml(obj): + return yaml.safe_dump(obj, sort_keys=False, default_flow_style=False, allow_unicode=True) + + +# ---- fqn / member normalization ---- + +def strip_quotes(s): + return str(s).strip().strip('"').strip("'") + + +def fqn_base(s): + """The method fqn without its signature/params — `a.b.C#m`.""" + s = strip_quotes(s) + i = s.find("(") + return (s[:i] if i != -1 else s).strip() + + +def member_of(item): + """A bucket/plan entry (dict or str) normalized to {method, signature?}.""" + if isinstance(item, dict): + m = strip_quotes(item.get("method", "")) + sig = str(item.get("signature", "")).strip() + return {"method": m, "signature": sig} if sig else {"method": m} + return {"method": strip_quotes(item)} + + +def member_key(item): + """Overload-precise key: method + signature. Matches the classification ledger.""" + if isinstance(item, dict): + return f"{strip_quotes(item.get('method', ''))}{str(item.get('signature', '')).strip()}" + return strip_quotes(item) + + +def class_of(fqn): + return fqn_base(fqn).split("#", 1)[0].strip() + + +def package_of(fqn): + cls = class_of(fqn) + return cls.rsplit(".", 1)[0] if "." in cls else "" + + +# ---- git ---- + +def git_head(): + """HEAD commit of the project tree, or None when there's no repo.""" + try: + out = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, + text=True, check=True) + return out.stdout.strip() or None + except (OSError, subprocess.CalledProcessError): + return None + + +# ---- approximation batch readers (shared by coverage + partition) ---- + +# a method counts classified once it sits in any batch classification bucket or in build.done +CLASSIFIED_BUCKETS = ("passthrough", "dataflow", "skipped", "engine_issues") +MODELED_BUCKETS = ("passthrough", "dataflow") + + +def batch_files(): + """Every approximation batch file (skipped.yaml is the merged view, not a batch).""" + return [Path(p) for p in sorted(glob.glob(str(APPROX / "*.yaml"))) + if Path(p).name != "skipped.yaml"] + + +def dropped_entries(): + """The dropped external methods as {method, signature?} rows (order preserved).""" + rows = [] + for e in load_yaml(DROPPED, []) or []: + if isinstance(e, dict) and e.get("method"): + row = {"method": strip_quotes(e["method"])} + if e.get("signature"): + row["signature"] = str(e["signature"]).strip() + rows.append(row) + return rows + + +def classified_keys(): + """method+signature keys of every method already classified across the batch files. + Overload-precise (matches the ledger, build.done, unbuilt and stuck checks): a method with + one overload classified does not mask a differently-propagating overload still dropped.""" + out = set() + for p in batch_files(): + doc = load_yaml(p, {}) or {} + for key in CLASSIFIED_BUCKETS: + for item in doc.get(key, []) or []: + if str(item).strip(): + out.add(member_key(item)) + for item in (doc.get("build") or {}).get("done", []) or []: + if str(item).strip(): + out.add(member_key(item)) + return out + + +def skipped_keys(): + """method+signature keys classified terminal — the `skipped` and `engine_issues` buckets of + every batch. Terminal means the method will never build a working carrier, so it must not hold + the approximations phase pending even if it is still modeled and still dropped by the scan. + (skipped.yaml is the merged view of these same buckets, so reading the batches alone suffices.)""" + out = set() + for p in batch_files(): + doc = load_yaml(p, {}) or {} + for bucket in ("skipped", "engine_issues"): + for item in doc.get(bucket, []) or []: + if str(item).strip(): + out.add(member_key(item)) + return out + + +def modeled_entries(): + """Every passthrough/dataflow entry across batches, tagged with its batch file.""" + out = [] + for p in batch_files(): + doc = load_yaml(p, {}) or {} + for kind in MODELED_BUCKETS: + for item in doc.get(kind, []) or []: + if str(item).strip(): + out.append((p, kind, member_of(item))) + return out + + +def build_done_keys(): + """Overload-precise keys of every built approximation across batches.""" + keys = set() + for p in batch_files(): + doc = load_yaml(p, {}) or {} + for item in (doc.get("build") or {}).get("done", []) or []: + if str(item).strip(): + keys.add(member_key(item)) + return keys + + +# ---- finding files ---- + +RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) +VERDICT_RE = re.compile(r'^verdict:\s*(.+?)\s*$', re.M) + + +def ledger_verdicted_keys(): + """method+signature keys already verdicted in classification.yaml (source ∪ safe).""" + doc = load_yaml(RULES_TR / "classification.yaml", {}) or {} + return {strip_quotes(x) for key in ("source", "safe") for x in (doc.get(key) or [])} diff --git a/skills/enactment-agent/scripts/generate.py b/skills/enactment-agent/scripts/generate.py new file mode 100644 index 000000000..b9de7adc3 --- /dev/null +++ b/skills/enactment-agent/scripts/generate.py @@ -0,0 +1,612 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = ["pyyaml==6.0.3"] +# /// +""" +generate.py — the orchestrator's writing helper. Every subcommand mutates durable +state at a fan-out join; none is read-only (use get_status.py for checks). Run with uv +from the project root: `uv run scripts/generate.py `. + + init bootstrap the .opentaint tree + state.yaml from the workflow flags + partition analyze dropped external methods -> per-root batch plans (approximations) + partition discover coverage.yaml's used members -> balanced discover plans + mark-safe discover plans' verdicts -> classification.yaml ledger (+prune plans) + merge-skipped batch skipped/engine_issues -> approximations/skipped.yaml (+prune plans) + findings results/report.sarif -> per-rule finding tracking files (idempotent) +""" +import argparse +import glob +import hashlib +import json +import math +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, + PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, + SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, + dropped_entries, dump_yaml, fqn_base, git_head, ledger_verdicted_keys, + load_yaml, member_key, package_of, strip_quotes) + +ANALYZE_BUDGET = 20 # methods per approximation batch +ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch +ROOT_DEPTH = 2 # library root = first 2 dotted segments +DISCOVER_TARGET, DISCOVER_BAND = 50, 15 # project-used members per discover plan (~50, loose) + +DISCOVER_PLANS = RULES_TR / "plans" +APPROX_PLANS = APPROX / "plans" + + +# ---- init: bootstrap the working tree + state.yaml ---- + +# the durable directories a run writes into; the leaves/scripts mkdir on write, but seeding +# them up front gives every stage a place to land and makes the empty tree self-describing. +INIT_DIRS = [TRACKING, APPROX, SOURCES_TR, SINKS_TR, JOINS_TR, FINDINGS_TR, + RESULTS, RULES, PASS_THROUGH, DATAFLOW] +ENACTMENT_DIRS = [REFERENCE_TR, BOUNDARIES_TR] # enactment mode only +STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves + + +def cmd_init(args): + enactment = args.mode == "enactment" + if enactment and not args.findings: + raise SystemExit("init --mode enactment requires --findings ") + if not enactment and not args.scan_level: + raise SystemExit("init --mode discovery requires --scan-level") + # enactment reproduces a supplied finding set, which always needs the full rule + approximation + # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. + scan_level = "deep" if enactment else args.scan_level + + for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): + d.mkdir(parents=True, exist_ok=True) + state_path = TRACKING / "state.yaml" + prior = load_yaml(state_path, {}) or {} + resume = bool(prior) + if resume and prior.get("mode", "discovery") != args.mode: + raise SystemExit(f"{state_path} is a {prior.get('mode', 'discovery')} run — a mode switch " + "would strand its tracking; start the other mode in a fresh project tree") + state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, + "language": args.language or prior.get("language")} + if enactment: + state["findings"] = args.findings or prior.get("findings") + for k in STATE_DERIVED: # never clobber what build/scan already learned + state[k] = prior.get(k) + state_path.write_text(dump_yaml(state), encoding="utf-8") + + # history: append one run entry on a fresh init, never on resume (the derived knobs survived) + hist_path = TRACKING / "history.yaml" + runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] + if not resume: + runs.append({"commit": git_head(), + "type": f"{args.mode}/{scan_level}/{args.triage_level}" + + ("" if args.controls == "on" else "/no-controls")}) + hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") + + how = "resumed (derived knobs preserved)" if resume else "fresh" + print(f"init {how}: mode={args.mode} scan_level={scan_level} " + f"triage_level={state['triage_level']} language={state['language']}") + if enactment: + print(f"findings={state['findings']}") + print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " + "directories under .opentaint/") + print("next: uv run scripts/get_status.py --full") + return 0 + + +def regen_plans(out_dir): + # partition regenerates the whole plan set from the current unclassified state, so drop any + # stale plans first — otherwise a re-partition leaves already-consumed plans as leftover cruft. + out_dir.mkdir(parents=True, exist_ok=True) + for p in out_dir.glob("*.yaml"): + p.unlink() + + +# ---- partition: shared bin-packing ---- + +def root_of(fqn, depth=ROOT_DEPTH): + segs = class_of(fqn).split(".") + return ".".join(segs[:depth]) if len(segs) >= depth else class_of(fqn) + + +def in_packages(cls, prefixes): + # dotted-boundary match: `a.b.collect` never matches a sibling `a.b.collectX` + return any(cls == p or cls.startswith(p + ".") for p in prefixes) + + +def atomize(fqns, cap): + # split into atomic scopes (prefix, [fqns]); each scope is a whole (sub)package (or a subtree + # under cap) — a package is NEVER split across scopes, so it lands in exactly one bin and no two + # agents ever share a package's per-package unit. A package over cap stays oversized. + scopes = [] + + def recurse(prefix, items): + if len(items) <= cap: + scopes.append((prefix, items)) + return + depth = len(prefix.split(".")) + buckets, leaf = {}, [] + for f in items: + pkg = package_of(f) + segs = pkg.split(".") if pkg else [] + if pkg == prefix or len(segs) <= depth: + leaf.append(f) + else: + child = ".".join(segs[: depth + 1]) + buckets.setdefault(child, []).append(f) + if leaf: + scopes.append((prefix, leaf)) + for child, cf in buckets.items(): + recurse(child, cf) + + top = {} + for f in fqns: + pkg = package_of(f) + top.setdefault(pkg.split(".")[0] if pkg else class_of(f), []).append(f) + for seg0, items in top.items(): + recurse(seg0, items) + return scopes + + +def pack(scopes, target, cap): + # longest-processing-time bin-packing. An atomic scope larger than cap (a package that can't + # be split) gets its own bin instead of forcing the whole set to one-scope-per-bin. + plans = [{p: v} for p, v in scopes if len(v) > cap] + items = sorted((s for s in scopes if len(s[1]) <= cap), key=lambda s: len(s[1]), reverse=True) + if items: + total = sum(len(v) for _, v in items) + k = max(1, math.ceil(total / cap), round(total / target)) + while True: + loads = [0] * k + bins = [{} for _ in range(k)] + for prefix, v in items: + i = min(range(k), key=lambda j: loads[j]) + bins[i][prefix] = v + loads[i] += len(v) + if max(loads) <= cap or k >= len(items): + break + k += 1 + plans += [b for b in bins if b] + return plans + + +def write_plans(plans, out_dir, prefix_id): + out_dir.mkdir(parents=True, exist_ok=True) + paths = [] + for i, scopes in enumerate(plans, 1): + pid = f"{prefix_id}-{i:03d}" + norm = {p.replace(".", "-"): sorted(v, key=lambda x: (x["method"], x.get("signature", "")) + if isinstance(x, dict) else x) + for p, v in sorted(scopes.items())} + path = out_dir / f"{pid}.yaml" + # source: null is the unprocessed sentinel — a discover agent overwrites it with the + # list of sources it found (an empty list when it finds none). mark-safe folds only + # plans whose sentinel was replaced, so an un-returned plan is never marked safe. + path.write_text(dump_yaml({"id": pid, "scopes": norm, "source": None}), encoding="utf-8") + paths.append(str(path)) + return paths + + +# ---- partition analyze ---- + +def _root_next_index(prefix): + # additive numbering: a re-partition round must never reuse an id an existing batch already + # owns, or the new plan's analyze agent would overwrite that batch. Continue past the highest + # index any existing batch OR leftover plan already claims for this root. + mx = 0 + for d in (APPROX, APPROX_PLANS): + for p in glob.glob(str(d / f"{prefix}-*.yaml")): + m = re.match(rf"^{re.escape(prefix)}-(\d+)\.yaml$", Path(p).name) + if m: + mx = max(mx, int(m.group(1))) + return mx + 1 + + +def cmd_analyze(args): + regen_plans(APPROX_PLANS) + classified = classified_keys() + rows = [r for r in dropped_entries() if member_key(r) not in classified] + if not rows: + print("nothing to plan — every dropped method already classified", file=sys.stderr) + return 0 + + by_root = {} + for r in rows: + by_root.setdefault(root_of(r["method"]), []).append(r) + count = lambda rs: len({fqn_base(r["method"]) for r in rs}) + misc = [] + for root in [k for k, rs in by_root.items() if count(rs) <= ANALYZE_MISC]: + misc += by_root.pop(root) + if misc: + by_root["misc"] = misc + + paths = [] + for root in sorted(by_root): + by_fqn = {} + for r in by_root[root]: + by_fqn.setdefault(fqn_base(r["method"]), []).append(r) + bins = pack(atomize(sorted(by_fqn), ANALYZE_BUDGET), ANALYZE_BUDGET, ANALYZE_BUDGET) + prefix = root.replace(".", "-") + start = _root_next_index(prefix) + for i, b in enumerate(bins): + scopes = {} + for f in {f for v in b.values() for f in v}: # re-group the batch by class + scopes.setdefault(class_of(f), []).extend(by_fqn[f]) + norm = {cls: sorted(v, key=lambda x: (x["method"], x.get("signature", ""))) + for cls, v in sorted(scopes.items())} + pid = f"{prefix}-{start + i:03d}" + path = APPROX_PLANS / f"{pid}.yaml" + path.write_text(dump_yaml({"scopes": norm}), encoding="utf-8") + paths.append(str(path)) + for p in paths: + print(p) + return 0 + + +# ---- partition discover ---- + +def yaml_modules(model_yaml): + # each module in project.yaml as (packages, moduleClasses); only `packages` says which of a + # classpath-mode model's moduleClasses (dependency jars included) is project code + doc = load_yaml(model_yaml, {}) or {} + mods = [] + + def walk(node): + if isinstance(node, dict): + if isinstance(node.get("moduleClasses"), list): + mods.append(([str(p) for p in (node.get("packages") or [])], + [str(c) for c in node["moduleClasses"]])) + for v in node.values(): + walk(v) + elif isinstance(node, list): + for x in node: + walk(x) + + walk(doc) + return mods + + +def is_project_class(cls, packages): + # mirrors the engine's ProjectClasses.isModuleClass; empty packages = a project-only module + return not packages or any(cls.startswith(p) for p in packages) + + +CALL_RE = re.compile(r"//\s*(?:Interface)?Method\s+(\S+?)\.(?):(\S+)") + + +def extract_usages(): + # disassemble project classes, collect // Method / // InterfaceMethod call sites with their + # JVM descriptor; returns (fqn, signature) pairs so an overloaded member stays disambiguated + fqns = set() + for packages, module_classes in yaml_modules(MODEL / "project.yaml"): + for entry in module_classes: + p = MODEL / entry + if p.is_dir(): + classes = [str(c.relative_to(p))[:-6].replace("/", ".") for c in p.rglob("*.class")] + elif p.is_file(): + try: + listing = subprocess.run(["jar", "tf", str(p)], capture_output=True, + text=True, check=True).stdout + except (OSError, subprocess.CalledProcessError): + continue + if not packages: + print(f"warning: {entry} is a jar in a module with no declared `packages` — its " + f"plans will cover the library's own calls, not the project's", file=sys.stderr) + classes = [c[:-6].replace("/", ".") for c in listing.splitlines() + if c.endswith(".class")] + else: + continue + classes = [c for c in classes if is_project_class(c, packages)] + cp = str(p) + for i in range(0, len(classes), 200): # batch to keep argv under the limit + batch = classes[i:i + 200] + try: + out = subprocess.run(["javap", "-c", "-p", "-classpath", cp, *batch], + capture_output=True, text=True).stdout + except OSError: + continue + for owner, method, sig in CALL_RE.findall(out): + fqns.add((f"{owner.replace('/', '.')}#{method}", sig)) + return fqns + + +def pending_packages(): + cov = load_yaml(TRACKING / "coverage.yaml", {}) or {} + return tuple(p for p in (cov.get("packages") or []) if isinstance(p, str) and p) + + +def cmd_discover(args): + regen_plans(DISCOVER_PLANS) + packages = pending_packages() + if not packages: + print("nothing to plan — no pending package in coverage.yaml", file=sys.stderr) + return 0 + verdicted = ledger_verdicted_keys() # method+signature keys + sigs = {} # bare member fqn -> its pending signatures + for f, sig in extract_usages(): + if in_packages(class_of(f), packages) and f + sig not in verdicted: + sigs.setdefault(f, set()).add(sig) + todo = sorted(sigs) + if not todo: + print("nothing to plan — every used member already verdicted", file=sys.stderr) + return 0 + cap = DISCOVER_TARGET + DISCOVER_BAND + plans = pack(atomize(todo, cap), DISCOVER_TARGET, cap) + rows = {f: [{"method": f, "signature": s} for s in sorted(sigs[f])] for f in sigs} + plans = [{pkg: [r for f in members for r in rows[f]] for pkg, members in plan.items()} + for plan in plans] + for p in write_plans(plans, DISCOVER_PLANS, "lib"): + print(p) + return 0 + + +def cmd_partition(args): + return cmd_analyze(args) if args.kind == "analyze" else cmd_discover(args) + + +# ---- mark-safe (discover join) ---- + +def cmd_mark_safe(args): + plans = sorted(glob.glob(str(DISCOVER_PLANS / "lib-*.yaml"))) + if not plans: + print("no discover plans to reconcile", file=sys.stderr) + return 0 + ledger = RULES_TR / "classification.yaml" + doc = load_yaml(ledger, {}) or {} + source = {member_key(x) for x in (doc.get("source") or [])} + safe = {member_key(x) for x in (doc.get("safe") or [])} + processed, unprocessed = [], [] + for p in plans: + pdoc = load_yaml(p, {}) or {} + raw = pdoc.get("source") + if raw is None: # sentinel intact — no discover agent returned for it + unprocessed.append(p) + continue + members = {member_key(m) for v in (pdoc.get("scopes") or {}).values() for m in v} + srcs = {member_key(x) for x in raw} + source |= srcs + safe |= members - srcs + processed.append(p) + print(f"{Path(p).name}: {len(srcs)} sources, {len(members - srcs)} safe") + if not processed: + print("no processed discover plans (every plan still carries source: null) — " + "fan out discover-attack-surface first", file=sys.stderr) + return 0 + safe -= source + ledger.parent.mkdir(parents=True, exist_ok=True) + ledger.write_text(dump_yaml({"source": sorted(source), "safe": sorted(safe)}), encoding="utf-8") + print(f"classification.yaml: {len(source)} source, {len(safe)} safe total") + if not args.keep: + for p in processed: + Path(p).unlink() + print(f"pruned {len(processed)} reconciled discover plan(s)") + if unprocessed: + print(f"left {len(unprocessed)} unprocessed plan(s) (source: null) for re-dispatch: " + + ", ".join(Path(p).name for p in unprocessed)) + return 0 + + +# ---- merge-skipped (analyze join) ---- + +def _skip_member(item): + # normalize a skipped/engine_issues entry to {method, signature?}, dropping the reason + if isinstance(item, dict): + m = strip_quotes(item.get("method", "")) + sig = str(item.get("signature", "")).strip() + return {"method": m, "signature": sig} if sig else {"method": m} + return {"method": strip_quotes(item)} + + +def _collect(docs, bucket): + seen = {} + for doc in docs: + for item in doc.get(bucket, []) or []: + m = _skip_member(item) + if m["method"]: + seen[(m["method"], m.get("signature", ""))] = m + return [seen[k] for k in sorted(seen)] + + +def cmd_merge_skipped(args): + # collects the `skipped` and `engine_issues` buckets of every batch into skipped.yaml, keeping + # them as two separate groups — regular skips under `methods`, engine issues under `engine_issues`. + docs = [load_yaml(p, {}) or {} for p in + (Path(x) for x in sorted(glob.glob(str(APPROX / "*.yaml")))) + if p.name != "skipped.yaml"] + out = {"methods": _collect(docs, "skipped"), "engine_issues": _collect(docs, "engine_issues")} + (APPROX / "skipped.yaml").write_text(dump_yaml(out), encoding="utf-8") + print(f"skipped.yaml: {len(out['methods'])} methods, {len(out['engine_issues'])} engine_issues") + if not args.keep and APPROX_PLANS.is_dir(): + pruned = [p for p in glob.glob(str(APPROX_PLANS / "*.yaml"))] + for p in pruned: + Path(p).unlink() + if pruned: + print(f"pruned {len(pruned)} consumed approximation plan(s)") + return 0 + + +# ---- findings (SARIF -> per-rule tracking files) ---- + +ADJ = ["brave", "calm", "eager", "fuzzy", "gentle", "jolly", "keen", "lucid", + "merry", "noble", "proud", "quiet", "rapid", "sly", "tidy", "vivid", + "witty", "zesty", "amber", "bold"] +NOUN = ["hopper", "eagle", "otter", "falcon", "maple", "comet", "harbor", + "willow", "pixel", "river", "ember", "cobra", "lotus", "raven", + "quartz", "badger", "cedar", "drake", "finch", "gull"] + +_FP_PREFERENCE = ("vulnerabilitySourceSinkHash", "vulnerabilityWithTraceHash") + +HASHES_RE = re.compile(r'^sarif_hashes:\s*\[(.*)\]\s*$', re.M) +HASHES_BLOCK_RE = re.compile(r'^sarif_hashes:\s*\n((?:[ \t]+-[^\n]*\n?)+)', re.M) + + +def docker_name(seed, taken): + h = int(hashlib.sha1(seed.encode()).hexdigest(), 16) + base = f"{ADJ[h % len(ADJ)]}-{NOUN[(h // len(ADJ)) % len(NOUN)]}" + name, n = base, 2 + while name in taken: + name, n = f"{base}-{n}", n + 1 + return name + + +def result_hash(res): + fp = res.get("fingerprints") or res.get("partialFingerprints") + if isinstance(fp, dict) and fp: + for pref in _FP_PREFERENCE: + for k, v in fp.items(): + if k.startswith(pref): + return str(v)[:16] + return str(sorted(fp.values())[0])[:16] + parts = [res.get("ruleId", "")] + locs = list(res.get("locations", [])) + for cf in res.get("codeFlows", []): + for tf in cf.get("threadFlows", []): + locs += [st.get("location", {}) for st in tf.get("locations", [])] + for loc in locs: + pl = loc.get("physicalLocation", {}) + parts.append(pl.get("artifactLocation", {}).get("uri", "")) + parts.append(json.dumps(pl.get("region", {}), sort_keys=True)) + return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16] + + +def scan_results(sarif): + out = {} + for run in sarif.get("runs") or []: + for res in run.get("results") or []: + rid = res.get("ruleId") or "unknown" + msg = (res.get("message", {}) or {}).get("text", "").strip() + out.setdefault(rid, {})[result_hash(res)] = msg + return out + + +def fmt_list(hashes): + return "[" + ", ".join(hashes) + "]" + + +def parse_hashes(text): + m = HASHES_RE.search(text) + if m: + return [h.strip() for h in m.group(1).split(",") if h.strip()] + m = HASHES_BLOCK_RE.search(text) + if m: + return [ln.strip().lstrip("-").strip() + for ln in m.group(1).splitlines() if ln.strip().lstrip("-").strip()] + return [] + + +def replace_hashes(text, merged): + line = "sarif_hashes: " + fmt_list(merged) + if HASHES_RE.search(text): + return HASHES_RE.sub(lambda m: line, text, count=1) + if HASHES_BLOCK_RE.search(text): + return HASHES_BLOCK_RE.sub(line + "\n", text, count=1) + return line + "\n" + text + + +def new_file_text(rid, hashes, notes): + body = "\n".join(" " + ln for ln in (notes or "(no analyzer message)").splitlines()) + return (f"sarif_hashes: {fmt_list(hashes)}\n" + f"rule_id: {rid}\n" + f"verdict: pending\n" + f"notes: >\n{body}\n" + f"poc: pending\n") + + +def cmd_findings(args): + sarif = json.loads(SARIF.read_text(encoding="utf-8")) + by_rule = scan_results(sarif) + out = FINDINGS_TR + out.mkdir(parents=True, exist_ok=True) + + existing = {} + taken = set() + for p in sorted(glob.glob(str(out / "*.yaml"))): + text = Path(p).read_text(encoding="utf-8") + rid = RULE_RE.search(text) + verdict = VERDICT_RE.search(text) + taken.add(Path(p).stem) + if rid: + existing.setdefault(rid.group(1).strip(), []).append( + (Path(p), parse_hashes(text), verdict.group(1).strip() if verdict else "pending")) + + created = updated = unchanged = reconcile = 0 + for rid, hashmap in sorted(by_rule.items()): + scanned = set(hashmap) + files = existing.get(rid) + if not files: + name = docker_name(rid, taken) + taken.add(name) + notes = "\n".join(sorted({m for m in hashmap.values() if m})) + (out / f"{name}.yaml").write_text(new_file_text(rid, sorted(scanned), notes), + encoding="utf-8") + created += 1 + continue + already = set().union(*(set(h) for _, h, _ in files)) + new = sorted(scanned - already) + if not new: + unchanged += 1 + continue + pending = next(((p, h) for p, h, v in files if v == "pending"), None) + if pending: + path, hashes = pending + text = replace_hashes(path.read_text(encoding="utf-8"), sorted(set(hashes) | set(new))) + text = re.sub(r'^verdict:\s*.+$', "verdict: pending", text, count=1, flags=re.M) + path.write_text(text, encoding="utf-8") + updated += 1 + continue + name = docker_name(rid, taken) + taken.add(name) + msgs = sorted({hashmap[h] for h in new if hashmap.get(h)}) + notes = ("reconcile: new results under a rule whose findings are already triaged — " + "match each against this rule's triaged findings by flow before judging; if the " + "vulnerability is the same, merge its hashes into that finding and inherit its " + "verdict instead of re-triaging\n" + "\n".join(msgs)) + (out / f"{name}.yaml").write_text(new_file_text(rid, sorted(new), notes), encoding="utf-8") + reconcile += 1 + + print(f"findings: {created} created, {updated} updated, {unchanged} unchanged, " + f"{reconcile} to reconcile ({len(by_rule)} rules in scan)") + return 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") + i.add_argument("--mode", default="discovery", choices=["discovery", "enactment"], + help="discovery: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--scan-level", choices=["lite", "normal", "deep"], + help="discovery mode only; enactment is always deep") + i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) + i.add_argument("--language", default=None, help="target language, determined by the orchestrator") + i.add_argument("--findings", default=None, + help="enactment mode: path to the supplied finding manifest/report/directory") + i.set_defaults(func=cmd_init) + + p = sub.add_parser("partition", help="split classification work into per-agent plans") + p.add_argument("kind", choices=["analyze", "discover"]) + p.set_defaults(func=cmd_partition) + + m = sub.add_parser("mark-safe", help="merge discover plans into classification.yaml") + m.add_argument("--keep", action="store_true", help="keep the reconciled discover plans") + m.set_defaults(func=cmd_mark_safe) + + s = sub.add_parser("merge-skipped", help="rebuild approximations/skipped.yaml from batches") + s.add_argument("--keep", action="store_true", help="keep the consumed approximation plans") + s.set_defaults(func=cmd_merge_skipped) + + f = sub.add_parser("findings", help="seed per-rule finding files from results/report.sarif") + f.set_defaults(func=cmd_findings) + + + args = ap.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/enactment-agent/scripts/get_status.py b/skills/enactment-agent/scripts/get_status.py new file mode 100644 index 000000000..776d20179 --- /dev/null +++ b/skills/enactment-agent/scripts/get_status.py @@ -0,0 +1,470 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = ["pyyaml==6.0.3"] +# /// +""" +get_status.py — the orchestrator's status source. Derives, from the .opentaint tree, +which pipeline phase is current and the exact orchestrator tasks for it; writes nothing +(use generate.py to mutate). Run with uv from the project root: + + uv run scripts/get_status.py current stage + its orchestrator tasks + uv run scripts/get_status.py --full every in-scope phase as DONE/IN_PROGRESS/PENDING + +Call it at each stage boundary to decide the next move, and --full at run start / on +resume. It lists every pending plan, batch, unit, and finding to hand out — dispatch what +it names rather than re-deriving state by hand. +""" +import argparse +import glob +import os +import re +import subprocess +import sys +from pathlib import Path + +from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, + PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, + SOURCES_TR, TRACKING, build_done_keys, classified_keys, dropped_entries, + git_head, load_yaml, member_key, modeled_entries, skipped_keys, + strip_quotes) + +STATE = load_yaml(TRACKING / "state.yaml", {}) or {} +MODE = STATE.get("mode") or "discovery" +SCAN_LEVEL = STATE.get("scan_level") +TRIAGE_LEVEL = STATE.get("triage_level") + +DISCOVER_PLANS = RULES_TR / "plans" +APPROX_PLANS = APPROX / "plans" +VULN = ROOT / "vulnerabilities.md" +ENACTMENT = ROOT / "enactment.md" +GLOBAL_CAP = 10 + + +def short(c): + return str(c)[:8] if c else c + + +# ---- tree readers ---- + +def load_units(d): + return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ + if Path(d).is_dir() else [] + + +def load_docs(d): + """(path, doc) for every tracking file in a directory — reference, boundary, control.""" + return [(p, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ + if Path(d).is_dir() else [] + + +def load_joins(): + return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(JOINS_TR.glob("*.yaml"))] \ + if JOINS_TR.is_dir() else [] + + +def load_findings(): + out = [] + if FINDINGS_TR.is_dir(): + for p in sorted(FINDINGS_TR.glob("*.yaml")): + doc = load_yaml(p, {}) or {} + notes = str(doc.get("notes") or "") + out.append({"path": str(p), "name": p.stem, + "verdict": str(doc.get("verdict", "pending")).strip(), + "poc": str(doc.get("poc", "pending")).strip(), + "reconcile": notes.lstrip().startswith("reconcile")}) + return out + + +def newest_mtime(paths): + m = 0.0 + for p in paths: + try: + m = max(m, p.stat().st_mtime) + except OSError: + pass + return m + + +def approx_dirty(): + # scan older than the newest applied approximation artifact -> a rescan is pending + if not SARIF.is_file(): + return False + arts = list(PASS_THROUGH.rglob("*")) + list(DATAFLOW.rglob("*")) + return newest_mtime([p for p in arts if p.is_file()]) > SARIF.stat().st_mtime + + +def rules_dirty(): + if not SARIF.is_file(): + return False + return newest_mtime([p for p in RULES.rglob("*.yaml") if p.is_file()]) > SARIF.stat().st_mtime + + +def scan_stale(): + # SARIF older than the model -> a rebuild happened, rescan before trusting it + manifest = MODEL / "project.yaml" + if not SARIF.is_file() or not manifest.is_file(): + return False + return manifest.stat().st_mtime > SARIF.stat().st_mtime + + +def unit_next(doc, kind, side): + # the next dispatch step for a not-yet-passing rule unit + if (doc.get("stages") or {}).get("test_project") != "done": + return f"create-test-project type {kind}" + return f"create-rule side {side}" + + +def _join_source_refs(): + return {str(s).strip() for _, doc in load_joins() + for s in (doc.get("sources") or []) if str(s).strip()} + + +def _join_sink_refs(): + return {str(j["sink"]).strip() for _, doc in load_joins() + for j in (doc.get("joins") or []) if isinstance(j, dict) and j.get("sink")} + + +def _created_refs(units, field): + """rule_ids on the units that resolve to a rule file under .opentaint/rules (created, not + a built-in ref, which is indistinguishable by path but never sits on disk here).""" + refs = set() + for _, doc in units: + for e in doc.get(field) or []: + rid = str(e.get("rule_id", "")).strip() if isinstance(e, dict) else "" + if rid and (RULES / re.split(r"[:#]", rid, 1)[0]).is_file(): + refs.add(rid) + return refs + + +def _pending_units(units, kind, side): + # units not passing and not settled by a blocker; each tagged with its next step + out = [] + for name, doc in units: + st = doc.get("stages") or {} + if st.get("tests_passing") != "done" and not (doc.get("blocker") or st.get("blocker")): + out.append(f" {name} {unit_next(doc, kind, side)}") + return out + + +# ---- phase derivations: each returns (done, tasks, note) ---- +# done: the phase is complete. tasks: the brief lines when it is the current stage. +# note: a short suffix shown only on the --full line (e.g. build from a dirty tree). + +def ph_build(): + if not (MODEL / "project.yaml").is_file(): + return False, ["determine project language and write to state.yaml.language", + "dispatch build-project"], None + head, mc = git_head(), STATE.get("model_commit") + if head is None: + return True, [], "no git — taken as current" + if mc is None: + return True, [], "from dirty tree" + if head == mc: + return True, [], None + return False, [f"model stale: HEAD {short(head)} != model_commit {short(mc)}", + "dispatch build-project"], None + + +def ph_discover(): + if not (TRACKING / "coverage.yaml").is_file(): + return False, ["dispatch triage-dependencies"], None + leftover = sorted(glob.glob(str(DISCOVER_PLANS / "*.yaml"))) + units = load_units(SOURCES_TR) + ledger = load_yaml(RULES_TR / "classification.yaml", {}) or {} + if leftover: + tasks = [f"dispatch discover-attack-surface, one per plan (cap {GLOBAL_CAP}):"] + tasks += [f" {p}" for p in leftover] + tasks.append("then run `scripts/generate.py mark-safe` to reconcile the plans") + return False, tasks, None + if not ledger and not units: + return False, ["run `scripts/generate.py partition discover` to plan the used members"], None + return True, [], None + + +def ph_source_rules(): + units = load_units(SOURCES_TR) + if not units: + return True, [], "built-in covered" + pend = _pending_units(units, "rule-source", "sources") + if pend: + return False, ["pending units:"] + pend, None + missing = sorted(_created_refs(units, "sources") - _join_source_refs()) + if missing: + return False, ["created sources not wired to a join", "dispatch assemble-lib-rules"], None + return True, [], None + + +def ph_scan(): + if not SARIF.is_file() or scan_stale(): + return False, ["dispatch run-scan"], None + return True, [], None + + +def ph_approximations(): + if not SARIF.is_file(): + return False, ["dispatch run-scan"], None + classified = classified_keys() + uncovered = [e for e in dropped_entries() if member_key(e) not in classified] + if uncovered: + plans = sorted(glob.glob(str(APPROX_PLANS / "*.yaml"))) + if plans: + tasks = [f"dispatch analyze-external-methods, one per plan (cap {GLOBAL_CAP}):"] + tasks += [f" {p}" for p in plans] + tasks.append("then run `scripts/generate.py merge-skipped` to merge the batches") + return False, tasks, None + n = len({e["method"] for e in uncovered}) + return False, [f"{n} methods unclassified", + "run `scripts/generate.py partition analyze` to split them into batch plans"], None + done = build_done_keys() + terminal = skipped_keys() # skipped/engine-issue carriers never build — don't hold the gate + unbuilt = [(p, kind) for p, kind, m in modeled_entries() + if member_key(m) not in done and member_key(m) not in terminal] + if unbuilt: + by_kind = {} + for p, kind in unbuilt: + by_kind.setdefault(kind, set()).add(p.stem) + tasks = ["build unbuilt batches:"] + if by_kind.get("passthrough"): + tasks.append(" passthrough create-pass-through-approximation: " + + ", ".join(sorted(by_kind["passthrough"]))) + if by_kind.get("dataflow"): + tasks.append(" dataflow create-test-project type dataflow, then " + "create-dataflow-approximation: " + ", ".join(sorted(by_kind["dataflow"]))) + return False, tasks, None + if approx_dirty(): + return False, ["approximations built after the last scan", "dispatch run-scan"], None + stuck = sorted({e["method"] for e in dropped_entries() + if member_key(e) in done and member_key(e) not in terminal}) + if stuck: + return False, [f"built but still dropped ({len(stuck)}), escalate:"] \ + + [f" {m}" for m in stuck], None + return True, [], None + + +def ph_sink_rules(): + units = load_units(SINKS_TR) + pend = _pending_units(units, "rule-sink", "sinks") + if pend: + return False, ["pending units:"] + pend, None + refs = _join_sink_refs() + missing = sorted({e["rule_id"] for _, doc in units for e in (doc.get("sinks") or []) + if isinstance(e, dict) and e.get("rule_id") + and str(e["rule_id"]).strip() not in refs}) + if missing: + return False, ["sink rules not wired to a join", "dispatch assemble-lib-rules"], None + if rules_dirty(): + return False, ["rules changed after the last scan", "dispatch run-scan"], None + return True, [], None + + +def ph_triage(): + findings = load_findings() + if not findings: + if not SARIF.is_file(): + return False, ["dispatch run-scan"], None + return False, ["run `scripts/generate.py findings` to seed the finding files"], None + pend = [f" {f['path']}" + (" (reconcile)" if f["reconcile"] else "") + for f in findings if f["verdict"] == "pending"] + if pend: + return False, ["dispatch analyze-findings over pending findings:"] + pend, None + tp = sum(1 for f in findings if f["verdict"] == "TP") + stale = newest_mtime([Path(f["path"]) for f in findings]) > (VULN.stat().st_mtime + if VULN.is_file() else 0) + if not VULN.is_file() or stale: + return False, [f"rewrite .opentaint/vulnerabilities.md from the TP findings ({tp} TP)"], None + return True, [], None + + +def ph_poc(): + findings = load_findings() + tps = [f for f in findings if f["verdict"] == "TP"] + pend = [f" {f['path']}" for f in tps if f["poc"] == "pending"] + if pend: + return False, ["generate-poc serially over TP findings without a PoC:"] + pend, None + servers = (load_yaml(TRACKING / "poc-servers.yaml", {}) or {}).get("servers") or [] + if servers: + return False, ["tear down the instances in poc-servers.yaml and clear the registry", + "refresh .opentaint/vulnerabilities.md"], None + return True, [], None + + +# ---- enactment-mode phases ---- + +def ph_reference_set(): + docs = load_docs(REFERENCE_TR) + if not docs: + src = STATE.get("findings") or "state.yaml findings unset" + return False, [f"normalize the supplied findings ({src}) into " + ".opentaint/tracking/reference/.yaml"], None + missing = sorted(p.stem for p, d in docs if not strip_quotes(d.get("family", ""))) + if missing: + return False, ["reference findings not assigned to a boundary family:"] \ + + [f" {m}" for m in missing], None + return True, [], None + + +def families(): + return sorted({strip_quotes(d.get("family", "")) for _, d in load_docs(REFERENCE_TR) + if strip_quotes(d.get("family", ""))}) + + +def ph_boundaries(): + specs = {p.stem: d for p, d in load_docs(BOUNDARIES_TR)} + fams = families() + missing = [f for f in fams if f not in specs] + if missing: + return False, ["dispatch discover-universal-boundaries, one per family:"] \ + + [f" {f}" for f in missing], None + # a split renames the family on its reference findings, so every spec here owns its findings + unsaturated = [f for f in fams + if str((specs[f].get("saturation") or {}).get("status", "")).strip() + != "saturated"] + if unsaturated: + return False, ["boundary specs not saturated:"] + [f" {f}" for f in unsaturated], None + unfactored = sorted(p.stem for p, d in load_docs(REFERENCE_TR) + if p.stem not in (specs.get(strip_quotes(d.get("family", "")), {}) + .get("factorization") or {})) + if unfactored: + return False, ["reference findings with no factorization in their spec:"] \ + + [f" {r}" for r in unfactored], None + unseeded = [f for f in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] + if unseeded: + return False, ["seed the source and sink units from these specs' candidate_patterns:"] \ + + [f" {f}" for f in unseeded], None + return True, [], None + + +def ph_crossref(): + if not SARIF.is_file(): + return False, ["dispatch run-scan"], None + docs = load_docs(REFERENCE_TR) + scanned = SARIF.stat().st_mtime + pend = [p for p, d in docs + if str(d.get("crossref", "pending")).strip() != "done" or p.stat().st_mtime < scanned] + if pend: + return False, [f"cross-reference the scan against {len(pend)} reference finding(s):"] \ + + [f" {p}" for p in pend], None + blocked = sorted({str(m) for _, d in docs for m in (d.get("blocked_at") or [])}) + if blocked: + return False, ["expected traces stop at unmodeled carriers:"] + [f" {m}" for m in blocked] \ + + ["model them in an approximation round, rescan, then cross-reference again"], None + rep = sum(1 for _, d in docs if str(d.get("status", "")).strip() == "reproduced") + stale = newest_mtime([p for p, _ in docs]) > (ENACTMENT.stat().st_mtime + if ENACTMENT.is_file() else 0) + if not ENACTMENT.is_file() or stale: + return False, [f"rewrite .opentaint/enactment.md coverage manifest " + f"({rep}/{len(docs)} reproduced)"], None + return True, [], None + + +DISCOVERY_PHASES = [ + ("build", ph_build, lambda: True), + ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), + ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), + ("scan", ph_scan, lambda: True), + ("approximations", ph_approximations, lambda: SCAN_LEVEL in ("normal", "deep")), + ("sink_rules", ph_sink_rules, lambda: SCAN_LEVEL == "deep"), + ("triage", ph_triage, lambda: True), + ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), +] + +# enactment reproduces a supplied finding set: the reference set and its saturated boundaries +# replace dependency discovery, and both rule sides are authored before the first scan so that +# scan is rule-first. The cross-reference closes the run — it judges what the finished rule set, +# its approximations, its verdicts and its controls actually reproduced. +ENACTMENT_PHASES = [ + ("build", ph_build, lambda: True), + ("reference_set", ph_reference_set, lambda: True), + ("boundaries", ph_boundaries, lambda: True), + ("source_rules", ph_source_rules, lambda: True), + ("sink_rules", ph_sink_rules, lambda: True), + ("scan", ph_scan, lambda: True), + ("approximations", ph_approximations, lambda: True), + ("triage", ph_triage, lambda: True), + ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), + ("crossref", ph_crossref, lambda: True), +] + +PHASES = ENACTMENT_PHASES if MODE == "enactment" else DISCOVERY_PHASES + + +# ---- caps ---- + +def free_gb(): + mi = Path("/proc/meminfo") + if mi.is_file(): + for ln in mi.read_text().splitlines(): + if ln.startswith("MemAvailable:"): + return int(ln.split()[1]) // (1024 * 1024) + try: + total = int(subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, + text=True, check=True).stdout.strip()) + return total // (1024 ** 3) + except (OSError, subprocess.CalledProcessError, ValueError): + return None + + +def heavy_cap(): + cores = os.cpu_count() or 1 + fg = free_gb() + return max(1, min(cores, (fg // 2 if fg else cores), GLOBAL_CAP)) + + +# ---- output ---- + +def in_scope(): + return [(name, fn) for name, fn, gate in PHASES if gate()] + + +def evaluate(): + """Every in-scope phase as (name, done, tasks, note), with the current stage marked.""" + rows = [(name,) + fn() for name, fn in in_scope()] + current = next((i for i, r in enumerate(rows) if not r[1]), None) + return rows, current + + +def cmd_full(): + commit = short(STATE.get("model_commit")) or "none" + print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " + f"language={STATE.get('language')} commit={commit} " + f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") + if MODE == "enactment": + print(f"findings={STATE.get('findings')}") + rows, current = evaluate() + # a phase downstream of the current stage that vacuously satisfies its own check is not + # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. + for i, (name, done, tasks, note) in enumerate(rows): + if current is None or i < current: + state = "DONE" + elif i == current: + state = "IN_PROGRESS" + else: + state = "PENDING" + suffix = f" ({note})" if note and state == "DONE" else "" + print(f"{name:<15} {state}{suffix}") + return 0 + + +def cmd_brief(): + rows, current = evaluate() + if current is None: + print("run complete") + return 0 + name, _done, tasks, _note = rows[current] + print(f"{name} IN_PROGRESS") + for t in tasks: + print(f" {t}") + return 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--full", action="store_true", + help="every in-scope phase as DONE/IN_PROGRESS/PENDING") + args = ap.parse_args() + return cmd_full() if args.full else cmd_brief() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/orchestrate-stage/SKILL.md b/skills/orchestrate-stage/SKILL.md index e46ab21f9..adf9e1726 100644 --- a/skills/orchestrate-stage/SKILL.md +++ b/skills/orchestrate-stage/SKILL.md @@ -22,6 +22,8 @@ sinks → /references/sinks.md triage → /references/triage.md poc → /references/poc.md escalation → /references/escalation.md +boundaries → /references/boundaries.md (enactment mode) +crossref → /references/crossref.md (enactment mode) ``` Run the bundled script to get the setup overview before proceeding to the reference's instructions: diff --git a/skills/orchestrate-stage/references/boundaries.md b/skills/orchestrate-stage/references/boundaries.md new file mode 100644 index 000000000..715b19bcb --- /dev/null +++ b/skills/orchestrate-stage/references/boundaries.md @@ -0,0 +1,112 @@ +# Reference set + universal boundaries + +Turn the supplied findings into a normalized reference set, generalize each finding family into one saturated source and sink boundary, and seed the source and sink units those boundaries imply. Enactment mode only — it replaces dependency discovery, and everything it writes feeds the ordinary rule-authoring stages. + +## Normalize the reference set + +`state.yaml` names the supplied findings under `findings` — a manifest, SARIF, report, or directory of finding documents. Write one `.opentaint/tracking/reference/.yaml` per supplied finding. + +`.opentaint/tracking/reference/.yaml` — one supplied finding, normalized to a stable identity and carried through boundary discovery and the cross-reference. The file is named for the finding's own id. `family` ties it to its boundary spec and is rewritten when a family splits. `status` is `pending` until the cross-reference judges it, then `reproduced` or `unreproduced`; `cause` explains an `unreproduced` one so the pipeline knows who owns it — `rule` (a boundary, restriction, or sanitizer is wrong), `approximation` (an opaque carrier breaks the path), or `engine` (a modeling limit, paired with `blocker`). `blocked_at` lists the carriers still to model and is cleared once they are modeled or judged terminal. `matched_hashes` are the SARIF result hashes whose trace carries this finding's identity — never a rule-id match alone. Keep it clear from comments + +```yaml +id: DSC-014 +vuln_class: ssrf +family: ssrf +source: request body field `callbackUrl` on POST /api/webhook/register +propagation: WebhookReqVO -> WebhookDO -> WebhookService#dispatch +sink: RestTemplate#getForObject in WebhookService#dispatch +expected_location: yudao-module-infra/.../WebhookService.java:88 +guards: URL parsed with new URI(...), no private-range rejection +status: reproduced +cause: null +blocker: null +blocked_at: [] +matched_hashes: [a1b2c3d4e5f6a7b8] +crossref: done +notes: > + crossref: join ssrf-webhook-ext fired at WebhookService#dispatch:88 with the trace entering at + the registration body — same attack path as the reference finding +``` + +Give each finding a stable id of its own — the supplied one when it has one. Preserve separately triggerable attack paths as separate findings even when they share a sink. Never drop a finding for being a poor fit for taint analysis: an authorization, integrity, configuration, hard-coded-secret, or structural-control finding gets a reference file like any other, and becomes an explicit pseudo-boundary later. + +Then group the findings into families and set `family` on each. Partition by vulnerability class or by a cohesive finding family — never by file batches or arbitrary count. A family is the set of findings you expect to share one source and one sink. + +Fan out this normalization when the supplied set is large: one leaf per slice of the supplied report, each writing its own reference files. Assign the families yourself once every file exists, since that decision needs the whole set. + +## Discover the boundaries + +Fan out discover-universal-boundaries, one leaf per family. + +Inputs each: +- `language` +- `findings` — the path `state.yaml` names +- `finding-ids` — the ids assigned to this family +- `family` + +Expect back — `.opentaint/tracking/boundaries/.yaml` with `saturation.status: saturated`, one `factorization` entry per assigned finding, and the controls listed separately from the positive boundaries. A leaf that splits its family writes one spec per subfamily and rewrites `family` on each reference finding it moved, so every finding still points at the spec that owns it. + +`.opentaint/tracking/boundaries/.yaml` — one boundary family: the single source and single sink every finding in the family factors through, plus the controls that recover precision. `candidate_patterns` are concrete enough for `create-rule` to test, and seed the family's source and sink units verbatim. `factorization` carries one entry per assigned reference finding, `status` one of `covered` (factors through both boundaries as-is), `needs-restriction` (factors only under a named `context_restrictions` entry), or `unfactored` (does not factor — explain in `open_questions` and split or add a pseudo-boundary). `saturation` records the widen-and-recheck rounds and only reads `saturated` once a full round changed neither the boundaries nor any factorization. `approximation_candidates` are opaque carriers noted for later — never acted on before a scan proves the trace stops there. Keep it clear from comments + +```yaml +family: ssrf +findings: [DSC-014, DSC-021] +source: + semantic_boundary: external request value entering a controller + candidate_patterns: + - { method: org.springframework.web.bind.annotation.RequestBody, signature: null, note: annotated controller parameter } + context_restrictions: [] +sink: + semantic_boundary: outbound HTTP request with a caller-supplied URL + candidate_patterns: + - { method: org.springframework.web.client.RestTemplate#getForObject, signature: "(Ljava/lang/String;Ljava/lang/Class;[Ljava/lang/Object;)Ljava/lang/Object;", vuln_class: ssrf, note: egress with attacker URL } + context_restrictions: [] +sanitizers: [] +negative_patterns: [] +factorization: + DSC-014: { source: request body callbackUrl, sink: RestTemplate#getForObject, status: covered } + DSC-021: { source: request param targetUrl, sink: RestTemplate#getForObject, status: needs-restriction, restriction: admin-only controller } +approximation_candidates: [] +open_questions: [] +saturation: + rounds: 3 + status: saturated +stages: + units_seeded: done +``` + +A spec returning with an `unfactored` finding is not a failure to retry blindly — read its `open_questions`, and either re-dispatch the leaf with the finding split out as its own family or accept the pseudo-boundary it proposes. + +## Seed the rule units + +For each saturated spec, write its `candidate_patterns` into the family's source and sink units, then set `stages.units_seeded: done` on the spec. The unit file name is the family, so the two sides and the spec stay tied together. + +`.opentaint/tracking/rules/sources/.yaml` — one source unit per package (a dependency can span several packages, each its own unit), the file named for that package with `.` → `-`. `dependencies` names the dependency the package comes from, `sources` each an entry point `{ method, signature, note, rule_id }` (`signature` the member's JVM descriptor, always quoted so array types `[…` stay valid YAML in a flow mapping), `stages` tracks the unit through rule authoring, and a `blocker` string is added under it when the unit can't be made to pass. Keep it clear from comments + +```yaml +dependencies: + - org.springframework:spring-websocket:6.1.0 +sources: + - { method: org.springframework.web.socket.TextMessage#getPayload, signature: "()Ljava/lang/String;", note: untrusted WebSocket frame data, rule_id: null } +stages: + test_project: pending + tests_passing: pending +``` + +`.opentaint/tracking/rules/sinks/.yaml` — one sink unit per package (a dependency can span several packages, each its own unit), the file named for that package with `.` → `-`. `dependencies` names the dependency the package comes from, `sinks` each a dangerous operation reached by the taint frontier `{ method, signature, vuln_class, note, rule_id }` — `signature` the member's JVM descriptor so overloads stay distinct, always quoted (array types contain `[`, which is invalid unquoted in a flow mapping), `vuln_class` per entry since one package can host several, `note` a few words on the danger, the tainted argument left unpinned. `stages` tracks the unit through rule authoring. Keep it clear from comments + +```yaml +dependencies: + - cn.hutool:hutool-core:5.8.20 +sinks: + - { method: cn.hutool.core.io.FileUtil#writeBytes, signature: "([BLjava/lang/String;)Ljava/io/File;", vuln_class: path-traversal, note: writes data to an untrusted path, rule_id: null } +stages: + test_project: pending + tests_passing: pending +``` + +Copy `method`, `signature`, and `note` from the spec's `candidate_patterns` (plus `vuln_class` on the sink side), leave `rule_id: null` and the `stages` pending, and fill `dependencies` with the dependency each pattern's package comes from — empty when the boundary is a project member, as a structural pseudo-boundary usually is. Do not carry `context_restrictions`, `sanitizers`, or `negative_patterns` into the units: the boundary is the positive pattern, and the controls stay listed in the spec. + +## Stage gate + +`get_status.py` drives `reference_set` then `boundaries`, naming findings without a family, families without a spec, unsaturated specs, unfactored findings, and unseeded specs. Finish when both are `DONE`, or when the next step it reports is source rules. diff --git a/skills/orchestrate-stage/references/crossref.md b/skills/orchestrate-stage/references/crossref.md new file mode 100644 index 000000000..379ae42c0 --- /dev/null +++ b/skills/orchestrate-stage/references/crossref.md @@ -0,0 +1,63 @@ +# Cross-reference — scan results against the reference set + +Judge, per supplied finding, whether the latest scan reproduced it, and assemble the coverage manifest. Enactment mode only, and the stage that closes the run: every rule, approximation, and verdict is already in place, so what the scan shows now is what the run delivered. Nothing else may set a reference finding's `status`. + +It is also the stage that decides what the run still owes. A judgement here can send the pipeline back — an unmodeled carrier to an approximation round, a rule-caused miss to the stage that authored the rule — and status will report that earlier phase as current again. That is the loop working; re-enter this stage after the rescan rather than closing on stale results. + +## Match by identity, never by rule id + +For each reference finding `get_status.py` lists as pending, compare the scan's results against its recorded identity: the source the trace enters at, the propagation it goes through, the sink it reaches, and the location. A result counts as a reproduction only when its trace carries the same attack path. A rule firing somewhere, a matching vulnerability class, or a matching file is not a match. + +Record the outcome on the reference file: + +`.opentaint/tracking/reference/.yaml` — one supplied finding, normalized to a stable identity and carried through boundary discovery and the cross-reference. The file is named for the finding's own id. `family` ties it to its boundary spec and is rewritten when a family splits. `status` is `pending` until the cross-reference judges it, then `reproduced` or `unreproduced`; `cause` explains an `unreproduced` one so the pipeline knows who owns it — `rule` (a boundary, restriction, or sanitizer is wrong), `approximation` (an opaque carrier breaks the path), or `engine` (a modeling limit, paired with `blocker`). `blocked_at` lists the carriers still to model and is cleared once they are modeled or judged terminal. `matched_hashes` are the SARIF result hashes whose trace carries this finding's identity — never a rule-id match alone. Keep it clear from comments + +```yaml +id: DSC-014 +vuln_class: ssrf +family: ssrf +source: request body field `callbackUrl` on POST /api/webhook/register +propagation: WebhookReqVO -> WebhookDO -> WebhookService#dispatch +sink: RestTemplate#getForObject in WebhookService#dispatch +expected_location: yudao-module-infra/.../WebhookService.java:88 +guards: URL parsed with new URI(...), no private-range rejection +status: reproduced +cause: null +blocker: null +blocked_at: [] +matched_hashes: [a1b2c3d4e5f6a7b8] +crossref: done +notes: > + crossref: join ssrf-webhook-ext fired at WebhookService#dispatch:88 with the trace entering at + the registration body — same attack path as the reference finding +``` + +- reproduced — set `status: reproduced` and put the matching SARIF result hashes in `matched_hashes` +- overmatch — the trace reproduces, but the same rule also fires on flows that are not this finding: still `reproduced`, and note the overmatch in `notes` +- stops at an opaque carrier — `status: unreproduced`, `cause: approximation`, and the exact carrier(s) in `blocked_at`. Get the carrier from `.opentaint/results/dropped-external-methods.yaml` or a localized reachability trace, not from a guess +- no source or sink fact on the expected path — `status: unreproduced`, `cause: rule`. Say in `notes` which side is missing and why, and report it to MAIN: the stage that authored the rule fixes it and the run comes back here +- reproducible only by a modeling limit the engine cannot express — `status: unreproduced`, `cause: engine`, one-line `blocker`, and `blocked_at` cleared + +Set `crossref: done` on every file you judge. A later rescan makes them pending again, which is the loop working: judge them against the new results rather than trusting the old verdict. + +Independently, note scan results that carry no reference finding — those are OpenTaint-exclusive and go to triage like any other finding. Do not count them as reproductions. + +## Close the blocked carriers + +When `get_status.py` reports traces stopping at unmodeled carriers, that work belongs to an approximation round, not here: report the carriers to MAIN, which runs the round and the rescan and re-enters this stage. Only after the rescan proves a carrier still breaks the path does it become an `engine` cause with a `blocker`. + +## Write the coverage manifest + +Once no reference finding is pending or blocked, rewrite `.opentaint/enactment.md` from the current reference files: + +- the finding-level coverage table — one row per supplied finding: id, vulnerability class, family, `status`, and the one-clause reason for an unreproduced one +- the three counts kept apart: raw SARIF results, validated findings, unique vulnerability identities +- reproduced, OpenTaint-exclusive, and reference-exclusive findings as separate sections, exclusives only when triage validated them +- the reusable artifacts the run produced: source rules, sink rules, joins, approximations +- one blocker line per non-reproduced finding, naming the rule, modeling, or engine limitation that remains + +Reflect only current state — rewrite the manifest, don't append to it. + +## Stage gate + +`get_status.py` names pending reference findings, blocked carriers, then the manifest. Finish when `crossref` is `DONE`. Report reproduced/unreproduced totals and every blocker. diff --git a/skills/orchestrate-stage/scripts/_common.py b/skills/orchestrate-stage/scripts/_common.py index 092f216dd..8c7f0d5dd 100644 --- a/skills/orchestrate-stage/scripts/_common.py +++ b/skills/orchestrate-stage/scripts/_common.py @@ -6,6 +6,7 @@ scripts from the project root. """ import glob +import re import subprocess from pathlib import Path @@ -20,6 +21,8 @@ SINKS_TR = RULES_TR / "sinks" JOINS_TR = RULES_TR / "joins" FINDINGS_TR = TRACKING / "findings" +REFERENCE_TR = TRACKING / "reference" # enactment mode: the supplied findings, normalized +BOUNDARIES_TR = TRACKING / "boundaries" # enactment mode: per-family boundary specs RESULTS = ROOT / "results" DROPPED = RESULTS / "dropped-external-methods.yaml" SARIF = RESULTS / "report.sarif" @@ -176,6 +179,12 @@ def build_done_keys(): return keys +# ---- finding files ---- + +RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) +VERDICT_RE = re.compile(r'^verdict:\s*(.+?)\s*$', re.M) + + def ledger_verdicted_keys(): """method+signature keys already verdicted in classification.yaml (source ∪ safe).""" doc = load_yaml(RULES_TR / "classification.yaml", {}) or {} diff --git a/skills/orchestrate-stage/scripts/generate.py b/skills/orchestrate-stage/scripts/generate.py index 8f98987bc..b9de7adc3 100644 --- a/skills/orchestrate-stage/scripts/generate.py +++ b/skills/orchestrate-stage/scripts/generate.py @@ -26,12 +26,11 @@ import yaml -from _common import (APPROX, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, - SOURCES_TR, TRACKING, class_of, classified_keys, - dropped_entries, dump_yaml, fqn_base, git_head, - ledger_verdicted_keys, load_yaml, member_key, package_of, - strip_quotes) +from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, + PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, + SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, + dropped_entries, dump_yaml, fqn_base, git_head, ledger_verdicted_keys, + load_yaml, member_key, package_of, strip_quotes) ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch @@ -48,17 +47,32 @@ # them up front gives every stage a place to land and makes the empty tree self-describing. INIT_DIRS = [TRACKING, APPROX, SOURCES_TR, SINKS_TR, JOINS_TR, FINDINGS_TR, RESULTS, RULES, PASS_THROUGH, DATAFLOW] +ENACTMENT_DIRS = [REFERENCE_TR, BOUNDARIES_TR] # enactment mode only STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves def cmd_init(args): - for d in INIT_DIRS: + enactment = args.mode == "enactment" + if enactment and not args.findings: + raise SystemExit("init --mode enactment requires --findings ") + if not enactment and not args.scan_level: + raise SystemExit("init --mode discovery requires --scan-level") + # enactment reproduces a supplied finding set, which always needs the full rule + approximation + # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. + scan_level = "deep" if enactment else args.scan_level + + for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) state_path = TRACKING / "state.yaml" prior = load_yaml(state_path, {}) or {} resume = bool(prior) - state = {"scan_level": args.scan_level, "triage_level": args.triage_level, + if resume and prior.get("mode", "discovery") != args.mode: + raise SystemExit(f"{state_path} is a {prior.get('mode', 'discovery')} run — a mode switch " + "would strand its tracking; start the other mode in a fresh project tree") + state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} + if enactment: + state["findings"] = args.findings or prior.get("findings") for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") @@ -67,13 +81,18 @@ def cmd_init(args): hist_path = TRACKING / "history.yaml" runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] if not resume: - runs.append({"commit": git_head(), "type": f"{args.scan_level}/{args.triage_level}"}) + runs.append({"commit": git_head(), + "type": f"{args.mode}/{scan_level}/{args.triage_level}" + + ("" if args.controls == "on" else "/no-controls")}) hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") - mode = "resumed (derived knobs preserved)" if resume else "fresh" - print(f"init {mode}: scan_level={state['scan_level']} triage_level={state['triage_level']} " - f"language={state['language']}") - print(f"seeded {len(INIT_DIRS)} directories under .opentaint/") + how = "resumed (derived knobs preserved)" if resume else "fresh" + print(f"init {how}: mode={args.mode} scan_level={scan_level} " + f"triage_level={state['triage_level']} language={state['language']}") + if enactment: + print(f"findings={state['findings']}") + print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " + "directories under .opentaint/") print("next: uv run scripts/get_status.py --full") return 0 @@ -420,10 +439,8 @@ def cmd_merge_skipped(args): _FP_PREFERENCE = ("vulnerabilitySourceSinkHash", "vulnerabilityWithTraceHash") -RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) HASHES_RE = re.compile(r'^sarif_hashes:\s*\[(.*)\]\s*$', re.M) HASHES_BLOCK_RE = re.compile(r'^sarif_hashes:\s*\n((?:[ \t]+-[^\n]*\n?)+)', re.M) -VERDICT_RE = re.compile(r'^verdict:\s*(.+?)\s*$', re.M) def docker_name(seed, taken): @@ -561,9 +578,14 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--scan-level", required=True, choices=["lite", "normal", "deep"]) + i.add_argument("--mode", default="discovery", choices=["discovery", "enactment"], + help="discovery: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--scan-level", choices=["lite", "normal", "deep"], + help="discovery mode only; enactment is always deep") i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) i.add_argument("--language", default=None, help="target language, determined by the orchestrator") + i.add_argument("--findings", default=None, + help="enactment mode: path to the supplied finding manifest/report/directory") i.set_defaults(func=cmd_init) p = sub.add_parser("partition", help="split classification work into per-agent plans") @@ -581,6 +603,7 @@ def main(): f = sub.add_parser("findings", help="seed per-rule finding files from results/report.sarif") f.set_defaults(func=cmd_findings) + args = ap.parse_args() return args.func(args) diff --git a/skills/orchestrate-stage/scripts/get_status.py b/skills/orchestrate-stage/scripts/get_status.py index aca3b06b6..776d20179 100644 --- a/skills/orchestrate-stage/scripts/get_status.py +++ b/skills/orchestrate-stage/scripts/get_status.py @@ -22,19 +22,21 @@ import sys from pathlib import Path -from _common import (APPROX, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, - SOURCES_TR, TRACKING, build_done_keys, classified_keys, - dropped_entries, git_head, load_yaml, member_key, - modeled_entries, skipped_keys) +from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, + PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, + SOURCES_TR, TRACKING, build_done_keys, classified_keys, dropped_entries, + git_head, load_yaml, member_key, modeled_entries, skipped_keys, + strip_quotes) STATE = load_yaml(TRACKING / "state.yaml", {}) or {} +MODE = STATE.get("mode") or "discovery" SCAN_LEVEL = STATE.get("scan_level") TRIAGE_LEVEL = STATE.get("triage_level") DISCOVER_PLANS = RULES_TR / "plans" APPROX_PLANS = APPROX / "plans" VULN = ROOT / "vulnerabilities.md" +ENACTMENT = ROOT / "enactment.md" GLOBAL_CAP = 10 @@ -49,6 +51,12 @@ def load_units(d): if Path(d).is_dir() else [] +def load_docs(d): + """(path, doc) for every tracking file in a directory — reference, boundary, control.""" + return [(p, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ + if Path(d).is_dir() else [] + + def load_joins(): return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(JOINS_TR.glob("*.yaml"))] \ if JOINS_TR.is_dir() else [] @@ -280,7 +288,76 @@ def ph_poc(): return True, [], None -PHASES = [ +# ---- enactment-mode phases ---- + +def ph_reference_set(): + docs = load_docs(REFERENCE_TR) + if not docs: + src = STATE.get("findings") or "state.yaml findings unset" + return False, [f"normalize the supplied findings ({src}) into " + ".opentaint/tracking/reference/.yaml"], None + missing = sorted(p.stem for p, d in docs if not strip_quotes(d.get("family", ""))) + if missing: + return False, ["reference findings not assigned to a boundary family:"] \ + + [f" {m}" for m in missing], None + return True, [], None + + +def families(): + return sorted({strip_quotes(d.get("family", "")) for _, d in load_docs(REFERENCE_TR) + if strip_quotes(d.get("family", ""))}) + + +def ph_boundaries(): + specs = {p.stem: d for p, d in load_docs(BOUNDARIES_TR)} + fams = families() + missing = [f for f in fams if f not in specs] + if missing: + return False, ["dispatch discover-universal-boundaries, one per family:"] \ + + [f" {f}" for f in missing], None + # a split renames the family on its reference findings, so every spec here owns its findings + unsaturated = [f for f in fams + if str((specs[f].get("saturation") or {}).get("status", "")).strip() + != "saturated"] + if unsaturated: + return False, ["boundary specs not saturated:"] + [f" {f}" for f in unsaturated], None + unfactored = sorted(p.stem for p, d in load_docs(REFERENCE_TR) + if p.stem not in (specs.get(strip_quotes(d.get("family", "")), {}) + .get("factorization") or {})) + if unfactored: + return False, ["reference findings with no factorization in their spec:"] \ + + [f" {r}" for r in unfactored], None + unseeded = [f for f in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] + if unseeded: + return False, ["seed the source and sink units from these specs' candidate_patterns:"] \ + + [f" {f}" for f in unseeded], None + return True, [], None + + +def ph_crossref(): + if not SARIF.is_file(): + return False, ["dispatch run-scan"], None + docs = load_docs(REFERENCE_TR) + scanned = SARIF.stat().st_mtime + pend = [p for p, d in docs + if str(d.get("crossref", "pending")).strip() != "done" or p.stat().st_mtime < scanned] + if pend: + return False, [f"cross-reference the scan against {len(pend)} reference finding(s):"] \ + + [f" {p}" for p in pend], None + blocked = sorted({str(m) for _, d in docs for m in (d.get("blocked_at") or [])}) + if blocked: + return False, ["expected traces stop at unmodeled carriers:"] + [f" {m}" for m in blocked] \ + + ["model them in an approximation round, rescan, then cross-reference again"], None + rep = sum(1 for _, d in docs if str(d.get("status", "")).strip() == "reproduced") + stale = newest_mtime([p for p, _ in docs]) > (ENACTMENT.stat().st_mtime + if ENACTMENT.is_file() else 0) + if not ENACTMENT.is_file() or stale: + return False, [f"rewrite .opentaint/enactment.md coverage manifest " + f"({rep}/{len(docs)} reproduced)"], None + return True, [], None + + +DISCOVERY_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), @@ -291,6 +368,25 @@ def ph_poc(): ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), ] +# enactment reproduces a supplied finding set: the reference set and its saturated boundaries +# replace dependency discovery, and both rule sides are authored before the first scan so that +# scan is rule-first. The cross-reference closes the run — it judges what the finished rule set, +# its approximations, its verdicts and its controls actually reproduced. +ENACTMENT_PHASES = [ + ("build", ph_build, lambda: True), + ("reference_set", ph_reference_set, lambda: True), + ("boundaries", ph_boundaries, lambda: True), + ("source_rules", ph_source_rules, lambda: True), + ("sink_rules", ph_sink_rules, lambda: True), + ("scan", ph_scan, lambda: True), + ("approximations", ph_approximations, lambda: True), + ("triage", ph_triage, lambda: True), + ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), + ("crossref", ph_crossref, lambda: True), +] + +PHASES = ENACTMENT_PHASES if MODE == "enactment" else DISCOVERY_PHASES + # ---- caps ---- @@ -329,8 +425,11 @@ def evaluate(): def cmd_full(): commit = short(STATE.get("model_commit")) or "none" - print(f"scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} language={STATE.get('language')} " - f"commit={commit} cap={GLOBAL_CAP} (heavy {heavy_cap()})") + print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " + f"language={STATE.get('language')} commit={commit} " + f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") + if MODE == "enactment": + print(f"findings={STATE.get('findings')}") rows, current = evaluate() # a phase downstream of the current stage that vacuously satisfies its own check is not # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. From 8426f8950dd704dbd4e89f94a6fce7dc61687bd5 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Thu, 30 Jul 2026 12:45:58 +0200 Subject: [PATCH 2/5] feat(docs): Add the enactment pipeline and split the appsec entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenTaint work now runs one of two pipelines over the same machine and the same `.opentaint/` tree, differing only in where the source and sink rules come from: - assessment (`assessment-agent`, renamed from `appsec-agent`) — discovered from the project's dependency attack surface, as before - enactment (`enactment-agent`) — generalized from a supplied finding set, so both rule sides exist before the first scan and that scan is rule-first `appsec-agent` becomes a thin entry point: toolchain and nesting checks, then it picks the pipeline and loads it in the same session (MAIN must keep the build and scans). An existing `state.yaml` decides for it; `mode` is not switchable, since neither pipeline's tracking means anything to the other. The shared phase machine keys off `state.yaml`'s `mode`, so `get_status.py` holds ASSESSMENT_PHASES and ENACTMENT_PHASES and `generate.py init` takes `--mode`. Enactment adds `reference_set`, `boundaries`, and `crossref`; it closes on the cross-reference, which judges each supplied finding by trace identity — never by a rule-id match — and writes `.opentaint/enactment.md`. One more piece both pipelines share: - `discover-universal-boundaries`, the leaf that generalizes one finding family into a saturated source and sink boundary — widen, re-factor the whole family, split rather than listing one pattern branch per finding New tracking: `reference/.yaml` and `boundaries/.yaml`. Rule units keep their existing format and path, so `create-rule` and `create-test-project` are untouched. --- skills-templates/appsec-agent/main.md.j2 | 18 +- .../appsec-agent/sections/choose.md | 22 + .../appsec-agent/sections/handoff.md | 10 + .../appsec-agent/sections/preamble.md | 9 +- .../appsec-agent/sections/setup.md | 29 +- skills-templates/assessment-agent/main.md.j2 | 30 + skills-templates/assessment-agent/scripts | 1 + .../sections/dispatching.md | 0 .../sections/key-constraints.md | 0 .../assessment-agent/sections/preamble.md | 7 + .../assessment-agent/sections/setup.md | 41 ++ .../sections/state-resumption.md | 2 +- .../sections/workflow.md | 0 .../enactment-agent/sections/preamble.md | 4 +- .../enactment-agent/sections/setup.md | 4 +- skills-templates/shared/scripts/_common.py | 2 +- skills-templates/shared/scripts/generate.py | 12 +- skills-templates/shared/scripts/get_status.py | 6 +- skills/appsec-agent/SKILL.md | 130 +--- skills/appsec-agent/scripts/_common.py | 2 +- skills/appsec-agent/scripts/generate.py | 12 +- skills/appsec-agent/scripts/get_status.py | 6 +- skills/assessment-agent/SKILL.md | 152 +++++ skills/assessment-agent/scripts/_common.py | 191 ++++++ skills/assessment-agent/scripts/generate.py | 612 ++++++++++++++++++ skills/assessment-agent/scripts/get_status.py | 470 ++++++++++++++ skills/enactment-agent/SKILL.md | 8 +- skills/enactment-agent/scripts/_common.py | 2 +- skills/enactment-agent/scripts/generate.py | 12 +- skills/enactment-agent/scripts/get_status.py | 6 +- skills/orchestrate-stage/scripts/_common.py | 2 +- skills/orchestrate-stage/scripts/generate.py | 12 +- .../orchestrate-stage/scripts/get_status.py | 6 +- 33 files changed, 1631 insertions(+), 189 deletions(-) create mode 100644 skills-templates/appsec-agent/sections/choose.md create mode 100644 skills-templates/appsec-agent/sections/handoff.md create mode 100644 skills-templates/assessment-agent/main.md.j2 create mode 120000 skills-templates/assessment-agent/scripts rename skills-templates/{appsec-agent => assessment-agent}/sections/dispatching.md (100%) rename skills-templates/{appsec-agent => assessment-agent}/sections/key-constraints.md (100%) create mode 100644 skills-templates/assessment-agent/sections/preamble.md create mode 100644 skills-templates/assessment-agent/sections/setup.md rename skills-templates/{appsec-agent => assessment-agent}/sections/state-resumption.md (97%) rename skills-templates/{appsec-agent => assessment-agent}/sections/workflow.md (100%) create mode 100644 skills/assessment-agent/SKILL.md create mode 100644 skills/assessment-agent/scripts/_common.py create mode 100644 skills/assessment-agent/scripts/generate.py create mode 100644 skills/assessment-agent/scripts/get_status.py diff --git a/skills-templates/appsec-agent/main.md.j2 b/skills-templates/appsec-agent/main.md.j2 index bc58f87ea..0229fb5cc 100644 --- a/skills-templates/appsec-agent/main.md.j2 +++ b/skills-templates/appsec-agent/main.md.j2 @@ -1,6 +1,6 @@ --- name: appsec-agent -description: Run an end-to-end OpenTaint application-security analysis while owning the long project build and scans and delegating each other pipeline stage. Use when the user asks to find vulnerabilities, or scan an application for security issues +description: Entry point for OpenTaint application-security work — confirms the toolchain, picks the pipeline the request needs, and hands off to it. Use when the user asks to find vulnerabilities, scan an application for security issues, reproduce or validate a supplied finding set, or continue an OpenTaint run license: Apache-2.0 metadata: author: opentaint @@ -13,18 +13,10 @@ metadata: {% include "sections/setup.md" %} -## Workflow +## Choose the pipeline -{% include "sections/workflow.md" %} +{% include "sections/choose.md" %} -## Dispatching +## Hand off -{% include "sections/dispatching.md" %} - -## State and resumption - -{% include "sections/state-resumption.md" %} - -## Key constraints - -{% include "sections/key-constraints.md" %} +{% include "sections/handoff.md" %} diff --git a/skills-templates/appsec-agent/sections/choose.md b/skills-templates/appsec-agent/sections/choose.md new file mode 100644 index 000000000..aa7913788 --- /dev/null +++ b/skills-templates/appsec-agent/sections/choose.md @@ -0,0 +1,22 @@ +### An in-flight run decides for you + +If `.opentaint/tracking/state.yaml` already exists, the tree is already committed to a mode and the choice is made — resume that pipeline. Read the mode from status rather than by hand: + +```bash +uv run /scripts/get_status.py --full +``` + +Its header prints `mode=`, the run's levels, and the current phase. `mode=assessment` → `assessment-agent`; `mode=enactment` → `enactment-agent`. Tell the user what's in flight and where it stands before continuing. + +A mode is not switchable: the bootstrap refuses it, because each pipeline's tracking is meaningless to the other — an assessment tree has no reference set behind it, and an enactment tree's rules were never derived from a dependency sweep. If the user genuinely wants the other pipeline over the same project, that is a fresh `.opentaint/` tree, and say so plainly rather than starting one silently. + +### A fresh run + +Decide from what the user brought, then confirm it with them before handing off: + +- **enactment** — they supplied findings, a scanner report, penetration-test results, or source-to-sink traces, and want them reproduced, validated, converted into reusable rules, or cross-checked against OpenTaint. "Does OpenTaint catch these?", "reproduce this report", "turn these findings into rules" +- **assessment** — everything else: no finding set, the goal is what the project is vulnerable to. "Find vulnerabilities", "scan this app", "is this endpoint exploitable?" + +The signal is whether a finding set exists to be measured against, not the vocabulary. A user who says "audit this against last year's pentest" and has the pentest is enactment; a user who says "reproduce the bug I think is in here" and has only a hunch is assessment. + +When it's genuinely ambiguous — a report exists but the user wants new findings too — ask. Don't fold both into one run: pick the pipeline they care about now, and note that the other is a separate run over its own tree. diff --git a/skills-templates/appsec-agent/sections/handoff.md b/skills-templates/appsec-agent/sections/handoff.md new file mode 100644 index 000000000..8607b82bd --- /dev/null +++ b/skills-templates/appsec-agent/sections/handoff.md @@ -0,0 +1,10 @@ +Load the chosen skill in this same session and follow it from its setup: + +``` +assessment → assessment-agent +enactment → enactment-agent +``` + +Not a subagent. MAIN must own the long build and every full-project scan, so the pipeline continues as this session, with the choice above already settled and the toolchain and nesting checks already done. It runs the rest of its own setup — language, levels, bootstrap — and everything after that is its document, not this one. + +Tell the user which pipeline you picked and why, in one line, before you hand off. diff --git a/skills-templates/appsec-agent/sections/preamble.md b/skills-templates/appsec-agent/sections/preamble.md index 81065ad8a..905089782 100644 --- a/skills-templates/appsec-agent/sections/preamble.md +++ b/skills-templates/appsec-agent/sections/preamble.md @@ -1,5 +1,10 @@ # AppSec Agent -Orchestrate an end-to-end OpenTaint security analysis. Keep the long project build and every full-project scan in this main session; delegate each bounded source, approximation, sink, triage, and PoC stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. +The entry point for OpenTaint application-security work. Confirm the environment, decide which of the two pipelines the request needs, and hand off to it in this same session. -OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. The run produces confirmed vulnerabilities plus reusable project-specific rules and approximations under one self-contained `.opentaint/` directory at the project root. +OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. Both pipelines run the same machine — MAIN owns the long build and every full-project scan, `orchestrate-stage` subagents own the bounded stages, and all durable state lives under one self-contained `.opentaint/` directory at the project root. They differ only in where the source and sink rules come from: + +- **assessment** (`assessment-agent`) — find vulnerabilities the project was not known to have. Source and sink rules come from discovering the project's dependency attack surface +- **enactment** (`enactment-agent`) — reproduce a finding set the user supplies, as verified rules. Source and sink rules come from generalizing those findings into reusable boundaries + +This skill does no analysis of its own and writes nothing except by handing off. Don't bootstrap the tree here — each pipeline's own setup does that, and it is what commits the tree to a mode. diff --git a/skills-templates/appsec-agent/sections/setup.md b/skills-templates/appsec-agent/sections/setup.md index 5a51ee615..a34182f60 100644 --- a/skills-templates/appsec-agent/sections/setup.md +++ b/skills-templates/appsec-agent/sections/setup.md @@ -9,31 +9,6 @@ After installing, run `opentaint health` to confirm everything's resolved. ### 2. Confirm agent nesting -This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. +Both pipelines require two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. -### 3. Determine the language - -Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. - -### 4. Choose the workflow - -Ask the user for both knobs together: - -1. Scan level — `lite` · `normal` · `deep` - - lite — build + scan (expected, when there are already existing artifacts) - - normal — build + scan + custom approximations - - deep — build + scan + custom approximations + custom rules - - recommend by what's on disk: a cold start (no `.opentaint` artifacts) → deep; a prior run's artifacts already present → lite -2. Triage level — `static` · `dynamic` - - static — classify findings from the model, no running app - - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option - -### 5. Bootstrap - -Seed the run state and the working tree with the chosen levels and language: - -```bash -uv run /scripts/generate.py init --scan-level --triage-level --language -``` - -It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree. If the user wants a supplied finding set reproduced rather than the project searched for vulnerabilities, that is the enactment pipeline — stop here and load `enactment-agent` instead. +These two checks are the only setup steps the pipeline you hand off to may skip. diff --git a/skills-templates/assessment-agent/main.md.j2 b/skills-templates/assessment-agent/main.md.j2 new file mode 100644 index 000000000..5f23c50a7 --- /dev/null +++ b/skills-templates/assessment-agent/main.md.j2 @@ -0,0 +1,30 @@ +--- +name: assessment-agent +description: Assess a project for unknown vulnerabilities with OpenTaint, owning the long project build and scans and delegating each other pipeline stage. Use when the user asks to find vulnerabilities or scan an application for security issues, with no finding set supplied to reproduce +license: Apache-2.0 +metadata: + author: opentaint + version: "{{ version }}" +--- + +{% include "sections/preamble.md" %} + +## Setup + +{% include "sections/setup.md" %} + +## Workflow + +{% include "sections/workflow.md" %} + +## Dispatching + +{% include "sections/dispatching.md" %} + +## State and resumption + +{% include "sections/state-resumption.md" %} + +## Key constraints + +{% include "sections/key-constraints.md" %} diff --git a/skills-templates/assessment-agent/scripts b/skills-templates/assessment-agent/scripts new file mode 120000 index 000000000..aca7e2caf --- /dev/null +++ b/skills-templates/assessment-agent/scripts @@ -0,0 +1 @@ +../shared/scripts \ No newline at end of file diff --git a/skills-templates/appsec-agent/sections/dispatching.md b/skills-templates/assessment-agent/sections/dispatching.md similarity index 100% rename from skills-templates/appsec-agent/sections/dispatching.md rename to skills-templates/assessment-agent/sections/dispatching.md diff --git a/skills-templates/appsec-agent/sections/key-constraints.md b/skills-templates/assessment-agent/sections/key-constraints.md similarity index 100% rename from skills-templates/appsec-agent/sections/key-constraints.md rename to skills-templates/assessment-agent/sections/key-constraints.md diff --git a/skills-templates/assessment-agent/sections/preamble.md b/skills-templates/assessment-agent/sections/preamble.md new file mode 100644 index 000000000..a38ecee52 --- /dev/null +++ b/skills-templates/assessment-agent/sections/preamble.md @@ -0,0 +1,7 @@ +# Assessment Agent + +Assess a project for vulnerabilities it was not already known to have. Keep the long project build and every full-project scan in this main session; delegate each bounded source, approximation, sink, triage, and PoC stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. + +This is the assessment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `enactment-agent`'s, which reproduces a finding set the user supplies; use that one when there is such a set. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface here, generalized from the supplied findings there. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. + +OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. The run produces confirmed vulnerabilities plus reusable project-specific rules and approximations under one self-contained `.opentaint/` directory at the project root. diff --git a/skills-templates/assessment-agent/sections/setup.md b/skills-templates/assessment-agent/sections/setup.md new file mode 100644 index 000000000..6ad14cfe9 --- /dev/null +++ b/skills-templates/assessment-agent/sections/setup.md @@ -0,0 +1,41 @@ +Skip only what `appsec-agent` already did when it handed off — the toolchain and nesting checks. Everything from step 3 on is this pipeline's own, including the bootstrap. + +### 1. Confirm the toolchain + +Confirm `opentaint` is on PATH with `opentaint -v`. If it's missing, don't proceed silently — tell the user and offer the install command for their platform, run an install only on explicit confirmation: + +- macOS / Linux, in order: `brew install --cask seqra/tap/opentaint` · `npm install -g @seqra/opentaint` +- Windows: `npm install -g @seqra/opentaint` + +After installing, run `opentaint health` to confirm everything's resolved. + +### 2. Confirm agent nesting + +This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. + +### 3. Determine the language + +Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. + +### 4. Choose the workflow + +Ask the user for both knobs together: + +1. Scan level — `lite` · `normal` · `deep` + - lite — build + scan (expected, when there are already existing artifacts) + - normal — build + scan + custom approximations + - deep — build + scan + custom approximations + custom rules + - recommend by what's on disk: a cold start (no `.opentaint` artifacts) → deep; a prior run's artifacts already present → lite +2. Triage level — `static` · `dynamic` + - static — classify findings from the model, no running app + - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option + +### 5. Bootstrap + +Seed the run state and the working tree with the chosen levels and language: + +```bash +uv run /scripts/generate.py init --scan-level --triage-level --language +``` + +It writes `state.yaml` with `mode: assessment`, seeds `history.yaml`, and creates the `.opentaint/` tree. diff --git a/skills-templates/appsec-agent/sections/state-resumption.md b/skills-templates/assessment-agent/sections/state-resumption.md similarity index 97% rename from skills-templates/appsec-agent/sections/state-resumption.md rename to skills-templates/assessment-agent/sections/state-resumption.md index 7b5414109..27cd0c37f 100644 --- a/skills-templates/appsec-agent/sections/state-resumption.md +++ b/skills-templates/assessment-agent/sections/state-resumption.md @@ -18,7 +18,7 @@ The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` deriv `state.yaml` shape: ```yaml -mode: discovery +mode: assessment scan_level: deep triage_level: dynamic language: java diff --git a/skills-templates/appsec-agent/sections/workflow.md b/skills-templates/assessment-agent/sections/workflow.md similarity index 100% rename from skills-templates/appsec-agent/sections/workflow.md rename to skills-templates/assessment-agent/sections/workflow.md diff --git a/skills-templates/enactment-agent/sections/preamble.md b/skills-templates/enactment-agent/sections/preamble.md index f0c26ea72..872c173ca 100644 --- a/skills-templates/enactment-agent/sections/preamble.md +++ b/skills-templates/enactment-agent/sections/preamble.md @@ -2,6 +2,8 @@ Reproduce a supplied set of findings with OpenTaint. Every finding ends the run either matched by a verified OpenTaint result whose trace carries the finding's own identity, or recorded with the exact rule, modeling, or engine limitation that stopped it. Keep the long project build and every full-project scan in this main session; delegate each bounded stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. -This is the enactment pipeline — the same machine as `appsec-agent`, and the same `.opentaint/` tree, differing in how the source and sink rules are produced. There, they come from discovering the project's dependency attack surface; here, from generalizing the supplied findings into reusable boundaries, so both sides exist before the first scan and that scan is rule-first. Use `appsec-agent` instead when the goal is to find unknown vulnerabilities. +This is the enactment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `assessment-agent`'s, which searches the project for vulnerabilities it was not known to have; use that one when nothing was supplied to reproduce. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface there, generalized from the supplied findings here, so both sides exist before the first scan and that scan is rule-first. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. + +You may be loaded directly, or handed off by `appsec-agent` once it identified the request as enactment. Either way the setup below is this pipeline's, and running it is what commits the tree to `mode: enactment`. No finding is dropped for being a poor fit for taint analysis. Authorization, integrity, configuration, hard-coded-secret, and structural-control findings are modeled as explicit pseudo-taint boundaries. diff --git a/skills-templates/enactment-agent/sections/setup.md b/skills-templates/enactment-agent/sections/setup.md index 9041b7dfc..96825ea39 100644 --- a/skills-templates/enactment-agent/sections/setup.md +++ b/skills-templates/enactment-agent/sections/setup.md @@ -1,3 +1,5 @@ +Skip only what `appsec-agent` already did when it handed off — the toolchain and nesting checks. Everything from step 3 on is this pipeline's own, including the bootstrap. + ### 1. Confirm the toolchain Confirm `opentaint` is on PATH with `opentaint -v`. If it's missing, don't proceed silently — tell the user and offer the install command for their platform, run an install only on explicit confirmation: @@ -37,4 +39,4 @@ Seed the run state and the working tree: uv run /scripts/generate.py init --mode enactment --triage-level --language --findings ``` -It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. It refuses to convert an existing discovery run — that tracking has no reference set behind it, so enactment starts in its own project tree. +It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. It refuses to convert an existing assessment run — that tracking has no reference set behind it, so enactment starts in its own project tree. diff --git a/skills-templates/shared/scripts/_common.py b/skills-templates/shared/scripts/_common.py index 8c7f0d5dd..cd0873805 100644 --- a/skills-templates/shared/scripts/_common.py +++ b/skills-templates/shared/scripts/_common.py @@ -1,4 +1,4 @@ -"""Shared helpers for the appsec-agent orchestrator scripts. +"""Shared helpers for the OpenTaint pipeline orchestrator scripts. Not a runnable script — imported by the PEP723 entry points (get_status.py, generate.py), which carry the pyyaml dependency. Every path resolves under the fixed diff --git a/skills-templates/shared/scripts/generate.py b/skills-templates/shared/scripts/generate.py index b9de7adc3..7d8af1590 100644 --- a/skills-templates/shared/scripts/generate.py +++ b/skills-templates/shared/scripts/generate.py @@ -56,7 +56,7 @@ def cmd_init(args): if enactment and not args.findings: raise SystemExit("init --mode enactment requires --findings ") if not enactment and not args.scan_level: - raise SystemExit("init --mode discovery requires --scan-level") + raise SystemExit("init --mode assessment requires --scan-level") # enactment reproduces a supplied finding set, which always needs the full rule + approximation # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. scan_level = "deep" if enactment else args.scan_level @@ -66,8 +66,8 @@ def cmd_init(args): state_path = TRACKING / "state.yaml" prior = load_yaml(state_path, {}) or {} resume = bool(prior) - if resume and prior.get("mode", "discovery") != args.mode: - raise SystemExit(f"{state_path} is a {prior.get('mode', 'discovery')} run — a mode switch " + if resume and prior.get("mode", "assessment") != args.mode: + raise SystemExit(f"{state_path} is a {prior.get('mode', 'assessment')} run — a mode switch " "would strand its tracking; start the other mode in a fresh project tree") state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} @@ -578,10 +578,10 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--mode", default="discovery", choices=["discovery", "enactment"], - help="discovery: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--mode", default="assessment", choices=["assessment", "enactment"], + help="assessment: find vulnerabilities. enactment: reproduce supplied findings") i.add_argument("--scan-level", choices=["lite", "normal", "deep"], - help="discovery mode only; enactment is always deep") + help="assessment mode only; enactment is always deep") i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) i.add_argument("--language", default=None, help="target language, determined by the orchestrator") i.add_argument("--findings", default=None, diff --git a/skills-templates/shared/scripts/get_status.py b/skills-templates/shared/scripts/get_status.py index 776d20179..53ae0ff07 100644 --- a/skills-templates/shared/scripts/get_status.py +++ b/skills-templates/shared/scripts/get_status.py @@ -29,7 +29,7 @@ strip_quotes) STATE = load_yaml(TRACKING / "state.yaml", {}) or {} -MODE = STATE.get("mode") or "discovery" +MODE = STATE.get("mode") or "assessment" SCAN_LEVEL = STATE.get("scan_level") TRIAGE_LEVEL = STATE.get("triage_level") @@ -357,7 +357,7 @@ def ph_crossref(): return True, [], None -DISCOVERY_PHASES = [ +ASSESSMENT_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), @@ -385,7 +385,7 @@ def ph_crossref(): ("crossref", ph_crossref, lambda: True), ] -PHASES = ENACTMENT_PHASES if MODE == "enactment" else DISCOVERY_PHASES +PHASES = ENACTMENT_PHASES if MODE == "enactment" else ASSESSMENT_PHASES # ---- caps ---- diff --git a/skills/appsec-agent/SKILL.md b/skills/appsec-agent/SKILL.md index 4f31494f0..21cb3653f 100644 --- a/skills/appsec-agent/SKILL.md +++ b/skills/appsec-agent/SKILL.md @@ -1,6 +1,6 @@ --- name: appsec-agent -description: Run an end-to-end OpenTaint application-security analysis while owning the long project build and scans and delegating each other pipeline stage. Use when the user asks to find vulnerabilities, or scan an application for security issues +description: Entry point for OpenTaint application-security work — confirms the toolchain, picks the pipeline the request needs, and hands off to it. Use when the user asks to find vulnerabilities, scan an application for security issues, reproduce or validate a supplied finding set, or continue an OpenTaint run license: Apache-2.0 metadata: author: opentaint @@ -9,9 +9,14 @@ metadata: # AppSec Agent -Orchestrate an end-to-end OpenTaint security analysis. Keep the long project build and every full-project scan in this main session; delegate each bounded source, approximation, sink, triage, and PoC stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. +The entry point for OpenTaint application-security work. Confirm the environment, decide which of the two pipelines the request needs, and hand off to it in this same session. -OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. The run produces confirmed vulnerabilities plus reusable project-specific rules and approximations under one self-contained `.opentaint/` directory at the project root. +OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. Both pipelines run the same machine — MAIN owns the long build and every full-project scan, `orchestrate-stage` subagents own the bounded stages, and all durable state lives under one self-contained `.opentaint/` directory at the project root. They differ only in where the source and sink rules come from: + +- **assessment** (`assessment-agent`) — find vulnerabilities the project was not known to have. Source and sink rules come from discovering the project's dependency attack surface +- **enactment** (`enactment-agent`) — reproduce a finding set the user supplies, as verified rules. Source and sink rules come from generalizing those findings into reusable boundaries + +This skill does no analysis of its own and writes nothing except by handing off. Don't bootstrap the tree here — each pipeline's own setup does that, and it is what commits the tree to a mode. ## Setup @@ -26,123 +31,44 @@ After installing, run `opentaint health` to confirm everything's resolved. ### 2. Confirm agent nesting -This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. - -### 3. Determine the language - -Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. +Both pipelines require two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. -### 4. Choose the workflow +These two checks are the only setup steps the pipeline you hand off to may skip. -Ask the user for both knobs together: +## Choose the pipeline -1. Scan level — `lite` · `normal` · `deep` - - lite — build + scan (expected, when there are already existing artifacts) - - normal — build + scan + custom approximations - - deep — build + scan + custom approximations + custom rules - - recommend by what's on disk: a cold start (no `.opentaint` artifacts) → deep; a prior run's artifacts already present → lite -2. Triage level — `static` · `dynamic` - - static — classify findings from the model, no running app - - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option +### An in-flight run decides for you -### 5. Bootstrap - -Seed the run state and the working tree with the chosen levels and language: +If `.opentaint/tracking/state.yaml` already exists, the tree is already committed to a mode and the choice is made — resume that pipeline. Read the mode from status rather than by hand: ```bash -uv run /scripts/generate.py init --scan-level --triage-level --language +uv run /scripts/get_status.py --full ``` -It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree. If the user wants a supplied finding set reproduced rather than the project searched for vulnerabilities, that is the enactment pipeline — stop here and load `enactment-agent` instead. - -## Workflow - -The run is one fixed pipeline; the selected levels determine which phases are in scope. Use `uv run /scripts/get_status.py` to choose the next action: - -``` -build → MAIN: build -discover / source_rules → stage subagent: sources -scan → MAIN: scan -approximations → stage subagent: approx-round, then MAIN: rescan; repeat -sink_rules → stage subagent: sinks, then MAIN: rescan -triage → stage subagent: triage -poc → stage subagent: poc -``` - -### Build in MAIN - -When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. - -Record the returned `build_jdk` in `.opentaint/tracking/state.yaml`. Record `model_commit` as the full HEAD only when no source file is uncommitted, otherwise set it to null. Build non-convergence blocks the run because no later phase can proceed without the model. +Its header prints `mode=`, the run's levels, and the current phase. `mode=assessment` → `assessment-agent`; `mode=enactment` → `enactment-agent`. Tell the user what's in flight and where it stands before continuing. -### Scan in MAIN +A mode is not switchable: the bootstrap refuses it, because each pipeline's tracking is meaningless to the other — an assessment tree has no reference set behind it, and an enactment tree's rules were never derived from a dependency sweep. If the user genuinely wants the other pipeline over the same project, that is a fresh `.opentaint/` tree, and say so plainly rather than starting one silently. -When status reports `scan`, or a stage returns with a rescan pending, load and follow the `run-scan` skill in this main session. Start the scan with the harness's main-session background-command facility, keep the engine's self-timeout, add a 1200-second outer backstop, and wait for the process completion event. +### A fresh run -A valid `.opentaint/results/report.sarif` means the scan completed, including exit 254 after an engine timeout. Record `max_memory: 16G` when the scan had to bump memory and reuse it on later scans. If no SARIF exists after the allowed retry/backstop, follow the repair path below for a malformed rule/approximation; otherwise dispatch `orchestrate-stage` with `stage: escalation` and the scan `setup` to write the scan-wide resource issue, then stop. +Decide from what the user brought, then confirm it with them before handing off: -When a scan or later stage reports a malformed approximation, unloadable created rule, ineffective join, or a created rule's false positive/negative, route the exact diagnosis and artifact path/id to the responsible stage agent per Dispatching, then scan again in MAIN. +- **enactment** — they supplied findings, a scanner report, penetration-test results, or source-to-sink traces, and want them reproduced, validated, converted into reusable rules, or cross-checked against OpenTaint. "Does OpenTaint catch these?", "reproduce this report", "turn these findings into rules" +- **assessment** — everything else: no finding set, the goal is what the project is vulnerable to. "Find vulnerabilities", "scan this app", "is this endpoint exploitable?" -After every build, scan, or stage return, run `uv run /scripts/get_status.py` once to choose the next action. Use `--full` at run start, on resume, or when the brief output does not settle the question. +The signal is whether a finding set exists to be measured against, not the vocabulary. A user who says "audit this against last year's pentest" and has the pentest is enactment; a user who says "reproduce the bug I think is in here" and has only a hunch is assessment. -## Dispatching +When it's genuinely ambiguous — a report exists but the user wants new findings too — ask. Don't fold both into one run: pick the pipeline they care about now, and note that the other is a separate run over its own tree. -Dispatch exactly one stage-orchestrator subagent for each stage invocation: +## Hand off -``` -Invoke the Skill orchestrate-stage first, then follow its instructions precisely -Inputs: - stage: -``` - -For a `deep` approximation round, also pass `sinks: true`. A subagent inherits the project-root working directory, so omit `project-root`. - -Stage context: - -- `sources` — discover dependency sources, author their rules, and wire the joins -- `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier -- `sinks` — author classified sink rules and wire the joins -- `triage` — classify the latest findings and refresh the vulnerability report -- `poc` — reproduce confirmed findings and add the outcomes to the report -- `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure - -Keep each agent id until the next scan validates its artifacts. On a stage-owned error, resume that agent with `stage: escalation`, the exact error, and the artifact path/id. If its thread is unavailable, start a re-entrant `orchestrate-stage` agent with that diagnosis. - -Dispatch each subagent fresh, don't fork context into it. Then wait for it natively, don't monitor or poll every minute. If the harness forces a wait timeout, set it to ~1h and re-wait when it returns. +Load the chosen skill in this same session and follow it from its setup: -## State and resumption - -Use this ownership map to route work and scan errors: - -``` -.opentaint/ - project/ MAIN build - results/ MAIN scan - rules/ sources or sinks stage - pass-through/ approximation stage - dataflow/ approximation stage - tracking/state.yaml MAIN run knobs - tracking/ stage agents, leaves, and join scripts otherwise - vulnerabilities.md triage / PoC stage - issues/ escalation stage ``` - -The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan. - -`state.yaml` shape: - -```yaml -mode: discovery -scan_level: deep -triage_level: dynamic -language: java -model_commit: 0123456789abcdef0123456789abcdef01234567 -build_jdk: null -max_memory: null +assessment → assessment-agent +enactment → enactment-agent ``` -## Key constraints +Not a subagent. MAIN must own the long build and every full-project scan, so the pipeline continues as this session, with the choice above already settled and the toolchain and nesting checks already done. It runs the rest of its own setup — language, levels, bootstrap — and everything after that is its document, not this one. -- read pipeline state through `/scripts/get_status.py`, not by hand — don't re-derive it with glob/grep/`python3 -c`/yaml scans over `.opentaint/tracking`, `results`, or the `*.yaml`, nor open finding/unit/SARIF files just to review progress. If its output doesn't settle the question, re-run it with `--full` before opening any file -- don't author or edit stage-owned artifacts or tracking; MAIN writes only `model_commit`, `build_jdk`, and `max_memory` in `state.yaml` -- keep one generated project model for the run; never hand-edit or replace it mid-analysis — fix the build and rebuild before starting a new run +Tell the user which pipeline you picked and why, in one line, before you hand off. diff --git a/skills/appsec-agent/scripts/_common.py b/skills/appsec-agent/scripts/_common.py index 8c7f0d5dd..cd0873805 100644 --- a/skills/appsec-agent/scripts/_common.py +++ b/skills/appsec-agent/scripts/_common.py @@ -1,4 +1,4 @@ -"""Shared helpers for the appsec-agent orchestrator scripts. +"""Shared helpers for the OpenTaint pipeline orchestrator scripts. Not a runnable script — imported by the PEP723 entry points (get_status.py, generate.py), which carry the pyyaml dependency. Every path resolves under the fixed diff --git a/skills/appsec-agent/scripts/generate.py b/skills/appsec-agent/scripts/generate.py index b9de7adc3..7d8af1590 100644 --- a/skills/appsec-agent/scripts/generate.py +++ b/skills/appsec-agent/scripts/generate.py @@ -56,7 +56,7 @@ def cmd_init(args): if enactment and not args.findings: raise SystemExit("init --mode enactment requires --findings ") if not enactment and not args.scan_level: - raise SystemExit("init --mode discovery requires --scan-level") + raise SystemExit("init --mode assessment requires --scan-level") # enactment reproduces a supplied finding set, which always needs the full rule + approximation # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. scan_level = "deep" if enactment else args.scan_level @@ -66,8 +66,8 @@ def cmd_init(args): state_path = TRACKING / "state.yaml" prior = load_yaml(state_path, {}) or {} resume = bool(prior) - if resume and prior.get("mode", "discovery") != args.mode: - raise SystemExit(f"{state_path} is a {prior.get('mode', 'discovery')} run — a mode switch " + if resume and prior.get("mode", "assessment") != args.mode: + raise SystemExit(f"{state_path} is a {prior.get('mode', 'assessment')} run — a mode switch " "would strand its tracking; start the other mode in a fresh project tree") state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} @@ -578,10 +578,10 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--mode", default="discovery", choices=["discovery", "enactment"], - help="discovery: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--mode", default="assessment", choices=["assessment", "enactment"], + help="assessment: find vulnerabilities. enactment: reproduce supplied findings") i.add_argument("--scan-level", choices=["lite", "normal", "deep"], - help="discovery mode only; enactment is always deep") + help="assessment mode only; enactment is always deep") i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) i.add_argument("--language", default=None, help="target language, determined by the orchestrator") i.add_argument("--findings", default=None, diff --git a/skills/appsec-agent/scripts/get_status.py b/skills/appsec-agent/scripts/get_status.py index 776d20179..53ae0ff07 100644 --- a/skills/appsec-agent/scripts/get_status.py +++ b/skills/appsec-agent/scripts/get_status.py @@ -29,7 +29,7 @@ strip_quotes) STATE = load_yaml(TRACKING / "state.yaml", {}) or {} -MODE = STATE.get("mode") or "discovery" +MODE = STATE.get("mode") or "assessment" SCAN_LEVEL = STATE.get("scan_level") TRIAGE_LEVEL = STATE.get("triage_level") @@ -357,7 +357,7 @@ def ph_crossref(): return True, [], None -DISCOVERY_PHASES = [ +ASSESSMENT_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), @@ -385,7 +385,7 @@ def ph_crossref(): ("crossref", ph_crossref, lambda: True), ] -PHASES = ENACTMENT_PHASES if MODE == "enactment" else DISCOVERY_PHASES +PHASES = ENACTMENT_PHASES if MODE == "enactment" else ASSESSMENT_PHASES # ---- caps ---- diff --git a/skills/assessment-agent/SKILL.md b/skills/assessment-agent/SKILL.md new file mode 100644 index 000000000..325e175b5 --- /dev/null +++ b/skills/assessment-agent/SKILL.md @@ -0,0 +1,152 @@ +--- +name: assessment-agent +description: Assess a project for unknown vulnerabilities with OpenTaint, owning the long project build and scans and delegating each other pipeline stage. Use when the user asks to find vulnerabilities or scan an application for security issues, with no finding set supplied to reproduce +license: Apache-2.0 +metadata: + author: opentaint + version: "0.3.0" +--- + +# Assessment Agent + +Assess a project for vulnerabilities it was not already known to have. Keep the long project build and every full-project scan in this main session; delegate each bounded source, approximation, sink, triage, and PoC stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. + +This is the assessment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `enactment-agent`'s, which reproduces a finding set the user supplies; use that one when there is such a set. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface here, generalized from the supplied findings there. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. + +OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. The run produces confirmed vulnerabilities plus reusable project-specific rules and approximations under one self-contained `.opentaint/` directory at the project root. + +## Setup + +Skip only what `appsec-agent` already did when it handed off — the toolchain and nesting checks. Everything from step 3 on is this pipeline's own, including the bootstrap. + +### 1. Confirm the toolchain + +Confirm `opentaint` is on PATH with `opentaint -v`. If it's missing, don't proceed silently — tell the user and offer the install command for their platform, run an install only on explicit confirmation: + +- macOS / Linux, in order: `brew install --cask seqra/tap/opentaint` · `npm install -g @seqra/opentaint` +- Windows: `npm install -g @seqra/opentaint` + +After installing, run `opentaint health` to confirm everything's resolved. + +### 2. Confirm agent nesting + +This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. + +### 3. Determine the language + +Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. + +### 4. Choose the workflow + +Ask the user for both knobs together: + +1. Scan level — `lite` · `normal` · `deep` + - lite — build + scan (expected, when there are already existing artifacts) + - normal — build + scan + custom approximations + - deep — build + scan + custom approximations + custom rules + - recommend by what's on disk: a cold start (no `.opentaint` artifacts) → deep; a prior run's artifacts already present → lite +2. Triage level — `static` · `dynamic` + - static — classify findings from the model, no running app + - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option + +### 5. Bootstrap + +Seed the run state and the working tree with the chosen levels and language: + +```bash +uv run /scripts/generate.py init --scan-level --triage-level --language +``` + +It writes `state.yaml` with `mode: assessment`, seeds `history.yaml`, and creates the `.opentaint/` tree. + +## Workflow + +The run is one fixed pipeline; the selected levels determine which phases are in scope. Use `uv run /scripts/get_status.py` to choose the next action: + +``` +build → MAIN: build +discover / source_rules → stage subagent: sources +scan → MAIN: scan +approximations → stage subagent: approx-round, then MAIN: rescan; repeat +sink_rules → stage subagent: sinks, then MAIN: rescan +triage → stage subagent: triage +poc → stage subagent: poc +``` + +### Build in MAIN + +When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. + +Record the returned `build_jdk` in `.opentaint/tracking/state.yaml`. Record `model_commit` as the full HEAD only when no source file is uncommitted, otherwise set it to null. Build non-convergence blocks the run because no later phase can proceed without the model. + +### Scan in MAIN + +When status reports `scan`, or a stage returns with a rescan pending, load and follow the `run-scan` skill in this main session. Start the scan with the harness's main-session background-command facility, keep the engine's self-timeout, add a 1200-second outer backstop, and wait for the process completion event. + +A valid `.opentaint/results/report.sarif` means the scan completed, including exit 254 after an engine timeout. Record `max_memory: 16G` when the scan had to bump memory and reuse it on later scans. If no SARIF exists after the allowed retry/backstop, follow the repair path below for a malformed rule/approximation; otherwise dispatch `orchestrate-stage` with `stage: escalation` and the scan `setup` to write the scan-wide resource issue, then stop. + +When a scan or later stage reports a malformed approximation, unloadable created rule, ineffective join, or a created rule's false positive/negative, route the exact diagnosis and artifact path/id to the responsible stage agent per Dispatching, then scan again in MAIN. + +After every build, scan, or stage return, run `uv run /scripts/get_status.py` once to choose the next action. Use `--full` at run start, on resume, or when the brief output does not settle the question. + +## Dispatching + +Dispatch exactly one stage-orchestrator subagent for each stage invocation: + +``` +Invoke the Skill orchestrate-stage first, then follow its instructions precisely +Inputs: + stage: +``` + +For a `deep` approximation round, also pass `sinks: true`. A subagent inherits the project-root working directory, so omit `project-root`. + +Stage context: + +- `sources` — discover dependency sources, author their rules, and wire the joins +- `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier +- `sinks` — author classified sink rules and wire the joins +- `triage` — classify the latest findings and refresh the vulnerability report +- `poc` — reproduce confirmed findings and add the outcomes to the report +- `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure + +Keep each agent id until the next scan validates its artifacts. On a stage-owned error, resume that agent with `stage: escalation`, the exact error, and the artifact path/id. If its thread is unavailable, start a re-entrant `orchestrate-stage` agent with that diagnosis. + +Dispatch each subagent fresh, don't fork context into it. Then wait for it natively, don't monitor or poll every minute. If the harness forces a wait timeout, set it to ~1h and re-wait when it returns. + +## State and resumption + +Use this ownership map to route work and scan errors: + +``` +.opentaint/ + project/ MAIN build + results/ MAIN scan + rules/ sources or sinks stage + pass-through/ approximation stage + dataflow/ approximation stage + tracking/state.yaml MAIN run knobs + tracking/ stage agents, leaves, and join scripts otherwise + vulnerabilities.md triage / PoC stage + issues/ escalation stage +``` + +The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan. + +`state.yaml` shape: + +```yaml +mode: assessment +scan_level: deep +triage_level: dynamic +language: java +model_commit: 0123456789abcdef0123456789abcdef01234567 +build_jdk: null +max_memory: null +``` + +## Key constraints + +- read pipeline state through `/scripts/get_status.py`, not by hand — don't re-derive it with glob/grep/`python3 -c`/yaml scans over `.opentaint/tracking`, `results`, or the `*.yaml`, nor open finding/unit/SARIF files just to review progress. If its output doesn't settle the question, re-run it with `--full` before opening any file +- don't author or edit stage-owned artifacts or tracking; MAIN writes only `model_commit`, `build_jdk`, and `max_memory` in `state.yaml` +- keep one generated project model for the run; never hand-edit or replace it mid-analysis — fix the build and rebuild before starting a new run diff --git a/skills/assessment-agent/scripts/_common.py b/skills/assessment-agent/scripts/_common.py new file mode 100644 index 000000000..cd0873805 --- /dev/null +++ b/skills/assessment-agent/scripts/_common.py @@ -0,0 +1,191 @@ +"""Shared helpers for the OpenTaint pipeline orchestrator scripts. + +Not a runnable script — imported by the PEP723 entry points (get_status.py, generate.py), +which carry the pyyaml dependency. Every path resolves under the fixed +/.opentaint/ tree relative to the current directory, so run the entry +scripts from the project root. +""" +import glob +import re +import subprocess +from pathlib import Path + +import yaml + +ROOT = Path(".opentaint") +MODEL = ROOT / "project" +TRACKING = ROOT / "tracking" +APPROX = TRACKING / "approximations" +RULES_TR = TRACKING / "rules" +SOURCES_TR = RULES_TR / "sources" +SINKS_TR = RULES_TR / "sinks" +JOINS_TR = RULES_TR / "joins" +FINDINGS_TR = TRACKING / "findings" +REFERENCE_TR = TRACKING / "reference" # enactment mode: the supplied findings, normalized +BOUNDARIES_TR = TRACKING / "boundaries" # enactment mode: per-family boundary specs +RESULTS = ROOT / "results" +DROPPED = RESULTS / "dropped-external-methods.yaml" +SARIF = RESULTS / "report.sarif" +RULES = ROOT / "rules" +PASS_THROUGH = ROOT / "pass-through" +DATAFLOW = ROOT / "dataflow" + + +# ---- yaml io ---- + +def load_yaml(path, default=None): + p = Path(path) + if not p.is_file(): + return default + try: + return yaml.safe_load(p.read_text(encoding="utf-8")) or default + except yaml.YAMLError as e: + raise SystemExit(f"{p}: invalid YAML — {e}\n" + " a JVM signature containing '[' must be quoted in flow style " + "(signature: \"([BLjava/lang/String;)V\")") + + +def dump_yaml(obj): + return yaml.safe_dump(obj, sort_keys=False, default_flow_style=False, allow_unicode=True) + + +# ---- fqn / member normalization ---- + +def strip_quotes(s): + return str(s).strip().strip('"').strip("'") + + +def fqn_base(s): + """The method fqn without its signature/params — `a.b.C#m`.""" + s = strip_quotes(s) + i = s.find("(") + return (s[:i] if i != -1 else s).strip() + + +def member_of(item): + """A bucket/plan entry (dict or str) normalized to {method, signature?}.""" + if isinstance(item, dict): + m = strip_quotes(item.get("method", "")) + sig = str(item.get("signature", "")).strip() + return {"method": m, "signature": sig} if sig else {"method": m} + return {"method": strip_quotes(item)} + + +def member_key(item): + """Overload-precise key: method + signature. Matches the classification ledger.""" + if isinstance(item, dict): + return f"{strip_quotes(item.get('method', ''))}{str(item.get('signature', '')).strip()}" + return strip_quotes(item) + + +def class_of(fqn): + return fqn_base(fqn).split("#", 1)[0].strip() + + +def package_of(fqn): + cls = class_of(fqn) + return cls.rsplit(".", 1)[0] if "." in cls else "" + + +# ---- git ---- + +def git_head(): + """HEAD commit of the project tree, or None when there's no repo.""" + try: + out = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, + text=True, check=True) + return out.stdout.strip() or None + except (OSError, subprocess.CalledProcessError): + return None + + +# ---- approximation batch readers (shared by coverage + partition) ---- + +# a method counts classified once it sits in any batch classification bucket or in build.done +CLASSIFIED_BUCKETS = ("passthrough", "dataflow", "skipped", "engine_issues") +MODELED_BUCKETS = ("passthrough", "dataflow") + + +def batch_files(): + """Every approximation batch file (skipped.yaml is the merged view, not a batch).""" + return [Path(p) for p in sorted(glob.glob(str(APPROX / "*.yaml"))) + if Path(p).name != "skipped.yaml"] + + +def dropped_entries(): + """The dropped external methods as {method, signature?} rows (order preserved).""" + rows = [] + for e in load_yaml(DROPPED, []) or []: + if isinstance(e, dict) and e.get("method"): + row = {"method": strip_quotes(e["method"])} + if e.get("signature"): + row["signature"] = str(e["signature"]).strip() + rows.append(row) + return rows + + +def classified_keys(): + """method+signature keys of every method already classified across the batch files. + Overload-precise (matches the ledger, build.done, unbuilt and stuck checks): a method with + one overload classified does not mask a differently-propagating overload still dropped.""" + out = set() + for p in batch_files(): + doc = load_yaml(p, {}) or {} + for key in CLASSIFIED_BUCKETS: + for item in doc.get(key, []) or []: + if str(item).strip(): + out.add(member_key(item)) + for item in (doc.get("build") or {}).get("done", []) or []: + if str(item).strip(): + out.add(member_key(item)) + return out + + +def skipped_keys(): + """method+signature keys classified terminal — the `skipped` and `engine_issues` buckets of + every batch. Terminal means the method will never build a working carrier, so it must not hold + the approximations phase pending even if it is still modeled and still dropped by the scan. + (skipped.yaml is the merged view of these same buckets, so reading the batches alone suffices.)""" + out = set() + for p in batch_files(): + doc = load_yaml(p, {}) or {} + for bucket in ("skipped", "engine_issues"): + for item in doc.get(bucket, []) or []: + if str(item).strip(): + out.add(member_key(item)) + return out + + +def modeled_entries(): + """Every passthrough/dataflow entry across batches, tagged with its batch file.""" + out = [] + for p in batch_files(): + doc = load_yaml(p, {}) or {} + for kind in MODELED_BUCKETS: + for item in doc.get(kind, []) or []: + if str(item).strip(): + out.append((p, kind, member_of(item))) + return out + + +def build_done_keys(): + """Overload-precise keys of every built approximation across batches.""" + keys = set() + for p in batch_files(): + doc = load_yaml(p, {}) or {} + for item in (doc.get("build") or {}).get("done", []) or []: + if str(item).strip(): + keys.add(member_key(item)) + return keys + + +# ---- finding files ---- + +RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) +VERDICT_RE = re.compile(r'^verdict:\s*(.+?)\s*$', re.M) + + +def ledger_verdicted_keys(): + """method+signature keys already verdicted in classification.yaml (source ∪ safe).""" + doc = load_yaml(RULES_TR / "classification.yaml", {}) or {} + return {strip_quotes(x) for key in ("source", "safe") for x in (doc.get(key) or [])} diff --git a/skills/assessment-agent/scripts/generate.py b/skills/assessment-agent/scripts/generate.py new file mode 100644 index 000000000..7d8af1590 --- /dev/null +++ b/skills/assessment-agent/scripts/generate.py @@ -0,0 +1,612 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = ["pyyaml==6.0.3"] +# /// +""" +generate.py — the orchestrator's writing helper. Every subcommand mutates durable +state at a fan-out join; none is read-only (use get_status.py for checks). Run with uv +from the project root: `uv run scripts/generate.py `. + + init bootstrap the .opentaint tree + state.yaml from the workflow flags + partition analyze dropped external methods -> per-root batch plans (approximations) + partition discover coverage.yaml's used members -> balanced discover plans + mark-safe discover plans' verdicts -> classification.yaml ledger (+prune plans) + merge-skipped batch skipped/engine_issues -> approximations/skipped.yaml (+prune plans) + findings results/report.sarif -> per-rule finding tracking files (idempotent) +""" +import argparse +import glob +import hashlib +import json +import math +import re +import subprocess +import sys +from pathlib import Path + +import yaml + +from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, + PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, + SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, + dropped_entries, dump_yaml, fqn_base, git_head, ledger_verdicted_keys, + load_yaml, member_key, package_of, strip_quotes) + +ANALYZE_BUDGET = 20 # methods per approximation batch +ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch +ROOT_DEPTH = 2 # library root = first 2 dotted segments +DISCOVER_TARGET, DISCOVER_BAND = 50, 15 # project-used members per discover plan (~50, loose) + +DISCOVER_PLANS = RULES_TR / "plans" +APPROX_PLANS = APPROX / "plans" + + +# ---- init: bootstrap the working tree + state.yaml ---- + +# the durable directories a run writes into; the leaves/scripts mkdir on write, but seeding +# them up front gives every stage a place to land and makes the empty tree self-describing. +INIT_DIRS = [TRACKING, APPROX, SOURCES_TR, SINKS_TR, JOINS_TR, FINDINGS_TR, + RESULTS, RULES, PASS_THROUGH, DATAFLOW] +ENACTMENT_DIRS = [REFERENCE_TR, BOUNDARIES_TR] # enactment mode only +STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves + + +def cmd_init(args): + enactment = args.mode == "enactment" + if enactment and not args.findings: + raise SystemExit("init --mode enactment requires --findings ") + if not enactment and not args.scan_level: + raise SystemExit("init --mode assessment requires --scan-level") + # enactment reproduces a supplied finding set, which always needs the full rule + approximation + # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. + scan_level = "deep" if enactment else args.scan_level + + for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): + d.mkdir(parents=True, exist_ok=True) + state_path = TRACKING / "state.yaml" + prior = load_yaml(state_path, {}) or {} + resume = bool(prior) + if resume and prior.get("mode", "assessment") != args.mode: + raise SystemExit(f"{state_path} is a {prior.get('mode', 'assessment')} run — a mode switch " + "would strand its tracking; start the other mode in a fresh project tree") + state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, + "language": args.language or prior.get("language")} + if enactment: + state["findings"] = args.findings or prior.get("findings") + for k in STATE_DERIVED: # never clobber what build/scan already learned + state[k] = prior.get(k) + state_path.write_text(dump_yaml(state), encoding="utf-8") + + # history: append one run entry on a fresh init, never on resume (the derived knobs survived) + hist_path = TRACKING / "history.yaml" + runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] + if not resume: + runs.append({"commit": git_head(), + "type": f"{args.mode}/{scan_level}/{args.triage_level}" + + ("" if args.controls == "on" else "/no-controls")}) + hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") + + how = "resumed (derived knobs preserved)" if resume else "fresh" + print(f"init {how}: mode={args.mode} scan_level={scan_level} " + f"triage_level={state['triage_level']} language={state['language']}") + if enactment: + print(f"findings={state['findings']}") + print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " + "directories under .opentaint/") + print("next: uv run scripts/get_status.py --full") + return 0 + + +def regen_plans(out_dir): + # partition regenerates the whole plan set from the current unclassified state, so drop any + # stale plans first — otherwise a re-partition leaves already-consumed plans as leftover cruft. + out_dir.mkdir(parents=True, exist_ok=True) + for p in out_dir.glob("*.yaml"): + p.unlink() + + +# ---- partition: shared bin-packing ---- + +def root_of(fqn, depth=ROOT_DEPTH): + segs = class_of(fqn).split(".") + return ".".join(segs[:depth]) if len(segs) >= depth else class_of(fqn) + + +def in_packages(cls, prefixes): + # dotted-boundary match: `a.b.collect` never matches a sibling `a.b.collectX` + return any(cls == p or cls.startswith(p + ".") for p in prefixes) + + +def atomize(fqns, cap): + # split into atomic scopes (prefix, [fqns]); each scope is a whole (sub)package (or a subtree + # under cap) — a package is NEVER split across scopes, so it lands in exactly one bin and no two + # agents ever share a package's per-package unit. A package over cap stays oversized. + scopes = [] + + def recurse(prefix, items): + if len(items) <= cap: + scopes.append((prefix, items)) + return + depth = len(prefix.split(".")) + buckets, leaf = {}, [] + for f in items: + pkg = package_of(f) + segs = pkg.split(".") if pkg else [] + if pkg == prefix or len(segs) <= depth: + leaf.append(f) + else: + child = ".".join(segs[: depth + 1]) + buckets.setdefault(child, []).append(f) + if leaf: + scopes.append((prefix, leaf)) + for child, cf in buckets.items(): + recurse(child, cf) + + top = {} + for f in fqns: + pkg = package_of(f) + top.setdefault(pkg.split(".")[0] if pkg else class_of(f), []).append(f) + for seg0, items in top.items(): + recurse(seg0, items) + return scopes + + +def pack(scopes, target, cap): + # longest-processing-time bin-packing. An atomic scope larger than cap (a package that can't + # be split) gets its own bin instead of forcing the whole set to one-scope-per-bin. + plans = [{p: v} for p, v in scopes if len(v) > cap] + items = sorted((s for s in scopes if len(s[1]) <= cap), key=lambda s: len(s[1]), reverse=True) + if items: + total = sum(len(v) for _, v in items) + k = max(1, math.ceil(total / cap), round(total / target)) + while True: + loads = [0] * k + bins = [{} for _ in range(k)] + for prefix, v in items: + i = min(range(k), key=lambda j: loads[j]) + bins[i][prefix] = v + loads[i] += len(v) + if max(loads) <= cap or k >= len(items): + break + k += 1 + plans += [b for b in bins if b] + return plans + + +def write_plans(plans, out_dir, prefix_id): + out_dir.mkdir(parents=True, exist_ok=True) + paths = [] + for i, scopes in enumerate(plans, 1): + pid = f"{prefix_id}-{i:03d}" + norm = {p.replace(".", "-"): sorted(v, key=lambda x: (x["method"], x.get("signature", "")) + if isinstance(x, dict) else x) + for p, v in sorted(scopes.items())} + path = out_dir / f"{pid}.yaml" + # source: null is the unprocessed sentinel — a discover agent overwrites it with the + # list of sources it found (an empty list when it finds none). mark-safe folds only + # plans whose sentinel was replaced, so an un-returned plan is never marked safe. + path.write_text(dump_yaml({"id": pid, "scopes": norm, "source": None}), encoding="utf-8") + paths.append(str(path)) + return paths + + +# ---- partition analyze ---- + +def _root_next_index(prefix): + # additive numbering: a re-partition round must never reuse an id an existing batch already + # owns, or the new plan's analyze agent would overwrite that batch. Continue past the highest + # index any existing batch OR leftover plan already claims for this root. + mx = 0 + for d in (APPROX, APPROX_PLANS): + for p in glob.glob(str(d / f"{prefix}-*.yaml")): + m = re.match(rf"^{re.escape(prefix)}-(\d+)\.yaml$", Path(p).name) + if m: + mx = max(mx, int(m.group(1))) + return mx + 1 + + +def cmd_analyze(args): + regen_plans(APPROX_PLANS) + classified = classified_keys() + rows = [r for r in dropped_entries() if member_key(r) not in classified] + if not rows: + print("nothing to plan — every dropped method already classified", file=sys.stderr) + return 0 + + by_root = {} + for r in rows: + by_root.setdefault(root_of(r["method"]), []).append(r) + count = lambda rs: len({fqn_base(r["method"]) for r in rs}) + misc = [] + for root in [k for k, rs in by_root.items() if count(rs) <= ANALYZE_MISC]: + misc += by_root.pop(root) + if misc: + by_root["misc"] = misc + + paths = [] + for root in sorted(by_root): + by_fqn = {} + for r in by_root[root]: + by_fqn.setdefault(fqn_base(r["method"]), []).append(r) + bins = pack(atomize(sorted(by_fqn), ANALYZE_BUDGET), ANALYZE_BUDGET, ANALYZE_BUDGET) + prefix = root.replace(".", "-") + start = _root_next_index(prefix) + for i, b in enumerate(bins): + scopes = {} + for f in {f for v in b.values() for f in v}: # re-group the batch by class + scopes.setdefault(class_of(f), []).extend(by_fqn[f]) + norm = {cls: sorted(v, key=lambda x: (x["method"], x.get("signature", ""))) + for cls, v in sorted(scopes.items())} + pid = f"{prefix}-{start + i:03d}" + path = APPROX_PLANS / f"{pid}.yaml" + path.write_text(dump_yaml({"scopes": norm}), encoding="utf-8") + paths.append(str(path)) + for p in paths: + print(p) + return 0 + + +# ---- partition discover ---- + +def yaml_modules(model_yaml): + # each module in project.yaml as (packages, moduleClasses); only `packages` says which of a + # classpath-mode model's moduleClasses (dependency jars included) is project code + doc = load_yaml(model_yaml, {}) or {} + mods = [] + + def walk(node): + if isinstance(node, dict): + if isinstance(node.get("moduleClasses"), list): + mods.append(([str(p) for p in (node.get("packages") or [])], + [str(c) for c in node["moduleClasses"]])) + for v in node.values(): + walk(v) + elif isinstance(node, list): + for x in node: + walk(x) + + walk(doc) + return mods + + +def is_project_class(cls, packages): + # mirrors the engine's ProjectClasses.isModuleClass; empty packages = a project-only module + return not packages or any(cls.startswith(p) for p in packages) + + +CALL_RE = re.compile(r"//\s*(?:Interface)?Method\s+(\S+?)\.(?):(\S+)") + + +def extract_usages(): + # disassemble project classes, collect // Method / // InterfaceMethod call sites with their + # JVM descriptor; returns (fqn, signature) pairs so an overloaded member stays disambiguated + fqns = set() + for packages, module_classes in yaml_modules(MODEL / "project.yaml"): + for entry in module_classes: + p = MODEL / entry + if p.is_dir(): + classes = [str(c.relative_to(p))[:-6].replace("/", ".") for c in p.rglob("*.class")] + elif p.is_file(): + try: + listing = subprocess.run(["jar", "tf", str(p)], capture_output=True, + text=True, check=True).stdout + except (OSError, subprocess.CalledProcessError): + continue + if not packages: + print(f"warning: {entry} is a jar in a module with no declared `packages` — its " + f"plans will cover the library's own calls, not the project's", file=sys.stderr) + classes = [c[:-6].replace("/", ".") for c in listing.splitlines() + if c.endswith(".class")] + else: + continue + classes = [c for c in classes if is_project_class(c, packages)] + cp = str(p) + for i in range(0, len(classes), 200): # batch to keep argv under the limit + batch = classes[i:i + 200] + try: + out = subprocess.run(["javap", "-c", "-p", "-classpath", cp, *batch], + capture_output=True, text=True).stdout + except OSError: + continue + for owner, method, sig in CALL_RE.findall(out): + fqns.add((f"{owner.replace('/', '.')}#{method}", sig)) + return fqns + + +def pending_packages(): + cov = load_yaml(TRACKING / "coverage.yaml", {}) or {} + return tuple(p for p in (cov.get("packages") or []) if isinstance(p, str) and p) + + +def cmd_discover(args): + regen_plans(DISCOVER_PLANS) + packages = pending_packages() + if not packages: + print("nothing to plan — no pending package in coverage.yaml", file=sys.stderr) + return 0 + verdicted = ledger_verdicted_keys() # method+signature keys + sigs = {} # bare member fqn -> its pending signatures + for f, sig in extract_usages(): + if in_packages(class_of(f), packages) and f + sig not in verdicted: + sigs.setdefault(f, set()).add(sig) + todo = sorted(sigs) + if not todo: + print("nothing to plan — every used member already verdicted", file=sys.stderr) + return 0 + cap = DISCOVER_TARGET + DISCOVER_BAND + plans = pack(atomize(todo, cap), DISCOVER_TARGET, cap) + rows = {f: [{"method": f, "signature": s} for s in sorted(sigs[f])] for f in sigs} + plans = [{pkg: [r for f in members for r in rows[f]] for pkg, members in plan.items()} + for plan in plans] + for p in write_plans(plans, DISCOVER_PLANS, "lib"): + print(p) + return 0 + + +def cmd_partition(args): + return cmd_analyze(args) if args.kind == "analyze" else cmd_discover(args) + + +# ---- mark-safe (discover join) ---- + +def cmd_mark_safe(args): + plans = sorted(glob.glob(str(DISCOVER_PLANS / "lib-*.yaml"))) + if not plans: + print("no discover plans to reconcile", file=sys.stderr) + return 0 + ledger = RULES_TR / "classification.yaml" + doc = load_yaml(ledger, {}) or {} + source = {member_key(x) for x in (doc.get("source") or [])} + safe = {member_key(x) for x in (doc.get("safe") or [])} + processed, unprocessed = [], [] + for p in plans: + pdoc = load_yaml(p, {}) or {} + raw = pdoc.get("source") + if raw is None: # sentinel intact — no discover agent returned for it + unprocessed.append(p) + continue + members = {member_key(m) for v in (pdoc.get("scopes") or {}).values() for m in v} + srcs = {member_key(x) for x in raw} + source |= srcs + safe |= members - srcs + processed.append(p) + print(f"{Path(p).name}: {len(srcs)} sources, {len(members - srcs)} safe") + if not processed: + print("no processed discover plans (every plan still carries source: null) — " + "fan out discover-attack-surface first", file=sys.stderr) + return 0 + safe -= source + ledger.parent.mkdir(parents=True, exist_ok=True) + ledger.write_text(dump_yaml({"source": sorted(source), "safe": sorted(safe)}), encoding="utf-8") + print(f"classification.yaml: {len(source)} source, {len(safe)} safe total") + if not args.keep: + for p in processed: + Path(p).unlink() + print(f"pruned {len(processed)} reconciled discover plan(s)") + if unprocessed: + print(f"left {len(unprocessed)} unprocessed plan(s) (source: null) for re-dispatch: " + + ", ".join(Path(p).name for p in unprocessed)) + return 0 + + +# ---- merge-skipped (analyze join) ---- + +def _skip_member(item): + # normalize a skipped/engine_issues entry to {method, signature?}, dropping the reason + if isinstance(item, dict): + m = strip_quotes(item.get("method", "")) + sig = str(item.get("signature", "")).strip() + return {"method": m, "signature": sig} if sig else {"method": m} + return {"method": strip_quotes(item)} + + +def _collect(docs, bucket): + seen = {} + for doc in docs: + for item in doc.get(bucket, []) or []: + m = _skip_member(item) + if m["method"]: + seen[(m["method"], m.get("signature", ""))] = m + return [seen[k] for k in sorted(seen)] + + +def cmd_merge_skipped(args): + # collects the `skipped` and `engine_issues` buckets of every batch into skipped.yaml, keeping + # them as two separate groups — regular skips under `methods`, engine issues under `engine_issues`. + docs = [load_yaml(p, {}) or {} for p in + (Path(x) for x in sorted(glob.glob(str(APPROX / "*.yaml")))) + if p.name != "skipped.yaml"] + out = {"methods": _collect(docs, "skipped"), "engine_issues": _collect(docs, "engine_issues")} + (APPROX / "skipped.yaml").write_text(dump_yaml(out), encoding="utf-8") + print(f"skipped.yaml: {len(out['methods'])} methods, {len(out['engine_issues'])} engine_issues") + if not args.keep and APPROX_PLANS.is_dir(): + pruned = [p for p in glob.glob(str(APPROX_PLANS / "*.yaml"))] + for p in pruned: + Path(p).unlink() + if pruned: + print(f"pruned {len(pruned)} consumed approximation plan(s)") + return 0 + + +# ---- findings (SARIF -> per-rule tracking files) ---- + +ADJ = ["brave", "calm", "eager", "fuzzy", "gentle", "jolly", "keen", "lucid", + "merry", "noble", "proud", "quiet", "rapid", "sly", "tidy", "vivid", + "witty", "zesty", "amber", "bold"] +NOUN = ["hopper", "eagle", "otter", "falcon", "maple", "comet", "harbor", + "willow", "pixel", "river", "ember", "cobra", "lotus", "raven", + "quartz", "badger", "cedar", "drake", "finch", "gull"] + +_FP_PREFERENCE = ("vulnerabilitySourceSinkHash", "vulnerabilityWithTraceHash") + +HASHES_RE = re.compile(r'^sarif_hashes:\s*\[(.*)\]\s*$', re.M) +HASHES_BLOCK_RE = re.compile(r'^sarif_hashes:\s*\n((?:[ \t]+-[^\n]*\n?)+)', re.M) + + +def docker_name(seed, taken): + h = int(hashlib.sha1(seed.encode()).hexdigest(), 16) + base = f"{ADJ[h % len(ADJ)]}-{NOUN[(h // len(ADJ)) % len(NOUN)]}" + name, n = base, 2 + while name in taken: + name, n = f"{base}-{n}", n + 1 + return name + + +def result_hash(res): + fp = res.get("fingerprints") or res.get("partialFingerprints") + if isinstance(fp, dict) and fp: + for pref in _FP_PREFERENCE: + for k, v in fp.items(): + if k.startswith(pref): + return str(v)[:16] + return str(sorted(fp.values())[0])[:16] + parts = [res.get("ruleId", "")] + locs = list(res.get("locations", [])) + for cf in res.get("codeFlows", []): + for tf in cf.get("threadFlows", []): + locs += [st.get("location", {}) for st in tf.get("locations", [])] + for loc in locs: + pl = loc.get("physicalLocation", {}) + parts.append(pl.get("artifactLocation", {}).get("uri", "")) + parts.append(json.dumps(pl.get("region", {}), sort_keys=True)) + return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16] + + +def scan_results(sarif): + out = {} + for run in sarif.get("runs") or []: + for res in run.get("results") or []: + rid = res.get("ruleId") or "unknown" + msg = (res.get("message", {}) or {}).get("text", "").strip() + out.setdefault(rid, {})[result_hash(res)] = msg + return out + + +def fmt_list(hashes): + return "[" + ", ".join(hashes) + "]" + + +def parse_hashes(text): + m = HASHES_RE.search(text) + if m: + return [h.strip() for h in m.group(1).split(",") if h.strip()] + m = HASHES_BLOCK_RE.search(text) + if m: + return [ln.strip().lstrip("-").strip() + for ln in m.group(1).splitlines() if ln.strip().lstrip("-").strip()] + return [] + + +def replace_hashes(text, merged): + line = "sarif_hashes: " + fmt_list(merged) + if HASHES_RE.search(text): + return HASHES_RE.sub(lambda m: line, text, count=1) + if HASHES_BLOCK_RE.search(text): + return HASHES_BLOCK_RE.sub(line + "\n", text, count=1) + return line + "\n" + text + + +def new_file_text(rid, hashes, notes): + body = "\n".join(" " + ln for ln in (notes or "(no analyzer message)").splitlines()) + return (f"sarif_hashes: {fmt_list(hashes)}\n" + f"rule_id: {rid}\n" + f"verdict: pending\n" + f"notes: >\n{body}\n" + f"poc: pending\n") + + +def cmd_findings(args): + sarif = json.loads(SARIF.read_text(encoding="utf-8")) + by_rule = scan_results(sarif) + out = FINDINGS_TR + out.mkdir(parents=True, exist_ok=True) + + existing = {} + taken = set() + for p in sorted(glob.glob(str(out / "*.yaml"))): + text = Path(p).read_text(encoding="utf-8") + rid = RULE_RE.search(text) + verdict = VERDICT_RE.search(text) + taken.add(Path(p).stem) + if rid: + existing.setdefault(rid.group(1).strip(), []).append( + (Path(p), parse_hashes(text), verdict.group(1).strip() if verdict else "pending")) + + created = updated = unchanged = reconcile = 0 + for rid, hashmap in sorted(by_rule.items()): + scanned = set(hashmap) + files = existing.get(rid) + if not files: + name = docker_name(rid, taken) + taken.add(name) + notes = "\n".join(sorted({m for m in hashmap.values() if m})) + (out / f"{name}.yaml").write_text(new_file_text(rid, sorted(scanned), notes), + encoding="utf-8") + created += 1 + continue + already = set().union(*(set(h) for _, h, _ in files)) + new = sorted(scanned - already) + if not new: + unchanged += 1 + continue + pending = next(((p, h) for p, h, v in files if v == "pending"), None) + if pending: + path, hashes = pending + text = replace_hashes(path.read_text(encoding="utf-8"), sorted(set(hashes) | set(new))) + text = re.sub(r'^verdict:\s*.+$', "verdict: pending", text, count=1, flags=re.M) + path.write_text(text, encoding="utf-8") + updated += 1 + continue + name = docker_name(rid, taken) + taken.add(name) + msgs = sorted({hashmap[h] for h in new if hashmap.get(h)}) + notes = ("reconcile: new results under a rule whose findings are already triaged — " + "match each against this rule's triaged findings by flow before judging; if the " + "vulnerability is the same, merge its hashes into that finding and inherit its " + "verdict instead of re-triaging\n" + "\n".join(msgs)) + (out / f"{name}.yaml").write_text(new_file_text(rid, sorted(new), notes), encoding="utf-8") + reconcile += 1 + + print(f"findings: {created} created, {updated} updated, {unchanged} unchanged, " + f"{reconcile} to reconcile ({len(by_rule)} rules in scan)") + return 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + sub = ap.add_subparsers(dest="cmd", required=True) + + i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") + i.add_argument("--mode", default="assessment", choices=["assessment", "enactment"], + help="assessment: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--scan-level", choices=["lite", "normal", "deep"], + help="assessment mode only; enactment is always deep") + i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) + i.add_argument("--language", default=None, help="target language, determined by the orchestrator") + i.add_argument("--findings", default=None, + help="enactment mode: path to the supplied finding manifest/report/directory") + i.set_defaults(func=cmd_init) + + p = sub.add_parser("partition", help="split classification work into per-agent plans") + p.add_argument("kind", choices=["analyze", "discover"]) + p.set_defaults(func=cmd_partition) + + m = sub.add_parser("mark-safe", help="merge discover plans into classification.yaml") + m.add_argument("--keep", action="store_true", help="keep the reconciled discover plans") + m.set_defaults(func=cmd_mark_safe) + + s = sub.add_parser("merge-skipped", help="rebuild approximations/skipped.yaml from batches") + s.add_argument("--keep", action="store_true", help="keep the consumed approximation plans") + s.set_defaults(func=cmd_merge_skipped) + + f = sub.add_parser("findings", help="seed per-rule finding files from results/report.sarif") + f.set_defaults(func=cmd_findings) + + + args = ap.parse_args() + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/assessment-agent/scripts/get_status.py b/skills/assessment-agent/scripts/get_status.py new file mode 100644 index 000000000..53ae0ff07 --- /dev/null +++ b/skills/assessment-agent/scripts/get_status.py @@ -0,0 +1,470 @@ +# /// script +# requires-python = ">=3.9" +# dependencies = ["pyyaml==6.0.3"] +# /// +""" +get_status.py — the orchestrator's status source. Derives, from the .opentaint tree, +which pipeline phase is current and the exact orchestrator tasks for it; writes nothing +(use generate.py to mutate). Run with uv from the project root: + + uv run scripts/get_status.py current stage + its orchestrator tasks + uv run scripts/get_status.py --full every in-scope phase as DONE/IN_PROGRESS/PENDING + +Call it at each stage boundary to decide the next move, and --full at run start / on +resume. It lists every pending plan, batch, unit, and finding to hand out — dispatch what +it names rather than re-deriving state by hand. +""" +import argparse +import glob +import os +import re +import subprocess +import sys +from pathlib import Path + +from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, + PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, + SOURCES_TR, TRACKING, build_done_keys, classified_keys, dropped_entries, + git_head, load_yaml, member_key, modeled_entries, skipped_keys, + strip_quotes) + +STATE = load_yaml(TRACKING / "state.yaml", {}) or {} +MODE = STATE.get("mode") or "assessment" +SCAN_LEVEL = STATE.get("scan_level") +TRIAGE_LEVEL = STATE.get("triage_level") + +DISCOVER_PLANS = RULES_TR / "plans" +APPROX_PLANS = APPROX / "plans" +VULN = ROOT / "vulnerabilities.md" +ENACTMENT = ROOT / "enactment.md" +GLOBAL_CAP = 10 + + +def short(c): + return str(c)[:8] if c else c + + +# ---- tree readers ---- + +def load_units(d): + return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ + if Path(d).is_dir() else [] + + +def load_docs(d): + """(path, doc) for every tracking file in a directory — reference, boundary, control.""" + return [(p, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ + if Path(d).is_dir() else [] + + +def load_joins(): + return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(JOINS_TR.glob("*.yaml"))] \ + if JOINS_TR.is_dir() else [] + + +def load_findings(): + out = [] + if FINDINGS_TR.is_dir(): + for p in sorted(FINDINGS_TR.glob("*.yaml")): + doc = load_yaml(p, {}) or {} + notes = str(doc.get("notes") or "") + out.append({"path": str(p), "name": p.stem, + "verdict": str(doc.get("verdict", "pending")).strip(), + "poc": str(doc.get("poc", "pending")).strip(), + "reconcile": notes.lstrip().startswith("reconcile")}) + return out + + +def newest_mtime(paths): + m = 0.0 + for p in paths: + try: + m = max(m, p.stat().st_mtime) + except OSError: + pass + return m + + +def approx_dirty(): + # scan older than the newest applied approximation artifact -> a rescan is pending + if not SARIF.is_file(): + return False + arts = list(PASS_THROUGH.rglob("*")) + list(DATAFLOW.rglob("*")) + return newest_mtime([p for p in arts if p.is_file()]) > SARIF.stat().st_mtime + + +def rules_dirty(): + if not SARIF.is_file(): + return False + return newest_mtime([p for p in RULES.rglob("*.yaml") if p.is_file()]) > SARIF.stat().st_mtime + + +def scan_stale(): + # SARIF older than the model -> a rebuild happened, rescan before trusting it + manifest = MODEL / "project.yaml" + if not SARIF.is_file() or not manifest.is_file(): + return False + return manifest.stat().st_mtime > SARIF.stat().st_mtime + + +def unit_next(doc, kind, side): + # the next dispatch step for a not-yet-passing rule unit + if (doc.get("stages") or {}).get("test_project") != "done": + return f"create-test-project type {kind}" + return f"create-rule side {side}" + + +def _join_source_refs(): + return {str(s).strip() for _, doc in load_joins() + for s in (doc.get("sources") or []) if str(s).strip()} + + +def _join_sink_refs(): + return {str(j["sink"]).strip() for _, doc in load_joins() + for j in (doc.get("joins") or []) if isinstance(j, dict) and j.get("sink")} + + +def _created_refs(units, field): + """rule_ids on the units that resolve to a rule file under .opentaint/rules (created, not + a built-in ref, which is indistinguishable by path but never sits on disk here).""" + refs = set() + for _, doc in units: + for e in doc.get(field) or []: + rid = str(e.get("rule_id", "")).strip() if isinstance(e, dict) else "" + if rid and (RULES / re.split(r"[:#]", rid, 1)[0]).is_file(): + refs.add(rid) + return refs + + +def _pending_units(units, kind, side): + # units not passing and not settled by a blocker; each tagged with its next step + out = [] + for name, doc in units: + st = doc.get("stages") or {} + if st.get("tests_passing") != "done" and not (doc.get("blocker") or st.get("blocker")): + out.append(f" {name} {unit_next(doc, kind, side)}") + return out + + +# ---- phase derivations: each returns (done, tasks, note) ---- +# done: the phase is complete. tasks: the brief lines when it is the current stage. +# note: a short suffix shown only on the --full line (e.g. build from a dirty tree). + +def ph_build(): + if not (MODEL / "project.yaml").is_file(): + return False, ["determine project language and write to state.yaml.language", + "dispatch build-project"], None + head, mc = git_head(), STATE.get("model_commit") + if head is None: + return True, [], "no git — taken as current" + if mc is None: + return True, [], "from dirty tree" + if head == mc: + return True, [], None + return False, [f"model stale: HEAD {short(head)} != model_commit {short(mc)}", + "dispatch build-project"], None + + +def ph_discover(): + if not (TRACKING / "coverage.yaml").is_file(): + return False, ["dispatch triage-dependencies"], None + leftover = sorted(glob.glob(str(DISCOVER_PLANS / "*.yaml"))) + units = load_units(SOURCES_TR) + ledger = load_yaml(RULES_TR / "classification.yaml", {}) or {} + if leftover: + tasks = [f"dispatch discover-attack-surface, one per plan (cap {GLOBAL_CAP}):"] + tasks += [f" {p}" for p in leftover] + tasks.append("then run `scripts/generate.py mark-safe` to reconcile the plans") + return False, tasks, None + if not ledger and not units: + return False, ["run `scripts/generate.py partition discover` to plan the used members"], None + return True, [], None + + +def ph_source_rules(): + units = load_units(SOURCES_TR) + if not units: + return True, [], "built-in covered" + pend = _pending_units(units, "rule-source", "sources") + if pend: + return False, ["pending units:"] + pend, None + missing = sorted(_created_refs(units, "sources") - _join_source_refs()) + if missing: + return False, ["created sources not wired to a join", "dispatch assemble-lib-rules"], None + return True, [], None + + +def ph_scan(): + if not SARIF.is_file() or scan_stale(): + return False, ["dispatch run-scan"], None + return True, [], None + + +def ph_approximations(): + if not SARIF.is_file(): + return False, ["dispatch run-scan"], None + classified = classified_keys() + uncovered = [e for e in dropped_entries() if member_key(e) not in classified] + if uncovered: + plans = sorted(glob.glob(str(APPROX_PLANS / "*.yaml"))) + if plans: + tasks = [f"dispatch analyze-external-methods, one per plan (cap {GLOBAL_CAP}):"] + tasks += [f" {p}" for p in plans] + tasks.append("then run `scripts/generate.py merge-skipped` to merge the batches") + return False, tasks, None + n = len({e["method"] for e in uncovered}) + return False, [f"{n} methods unclassified", + "run `scripts/generate.py partition analyze` to split them into batch plans"], None + done = build_done_keys() + terminal = skipped_keys() # skipped/engine-issue carriers never build — don't hold the gate + unbuilt = [(p, kind) for p, kind, m in modeled_entries() + if member_key(m) not in done and member_key(m) not in terminal] + if unbuilt: + by_kind = {} + for p, kind in unbuilt: + by_kind.setdefault(kind, set()).add(p.stem) + tasks = ["build unbuilt batches:"] + if by_kind.get("passthrough"): + tasks.append(" passthrough create-pass-through-approximation: " + + ", ".join(sorted(by_kind["passthrough"]))) + if by_kind.get("dataflow"): + tasks.append(" dataflow create-test-project type dataflow, then " + "create-dataflow-approximation: " + ", ".join(sorted(by_kind["dataflow"]))) + return False, tasks, None + if approx_dirty(): + return False, ["approximations built after the last scan", "dispatch run-scan"], None + stuck = sorted({e["method"] for e in dropped_entries() + if member_key(e) in done and member_key(e) not in terminal}) + if stuck: + return False, [f"built but still dropped ({len(stuck)}), escalate:"] \ + + [f" {m}" for m in stuck], None + return True, [], None + + +def ph_sink_rules(): + units = load_units(SINKS_TR) + pend = _pending_units(units, "rule-sink", "sinks") + if pend: + return False, ["pending units:"] + pend, None + refs = _join_sink_refs() + missing = sorted({e["rule_id"] for _, doc in units for e in (doc.get("sinks") or []) + if isinstance(e, dict) and e.get("rule_id") + and str(e["rule_id"]).strip() not in refs}) + if missing: + return False, ["sink rules not wired to a join", "dispatch assemble-lib-rules"], None + if rules_dirty(): + return False, ["rules changed after the last scan", "dispatch run-scan"], None + return True, [], None + + +def ph_triage(): + findings = load_findings() + if not findings: + if not SARIF.is_file(): + return False, ["dispatch run-scan"], None + return False, ["run `scripts/generate.py findings` to seed the finding files"], None + pend = [f" {f['path']}" + (" (reconcile)" if f["reconcile"] else "") + for f in findings if f["verdict"] == "pending"] + if pend: + return False, ["dispatch analyze-findings over pending findings:"] + pend, None + tp = sum(1 for f in findings if f["verdict"] == "TP") + stale = newest_mtime([Path(f["path"]) for f in findings]) > (VULN.stat().st_mtime + if VULN.is_file() else 0) + if not VULN.is_file() or stale: + return False, [f"rewrite .opentaint/vulnerabilities.md from the TP findings ({tp} TP)"], None + return True, [], None + + +def ph_poc(): + findings = load_findings() + tps = [f for f in findings if f["verdict"] == "TP"] + pend = [f" {f['path']}" for f in tps if f["poc"] == "pending"] + if pend: + return False, ["generate-poc serially over TP findings without a PoC:"] + pend, None + servers = (load_yaml(TRACKING / "poc-servers.yaml", {}) or {}).get("servers") or [] + if servers: + return False, ["tear down the instances in poc-servers.yaml and clear the registry", + "refresh .opentaint/vulnerabilities.md"], None + return True, [], None + + +# ---- enactment-mode phases ---- + +def ph_reference_set(): + docs = load_docs(REFERENCE_TR) + if not docs: + src = STATE.get("findings") or "state.yaml findings unset" + return False, [f"normalize the supplied findings ({src}) into " + ".opentaint/tracking/reference/.yaml"], None + missing = sorted(p.stem for p, d in docs if not strip_quotes(d.get("family", ""))) + if missing: + return False, ["reference findings not assigned to a boundary family:"] \ + + [f" {m}" for m in missing], None + return True, [], None + + +def families(): + return sorted({strip_quotes(d.get("family", "")) for _, d in load_docs(REFERENCE_TR) + if strip_quotes(d.get("family", ""))}) + + +def ph_boundaries(): + specs = {p.stem: d for p, d in load_docs(BOUNDARIES_TR)} + fams = families() + missing = [f for f in fams if f not in specs] + if missing: + return False, ["dispatch discover-universal-boundaries, one per family:"] \ + + [f" {f}" for f in missing], None + # a split renames the family on its reference findings, so every spec here owns its findings + unsaturated = [f for f in fams + if str((specs[f].get("saturation") or {}).get("status", "")).strip() + != "saturated"] + if unsaturated: + return False, ["boundary specs not saturated:"] + [f" {f}" for f in unsaturated], None + unfactored = sorted(p.stem for p, d in load_docs(REFERENCE_TR) + if p.stem not in (specs.get(strip_quotes(d.get("family", "")), {}) + .get("factorization") or {})) + if unfactored: + return False, ["reference findings with no factorization in their spec:"] \ + + [f" {r}" for r in unfactored], None + unseeded = [f for f in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] + if unseeded: + return False, ["seed the source and sink units from these specs' candidate_patterns:"] \ + + [f" {f}" for f in unseeded], None + return True, [], None + + +def ph_crossref(): + if not SARIF.is_file(): + return False, ["dispatch run-scan"], None + docs = load_docs(REFERENCE_TR) + scanned = SARIF.stat().st_mtime + pend = [p for p, d in docs + if str(d.get("crossref", "pending")).strip() != "done" or p.stat().st_mtime < scanned] + if pend: + return False, [f"cross-reference the scan against {len(pend)} reference finding(s):"] \ + + [f" {p}" for p in pend], None + blocked = sorted({str(m) for _, d in docs for m in (d.get("blocked_at") or [])}) + if blocked: + return False, ["expected traces stop at unmodeled carriers:"] + [f" {m}" for m in blocked] \ + + ["model them in an approximation round, rescan, then cross-reference again"], None + rep = sum(1 for _, d in docs if str(d.get("status", "")).strip() == "reproduced") + stale = newest_mtime([p for p, _ in docs]) > (ENACTMENT.stat().st_mtime + if ENACTMENT.is_file() else 0) + if not ENACTMENT.is_file() or stale: + return False, [f"rewrite .opentaint/enactment.md coverage manifest " + f"({rep}/{len(docs)} reproduced)"], None + return True, [], None + + +ASSESSMENT_PHASES = [ + ("build", ph_build, lambda: True), + ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), + ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), + ("scan", ph_scan, lambda: True), + ("approximations", ph_approximations, lambda: SCAN_LEVEL in ("normal", "deep")), + ("sink_rules", ph_sink_rules, lambda: SCAN_LEVEL == "deep"), + ("triage", ph_triage, lambda: True), + ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), +] + +# enactment reproduces a supplied finding set: the reference set and its saturated boundaries +# replace dependency discovery, and both rule sides are authored before the first scan so that +# scan is rule-first. The cross-reference closes the run — it judges what the finished rule set, +# its approximations, its verdicts and its controls actually reproduced. +ENACTMENT_PHASES = [ + ("build", ph_build, lambda: True), + ("reference_set", ph_reference_set, lambda: True), + ("boundaries", ph_boundaries, lambda: True), + ("source_rules", ph_source_rules, lambda: True), + ("sink_rules", ph_sink_rules, lambda: True), + ("scan", ph_scan, lambda: True), + ("approximations", ph_approximations, lambda: True), + ("triage", ph_triage, lambda: True), + ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), + ("crossref", ph_crossref, lambda: True), +] + +PHASES = ENACTMENT_PHASES if MODE == "enactment" else ASSESSMENT_PHASES + + +# ---- caps ---- + +def free_gb(): + mi = Path("/proc/meminfo") + if mi.is_file(): + for ln in mi.read_text().splitlines(): + if ln.startswith("MemAvailable:"): + return int(ln.split()[1]) // (1024 * 1024) + try: + total = int(subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, + text=True, check=True).stdout.strip()) + return total // (1024 ** 3) + except (OSError, subprocess.CalledProcessError, ValueError): + return None + + +def heavy_cap(): + cores = os.cpu_count() or 1 + fg = free_gb() + return max(1, min(cores, (fg // 2 if fg else cores), GLOBAL_CAP)) + + +# ---- output ---- + +def in_scope(): + return [(name, fn) for name, fn, gate in PHASES if gate()] + + +def evaluate(): + """Every in-scope phase as (name, done, tasks, note), with the current stage marked.""" + rows = [(name,) + fn() for name, fn in in_scope()] + current = next((i for i, r in enumerate(rows) if not r[1]), None) + return rows, current + + +def cmd_full(): + commit = short(STATE.get("model_commit")) or "none" + print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " + f"language={STATE.get('language')} commit={commit} " + f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") + if MODE == "enactment": + print(f"findings={STATE.get('findings')}") + rows, current = evaluate() + # a phase downstream of the current stage that vacuously satisfies its own check is not + # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. + for i, (name, done, tasks, note) in enumerate(rows): + if current is None or i < current: + state = "DONE" + elif i == current: + state = "IN_PROGRESS" + else: + state = "PENDING" + suffix = f" ({note})" if note and state == "DONE" else "" + print(f"{name:<15} {state}{suffix}") + return 0 + + +def cmd_brief(): + rows, current = evaluate() + if current is None: + print("run complete") + return 0 + name, _done, tasks, _note = rows[current] + print(f"{name} IN_PROGRESS") + for t in tasks: + print(f" {t}") + return 0 + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--full", action="store_true", + help="every in-scope phase as DONE/IN_PROGRESS/PENDING") + args = ap.parse_args() + return cmd_full() if args.full else cmd_brief() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/enactment-agent/SKILL.md b/skills/enactment-agent/SKILL.md index fde921bc0..38d1aabf3 100644 --- a/skills/enactment-agent/SKILL.md +++ b/skills/enactment-agent/SKILL.md @@ -11,12 +11,16 @@ metadata: Reproduce a supplied set of findings with OpenTaint. Every finding ends the run either matched by a verified OpenTaint result whose trace carries the finding's own identity, or recorded with the exact rule, modeling, or engine limitation that stopped it. Keep the long project build and every full-project scan in this main session; delegate each bounded stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. -This is the enactment pipeline — the same machine as `appsec-agent`, and the same `.opentaint/` tree, differing in how the source and sink rules are produced. There, they come from discovering the project's dependency attack surface; here, from generalizing the supplied findings into reusable boundaries, so both sides exist before the first scan and that scan is rule-first. Use `appsec-agent` instead when the goal is to find unknown vulnerabilities. +This is the enactment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `assessment-agent`'s, which searches the project for vulnerabilities it was not known to have; use that one when nothing was supplied to reproduce. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface there, generalized from the supplied findings here, so both sides exist before the first scan and that scan is rule-first. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. + +You may be loaded directly, or handed off by `appsec-agent` once it identified the request as enactment. Either way the setup below is this pipeline's, and running it is what commits the tree to `mode: enactment`. No finding is dropped for being a poor fit for taint analysis. Authorization, integrity, configuration, hard-coded-secret, and structural-control findings are modeled as explicit pseudo-taint boundaries. ## Setup +Skip only what `appsec-agent` already did when it handed off — the toolchain and nesting checks. Everything from step 3 on is this pipeline's own, including the bootstrap. + ### 1. Confirm the toolchain Confirm `opentaint` is on PATH with `opentaint -v`. If it's missing, don't proceed silently — tell the user and offer the install command for their platform, run an install only on explicit confirmation: @@ -56,7 +60,7 @@ Seed the run state and the working tree: uv run /scripts/generate.py init --mode enactment --triage-level --language --findings ``` -It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. It refuses to convert an existing discovery run — that tracking has no reference set behind it, so enactment starts in its own project tree. +It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. It refuses to convert an existing assessment run — that tracking has no reference set behind it, so enactment starts in its own project tree. ## Workflow diff --git a/skills/enactment-agent/scripts/_common.py b/skills/enactment-agent/scripts/_common.py index 8c7f0d5dd..cd0873805 100644 --- a/skills/enactment-agent/scripts/_common.py +++ b/skills/enactment-agent/scripts/_common.py @@ -1,4 +1,4 @@ -"""Shared helpers for the appsec-agent orchestrator scripts. +"""Shared helpers for the OpenTaint pipeline orchestrator scripts. Not a runnable script — imported by the PEP723 entry points (get_status.py, generate.py), which carry the pyyaml dependency. Every path resolves under the fixed diff --git a/skills/enactment-agent/scripts/generate.py b/skills/enactment-agent/scripts/generate.py index b9de7adc3..7d8af1590 100644 --- a/skills/enactment-agent/scripts/generate.py +++ b/skills/enactment-agent/scripts/generate.py @@ -56,7 +56,7 @@ def cmd_init(args): if enactment and not args.findings: raise SystemExit("init --mode enactment requires --findings ") if not enactment and not args.scan_level: - raise SystemExit("init --mode discovery requires --scan-level") + raise SystemExit("init --mode assessment requires --scan-level") # enactment reproduces a supplied finding set, which always needs the full rule + approximation # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. scan_level = "deep" if enactment else args.scan_level @@ -66,8 +66,8 @@ def cmd_init(args): state_path = TRACKING / "state.yaml" prior = load_yaml(state_path, {}) or {} resume = bool(prior) - if resume and prior.get("mode", "discovery") != args.mode: - raise SystemExit(f"{state_path} is a {prior.get('mode', 'discovery')} run — a mode switch " + if resume and prior.get("mode", "assessment") != args.mode: + raise SystemExit(f"{state_path} is a {prior.get('mode', 'assessment')} run — a mode switch " "would strand its tracking; start the other mode in a fresh project tree") state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} @@ -578,10 +578,10 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--mode", default="discovery", choices=["discovery", "enactment"], - help="discovery: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--mode", default="assessment", choices=["assessment", "enactment"], + help="assessment: find vulnerabilities. enactment: reproduce supplied findings") i.add_argument("--scan-level", choices=["lite", "normal", "deep"], - help="discovery mode only; enactment is always deep") + help="assessment mode only; enactment is always deep") i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) i.add_argument("--language", default=None, help="target language, determined by the orchestrator") i.add_argument("--findings", default=None, diff --git a/skills/enactment-agent/scripts/get_status.py b/skills/enactment-agent/scripts/get_status.py index 776d20179..53ae0ff07 100644 --- a/skills/enactment-agent/scripts/get_status.py +++ b/skills/enactment-agent/scripts/get_status.py @@ -29,7 +29,7 @@ strip_quotes) STATE = load_yaml(TRACKING / "state.yaml", {}) or {} -MODE = STATE.get("mode") or "discovery" +MODE = STATE.get("mode") or "assessment" SCAN_LEVEL = STATE.get("scan_level") TRIAGE_LEVEL = STATE.get("triage_level") @@ -357,7 +357,7 @@ def ph_crossref(): return True, [], None -DISCOVERY_PHASES = [ +ASSESSMENT_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), @@ -385,7 +385,7 @@ def ph_crossref(): ("crossref", ph_crossref, lambda: True), ] -PHASES = ENACTMENT_PHASES if MODE == "enactment" else DISCOVERY_PHASES +PHASES = ENACTMENT_PHASES if MODE == "enactment" else ASSESSMENT_PHASES # ---- caps ---- diff --git a/skills/orchestrate-stage/scripts/_common.py b/skills/orchestrate-stage/scripts/_common.py index 8c7f0d5dd..cd0873805 100644 --- a/skills/orchestrate-stage/scripts/_common.py +++ b/skills/orchestrate-stage/scripts/_common.py @@ -1,4 +1,4 @@ -"""Shared helpers for the appsec-agent orchestrator scripts. +"""Shared helpers for the OpenTaint pipeline orchestrator scripts. Not a runnable script — imported by the PEP723 entry points (get_status.py, generate.py), which carry the pyyaml dependency. Every path resolves under the fixed diff --git a/skills/orchestrate-stage/scripts/generate.py b/skills/orchestrate-stage/scripts/generate.py index b9de7adc3..7d8af1590 100644 --- a/skills/orchestrate-stage/scripts/generate.py +++ b/skills/orchestrate-stage/scripts/generate.py @@ -56,7 +56,7 @@ def cmd_init(args): if enactment and not args.findings: raise SystemExit("init --mode enactment requires --findings ") if not enactment and not args.scan_level: - raise SystemExit("init --mode discovery requires --scan-level") + raise SystemExit("init --mode assessment requires --scan-level") # enactment reproduces a supplied finding set, which always needs the full rule + approximation # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. scan_level = "deep" if enactment else args.scan_level @@ -66,8 +66,8 @@ def cmd_init(args): state_path = TRACKING / "state.yaml" prior = load_yaml(state_path, {}) or {} resume = bool(prior) - if resume and prior.get("mode", "discovery") != args.mode: - raise SystemExit(f"{state_path} is a {prior.get('mode', 'discovery')} run — a mode switch " + if resume and prior.get("mode", "assessment") != args.mode: + raise SystemExit(f"{state_path} is a {prior.get('mode', 'assessment')} run — a mode switch " "would strand its tracking; start the other mode in a fresh project tree") state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} @@ -578,10 +578,10 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--mode", default="discovery", choices=["discovery", "enactment"], - help="discovery: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--mode", default="assessment", choices=["assessment", "enactment"], + help="assessment: find vulnerabilities. enactment: reproduce supplied findings") i.add_argument("--scan-level", choices=["lite", "normal", "deep"], - help="discovery mode only; enactment is always deep") + help="assessment mode only; enactment is always deep") i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) i.add_argument("--language", default=None, help="target language, determined by the orchestrator") i.add_argument("--findings", default=None, diff --git a/skills/orchestrate-stage/scripts/get_status.py b/skills/orchestrate-stage/scripts/get_status.py index 776d20179..53ae0ff07 100644 --- a/skills/orchestrate-stage/scripts/get_status.py +++ b/skills/orchestrate-stage/scripts/get_status.py @@ -29,7 +29,7 @@ strip_quotes) STATE = load_yaml(TRACKING / "state.yaml", {}) or {} -MODE = STATE.get("mode") or "discovery" +MODE = STATE.get("mode") or "assessment" SCAN_LEVEL = STATE.get("scan_level") TRIAGE_LEVEL = STATE.get("triage_level") @@ -357,7 +357,7 @@ def ph_crossref(): return True, [], None -DISCOVERY_PHASES = [ +ASSESSMENT_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), @@ -385,7 +385,7 @@ def ph_crossref(): ("crossref", ph_crossref, lambda: True), ] -PHASES = ENACTMENT_PHASES if MODE == "enactment" else DISCOVERY_PHASES +PHASES = ENACTMENT_PHASES if MODE == "enactment" else ASSESSMENT_PHASES # ---- caps ---- From 27bffbab6380aedd426cbb6f19dd285ff3a748f5 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Fri, 31 Jul 2026 07:25:54 +0200 Subject: [PATCH 3/5] fix(docs): Make the assessment and enactment passes compose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two pipelines are not alternatives. One `.opentaint/` tree accumulates the artifacts of every pass over it, in either order and repeatedly across commits: enactment then assessment hunts with the boundaries the supplied report produced, assessment then enactment measures that report against the corpus the first pass built, and either re-run on a later HEAD is a regression check. `init` refused a mode switch and told the user to start a fresh tree, which made all of that impossible. `mode` is now the pipeline of the current pass, not a property of the tree: - `init` keeps every durable artifact across a switch and prints what carried over - `history.yaml` records one entry per pass, keyed on (commit, mode, levels); re-running init with the same knobs is a resume, not a new pass. `get_status.py --full` prints the chain once there is more than one - `findings` survives an assessment pass, so a later enactment pass inherits the set and `--findings` is required only the first time One consequence: an assessment pass rescans a tree that may carry a reference set, which invalidates the coverage manifest an enactment pass wrote. So `crossref` is gated on the reference set existing rather than on the mode — it closes an assessment pass too, re-judging the supplied findings against the new results. Identical work; only the triggering pass differs. Both pipeline skills now open by saying the run is one pass over a tree that outlives it, and their resumption sections forbid deleting or rewriting an artifact just because this pass didn't produce it. --- .../appsec-agent/sections/choose.md | 26 ++++++-- .../appsec-agent/sections/preamble.md | 4 +- .../assessment-agent/sections/dispatching.md | 3 +- .../assessment-agent/sections/preamble.md | 4 +- .../assessment-agent/sections/setup.md | 4 +- .../sections/state-resumption.md | 4 +- .../assessment-agent/sections/workflow.md | 3 + .../enactment-agent/sections/preamble.md | 6 +- .../enactment-agent/sections/setup.md | 4 +- .../sections/state-resumption.md | 6 +- .../references/crossref.md.j2 | 4 +- skills-templates/shared/scripts/generate.py | 65 +++++++++++++------ skills-templates/shared/scripts/get_status.py | 13 +++- skills/appsec-agent/SKILL.md | 30 ++++++--- skills/appsec-agent/scripts/generate.py | 65 +++++++++++++------ skills/appsec-agent/scripts/get_status.py | 13 +++- skills/assessment-agent/SKILL.md | 18 +++-- skills/assessment-agent/scripts/generate.py | 65 +++++++++++++------ skills/assessment-agent/scripts/get_status.py | 13 +++- skills/enactment-agent/SKILL.md | 16 +++-- skills/enactment-agent/scripts/generate.py | 65 +++++++++++++------ skills/enactment-agent/scripts/get_status.py | 13 +++- .../orchestrate-stage/references/crossref.md | 4 +- skills/orchestrate-stage/scripts/generate.py | 65 +++++++++++++------ .../orchestrate-stage/scripts/get_status.py | 13 +++- 25 files changed, 386 insertions(+), 140 deletions(-) diff --git a/skills-templates/appsec-agent/sections/choose.md b/skills-templates/appsec-agent/sections/choose.md index aa7913788..bbe81a063 100644 --- a/skills-templates/appsec-agent/sections/choose.md +++ b/skills-templates/appsec-agent/sections/choose.md @@ -1,22 +1,34 @@ -### An in-flight run decides for you +### The pipelines compose -If `.opentaint/tracking/state.yaml` already exists, the tree is already committed to a mode and the choice is made — resume that pipeline. Read the mode from status rather than by hand: +They are not alternatives, and picking one is not a commitment. One `.opentaint/` tree accumulates the artifacts of every pass over it, in either order and as many times as the project needs: + +- **enactment, then assessment** — reproduce the supplied report first, then hunt with the rules it produced. The boundaries derived from real findings are exactly the sources and sinks the assessment pass would otherwise have to discover +- **assessment, then enactment** — assess the project, then measure a report against the corpus that pass built. What the report names but the scan missed is now a rule or modeling gap you can point at +- **either, again on a later commit** — the tree is long-lived. A new HEAD makes the model stale, so the pass rebuilds and rescans, and every rule, approximation, and verdict carries over. That's how a run becomes a regression check rather than a one-off + +So `mode` in `state.yaml` is the pipeline of the *current pass*, not a property of the tree. Switching it is normal, needs no fresh tree, and strands nothing. + +### Read what the tree already holds + +If `.opentaint/tracking/state.yaml` exists, find out where the project stands before choosing: ```bash uv run /scripts/get_status.py --full ``` -Its header prints `mode=`, the run's levels, and the current phase. `mode=assessment` → `assessment-agent`; `mode=enactment` → `enactment-agent`. Tell the user what's in flight and where it stands before continuing. +Its header prints the current `mode`, the run's levels, the tracked finding set if there is one, and — once the tree has more than one pass — the `passes:` chain. The phase lines say whether that pass is finished or mid-flight. -A mode is not switchable: the bootstrap refuses it, because each pipeline's tracking is meaningless to the other — an assessment tree has no reference set behind it, and an enactment tree's rules were never derived from a dependency sweep. If the user genuinely wants the other pipeline over the same project, that is a fresh `.opentaint/` tree, and say so plainly rather than starting one silently. +- mid-flight pass — resume it: hand off to `assessment-agent` for `mode=assessment`, `enactment-agent` for `mode=enactment`. Don't start a different pass over the top of an unfinished one +- finished pass, and the user wants more — that's a new pass, and the choice below applies again +- tell the user what's there either way, in one line: which pass, where it stands, what carried over -### A fresh run +### Choose this pass Decide from what the user brought, then confirm it with them before handing off: - **enactment** — they supplied findings, a scanner report, penetration-test results, or source-to-sink traces, and want them reproduced, validated, converted into reusable rules, or cross-checked against OpenTaint. "Does OpenTaint catch these?", "reproduce this report", "turn these findings into rules" -- **assessment** — everything else: no finding set, the goal is what the project is vulnerable to. "Find vulnerabilities", "scan this app", "is this endpoint exploitable?" +- **assessment** — no finding set to measure against; the goal is what the project is vulnerable to. "Find vulnerabilities", "scan this app", "is this endpoint exploitable?" The signal is whether a finding set exists to be measured against, not the vocabulary. A user who says "audit this against last year's pentest" and has the pentest is enactment; a user who says "reproduce the bug I think is in here" and has only a hunch is assessment. -When it's genuinely ambiguous — a report exists but the user wants new findings too — ask. Don't fold both into one run: pick the pipeline they care about now, and note that the other is a separate run over its own tree. +When the user wants both — reproduce the report *and* find what it missed — say that it is two passes over one tree, recommend enactment first so the assessment inherits its boundaries, and run them one at a time. Never try to drive both in a single pass. diff --git a/skills-templates/appsec-agent/sections/preamble.md b/skills-templates/appsec-agent/sections/preamble.md index 905089782..55e43b55f 100644 --- a/skills-templates/appsec-agent/sections/preamble.md +++ b/skills-templates/appsec-agent/sections/preamble.md @@ -7,4 +7,6 @@ OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SA - **assessment** (`assessment-agent`) — find vulnerabilities the project was not known to have. Source and sink rules come from discovering the project's dependency attack surface - **enactment** (`enactment-agent`) — reproduce a finding set the user supplies, as verified rules. Source and sink rules come from generalizing those findings into reusable boundaries -This skill does no analysis of its own and writes nothing except by handing off. Don't bootstrap the tree here — each pipeline's own setup does that, and it is what commits the tree to a mode. +The two compose rather than compete: a project can run one after the other, in either order, and again on later commits, all over one accumulating `.opentaint/` tree. Each such run is a *pass*, and choosing a pipeline chooses this pass, not the project's fate. + +This skill does no analysis of its own and writes nothing except by handing off. Don't bootstrap the tree here — each pipeline's own setup does that. diff --git a/skills-templates/assessment-agent/sections/dispatching.md b/skills-templates/assessment-agent/sections/dispatching.md index 305ca095c..04179a73c 100644 --- a/skills-templates/assessment-agent/sections/dispatching.md +++ b/skills-templates/assessment-agent/sections/dispatching.md @@ -3,7 +3,7 @@ Dispatch exactly one stage-orchestrator subagent for each stage invocation: ``` Invoke the Skill orchestrate-stage first, then follow its instructions precisely Inputs: - stage: + stage: ``` For a `deep` approximation round, also pass `sinks: true`. A subagent inherits the project-root working directory, so omit `project-root`. @@ -14,6 +14,7 @@ Stage context: - `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier - `sinks` — author classified sink rules and wire the joins - `triage` — classify the latest findings and refresh the vulnerability report +- `crossref` — re-judge a reference set an earlier enactment pass left, and refresh its coverage manifest - `poc` — reproduce confirmed findings and add the outcomes to the report - `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure diff --git a/skills-templates/assessment-agent/sections/preamble.md b/skills-templates/assessment-agent/sections/preamble.md index a38ecee52..4419264a2 100644 --- a/skills-templates/assessment-agent/sections/preamble.md +++ b/skills-templates/assessment-agent/sections/preamble.md @@ -2,6 +2,8 @@ Assess a project for vulnerabilities it was not already known to have. Keep the long project build and every full-project scan in this main session; delegate each bounded source, approximation, sink, triage, and PoC stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. -This is the assessment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `enactment-agent`'s, which reproduces a finding set the user supplies; use that one when there is such a set. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface here, generalized from the supplied findings there. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. +This is the assessment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `enactment-agent`'s, which reproduces a finding set the user supplies. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface here, generalized from the supplied findings there. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. + +This run is one *pass* over a tree that outlives it. The pass may follow an enactment pass, in which case its boundaries are already on disk as rules and this pass hunts with them; it may be followed by one; and it may run again on a later commit as a regression check. So leave the tree richer than you found it, and don't treat an artifact you didn't create as debris. If the tree carries a reference set from an enactment pass, your rescans change what it reproduces, and `get_status.py` keeps the cross-reference in scope so its coverage manifest stays true. OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. The run produces confirmed vulnerabilities plus reusable project-specific rules and approximations under one self-contained `.opentaint/` directory at the project root. diff --git a/skills-templates/assessment-agent/sections/setup.md b/skills-templates/assessment-agent/sections/setup.md index 6ad14cfe9..d3658baad 100644 --- a/skills-templates/assessment-agent/sections/setup.md +++ b/skills-templates/assessment-agent/sections/setup.md @@ -38,4 +38,6 @@ Seed the run state and the working tree with the chosen levels and language: uv run /scripts/generate.py init --scan-level --triage-level --language ``` -It writes `state.yaml` with `mode: assessment`, seeds `history.yaml`, and creates the `.opentaint/` tree. +It writes `state.yaml` with `mode: assessment`, appends this pass to `history.yaml`, and creates the `.opentaint/` tree. + +Over a tree an earlier pass already built, it prints what carried over and keeps all of it — an enactment pass's boundary-derived rules, its approximations, and its verdicts are this pass's starting corpus, and `get_status.py` will report their phases `DONE` rather than redoing them. diff --git a/skills-templates/assessment-agent/sections/state-resumption.md b/skills-templates/assessment-agent/sections/state-resumption.md index 27cd0c37f..3b73589d5 100644 --- a/skills-templates/assessment-agent/sections/state-resumption.md +++ b/skills-templates/assessment-agent/sections/state-resumption.md @@ -13,9 +13,9 @@ Use this ownership map to route work and scan errors: issues/ escalation stage ``` -The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan. +The tree is long-lived and outlives this pass. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan, whichever pass created them. Never delete or rewrite an artifact because this pass didn't produce it — an enactment pass's boundary rules, reference set, and coverage manifest are as durable as your own. -`state.yaml` shape: +`state.yaml` shape — `mode` is this pass's pipeline, not a property of the tree, so a later enactment pass simply rewrites it and keeps everything else: ```yaml mode: assessment diff --git a/skills-templates/assessment-agent/sections/workflow.md b/skills-templates/assessment-agent/sections/workflow.md index 3b51e17ad..2498ef595 100644 --- a/skills-templates/assessment-agent/sections/workflow.md +++ b/skills-templates/assessment-agent/sections/workflow.md @@ -8,8 +8,11 @@ approximations → stage subagent: approx-round, then MAIN: rescan; sink_rules → stage subagent: sinks, then MAIN: rescan triage → stage subagent: triage poc → stage subagent: poc +crossref → stage subagent: crossref (only if an enactment pass left a reference set) ``` +`crossref` appears only when a previous enactment pass over this tree left a reference set. This pass's rescans changed what those supplied findings reproduce, so re-judging them and refreshing `.opentaint/enactment.md` is part of finishing — not optional cleanup. + ### Build in MAIN When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. diff --git a/skills-templates/enactment-agent/sections/preamble.md b/skills-templates/enactment-agent/sections/preamble.md index 872c173ca..d6d8eba7c 100644 --- a/skills-templates/enactment-agent/sections/preamble.md +++ b/skills-templates/enactment-agent/sections/preamble.md @@ -2,8 +2,10 @@ Reproduce a supplied set of findings with OpenTaint. Every finding ends the run either matched by a verified OpenTaint result whose trace carries the finding's own identity, or recorded with the exact rule, modeling, or engine limitation that stopped it. Keep the long project build and every full-project scan in this main session; delegate each bounded stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. -This is the enactment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `assessment-agent`'s, which searches the project for vulnerabilities it was not known to have; use that one when nothing was supplied to reproduce. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface there, generalized from the supplied findings here, so both sides exist before the first scan and that scan is rule-first. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. +This is the enactment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `assessment-agent`'s, which searches the project for vulnerabilities it was not known to have. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface there, generalized from the supplied findings here, so both sides exist before the first scan and that scan is rule-first. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. -You may be loaded directly, or handed off by `appsec-agent` once it identified the request as enactment. Either way the setup below is this pipeline's, and running it is what commits the tree to `mode: enactment`. +This run is one *pass* over a tree that outlives it. The pass may follow an assessment pass, inheriting its rules, approximations, and verdicts, and an assessment pass may follow this one to hunt with the boundaries it derived. Either can run again on a later commit. So reproduce this pass's findings and leave the tree richer than you found it; don't treat an artifact you didn't create as debris. + +You may be loaded directly, or handed off by `appsec-agent` once it identified the request as enactment. No finding is dropped for being a poor fit for taint analysis. Authorization, integrity, configuration, hard-coded-secret, and structural-control findings are modeled as explicit pseudo-taint boundaries. diff --git a/skills-templates/enactment-agent/sections/setup.md b/skills-templates/enactment-agent/sections/setup.md index 96825ea39..5f321e9a8 100644 --- a/skills-templates/enactment-agent/sections/setup.md +++ b/skills-templates/enactment-agent/sections/setup.md @@ -39,4 +39,6 @@ Seed the run state and the working tree: uv run /scripts/generate.py init --mode enactment --triage-level --language --findings ``` -It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. It refuses to convert an existing assessment run — that tracking has no reference set behind it, so enactment starts in its own project tree. +It writes `state.yaml` with `mode: enactment`, appends this pass to `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. + +Over a tree an earlier pass already built, it prints what carried over and keeps all of it — an assessment pass's rules, approximations, and verdicts are this pass's starting corpus, and `get_status.py` will report their phases `DONE` rather than redoing them. `--findings` is required only the first time; a later enactment pass inherits the tracked set unless you pass a new one. diff --git a/skills-templates/enactment-agent/sections/state-resumption.md b/skills-templates/enactment-agent/sections/state-resumption.md index 83158812c..cf1c2dcdf 100644 --- a/skills-templates/enactment-agent/sections/state-resumption.md +++ b/skills-templates/enactment-agent/sections/state-resumption.md @@ -16,9 +16,9 @@ Use this ownership map to route work and scan errors: issues/ escalation stage ``` -The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan. +The tree is long-lived and outlives this pass. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan, whichever pass created them. Never delete or rewrite an artifact because this pass didn't produce it — an assessment pass's discovered source units, approximations, and verdicts are as durable as your own. -`state.yaml` shape: +`state.yaml` shape — `mode` is this pass's pipeline, not a property of the tree, so a later assessment pass simply rewrites it and keeps everything else: ```yaml mode: enactment @@ -31,4 +31,4 @@ build_jdk: null max_memory: null ``` -`mode` is what selects this pipeline; `findings` is the supplied set the whole run is measured against. Both are written at bootstrap and never edited afterwards — pointing an in-flight run at a different finding file strands its reference set. +`mode` is what selects this pipeline for this pass; `findings` is the supplied set the pass is measured against, and it stays in `state.yaml` across an assessment pass so a later enactment pass resumes the same set. Neither is edited by hand mid-pass — pointing an in-flight pass at a different finding file strands its reference set. A genuinely different finding set is a new pass, bootstrapped with a new `--findings`. diff --git a/skills-templates/orchestrate-stage/references/crossref.md.j2 b/skills-templates/orchestrate-stage/references/crossref.md.j2 index 781ce95ab..14b5a8328 100644 --- a/skills-templates/orchestrate-stage/references/crossref.md.j2 +++ b/skills-templates/orchestrate-stage/references/crossref.md.j2 @@ -1,6 +1,8 @@ # Cross-reference — scan results against the reference set -Judge, per supplied finding, whether the latest scan reproduced it, and assemble the coverage manifest. Enactment mode only, and the stage that closes the run: every rule, approximation, and verdict is already in place, so what the scan shows now is what the run delivered. Nothing else may set a reference finding's `status`. +Judge, per supplied finding, whether the latest scan reproduced it, and assemble the coverage manifest. This is the stage that closes an enactment pass: every rule, approximation, and verdict is already in place, so what the scan shows now is what the pass delivered. Nothing else may set a reference finding's `status`. + +It also runs at the end of an assessment pass over a tree that already carries a reference set. That pass's new rules and rescans changed what those findings reproduce — usually for the better, occasionally by losing one — so re-judge them all and refresh the manifest. The work is identical; only the pass that triggered it differs. It is also the stage that decides what the run still owes. A judgement here can send the pipeline back — an unmodeled carrier to an approximation round, a rule-caused miss to the stage that authored the rule — and status will report that earlier phase as current again. That is the loop working; re-enter this stage after the rescan rather than closing on stale results. diff --git a/skills-templates/shared/scripts/generate.py b/skills-templates/shared/scripts/generate.py index 7d8af1590..5e204b3c5 100644 --- a/skills-templates/shared/scripts/generate.py +++ b/skills-templates/shared/scripts/generate.py @@ -29,8 +29,8 @@ from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, - dropped_entries, dump_yaml, fqn_base, git_head, ledger_verdicted_keys, - load_yaml, member_key, package_of, strip_quotes) + batch_files, dropped_entries, dump_yaml, fqn_base, git_head, + ledger_verdicted_keys, load_yaml, member_key, package_of, strip_quotes) ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch @@ -51,9 +51,27 @@ STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves +def carried_over(): + """What a pass inherits from the passes before it, as (label, count) — everything durable + the tree already holds. Both pipelines write into one tree, so a pass never starts empty + unless the tree is.""" + def n(paths): + return sum(1 for _ in paths) + return [("rule unit", n(SOURCES_TR.glob("*.yaml")) + n(SINKS_TR.glob("*.yaml"))), + ("created rule", n(p for p in RULES.rglob("*.yaml") if p.is_file())), + ("approximation batch", len(batch_files())), + ("reference finding", n(REFERENCE_TR.glob("*.yaml")) if REFERENCE_TR.is_dir() else 0), + ("triaged finding", n(FINDINGS_TR.glob("*.yaml")))] + + def cmd_init(args): + state_path = TRACKING / "state.yaml" + prior = load_yaml(state_path, {}) or {} enactment = args.mode == "enactment" - if enactment and not args.findings: + # the finding set is a property of the tree, not of one pass — an enactment pass that follows + # an earlier one inherits it, so --findings is required only the first time + findings = args.findings or prior.get("findings") + if enactment and not findings: raise SystemExit("init --mode enactment requires --findings ") if not enactment and not args.scan_level: raise SystemExit("init --mode assessment requires --scan-level") @@ -63,34 +81,43 @@ def cmd_init(args): for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) - state_path = TRACKING / "state.yaml" - prior = load_yaml(state_path, {}) or {} - resume = bool(prior) - if resume and prior.get("mode", "assessment") != args.mode: - raise SystemExit(f"{state_path} is a {prior.get('mode', 'assessment')} run — a mode switch " - "would strand its tracking; start the other mode in a fresh project tree") + # `mode` is this pass's pipeline, not a permanent property of the tree: the two compose, in + # either order and repeatedly across commits, and every artifact below is shared between them. state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} - if enactment: - state["findings"] = args.findings or prior.get("findings") + if findings: # kept even on an assessment pass, so a later one resumes it + state["findings"] = findings for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") - # history: append one run entry on a fresh init, never on resume (the derived knobs survived) + # history: one entry per pass. Re-running init with the same knobs on the same commit is a + # resume of the current pass, not a new one — a different mode, level, or commit starts one. hist_path = TRACKING / "history.yaml" runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] - if not resume: - runs.append({"commit": git_head(), - "type": f"{args.mode}/{scan_level}/{args.triage_level}" - + ("" if args.controls == "on" else "/no-controls")}) + entry = {"commit": git_head(), + "type": f"{args.mode}/{scan_level}/{args.triage_level}"} + new_pass = not runs or runs[-1] != entry + if new_pass: + runs.append(entry) hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") - how = "resumed (derived knobs preserved)" if resume else "fresh" + prior_mode = prior.get("mode") + if not prior: + how = "fresh tree" + elif prior_mode and prior_mode != args.mode: + how = f"new {args.mode} pass over the existing {prior_mode} tree" + elif new_pass: + how = f"new {args.mode} pass (pass {len(runs)})" + else: + how = "resumed (derived knobs preserved)" print(f"init {how}: mode={args.mode} scan_level={scan_level} " f"triage_level={state['triage_level']} language={state['language']}") - if enactment: - print(f"findings={state['findings']}") + if findings: + print(f"findings={findings}") + if prior: + kept = ", ".join(f"{c} {label}{'' if c == 1 else 's'}" for label, c in carried_over() if c) + print(f"carried over: {kept or 'nothing yet'}") print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " "directories under .opentaint/") print("next: uv run scripts/get_status.py --full") diff --git a/skills-templates/shared/scripts/get_status.py b/skills-templates/shared/scripts/get_status.py index 53ae0ff07..e8114ebdd 100644 --- a/skills-templates/shared/scripts/get_status.py +++ b/skills-templates/shared/scripts/get_status.py @@ -357,6 +357,13 @@ def ph_crossref(): return True, [], None +def has_reference_set(): + """A reference set outlives the pass that created it. Any later pass that rescans changes what + it reproduces, so the cross-reference stays in scope — otherwise an assessment pass would leave + a coverage manifest that silently describes an older scan.""" + return REFERENCE_TR.is_dir() and any(REFERENCE_TR.glob("*.yaml")) + + ASSESSMENT_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), @@ -366,6 +373,7 @@ def ph_crossref(): ("sink_rules", ph_sink_rules, lambda: SCAN_LEVEL == "deep"), ("triage", ph_triage, lambda: True), ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), + ("crossref", ph_crossref, has_reference_set), ] # enactment reproduces a supplied finding set: the reference set and its saturated boundaries @@ -428,8 +436,11 @@ def cmd_full(): print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " f"language={STATE.get('language')} commit={commit} " f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") - if MODE == "enactment": + if STATE.get("findings"): # printed on an assessment pass too — the set is still tracked print(f"findings={STATE.get('findings')}") + runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] + if len(runs) > 1: + print("passes: " + " -> ".join(str(r.get("type", "?")) for r in runs)) rows, current = evaluate() # a phase downstream of the current stage that vacuously satisfies its own check is not # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. diff --git a/skills/appsec-agent/SKILL.md b/skills/appsec-agent/SKILL.md index 21cb3653f..7fb7710e1 100644 --- a/skills/appsec-agent/SKILL.md +++ b/skills/appsec-agent/SKILL.md @@ -16,7 +16,9 @@ OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SA - **assessment** (`assessment-agent`) — find vulnerabilities the project was not known to have. Source and sink rules come from discovering the project's dependency attack surface - **enactment** (`enactment-agent`) — reproduce a finding set the user supplies, as verified rules. Source and sink rules come from generalizing those findings into reusable boundaries -This skill does no analysis of its own and writes nothing except by handing off. Don't bootstrap the tree here — each pipeline's own setup does that, and it is what commits the tree to a mode. +The two compose rather than compete: a project can run one after the other, in either order, and again on later commits, all over one accumulating `.opentaint/` tree. Each such run is a *pass*, and choosing a pipeline chooses this pass, not the project's fate. + +This skill does no analysis of its own and writes nothing except by handing off. Don't bootstrap the tree here — each pipeline's own setup does that. ## Setup @@ -37,28 +39,40 @@ These two checks are the only setup steps the pipeline you hand off to may skip. ## Choose the pipeline -### An in-flight run decides for you +### The pipelines compose + +They are not alternatives, and picking one is not a commitment. One `.opentaint/` tree accumulates the artifacts of every pass over it, in either order and as many times as the project needs: + +- **enactment, then assessment** — reproduce the supplied report first, then hunt with the rules it produced. The boundaries derived from real findings are exactly the sources and sinks the assessment pass would otherwise have to discover +- **assessment, then enactment** — assess the project, then measure a report against the corpus that pass built. What the report names but the scan missed is now a rule or modeling gap you can point at +- **either, again on a later commit** — the tree is long-lived. A new HEAD makes the model stale, so the pass rebuilds and rescans, and every rule, approximation, and verdict carries over. That's how a run becomes a regression check rather than a one-off + +So `mode` in `state.yaml` is the pipeline of the *current pass*, not a property of the tree. Switching it is normal, needs no fresh tree, and strands nothing. + +### Read what the tree already holds -If `.opentaint/tracking/state.yaml` already exists, the tree is already committed to a mode and the choice is made — resume that pipeline. Read the mode from status rather than by hand: +If `.opentaint/tracking/state.yaml` exists, find out where the project stands before choosing: ```bash uv run /scripts/get_status.py --full ``` -Its header prints `mode=`, the run's levels, and the current phase. `mode=assessment` → `assessment-agent`; `mode=enactment` → `enactment-agent`. Tell the user what's in flight and where it stands before continuing. +Its header prints the current `mode`, the run's levels, the tracked finding set if there is one, and — once the tree has more than one pass — the `passes:` chain. The phase lines say whether that pass is finished or mid-flight. -A mode is not switchable: the bootstrap refuses it, because each pipeline's tracking is meaningless to the other — an assessment tree has no reference set behind it, and an enactment tree's rules were never derived from a dependency sweep. If the user genuinely wants the other pipeline over the same project, that is a fresh `.opentaint/` tree, and say so plainly rather than starting one silently. +- mid-flight pass — resume it: hand off to `assessment-agent` for `mode=assessment`, `enactment-agent` for `mode=enactment`. Don't start a different pass over the top of an unfinished one +- finished pass, and the user wants more — that's a new pass, and the choice below applies again +- tell the user what's there either way, in one line: which pass, where it stands, what carried over -### A fresh run +### Choose this pass Decide from what the user brought, then confirm it with them before handing off: - **enactment** — they supplied findings, a scanner report, penetration-test results, or source-to-sink traces, and want them reproduced, validated, converted into reusable rules, or cross-checked against OpenTaint. "Does OpenTaint catch these?", "reproduce this report", "turn these findings into rules" -- **assessment** — everything else: no finding set, the goal is what the project is vulnerable to. "Find vulnerabilities", "scan this app", "is this endpoint exploitable?" +- **assessment** — no finding set to measure against; the goal is what the project is vulnerable to. "Find vulnerabilities", "scan this app", "is this endpoint exploitable?" The signal is whether a finding set exists to be measured against, not the vocabulary. A user who says "audit this against last year's pentest" and has the pentest is enactment; a user who says "reproduce the bug I think is in here" and has only a hunch is assessment. -When it's genuinely ambiguous — a report exists but the user wants new findings too — ask. Don't fold both into one run: pick the pipeline they care about now, and note that the other is a separate run over its own tree. +When the user wants both — reproduce the report *and* find what it missed — say that it is two passes over one tree, recommend enactment first so the assessment inherits its boundaries, and run them one at a time. Never try to drive both in a single pass. ## Hand off diff --git a/skills/appsec-agent/scripts/generate.py b/skills/appsec-agent/scripts/generate.py index 7d8af1590..5e204b3c5 100644 --- a/skills/appsec-agent/scripts/generate.py +++ b/skills/appsec-agent/scripts/generate.py @@ -29,8 +29,8 @@ from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, - dropped_entries, dump_yaml, fqn_base, git_head, ledger_verdicted_keys, - load_yaml, member_key, package_of, strip_quotes) + batch_files, dropped_entries, dump_yaml, fqn_base, git_head, + ledger_verdicted_keys, load_yaml, member_key, package_of, strip_quotes) ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch @@ -51,9 +51,27 @@ STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves +def carried_over(): + """What a pass inherits from the passes before it, as (label, count) — everything durable + the tree already holds. Both pipelines write into one tree, so a pass never starts empty + unless the tree is.""" + def n(paths): + return sum(1 for _ in paths) + return [("rule unit", n(SOURCES_TR.glob("*.yaml")) + n(SINKS_TR.glob("*.yaml"))), + ("created rule", n(p for p in RULES.rglob("*.yaml") if p.is_file())), + ("approximation batch", len(batch_files())), + ("reference finding", n(REFERENCE_TR.glob("*.yaml")) if REFERENCE_TR.is_dir() else 0), + ("triaged finding", n(FINDINGS_TR.glob("*.yaml")))] + + def cmd_init(args): + state_path = TRACKING / "state.yaml" + prior = load_yaml(state_path, {}) or {} enactment = args.mode == "enactment" - if enactment and not args.findings: + # the finding set is a property of the tree, not of one pass — an enactment pass that follows + # an earlier one inherits it, so --findings is required only the first time + findings = args.findings or prior.get("findings") + if enactment and not findings: raise SystemExit("init --mode enactment requires --findings ") if not enactment and not args.scan_level: raise SystemExit("init --mode assessment requires --scan-level") @@ -63,34 +81,43 @@ def cmd_init(args): for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) - state_path = TRACKING / "state.yaml" - prior = load_yaml(state_path, {}) or {} - resume = bool(prior) - if resume and prior.get("mode", "assessment") != args.mode: - raise SystemExit(f"{state_path} is a {prior.get('mode', 'assessment')} run — a mode switch " - "would strand its tracking; start the other mode in a fresh project tree") + # `mode` is this pass's pipeline, not a permanent property of the tree: the two compose, in + # either order and repeatedly across commits, and every artifact below is shared between them. state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} - if enactment: - state["findings"] = args.findings or prior.get("findings") + if findings: # kept even on an assessment pass, so a later one resumes it + state["findings"] = findings for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") - # history: append one run entry on a fresh init, never on resume (the derived knobs survived) + # history: one entry per pass. Re-running init with the same knobs on the same commit is a + # resume of the current pass, not a new one — a different mode, level, or commit starts one. hist_path = TRACKING / "history.yaml" runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] - if not resume: - runs.append({"commit": git_head(), - "type": f"{args.mode}/{scan_level}/{args.triage_level}" - + ("" if args.controls == "on" else "/no-controls")}) + entry = {"commit": git_head(), + "type": f"{args.mode}/{scan_level}/{args.triage_level}"} + new_pass = not runs or runs[-1] != entry + if new_pass: + runs.append(entry) hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") - how = "resumed (derived knobs preserved)" if resume else "fresh" + prior_mode = prior.get("mode") + if not prior: + how = "fresh tree" + elif prior_mode and prior_mode != args.mode: + how = f"new {args.mode} pass over the existing {prior_mode} tree" + elif new_pass: + how = f"new {args.mode} pass (pass {len(runs)})" + else: + how = "resumed (derived knobs preserved)" print(f"init {how}: mode={args.mode} scan_level={scan_level} " f"triage_level={state['triage_level']} language={state['language']}") - if enactment: - print(f"findings={state['findings']}") + if findings: + print(f"findings={findings}") + if prior: + kept = ", ".join(f"{c} {label}{'' if c == 1 else 's'}" for label, c in carried_over() if c) + print(f"carried over: {kept or 'nothing yet'}") print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " "directories under .opentaint/") print("next: uv run scripts/get_status.py --full") diff --git a/skills/appsec-agent/scripts/get_status.py b/skills/appsec-agent/scripts/get_status.py index 53ae0ff07..e8114ebdd 100644 --- a/skills/appsec-agent/scripts/get_status.py +++ b/skills/appsec-agent/scripts/get_status.py @@ -357,6 +357,13 @@ def ph_crossref(): return True, [], None +def has_reference_set(): + """A reference set outlives the pass that created it. Any later pass that rescans changes what + it reproduces, so the cross-reference stays in scope — otherwise an assessment pass would leave + a coverage manifest that silently describes an older scan.""" + return REFERENCE_TR.is_dir() and any(REFERENCE_TR.glob("*.yaml")) + + ASSESSMENT_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), @@ -366,6 +373,7 @@ def ph_crossref(): ("sink_rules", ph_sink_rules, lambda: SCAN_LEVEL == "deep"), ("triage", ph_triage, lambda: True), ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), + ("crossref", ph_crossref, has_reference_set), ] # enactment reproduces a supplied finding set: the reference set and its saturated boundaries @@ -428,8 +436,11 @@ def cmd_full(): print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " f"language={STATE.get('language')} commit={commit} " f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") - if MODE == "enactment": + if STATE.get("findings"): # printed on an assessment pass too — the set is still tracked print(f"findings={STATE.get('findings')}") + runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] + if len(runs) > 1: + print("passes: " + " -> ".join(str(r.get("type", "?")) for r in runs)) rows, current = evaluate() # a phase downstream of the current stage that vacuously satisfies its own check is not # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. diff --git a/skills/assessment-agent/SKILL.md b/skills/assessment-agent/SKILL.md index 325e175b5..ff91fb497 100644 --- a/skills/assessment-agent/SKILL.md +++ b/skills/assessment-agent/SKILL.md @@ -11,7 +11,9 @@ metadata: Assess a project for vulnerabilities it was not already known to have. Keep the long project build and every full-project scan in this main session; delegate each bounded source, approximation, sink, triage, and PoC stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. -This is the assessment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `enactment-agent`'s, which reproduces a finding set the user supplies; use that one when there is such a set. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface here, generalized from the supplied findings there. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. +This is the assessment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `enactment-agent`'s, which reproduces a finding set the user supplies. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface here, generalized from the supplied findings there. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. + +This run is one *pass* over a tree that outlives it. The pass may follow an enactment pass, in which case its boundaries are already on disk as rules and this pass hunts with them; it may be followed by one; and it may run again on a later commit as a regression check. So leave the tree richer than you found it, and don't treat an artifact you didn't create as debris. If the tree carries a reference set from an enactment pass, your rescans change what it reproduces, and `get_status.py` keeps the cross-reference in scope so its coverage manifest stays true. OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. The run produces confirmed vulnerabilities plus reusable project-specific rules and approximations under one self-contained `.opentaint/` directory at the project root. @@ -57,7 +59,9 @@ Seed the run state and the working tree with the chosen levels and language: uv run /scripts/generate.py init --scan-level --triage-level --language ``` -It writes `state.yaml` with `mode: assessment`, seeds `history.yaml`, and creates the `.opentaint/` tree. +It writes `state.yaml` with `mode: assessment`, appends this pass to `history.yaml`, and creates the `.opentaint/` tree. + +Over a tree an earlier pass already built, it prints what carried over and keeps all of it — an enactment pass's boundary-derived rules, its approximations, and its verdicts are this pass's starting corpus, and `get_status.py` will report their phases `DONE` rather than redoing them. ## Workflow @@ -71,8 +75,11 @@ approximations → stage subagent: approx-round, then MAIN: rescan; sink_rules → stage subagent: sinks, then MAIN: rescan triage → stage subagent: triage poc → stage subagent: poc +crossref → stage subagent: crossref (only if an enactment pass left a reference set) ``` +`crossref` appears only when a previous enactment pass over this tree left a reference set. This pass's rescans changed what those supplied findings reproduce, so re-judging them and refreshing `.opentaint/enactment.md` is part of finishing — not optional cleanup. + ### Build in MAIN When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. @@ -96,7 +103,7 @@ Dispatch exactly one stage-orchestrator subagent for each stage invocation: ``` Invoke the Skill orchestrate-stage first, then follow its instructions precisely Inputs: - stage: + stage: ``` For a `deep` approximation round, also pass `sinks: true`. A subagent inherits the project-root working directory, so omit `project-root`. @@ -107,6 +114,7 @@ Stage context: - `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier - `sinks` — author classified sink rules and wire the joins - `triage` — classify the latest findings and refresh the vulnerability report +- `crossref` — re-judge a reference set an earlier enactment pass left, and refresh its coverage manifest - `poc` — reproduce confirmed findings and add the outcomes to the report - `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure @@ -131,9 +139,9 @@ Use this ownership map to route work and scan errors: issues/ escalation stage ``` -The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan. +The tree is long-lived and outlives this pass. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan, whichever pass created them. Never delete or rewrite an artifact because this pass didn't produce it — an enactment pass's boundary rules, reference set, and coverage manifest are as durable as your own. -`state.yaml` shape: +`state.yaml` shape — `mode` is this pass's pipeline, not a property of the tree, so a later enactment pass simply rewrites it and keeps everything else: ```yaml mode: assessment diff --git a/skills/assessment-agent/scripts/generate.py b/skills/assessment-agent/scripts/generate.py index 7d8af1590..5e204b3c5 100644 --- a/skills/assessment-agent/scripts/generate.py +++ b/skills/assessment-agent/scripts/generate.py @@ -29,8 +29,8 @@ from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, - dropped_entries, dump_yaml, fqn_base, git_head, ledger_verdicted_keys, - load_yaml, member_key, package_of, strip_quotes) + batch_files, dropped_entries, dump_yaml, fqn_base, git_head, + ledger_verdicted_keys, load_yaml, member_key, package_of, strip_quotes) ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch @@ -51,9 +51,27 @@ STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves +def carried_over(): + """What a pass inherits from the passes before it, as (label, count) — everything durable + the tree already holds. Both pipelines write into one tree, so a pass never starts empty + unless the tree is.""" + def n(paths): + return sum(1 for _ in paths) + return [("rule unit", n(SOURCES_TR.glob("*.yaml")) + n(SINKS_TR.glob("*.yaml"))), + ("created rule", n(p for p in RULES.rglob("*.yaml") if p.is_file())), + ("approximation batch", len(batch_files())), + ("reference finding", n(REFERENCE_TR.glob("*.yaml")) if REFERENCE_TR.is_dir() else 0), + ("triaged finding", n(FINDINGS_TR.glob("*.yaml")))] + + def cmd_init(args): + state_path = TRACKING / "state.yaml" + prior = load_yaml(state_path, {}) or {} enactment = args.mode == "enactment" - if enactment and not args.findings: + # the finding set is a property of the tree, not of one pass — an enactment pass that follows + # an earlier one inherits it, so --findings is required only the first time + findings = args.findings or prior.get("findings") + if enactment and not findings: raise SystemExit("init --mode enactment requires --findings ") if not enactment and not args.scan_level: raise SystemExit("init --mode assessment requires --scan-level") @@ -63,34 +81,43 @@ def cmd_init(args): for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) - state_path = TRACKING / "state.yaml" - prior = load_yaml(state_path, {}) or {} - resume = bool(prior) - if resume and prior.get("mode", "assessment") != args.mode: - raise SystemExit(f"{state_path} is a {prior.get('mode', 'assessment')} run — a mode switch " - "would strand its tracking; start the other mode in a fresh project tree") + # `mode` is this pass's pipeline, not a permanent property of the tree: the two compose, in + # either order and repeatedly across commits, and every artifact below is shared between them. state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} - if enactment: - state["findings"] = args.findings or prior.get("findings") + if findings: # kept even on an assessment pass, so a later one resumes it + state["findings"] = findings for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") - # history: append one run entry on a fresh init, never on resume (the derived knobs survived) + # history: one entry per pass. Re-running init with the same knobs on the same commit is a + # resume of the current pass, not a new one — a different mode, level, or commit starts one. hist_path = TRACKING / "history.yaml" runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] - if not resume: - runs.append({"commit": git_head(), - "type": f"{args.mode}/{scan_level}/{args.triage_level}" - + ("" if args.controls == "on" else "/no-controls")}) + entry = {"commit": git_head(), + "type": f"{args.mode}/{scan_level}/{args.triage_level}"} + new_pass = not runs or runs[-1] != entry + if new_pass: + runs.append(entry) hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") - how = "resumed (derived knobs preserved)" if resume else "fresh" + prior_mode = prior.get("mode") + if not prior: + how = "fresh tree" + elif prior_mode and prior_mode != args.mode: + how = f"new {args.mode} pass over the existing {prior_mode} tree" + elif new_pass: + how = f"new {args.mode} pass (pass {len(runs)})" + else: + how = "resumed (derived knobs preserved)" print(f"init {how}: mode={args.mode} scan_level={scan_level} " f"triage_level={state['triage_level']} language={state['language']}") - if enactment: - print(f"findings={state['findings']}") + if findings: + print(f"findings={findings}") + if prior: + kept = ", ".join(f"{c} {label}{'' if c == 1 else 's'}" for label, c in carried_over() if c) + print(f"carried over: {kept or 'nothing yet'}") print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " "directories under .opentaint/") print("next: uv run scripts/get_status.py --full") diff --git a/skills/assessment-agent/scripts/get_status.py b/skills/assessment-agent/scripts/get_status.py index 53ae0ff07..e8114ebdd 100644 --- a/skills/assessment-agent/scripts/get_status.py +++ b/skills/assessment-agent/scripts/get_status.py @@ -357,6 +357,13 @@ def ph_crossref(): return True, [], None +def has_reference_set(): + """A reference set outlives the pass that created it. Any later pass that rescans changes what + it reproduces, so the cross-reference stays in scope — otherwise an assessment pass would leave + a coverage manifest that silently describes an older scan.""" + return REFERENCE_TR.is_dir() and any(REFERENCE_TR.glob("*.yaml")) + + ASSESSMENT_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), @@ -366,6 +373,7 @@ def ph_crossref(): ("sink_rules", ph_sink_rules, lambda: SCAN_LEVEL == "deep"), ("triage", ph_triage, lambda: True), ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), + ("crossref", ph_crossref, has_reference_set), ] # enactment reproduces a supplied finding set: the reference set and its saturated boundaries @@ -428,8 +436,11 @@ def cmd_full(): print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " f"language={STATE.get('language')} commit={commit} " f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") - if MODE == "enactment": + if STATE.get("findings"): # printed on an assessment pass too — the set is still tracked print(f"findings={STATE.get('findings')}") + runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] + if len(runs) > 1: + print("passes: " + " -> ".join(str(r.get("type", "?")) for r in runs)) rows, current = evaluate() # a phase downstream of the current stage that vacuously satisfies its own check is not # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. diff --git a/skills/enactment-agent/SKILL.md b/skills/enactment-agent/SKILL.md index 38d1aabf3..028af5017 100644 --- a/skills/enactment-agent/SKILL.md +++ b/skills/enactment-agent/SKILL.md @@ -11,9 +11,11 @@ metadata: Reproduce a supplied set of findings with OpenTaint. Every finding ends the run either matched by a verified OpenTaint result whose trace carries the finding's own identity, or recorded with the exact rule, modeling, or engine limitation that stopped it. Keep the long project build and every full-project scan in this main session; delegate each bounded stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. -This is the enactment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `assessment-agent`'s, which searches the project for vulnerabilities it was not known to have; use that one when nothing was supplied to reproduce. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface there, generalized from the supplied findings here, so both sides exist before the first scan and that scan is rule-first. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. +This is the enactment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `assessment-agent`'s, which searches the project for vulnerabilities it was not known to have. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface there, generalized from the supplied findings here, so both sides exist before the first scan and that scan is rule-first. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. -You may be loaded directly, or handed off by `appsec-agent` once it identified the request as enactment. Either way the setup below is this pipeline's, and running it is what commits the tree to `mode: enactment`. +This run is one *pass* over a tree that outlives it. The pass may follow an assessment pass, inheriting its rules, approximations, and verdicts, and an assessment pass may follow this one to hunt with the boundaries it derived. Either can run again on a later commit. So reproduce this pass's findings and leave the tree richer than you found it; don't treat an artifact you didn't create as debris. + +You may be loaded directly, or handed off by `appsec-agent` once it identified the request as enactment. No finding is dropped for being a poor fit for taint analysis. Authorization, integrity, configuration, hard-coded-secret, and structural-control findings are modeled as explicit pseudo-taint boundaries. @@ -60,7 +62,9 @@ Seed the run state and the working tree: uv run /scripts/generate.py init --mode enactment --triage-level --language --findings ``` -It writes `state.yaml`, seeds `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. It refuses to convert an existing assessment run — that tracking has no reference set behind it, so enactment starts in its own project tree. +It writes `state.yaml` with `mode: enactment`, appends this pass to `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. + +Over a tree an earlier pass already built, it prints what carried over and keeps all of it — an assessment pass's rules, approximations, and verdicts are this pass's starting corpus, and `get_status.py` will report their phases `DONE` rather than redoing them. `--findings` is required only the first time; a later enactment pass inherits the tracked set unless you pass a new one. ## Workflow @@ -151,9 +155,9 @@ Use this ownership map to route work and scan errors: issues/ escalation stage ``` -The tree is long-lived. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan. +The tree is long-lived and outlives this pass. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan, whichever pass created them. Never delete or rewrite an artifact because this pass didn't produce it — an assessment pass's discovered source units, approximations, and verdicts are as durable as your own. -`state.yaml` shape: +`state.yaml` shape — `mode` is this pass's pipeline, not a property of the tree, so a later assessment pass simply rewrites it and keeps everything else: ```yaml mode: enactment @@ -166,7 +170,7 @@ build_jdk: null max_memory: null ``` -`mode` is what selects this pipeline; `findings` is the supplied set the whole run is measured against. Both are written at bootstrap and never edited afterwards — pointing an in-flight run at a different finding file strands its reference set. +`mode` is what selects this pipeline for this pass; `findings` is the supplied set the pass is measured against, and it stays in `state.yaml` across an assessment pass so a later enactment pass resumes the same set. Neither is edited by hand mid-pass — pointing an in-flight pass at a different finding file strands its reference set. A genuinely different finding set is a new pass, bootstrapped with a new `--findings`. ## Key constraints diff --git a/skills/enactment-agent/scripts/generate.py b/skills/enactment-agent/scripts/generate.py index 7d8af1590..5e204b3c5 100644 --- a/skills/enactment-agent/scripts/generate.py +++ b/skills/enactment-agent/scripts/generate.py @@ -29,8 +29,8 @@ from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, - dropped_entries, dump_yaml, fqn_base, git_head, ledger_verdicted_keys, - load_yaml, member_key, package_of, strip_quotes) + batch_files, dropped_entries, dump_yaml, fqn_base, git_head, + ledger_verdicted_keys, load_yaml, member_key, package_of, strip_quotes) ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch @@ -51,9 +51,27 @@ STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves +def carried_over(): + """What a pass inherits from the passes before it, as (label, count) — everything durable + the tree already holds. Both pipelines write into one tree, so a pass never starts empty + unless the tree is.""" + def n(paths): + return sum(1 for _ in paths) + return [("rule unit", n(SOURCES_TR.glob("*.yaml")) + n(SINKS_TR.glob("*.yaml"))), + ("created rule", n(p for p in RULES.rglob("*.yaml") if p.is_file())), + ("approximation batch", len(batch_files())), + ("reference finding", n(REFERENCE_TR.glob("*.yaml")) if REFERENCE_TR.is_dir() else 0), + ("triaged finding", n(FINDINGS_TR.glob("*.yaml")))] + + def cmd_init(args): + state_path = TRACKING / "state.yaml" + prior = load_yaml(state_path, {}) or {} enactment = args.mode == "enactment" - if enactment and not args.findings: + # the finding set is a property of the tree, not of one pass — an enactment pass that follows + # an earlier one inherits it, so --findings is required only the first time + findings = args.findings or prior.get("findings") + if enactment and not findings: raise SystemExit("init --mode enactment requires --findings ") if not enactment and not args.scan_level: raise SystemExit("init --mode assessment requires --scan-level") @@ -63,34 +81,43 @@ def cmd_init(args): for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) - state_path = TRACKING / "state.yaml" - prior = load_yaml(state_path, {}) or {} - resume = bool(prior) - if resume and prior.get("mode", "assessment") != args.mode: - raise SystemExit(f"{state_path} is a {prior.get('mode', 'assessment')} run — a mode switch " - "would strand its tracking; start the other mode in a fresh project tree") + # `mode` is this pass's pipeline, not a permanent property of the tree: the two compose, in + # either order and repeatedly across commits, and every artifact below is shared between them. state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} - if enactment: - state["findings"] = args.findings or prior.get("findings") + if findings: # kept even on an assessment pass, so a later one resumes it + state["findings"] = findings for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") - # history: append one run entry on a fresh init, never on resume (the derived knobs survived) + # history: one entry per pass. Re-running init with the same knobs on the same commit is a + # resume of the current pass, not a new one — a different mode, level, or commit starts one. hist_path = TRACKING / "history.yaml" runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] - if not resume: - runs.append({"commit": git_head(), - "type": f"{args.mode}/{scan_level}/{args.triage_level}" - + ("" if args.controls == "on" else "/no-controls")}) + entry = {"commit": git_head(), + "type": f"{args.mode}/{scan_level}/{args.triage_level}"} + new_pass = not runs or runs[-1] != entry + if new_pass: + runs.append(entry) hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") - how = "resumed (derived knobs preserved)" if resume else "fresh" + prior_mode = prior.get("mode") + if not prior: + how = "fresh tree" + elif prior_mode and prior_mode != args.mode: + how = f"new {args.mode} pass over the existing {prior_mode} tree" + elif new_pass: + how = f"new {args.mode} pass (pass {len(runs)})" + else: + how = "resumed (derived knobs preserved)" print(f"init {how}: mode={args.mode} scan_level={scan_level} " f"triage_level={state['triage_level']} language={state['language']}") - if enactment: - print(f"findings={state['findings']}") + if findings: + print(f"findings={findings}") + if prior: + kept = ", ".join(f"{c} {label}{'' if c == 1 else 's'}" for label, c in carried_over() if c) + print(f"carried over: {kept or 'nothing yet'}") print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " "directories under .opentaint/") print("next: uv run scripts/get_status.py --full") diff --git a/skills/enactment-agent/scripts/get_status.py b/skills/enactment-agent/scripts/get_status.py index 53ae0ff07..e8114ebdd 100644 --- a/skills/enactment-agent/scripts/get_status.py +++ b/skills/enactment-agent/scripts/get_status.py @@ -357,6 +357,13 @@ def ph_crossref(): return True, [], None +def has_reference_set(): + """A reference set outlives the pass that created it. Any later pass that rescans changes what + it reproduces, so the cross-reference stays in scope — otherwise an assessment pass would leave + a coverage manifest that silently describes an older scan.""" + return REFERENCE_TR.is_dir() and any(REFERENCE_TR.glob("*.yaml")) + + ASSESSMENT_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), @@ -366,6 +373,7 @@ def ph_crossref(): ("sink_rules", ph_sink_rules, lambda: SCAN_LEVEL == "deep"), ("triage", ph_triage, lambda: True), ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), + ("crossref", ph_crossref, has_reference_set), ] # enactment reproduces a supplied finding set: the reference set and its saturated boundaries @@ -428,8 +436,11 @@ def cmd_full(): print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " f"language={STATE.get('language')} commit={commit} " f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") - if MODE == "enactment": + if STATE.get("findings"): # printed on an assessment pass too — the set is still tracked print(f"findings={STATE.get('findings')}") + runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] + if len(runs) > 1: + print("passes: " + " -> ".join(str(r.get("type", "?")) for r in runs)) rows, current = evaluate() # a phase downstream of the current stage that vacuously satisfies its own check is not # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. diff --git a/skills/orchestrate-stage/references/crossref.md b/skills/orchestrate-stage/references/crossref.md index 379ae42c0..4b8703c79 100644 --- a/skills/orchestrate-stage/references/crossref.md +++ b/skills/orchestrate-stage/references/crossref.md @@ -1,6 +1,8 @@ # Cross-reference — scan results against the reference set -Judge, per supplied finding, whether the latest scan reproduced it, and assemble the coverage manifest. Enactment mode only, and the stage that closes the run: every rule, approximation, and verdict is already in place, so what the scan shows now is what the run delivered. Nothing else may set a reference finding's `status`. +Judge, per supplied finding, whether the latest scan reproduced it, and assemble the coverage manifest. This is the stage that closes an enactment pass: every rule, approximation, and verdict is already in place, so what the scan shows now is what the pass delivered. Nothing else may set a reference finding's `status`. + +It also runs at the end of an assessment pass over a tree that already carries a reference set. That pass's new rules and rescans changed what those findings reproduce — usually for the better, occasionally by losing one — so re-judge them all and refresh the manifest. The work is identical; only the pass that triggered it differs. It is also the stage that decides what the run still owes. A judgement here can send the pipeline back — an unmodeled carrier to an approximation round, a rule-caused miss to the stage that authored the rule — and status will report that earlier phase as current again. That is the loop working; re-enter this stage after the rescan rather than closing on stale results. diff --git a/skills/orchestrate-stage/scripts/generate.py b/skills/orchestrate-stage/scripts/generate.py index 7d8af1590..5e204b3c5 100644 --- a/skills/orchestrate-stage/scripts/generate.py +++ b/skills/orchestrate-stage/scripts/generate.py @@ -29,8 +29,8 @@ from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, - dropped_entries, dump_yaml, fqn_base, git_head, ledger_verdicted_keys, - load_yaml, member_key, package_of, strip_quotes) + batch_files, dropped_entries, dump_yaml, fqn_base, git_head, + ledger_verdicted_keys, load_yaml, member_key, package_of, strip_quotes) ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch @@ -51,9 +51,27 @@ STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves +def carried_over(): + """What a pass inherits from the passes before it, as (label, count) — everything durable + the tree already holds. Both pipelines write into one tree, so a pass never starts empty + unless the tree is.""" + def n(paths): + return sum(1 for _ in paths) + return [("rule unit", n(SOURCES_TR.glob("*.yaml")) + n(SINKS_TR.glob("*.yaml"))), + ("created rule", n(p for p in RULES.rglob("*.yaml") if p.is_file())), + ("approximation batch", len(batch_files())), + ("reference finding", n(REFERENCE_TR.glob("*.yaml")) if REFERENCE_TR.is_dir() else 0), + ("triaged finding", n(FINDINGS_TR.glob("*.yaml")))] + + def cmd_init(args): + state_path = TRACKING / "state.yaml" + prior = load_yaml(state_path, {}) or {} enactment = args.mode == "enactment" - if enactment and not args.findings: + # the finding set is a property of the tree, not of one pass — an enactment pass that follows + # an earlier one inherits it, so --findings is required only the first time + findings = args.findings or prior.get("findings") + if enactment and not findings: raise SystemExit("init --mode enactment requires --findings ") if not enactment and not args.scan_level: raise SystemExit("init --mode assessment requires --scan-level") @@ -63,34 +81,43 @@ def cmd_init(args): for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) - state_path = TRACKING / "state.yaml" - prior = load_yaml(state_path, {}) or {} - resume = bool(prior) - if resume and prior.get("mode", "assessment") != args.mode: - raise SystemExit(f"{state_path} is a {prior.get('mode', 'assessment')} run — a mode switch " - "would strand its tracking; start the other mode in a fresh project tree") + # `mode` is this pass's pipeline, not a permanent property of the tree: the two compose, in + # either order and repeatedly across commits, and every artifact below is shared between them. state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} - if enactment: - state["findings"] = args.findings or prior.get("findings") + if findings: # kept even on an assessment pass, so a later one resumes it + state["findings"] = findings for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") - # history: append one run entry on a fresh init, never on resume (the derived knobs survived) + # history: one entry per pass. Re-running init with the same knobs on the same commit is a + # resume of the current pass, not a new one — a different mode, level, or commit starts one. hist_path = TRACKING / "history.yaml" runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] - if not resume: - runs.append({"commit": git_head(), - "type": f"{args.mode}/{scan_level}/{args.triage_level}" - + ("" if args.controls == "on" else "/no-controls")}) + entry = {"commit": git_head(), + "type": f"{args.mode}/{scan_level}/{args.triage_level}"} + new_pass = not runs or runs[-1] != entry + if new_pass: + runs.append(entry) hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") - how = "resumed (derived knobs preserved)" if resume else "fresh" + prior_mode = prior.get("mode") + if not prior: + how = "fresh tree" + elif prior_mode and prior_mode != args.mode: + how = f"new {args.mode} pass over the existing {prior_mode} tree" + elif new_pass: + how = f"new {args.mode} pass (pass {len(runs)})" + else: + how = "resumed (derived knobs preserved)" print(f"init {how}: mode={args.mode} scan_level={scan_level} " f"triage_level={state['triage_level']} language={state['language']}") - if enactment: - print(f"findings={state['findings']}") + if findings: + print(f"findings={findings}") + if prior: + kept = ", ".join(f"{c} {label}{'' if c == 1 else 's'}" for label, c in carried_over() if c) + print(f"carried over: {kept or 'nothing yet'}") print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " "directories under .opentaint/") print("next: uv run scripts/get_status.py --full") diff --git a/skills/orchestrate-stage/scripts/get_status.py b/skills/orchestrate-stage/scripts/get_status.py index 53ae0ff07..e8114ebdd 100644 --- a/skills/orchestrate-stage/scripts/get_status.py +++ b/skills/orchestrate-stage/scripts/get_status.py @@ -357,6 +357,13 @@ def ph_crossref(): return True, [], None +def has_reference_set(): + """A reference set outlives the pass that created it. Any later pass that rescans changes what + it reproduces, so the cross-reference stays in scope — otherwise an assessment pass would leave + a coverage manifest that silently describes an older scan.""" + return REFERENCE_TR.is_dir() and any(REFERENCE_TR.glob("*.yaml")) + + ASSESSMENT_PHASES = [ ("build", ph_build, lambda: True), ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), @@ -366,6 +373,7 @@ def ph_crossref(): ("sink_rules", ph_sink_rules, lambda: SCAN_LEVEL == "deep"), ("triage", ph_triage, lambda: True), ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), + ("crossref", ph_crossref, has_reference_set), ] # enactment reproduces a supplied finding set: the reference set and its saturated boundaries @@ -428,8 +436,11 @@ def cmd_full(): print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " f"language={STATE.get('language')} commit={commit} " f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") - if MODE == "enactment": + if STATE.get("findings"): # printed on an assessment pass too — the set is still tracked print(f"findings={STATE.get('findings')}") + runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] + if len(runs) > 1: + print("passes: " + " -> ".join(str(r.get("type", "?")) for r in runs)) rows, current = evaluate() # a phase downstream of the current stage that vacuously satisfies its own check is not # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. From 17c8c23340d16bbaa9afea70cbc50607a5918729 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Tue, 18 Aug 2026 18:36:56 +0200 Subject: [PATCH 4/5] docs(skills): Parameterize the boundary leaf by language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enactment split landed `discover-universal-boundaries` authored against Java: it takes `language` as an input and the boundaries stage passes it, but its workflow told the leaf to lean on annotations, `@Valid`, `$MAP.get(...)`, and a JVM descriptor for `candidate_patterns` — vocabulary that belongs to one language, in a body that is supposed to hold the reasoning method. Keep the reasoning, which is language-agnostic, and move the vocabulary into `references/.md` the way the leaf's siblings already do. The Java reference is grounded in the bundled ruleset rather than in generic advice, because what a family actually needs is decided by what the built-ins already match: `spring/untrusted-data-source.yaml` taints every non-scalar parameter of a mapping-annotated method and the servlet rule taints the whole `HttpServletRequest` of an entry method, which puts a Java family's real gap on non-HTTP transports, reactive accessors, and second-order re-entry. It carries the per-class sink coverage with its two notable absences, Bean Validation as the validation impostor, and the `Class#member` plus JVM descriptor shape the spec's `candidate_patterns` take. Also re-apply two skill doc fixes that an earlier rebase dropped: the silently-skipped-file note in the pass-through Java reference, and the `project.yaml` dependencies wording in triage-dependencies. skills/ is regenerated from skills-templates/ via build.py. --- .../assessment-agent/sections/setup.md | 2 +- .../references/java.md.j2 | 1 + .../references/java.md.j2 | 50 +++++++++++++++++++ .../sections/preamble.md | 2 +- .../sections/workflow.md | 12 ++--- .../enactment-agent/sections/setup.md | 2 +- .../references/approx-round.md | 1 + .../triage-dependencies/sections/workflow.md | 2 +- skills/assessment-agent/SKILL.md | 2 +- .../references/java.md | 1 + skills/discover-universal-boundaries/SKILL.md | 14 +++--- .../references/java.md | 50 +++++++++++++++++++ skills/enactment-agent/SKILL.md | 2 +- .../references/approx-round.md | 1 + skills/triage-dependencies/SKILL.md | 2 +- 15 files changed, 124 insertions(+), 20 deletions(-) create mode 100644 skills-templates/discover-universal-boundaries/references/java.md.j2 create mode 100644 skills/discover-universal-boundaries/references/java.md diff --git a/skills-templates/assessment-agent/sections/setup.md b/skills-templates/assessment-agent/sections/setup.md index d3658baad..2a60de3ee 100644 --- a/skills-templates/assessment-agent/sections/setup.md +++ b/skills-templates/assessment-agent/sections/setup.md @@ -15,7 +15,7 @@ This workflow requires two subagent levels: MAIN → stage orchestrator → leaf ### 3. Determine the language -Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. +Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. When a repo carries build markers for more than one, ask the user which to analyze. ### 4. Choose the workflow diff --git a/skills-templates/create-pass-through-approximation/references/java.md.j2 b/skills-templates/create-pass-through-approximation/references/java.md.j2 index 238e762c5..9022e8d6e 100644 --- a/skills-templates/create-pass-through-approximation/references/java.md.j2 +++ b/skills-templates/create-pass-through-approximation/references/java.md.j2 @@ -179,6 +179,7 @@ passThrough: ### 3. Common mistakes to check +- *every* method in a file still dropped, with no load error — the whole file was silently skipped: check it starts with the `language: java` header (a headerless or mis-headered file loads to nothing) - the `function` matcher doesn't match the real method — check the package, class, name, and `overrides` - a `from`/`to` points at the wrong position — it must land where the data actually is - a broken slot channel — the writer and reader name different `Class#slot#java.lang.Object` triples, or the slot isn't typed `java.lang.Object`, so the taint drops between them diff --git a/skills-templates/discover-universal-boundaries/references/java.md.j2 b/skills-templates/discover-universal-boundaries/references/java.md.j2 new file mode 100644 index 000000000..a0652708b --- /dev/null +++ b/skills-templates/discover-universal-boundaries/references/java.md.j2 @@ -0,0 +1,50 @@ +# discover-universal-boundaries — Java / JVM + +## Workflow + +### 1. Reconstruct every trace + +Dependency members come from the resolved jars under `.opentaint/project/dependencies`: locate a class with `unzip -l | grep `, read its signatures with `javap -p -s -classpath `, and prefer the source jar or a decompiler when the body matters. + +Java-shaped propagation that is not ingress: a DTO or entity getter, `Map#get` after the request was already bound, a service method delegating a value it was handed, an `Optional`/stream chain over it, and framework binding of a value the endpoint already accepted. + +### 2. Propose the source + +Built-in Java source rules live under `java/lib/{generic,spring}/` within the `opentaint health --rules` root, the project's own under `.opentaint/rules/java`. A boundary they already match needs no new one, and their reach is wider than it looks: + +- `lib/spring/untrusted-data-source.yaml` taints *every* non-scalar parameter of a method annotated `@RequestMapping`/`@GetMapping`/`@PostMapping`/`@PutMapping`/`@PatchMapping`/`@DeleteMapping`, whatever the parameter's own annotation — so `@RequestBody`, `@RequestParam`, `@PathVariable`, and `@ModelAttribute` on a Spring MVC endpoint are already sourced, as is a cookie read through `WebUtils#getCookie` +- `lib/generic/servlet-untrusted-data-source.yaml` taints the `HttpServletRequest` parameter of a servlet entry method (`doGet`, `doPost`, `doPut`, `doDelete`, `doTrace`, `_jspService`) as a whole object, plus `MessageBodyReader#readFrom`, a commons-fileupload `parseRequest(...).getName()`, and `Part#getSubmittedFileName` +- `lib/spring/untrusted-path-source.yaml` and `lib/generic/seam-untrusted-data-source.yaml` carry their own shapes + +A Java family's shared source is therefore usually *outside* those shapes, and that is where to aim: a non-HTTP transport (`TextMessage#getPayload`, `ConsumerRecord#value`, a JMS or gRPC payload), a reactive `ServerRequest`/`ServerHttpRequest` accessor, a non-Spring stack's own request accessor, or a second-order re-entry where a persisted attacker-controlled record is read back. + +Two shapes carry a Java boundary: an annotation on the declaration, with the enclosing annotated class or method as its `pattern-inside` context, or an accessor on a boundary type. Take the annotated parameter or the accessor itself, never the DTO getter downstream of it — the getter would taint trusted objects just as readily, and the binding step is already propagation. + +### 3. Propose the sink + +Where the primitive effects live, with the built-in `java/lib/` coverage named: + +- SSRF — `RestTemplate`, `URL#openConnection` (`generic/ssrf-sinks.yaml`); `WebClient` and `HttpClient#send` are not built in +- command injection — `Runtime#exec`, `ProcessBuilder` (`generic/command-injection-sinks.yaml`) +- SQL — `Statement#execute*`, `JdbcTemplate`, an `EntityManager` native query (`spring/jdbc-sqli-sinks.yaml`) +- path traversal — `Files#*` reached through `Paths#get`/`Path#resolve` (`generic/path-traversal-sinks.yaml`) +- SSTI, expression, and code injection — the template engine's `process`/`merge` (`generic/template-injection-sinks.yaml`), `SpelExpressionParser` (`spring/spel-injection-sinks.yaml`), `generic/code-injection-sinks.yaml` +- deserialization — `ObjectMapper#readValue`, a SnakeYAML `load` (`generic/unsafe-deserialization-sinks.yaml`); `ObjectInputStream#readObject` is not built in +- log injection — the `Logger` call's message or argument (`generic/logging-sinks.yaml`) +- and their own files for XXE, LDAP, SMTP, reflection, unvalidated redirect, response splitting, and XSS response writes + +Check the bundled set before proposing a sink boundary: it is broad enough that a family often needs only the source side. Prefer the library boundary over the project wrapper that calls it — a reusable library sink keeps the rule useful past this run. + +### 5. Identify the precision controls + +Java's validation impostors: `@Valid`/`@Validated` with Bean Validation constraints, `@NotNull`, `@Size`, or `@Pattern` on a shape that is not the invariant, and a `parse`/`valueOf` that only proves the value's type. Record them as not-security-relevant validators rather than sanitizers. + +Real Java controls: `InetAddress#getByName` plus a private-range rejection for SSRF, `Path#normalize` plus a `startsWith` containment check for traversal, an owner-scoped repository lookup for IDOR, `MessageDigest#isEqual` or HMAC verification for callbacks, and a context-correct encoder or CR/LF stripping for output and log injection. + +A restriction usually reads as `pattern-inside` on the enclosing annotated class or method — that is what keeps a universal accessor scoped to the endpoint that actually takes untrusted input. + +### 6. Write the specification + +`candidate_patterns` entries take `method` as `owner.Class#member` with `signature` its quoted JVM descriptor, so overloads stay distinct. An annotation-carried boundary takes the annotation's own FQN as `method` with `signature: null`, as the spec format shows. The dependency identity to record on the seeded units is the Maven GAV, `group:artifact:version`. + +`create-rule`'s `references/java.md` holds the pattern shapes those units turn into. diff --git a/skills-templates/discover-universal-boundaries/sections/preamble.md b/skills-templates/discover-universal-boundaries/sections/preamble.md index a55f99eaa..c54bd38e7 100644 --- a/skills-templates/discover-universal-boundaries/sections/preamble.md +++ b/skills-templates/discover-universal-boundaries/sections/preamble.md @@ -1,6 +1,6 @@ # Discover universal boundaries -Generalize one family of known finding traces into rule-ready boundaries: a single source and a single sink that every finding in the family factors through. +Generalize one family of known finding traces into rule-ready boundaries: a single source and a single sink that every finding in the family factors through. The boundary vocabulary — how a member is named, what carries a usage condition, which packages realize each primitive effect — is language-specific: read `references/.md` per Inputs and follow its numbered steps, which key to the ones below. The goal is not the broadest syntax that happens to match every trace. It is the most primitive semantic boundary that stays inside the vulnerability class — general enough that the family needs one rule per side, specific enough that the rule still means something. Precision is recovered afterwards with context restrictions and sanitizers, listed separately, never by narrowing the boundary back down to the findings you started from. diff --git a/skills-templates/discover-universal-boundaries/sections/workflow.md b/skills-templates/discover-universal-boundaries/sections/workflow.md index 8920c491b..439d528fb 100644 --- a/skills-templates/discover-universal-boundaries/sections/workflow.md +++ b/skills-templates/discover-universal-boundaries/sections/workflow.md @@ -9,7 +9,7 @@ For each assigned finding, read its reference file and then the project source i 5. the primitive security-relevant effect at the end; and 6. the vulnerable invariant that is absent or defeated. -Read enough surrounding source to tell the real boundary from incidental syntax. A getter, collection lookup, DTO accessor, or service method is usually propagation or context, not ingress. +Read enough surrounding source to tell the real boundary from incidental syntax — the language reference says how to reach a dependency member's source, and which local shapes are propagation rather than ingress. A getter, collection lookup, or service method is usually propagation or context, not ingress. ### 2. Propose the source @@ -23,9 +23,9 @@ Move backward from the finding-specific expressions until you reach the earliest - environment or runtime configuration entering a security decision; or - an explicit structural pseudo-source standing for attacker-selected identity or control state. -Reject a candidate that is merely `$MAP.get(...)` after untrusted data has already entered, a `$METAVAR.method(...)` with no boundary type/signature/annotation/enclosing entrypoint, a ubiquitous getter that would taint trusted objects just as readily, or an internal carrier that ordinary propagation or a later approximation should handle. +The language reference names the boundaries its built-in source rules already carry — one they match needs no new boundary — and what identifies a boundary in that language. Reject a candidate that is merely a map or collection read after untrusted data has already entered, a `$METAVAR.method(...)` with no boundary type, signature, declarative marker, or enclosing entrypoint, a ubiquitous getter that would taint trusted objects just as readily, or an internal carrier that ordinary propagation or a later approximation should handle. -Express narrow usage conditions separately, as typed patterns, annotations, `pattern-inside`, and `...`. Never bake incidental access syntax into the source. +Express narrow usage conditions separately, as typed patterns, `pattern-inside`, `...`, and whatever declarative marker the language offers. Never bake incidental access syntax into the source. ### 3. Propose the sink @@ -42,7 +42,7 @@ Move forward from the finding-specific service calls to the most primitive opera - secret activation, credential construction, or outbound use for secret exposure; and - logger argument consumption for log injection. -Don't stop at a controller-to-service call when the primitive effect is analyzable deeper in the project or a reusable library sink can express it. Use a structural sink only when the vulnerability *is* the missing control at that boundary, or when deeper propagation is genuinely unavailable. +The language reference names the packages that realize these effects, and its built-in sinks. Don't stop at a controller-to-service call when the primitive effect is analyzable deeper in the project or a reusable library sink can express it. Use a structural sink only when the vulnerability *is* the missing control at that boundary, or when deeper propagation is genuinely unavailable. ### 4. Saturate @@ -77,8 +77,8 @@ List these separately from the positive boundaries — the spec records them, an - `pattern-inside` contexts that constrain a universal boundary to the intended entrypoint, type, tenant, or vulnerability family; and - validators that are *not* security-relevant, recorded explicitly so nothing later mistakes them for sanitizers. -Real sanitization looks like resolved-IP private-range rejection for SSRF, canonical-path containment for traversal, strict identifier ownership checks for IDOR, signature verification for callbacks, and CR/LF neutralization for log injection. Presence, length, parsing, or `@Valid` alone normally sanitizes none of these. +Real sanitization looks like resolved-IP private-range rejection for SSRF, canonical-path containment for traversal, strict identifier ownership checks for IDOR, signature verification for callbacks, and CR/LF neutralization for log injection. Presence, length, and parsing alone normally sanitize none of these; the language reference names the usual impostors and what the real controls look like in that language. ### 6. Write the specification -Write one spec per family or subfamily. `candidate_patterns` must be concrete enough for `create-rule` to test — a fully-qualified method with its signature, or the annotation and type that identify the boundary. Record opaque carriers under `approximation_candidates`, and stop there: no approximation is created or recommended until a rule-first scan proves a trace stops at one. +Write one spec per family or subfamily. `candidate_patterns` must be concrete enough for `create-rule` to test, in the member shape that language's rule units use — the language reference gives that shape and the dependency identity to record with it. Record opaque carriers under `approximation_candidates`, and stop there: no approximation is created or recommended until a rule-first scan proves a trace stops at one. diff --git a/skills-templates/enactment-agent/sections/setup.md b/skills-templates/enactment-agent/sections/setup.md index 5f321e9a8..be0702367 100644 --- a/skills-templates/enactment-agent/sections/setup.md +++ b/skills-templates/enactment-agent/sections/setup.md @@ -19,7 +19,7 @@ The supplied findings are this run's input and the only thing it is measured aga ### 4. Determine the language -Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. +Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. When a repo carries build markers for more than one, ask the user which to analyze. ### 5. Choose the workflow diff --git a/skills-templates/orchestrate-stage/references/approx-round.md b/skills-templates/orchestrate-stage/references/approx-round.md index 95c1b0375..847747f20 100644 --- a/skills-templates/orchestrate-stage/references/approx-round.md +++ b/skills-templates/orchestrate-stage/references/approx-round.md @@ -37,6 +37,7 @@ It merges every batch's `skipped` / `engine_issues` into `approximations/skipped - `language` - `batch` - `methods` (optional) — only an explicitly assigned subset to redo + - dataflow step 1 → dispatch create-test-project - `language` - `type: dataflow` diff --git a/skills-templates/triage-dependencies/sections/workflow.md b/skills-templates/triage-dependencies/sections/workflow.md index 89bfc69d2..d5b05cdf9 100644 --- a/skills-templates/triage-dependencies/sections/workflow.md +++ b/skills-templates/triage-dependencies/sections/workflow.md @@ -1,6 +1,6 @@ ### 1. List the dependencies -Read `.opentaint/project/project.yaml` — the `dependencies` list under each per-language projects entry is every third-party dependency the model resolved. Resolve each to the library it is. Most of a large project's dependencies are transitive infrastructure +Read `.opentaint/project/project.yaml` — the `dependencies:` list under each per-language projects entry (e.g. `javaProjects:`) is every third-party dependency the model resolved. Resolve each to the library it is. Most of a large project's dependencies are transitive infrastructure ### 2. Mark each library diff --git a/skills/assessment-agent/SKILL.md b/skills/assessment-agent/SKILL.md index ff91fb497..ad32f515f 100644 --- a/skills/assessment-agent/SKILL.md +++ b/skills/assessment-agent/SKILL.md @@ -36,7 +36,7 @@ This workflow requires two subagent levels: MAIN → stage orchestrator → leaf ### 3. Determine the language -Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. +Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. When a repo carries build markers for more than one, ask the user which to analyze. ### 4. Choose the workflow diff --git a/skills/create-pass-through-approximation/references/java.md b/skills/create-pass-through-approximation/references/java.md index 238e762c5..9022e8d6e 100644 --- a/skills/create-pass-through-approximation/references/java.md +++ b/skills/create-pass-through-approximation/references/java.md @@ -179,6 +179,7 @@ passThrough: ### 3. Common mistakes to check +- *every* method in a file still dropped, with no load error — the whole file was silently skipped: check it starts with the `language: java` header (a headerless or mis-headered file loads to nothing) - the `function` matcher doesn't match the real method — check the package, class, name, and `overrides` - a `from`/`to` points at the wrong position — it must land where the data actually is - a broken slot channel — the writer and reader name different `Class#slot#java.lang.Object` triples, or the slot isn't typed `java.lang.Object`, so the taint drops between them diff --git a/skills/discover-universal-boundaries/SKILL.md b/skills/discover-universal-boundaries/SKILL.md index e2bb8f9d9..76655350c 100644 --- a/skills/discover-universal-boundaries/SKILL.md +++ b/skills/discover-universal-boundaries/SKILL.md @@ -9,7 +9,7 @@ metadata: # Discover universal boundaries -Generalize one family of known finding traces into rule-ready boundaries: a single source and a single sink that every finding in the family factors through. +Generalize one family of known finding traces into rule-ready boundaries: a single source and a single sink that every finding in the family factors through. The boundary vocabulary — how a member is named, what carries a usage condition, which packages realize each primitive effect — is language-specific: read `references/.md` per Inputs and follow its numbered steps, which key to the ones below. The goal is not the broadest syntax that happens to match every trace. It is the most primitive semantic boundary that stays inside the vulnerability class — general enough that the family needs one rule per side, specific enough that the rule still means something. Precision is recovered afterwards with context restrictions and sanitizers, listed separately, never by narrowing the boundary back down to the findings you started from. @@ -38,7 +38,7 @@ For each assigned finding, read its reference file and then the project source i 5. the primitive security-relevant effect at the end; and 6. the vulnerable invariant that is absent or defeated. -Read enough surrounding source to tell the real boundary from incidental syntax. A getter, collection lookup, DTO accessor, or service method is usually propagation or context, not ingress. +Read enough surrounding source to tell the real boundary from incidental syntax — the language reference says how to reach a dependency member's source, and which local shapes are propagation rather than ingress. A getter, collection lookup, or service method is usually propagation or context, not ingress. ### 2. Propose the source @@ -52,9 +52,9 @@ Move backward from the finding-specific expressions until you reach the earliest - environment or runtime configuration entering a security decision; or - an explicit structural pseudo-source standing for attacker-selected identity or control state. -Reject a candidate that is merely `$MAP.get(...)` after untrusted data has already entered, a `$METAVAR.method(...)` with no boundary type/signature/annotation/enclosing entrypoint, a ubiquitous getter that would taint trusted objects just as readily, or an internal carrier that ordinary propagation or a later approximation should handle. +The language reference names the boundaries its built-in source rules already carry — one they match needs no new boundary — and what identifies a boundary in that language. Reject a candidate that is merely a map or collection read after untrusted data has already entered, a `$METAVAR.method(...)` with no boundary type, signature, declarative marker, or enclosing entrypoint, a ubiquitous getter that would taint trusted objects just as readily, or an internal carrier that ordinary propagation or a later approximation should handle. -Express narrow usage conditions separately, as typed patterns, annotations, `pattern-inside`, and `...`. Never bake incidental access syntax into the source. +Express narrow usage conditions separately, as typed patterns, `pattern-inside`, `...`, and whatever declarative marker the language offers. Never bake incidental access syntax into the source. ### 3. Propose the sink @@ -71,7 +71,7 @@ Move forward from the finding-specific service calls to the most primitive opera - secret activation, credential construction, or outbound use for secret exposure; and - logger argument consumption for log injection. -Don't stop at a controller-to-service call when the primitive effect is analyzable deeper in the project or a reusable library sink can express it. Use a structural sink only when the vulnerability *is* the missing control at that boundary, or when deeper propagation is genuinely unavailable. +The language reference names the packages that realize these effects, and its built-in sinks. Don't stop at a controller-to-service call when the primitive effect is analyzable deeper in the project or a reusable library sink can express it. Use a structural sink only when the vulnerability *is* the missing control at that boundary, or when deeper propagation is genuinely unavailable. ### 4. Saturate @@ -106,11 +106,11 @@ List these separately from the positive boundaries — the spec records them, an - `pattern-inside` contexts that constrain a universal boundary to the intended entrypoint, type, tenant, or vulnerability family; and - validators that are *not* security-relevant, recorded explicitly so nothing later mistakes them for sanitizers. -Real sanitization looks like resolved-IP private-range rejection for SSRF, canonical-path containment for traversal, strict identifier ownership checks for IDOR, signature verification for callbacks, and CR/LF neutralization for log injection. Presence, length, parsing, or `@Valid` alone normally sanitizes none of these. +Real sanitization looks like resolved-IP private-range rejection for SSRF, canonical-path containment for traversal, strict identifier ownership checks for IDOR, signature verification for callbacks, and CR/LF neutralization for log injection. Presence, length, and parsing alone normally sanitize none of these; the language reference names the usual impostors and what the real controls look like in that language. ### 6. Write the specification -Write one spec per family or subfamily. `candidate_patterns` must be concrete enough for `create-rule` to test — a fully-qualified method with its signature, or the annotation and type that identify the boundary. Record opaque carriers under `approximation_candidates`, and stop there: no approximation is created or recommended until a rule-first scan proves a trace stops at one. +Write one spec per family or subfamily. `candidate_patterns` must be concrete enough for `create-rule` to test, in the member shape that language's rule units use — the language reference gives that shape and the dependency identity to record with it. Record opaque carriers under `approximation_candidates`, and stop there: no approximation is created or recommended until a rule-first scan proves a trace stops at one. ## Output diff --git a/skills/discover-universal-boundaries/references/java.md b/skills/discover-universal-boundaries/references/java.md new file mode 100644 index 000000000..a0652708b --- /dev/null +++ b/skills/discover-universal-boundaries/references/java.md @@ -0,0 +1,50 @@ +# discover-universal-boundaries — Java / JVM + +## Workflow + +### 1. Reconstruct every trace + +Dependency members come from the resolved jars under `.opentaint/project/dependencies`: locate a class with `unzip -l | grep `, read its signatures with `javap -p -s -classpath `, and prefer the source jar or a decompiler when the body matters. + +Java-shaped propagation that is not ingress: a DTO or entity getter, `Map#get` after the request was already bound, a service method delegating a value it was handed, an `Optional`/stream chain over it, and framework binding of a value the endpoint already accepted. + +### 2. Propose the source + +Built-in Java source rules live under `java/lib/{generic,spring}/` within the `opentaint health --rules` root, the project's own under `.opentaint/rules/java`. A boundary they already match needs no new one, and their reach is wider than it looks: + +- `lib/spring/untrusted-data-source.yaml` taints *every* non-scalar parameter of a method annotated `@RequestMapping`/`@GetMapping`/`@PostMapping`/`@PutMapping`/`@PatchMapping`/`@DeleteMapping`, whatever the parameter's own annotation — so `@RequestBody`, `@RequestParam`, `@PathVariable`, and `@ModelAttribute` on a Spring MVC endpoint are already sourced, as is a cookie read through `WebUtils#getCookie` +- `lib/generic/servlet-untrusted-data-source.yaml` taints the `HttpServletRequest` parameter of a servlet entry method (`doGet`, `doPost`, `doPut`, `doDelete`, `doTrace`, `_jspService`) as a whole object, plus `MessageBodyReader#readFrom`, a commons-fileupload `parseRequest(...).getName()`, and `Part#getSubmittedFileName` +- `lib/spring/untrusted-path-source.yaml` and `lib/generic/seam-untrusted-data-source.yaml` carry their own shapes + +A Java family's shared source is therefore usually *outside* those shapes, and that is where to aim: a non-HTTP transport (`TextMessage#getPayload`, `ConsumerRecord#value`, a JMS or gRPC payload), a reactive `ServerRequest`/`ServerHttpRequest` accessor, a non-Spring stack's own request accessor, or a second-order re-entry where a persisted attacker-controlled record is read back. + +Two shapes carry a Java boundary: an annotation on the declaration, with the enclosing annotated class or method as its `pattern-inside` context, or an accessor on a boundary type. Take the annotated parameter or the accessor itself, never the DTO getter downstream of it — the getter would taint trusted objects just as readily, and the binding step is already propagation. + +### 3. Propose the sink + +Where the primitive effects live, with the built-in `java/lib/` coverage named: + +- SSRF — `RestTemplate`, `URL#openConnection` (`generic/ssrf-sinks.yaml`); `WebClient` and `HttpClient#send` are not built in +- command injection — `Runtime#exec`, `ProcessBuilder` (`generic/command-injection-sinks.yaml`) +- SQL — `Statement#execute*`, `JdbcTemplate`, an `EntityManager` native query (`spring/jdbc-sqli-sinks.yaml`) +- path traversal — `Files#*` reached through `Paths#get`/`Path#resolve` (`generic/path-traversal-sinks.yaml`) +- SSTI, expression, and code injection — the template engine's `process`/`merge` (`generic/template-injection-sinks.yaml`), `SpelExpressionParser` (`spring/spel-injection-sinks.yaml`), `generic/code-injection-sinks.yaml` +- deserialization — `ObjectMapper#readValue`, a SnakeYAML `load` (`generic/unsafe-deserialization-sinks.yaml`); `ObjectInputStream#readObject` is not built in +- log injection — the `Logger` call's message or argument (`generic/logging-sinks.yaml`) +- and their own files for XXE, LDAP, SMTP, reflection, unvalidated redirect, response splitting, and XSS response writes + +Check the bundled set before proposing a sink boundary: it is broad enough that a family often needs only the source side. Prefer the library boundary over the project wrapper that calls it — a reusable library sink keeps the rule useful past this run. + +### 5. Identify the precision controls + +Java's validation impostors: `@Valid`/`@Validated` with Bean Validation constraints, `@NotNull`, `@Size`, or `@Pattern` on a shape that is not the invariant, and a `parse`/`valueOf` that only proves the value's type. Record them as not-security-relevant validators rather than sanitizers. + +Real Java controls: `InetAddress#getByName` plus a private-range rejection for SSRF, `Path#normalize` plus a `startsWith` containment check for traversal, an owner-scoped repository lookup for IDOR, `MessageDigest#isEqual` or HMAC verification for callbacks, and a context-correct encoder or CR/LF stripping for output and log injection. + +A restriction usually reads as `pattern-inside` on the enclosing annotated class or method — that is what keeps a universal accessor scoped to the endpoint that actually takes untrusted input. + +### 6. Write the specification + +`candidate_patterns` entries take `method` as `owner.Class#member` with `signature` its quoted JVM descriptor, so overloads stay distinct. An annotation-carried boundary takes the annotation's own FQN as `method` with `signature: null`, as the spec format shows. The dependency identity to record on the seeded units is the Maven GAV, `group:artifact:version`. + +`create-rule`'s `references/java.md` holds the pattern shapes those units turn into. diff --git a/skills/enactment-agent/SKILL.md b/skills/enactment-agent/SKILL.md index 028af5017..9063b12b4 100644 --- a/skills/enactment-agent/SKILL.md +++ b/skills/enactment-agent/SKILL.md @@ -42,7 +42,7 @@ The supplied findings are this run's input and the only thing it is measured aga ### 4. Determine the language -Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. +Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. When a repo carries build markers for more than one, ask the user which to analyze. ### 5. Choose the workflow diff --git a/skills/orchestrate-stage/references/approx-round.md b/skills/orchestrate-stage/references/approx-round.md index 95c1b0375..847747f20 100644 --- a/skills/orchestrate-stage/references/approx-round.md +++ b/skills/orchestrate-stage/references/approx-round.md @@ -37,6 +37,7 @@ It merges every batch's `skipped` / `engine_issues` into `approximations/skipped - `language` - `batch` - `methods` (optional) — only an explicitly assigned subset to redo + - dataflow step 1 → dispatch create-test-project - `language` - `type: dataflow` diff --git a/skills/triage-dependencies/SKILL.md b/skills/triage-dependencies/SKILL.md index ea8aab241..346a665f9 100644 --- a/skills/triage-dependencies/SKILL.md +++ b/skills/triage-dependencies/SKILL.md @@ -21,7 +21,7 @@ Provided by the caller, fall back to the default value when omitted. Ask back on ### 1. List the dependencies -Read `.opentaint/project/project.yaml` — the `dependencies` list under each per-language projects entry is every third-party dependency the model resolved. Resolve each to the library it is. Most of a large project's dependencies are transitive infrastructure +Read `.opentaint/project/project.yaml` — the `dependencies:` list under each per-language projects entry (e.g. `javaProjects:`) is every third-party dependency the model resolved. Resolve each to the library it is. Most of a large project's dependencies are transitive infrastructure ### 2. Mark each library From 20eaf2f9c8b6c20134c5489e3e11398547422189 Mon Sep 17 00:00:00 2001 From: Aleksandr Misonizhnik Date: Tue, 18 Aug 2026 23:26:00 +0200 Subject: [PATCH 5/5] feat(skills): One entry point, three modes, one pipeline order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enactment split left two orchestrators — assessment-agent and enactment-agent — behind an appsec-agent that only chose between them, and the two ran the pipeline in different orders: enactment authored both rule sides before its first scan, assessment authored sinks after the model round. Two documents describing one machine drift, and the divergent order made the shared stage references say "in enactment mode" about work that is not mode-specific at all. Collapse them back into `appsec-agent` and make the difference a `mode` knob with three values, which is all the difference ever was — where the run's boundary evidence comes from: - onboarding — the external-method frontier: every dependency member the project's own code calls, taken as a trust boundary until a leaf verdicts it. This is the old dependency sweep, named for what it does and scoped to the once-per-project pass it is; init refuses a second one over a tree that already carries the ledger, rules, and models it produced - discovery — the project, a diff, or an informal spec, resolved to the code it names - enactment — the supplied finding set, normalized into the reference set Universal rules are now every mode's output, not enactment's: intake ends with a family list whatever it started from, the boundaries stage generalizes each family into one universal source and one universal sink, and the seeded units are what rule authoring works from. `discover-universal-boundaries` takes `evidence` rather than `finding-ids`, and its reasoning is unchanged — a swept frontier member and a reference finding factor through a boundary the same way. One phase list, in the order the spine has always implied and every mode now follows: build, intake, boundaries, source_rules, scan, approximations, sink_rules, triage, then poc and — whenever the tree carries a reference set, whichever pass left it — crossref. Both boundary sides are decided before the scan that proves them; only the sink rules are authored after it, against the frontier that scan names, so a model can never stand in for a boundary nobody authored. Tracking gains `tracking/scope.yaml` (the family list for the modes that don't carry it on reference files), the boundary spec keys its factorization by `evidence`, and `partition discover` becomes `partition frontier` so the partitioner and the discovery mode no longer share a name for different things. skills/ is regenerated from skills-templates/ via build.py; no engine, rule, or model behaviour changes. --- README.md | 8 +- skills-templates/appsec-agent/main.md.j2 | 22 +- .../appsec-agent/sections/choose.md | 34 - .../sections/dispatching.md | 11 +- .../appsec-agent/sections/handoff.md | 10 - .../sections/key-constraints.md | 5 +- .../appsec-agent/sections/modes.md | 46 ++ .../appsec-agent/sections/preamble.md | 13 +- .../appsec-agent/sections/setup.md | 38 +- .../appsec-agent/sections/state-resumption.md | 36 + .../appsec-agent/sections/workflow.md | 40 ++ skills-templates/assessment-agent/main.md.j2 | 30 - skills-templates/assessment-agent/scripts | 1 - .../assessment-agent/sections/dispatching.md | 23 - .../sections/key-constraints.md | 3 - .../assessment-agent/sections/preamble.md | 9 - .../assessment-agent/sections/setup.md | 43 -- .../sections/state-resumption.md | 28 - .../assessment-agent/sections/workflow.md | 30 - .../discover-attack-surface/main.md.j2 | 2 +- .../sections/preamble.md | 2 +- .../discover-universal-boundaries/main.md.j2 | 2 +- .../sections/constraints.md | 10 +- .../sections/input.md | 4 +- .../sections/output.md | 4 +- .../sections/preamble.md | 4 +- .../sections/tracking.md | 2 +- .../sections/workflow.md | 20 +- skills-templates/enactment-agent/main.md.j2 | 30 - skills-templates/enactment-agent/scripts | 1 - .../enactment-agent/sections/preamble.md | 11 - .../enactment-agent/sections/setup.md | 44 -- .../sections/state-resumption.md | 34 - .../enactment-agent/sections/workflow.md | 39 -- .../references/boundaries.md.j2 | 28 +- .../references/crossref.md.j2 | 4 +- .../orchestrate-stage/references/intake.md.j2 | 75 ++ .../orchestrate-stage/references/sinks.md | 2 +- .../orchestrate-stage/references/sources.md | 36 +- .../orchestrate-stage/sections/workflow.md | 7 +- skills-templates/shared/scripts/_common.py | 19 +- skills-templates/shared/scripts/generate.py | 95 ++- skills-templates/shared/scripts/get_status.py | 114 ++-- .../shared/tracking/boundary-spec.md | 4 +- skills-templates/shared/tracking/scope.md | 14 + skills/appsec-agent/SKILL.md | 224 +++++- skills/appsec-agent/scripts/_common.py | 19 +- skills/appsec-agent/scripts/generate.py | 95 ++- skills/appsec-agent/scripts/get_status.py | 114 ++-- skills/assessment-agent/SKILL.md | 160 ----- skills/assessment-agent/scripts/_common.py | 191 ------ skills/assessment-agent/scripts/generate.py | 639 ------------------ skills/assessment-agent/scripts/get_status.py | 481 ------------- skills/discover-attack-surface/SKILL.md | 4 +- skills/discover-universal-boundaries/SKILL.md | 50 +- skills/enactment-agent/SKILL.md | 182 ----- skills/enactment-agent/scripts/_common.py | 191 ------ skills/enactment-agent/scripts/generate.py | 639 ------------------ skills/enactment-agent/scripts/get_status.py | 481 ------------- skills/orchestrate-stage/SKILL.md | 7 +- .../references/boundaries.md | 52 +- .../orchestrate-stage/references/crossref.md | 4 +- skills/orchestrate-stage/references/intake.md | 121 ++++ skills/orchestrate-stage/references/sinks.md | 2 +- .../orchestrate-stage/references/sources.md | 36 +- skills/orchestrate-stage/scripts/_common.py | 19 +- skills/orchestrate-stage/scripts/generate.py | 95 ++- .../orchestrate-stage/scripts/get_status.py | 114 ++-- 68 files changed, 1146 insertions(+), 3811 deletions(-) delete mode 100644 skills-templates/appsec-agent/sections/choose.md rename skills-templates/{enactment-agent => appsec-agent}/sections/dispatching.md (65%) delete mode 100644 skills-templates/appsec-agent/sections/handoff.md rename skills-templates/{enactment-agent => appsec-agent}/sections/key-constraints.md (59%) create mode 100644 skills-templates/appsec-agent/sections/modes.md create mode 100644 skills-templates/appsec-agent/sections/state-resumption.md create mode 100644 skills-templates/appsec-agent/sections/workflow.md delete mode 100644 skills-templates/assessment-agent/main.md.j2 delete mode 120000 skills-templates/assessment-agent/scripts delete mode 100644 skills-templates/assessment-agent/sections/dispatching.md delete mode 100644 skills-templates/assessment-agent/sections/key-constraints.md delete mode 100644 skills-templates/assessment-agent/sections/preamble.md delete mode 100644 skills-templates/assessment-agent/sections/setup.md delete mode 100644 skills-templates/assessment-agent/sections/state-resumption.md delete mode 100644 skills-templates/assessment-agent/sections/workflow.md delete mode 100644 skills-templates/enactment-agent/main.md.j2 delete mode 120000 skills-templates/enactment-agent/scripts delete mode 100644 skills-templates/enactment-agent/sections/preamble.md delete mode 100644 skills-templates/enactment-agent/sections/setup.md delete mode 100644 skills-templates/enactment-agent/sections/state-resumption.md delete mode 100644 skills-templates/enactment-agent/sections/workflow.md create mode 100644 skills-templates/orchestrate-stage/references/intake.md.j2 create mode 100644 skills-templates/shared/tracking/scope.md delete mode 100644 skills/assessment-agent/SKILL.md delete mode 100644 skills/assessment-agent/scripts/_common.py delete mode 100644 skills/assessment-agent/scripts/generate.py delete mode 100644 skills/assessment-agent/scripts/get_status.py delete mode 100644 skills/enactment-agent/SKILL.md delete mode 100644 skills/enactment-agent/scripts/_common.py delete mode 100644 skills/enactment-agent/scripts/generate.py delete mode 100644 skills/enactment-agent/scripts/get_status.py create mode 100644 skills/orchestrate-stage/references/intake.md diff --git a/README.md b/README.md index fb6352e60..1b916e56d 100644 --- a/README.md +++ b/README.md @@ -166,12 +166,16 @@ OpenTaint includes agent skills that turn static analysis into an end-to-end app npx skills add https://github.com/seqra/opentaint ``` -The `appsec-agent` skill orchestrates a full project assessment: build the project, run OpenTaint, discover the attack surface, add targeted rules, model missing library data flows, triage findings, and optionally generate dynamic proof-of-concept checks for confirmed vulnerabilities. +The `appsec-agent` skill is the entry point: it builds the project, runs OpenTaint, turns the project's attack surface into universal rules, models missing library data flows, triages findings, and optionally generates dynamic proof-of-concept checks for confirmed vulnerabilities. It runs in one of three modes, differing only in what the run starts from: + +- **onboarding** — the external-method frontier, taken as a trust boundary and classified once, to build the project's universal rule and model corpus +- **discovery** — the project, a diff, or an informal spec of what changed or what matters +- **enactment** — a finding set you supply, reproduced as verified rules Included skills cover the common security-analysis loop: - **Scan and triage:** `build-project`, `run-scan`, `analyze-findings`, `generate-poc` -- **Coverage expansion:** `triage-dependencies`, `discover-attack-surface`, `create-test-project`, `create-rule`, `assemble-lib-rules` +- **Coverage expansion:** `triage-dependencies`, `discover-attack-surface`, `discover-universal-boundaries`, `create-test-project`, `create-rule`, `assemble-lib-rules` - **Dataflow modeling:** `analyze-external-methods`, `create-pass-through-approximation`, `create-dataflow-approximation`, `debug-rule`, `report-analyzer-issue` --- diff --git a/skills-templates/appsec-agent/main.md.j2 b/skills-templates/appsec-agent/main.md.j2 index 0229fb5cc..5009c31d7 100644 --- a/skills-templates/appsec-agent/main.md.j2 +++ b/skills-templates/appsec-agent/main.md.j2 @@ -1,6 +1,6 @@ --- name: appsec-agent -description: Entry point for OpenTaint application-security work — confirms the toolchain, picks the pipeline the request needs, and hands off to it. Use when the user asks to find vulnerabilities, scan an application for security issues, reproduce or validate a supplied finding set, or continue an OpenTaint run +description: Run an end-to-end OpenTaint application-security analysis in one of three modes — onboarding a project's dependency frontier, discovering vulnerabilities from the project, a diff, or a spec, or enacting a supplied finding set — owning the long project build and scans and delegating each other pipeline stage. Use when the user asks to find vulnerabilities, scan an application for security issues, reproduce or validate a supplied finding set, or continue an OpenTaint run license: Apache-2.0 metadata: author: opentaint @@ -9,14 +9,26 @@ metadata: {% include "sections/preamble.md" %} +## Modes + +{% include "sections/modes.md" %} + ## Setup {% include "sections/setup.md" %} -## Choose the pipeline +## Workflow + +{% include "sections/workflow.md" %} + +## Dispatching + +{% include "sections/dispatching.md" %} + +## State and resumption -{% include "sections/choose.md" %} +{% include "sections/state-resumption.md" %} -## Hand off +## Key constraints -{% include "sections/handoff.md" %} +{% include "sections/key-constraints.md" %} diff --git a/skills-templates/appsec-agent/sections/choose.md b/skills-templates/appsec-agent/sections/choose.md deleted file mode 100644 index bbe81a063..000000000 --- a/skills-templates/appsec-agent/sections/choose.md +++ /dev/null @@ -1,34 +0,0 @@ -### The pipelines compose - -They are not alternatives, and picking one is not a commitment. One `.opentaint/` tree accumulates the artifacts of every pass over it, in either order and as many times as the project needs: - -- **enactment, then assessment** — reproduce the supplied report first, then hunt with the rules it produced. The boundaries derived from real findings are exactly the sources and sinks the assessment pass would otherwise have to discover -- **assessment, then enactment** — assess the project, then measure a report against the corpus that pass built. What the report names but the scan missed is now a rule or modeling gap you can point at -- **either, again on a later commit** — the tree is long-lived. A new HEAD makes the model stale, so the pass rebuilds and rescans, and every rule, approximation, and verdict carries over. That's how a run becomes a regression check rather than a one-off - -So `mode` in `state.yaml` is the pipeline of the *current pass*, not a property of the tree. Switching it is normal, needs no fresh tree, and strands nothing. - -### Read what the tree already holds - -If `.opentaint/tracking/state.yaml` exists, find out where the project stands before choosing: - -```bash -uv run /scripts/get_status.py --full -``` - -Its header prints the current `mode`, the run's levels, the tracked finding set if there is one, and — once the tree has more than one pass — the `passes:` chain. The phase lines say whether that pass is finished or mid-flight. - -- mid-flight pass — resume it: hand off to `assessment-agent` for `mode=assessment`, `enactment-agent` for `mode=enactment`. Don't start a different pass over the top of an unfinished one -- finished pass, and the user wants more — that's a new pass, and the choice below applies again -- tell the user what's there either way, in one line: which pass, where it stands, what carried over - -### Choose this pass - -Decide from what the user brought, then confirm it with them before handing off: - -- **enactment** — they supplied findings, a scanner report, penetration-test results, or source-to-sink traces, and want them reproduced, validated, converted into reusable rules, or cross-checked against OpenTaint. "Does OpenTaint catch these?", "reproduce this report", "turn these findings into rules" -- **assessment** — no finding set to measure against; the goal is what the project is vulnerable to. "Find vulnerabilities", "scan this app", "is this endpoint exploitable?" - -The signal is whether a finding set exists to be measured against, not the vocabulary. A user who says "audit this against last year's pentest" and has the pentest is enactment; a user who says "reproduce the bug I think is in here" and has only a hunch is assessment. - -When the user wants both — reproduce the report *and* find what it missed — say that it is two passes over one tree, recommend enactment first so the assessment inherits its boundaries, and run them one at a time. Never try to drive both in a single pass. diff --git a/skills-templates/enactment-agent/sections/dispatching.md b/skills-templates/appsec-agent/sections/dispatching.md similarity index 65% rename from skills-templates/enactment-agent/sections/dispatching.md rename to skills-templates/appsec-agent/sections/dispatching.md index b791656ad..98d46d57b 100644 --- a/skills-templates/enactment-agent/sections/dispatching.md +++ b/skills-templates/appsec-agent/sections/dispatching.md @@ -3,19 +3,20 @@ Dispatch exactly one stage-orchestrator subagent for each stage invocation: ``` Invoke the Skill orchestrate-stage first, then follow its instructions precisely Inputs: - stage: + stage: ``` -A subagent inherits the project-root working directory, so omit `project-root`. +For a `deep` model round, also pass `sinks: true`. A subagent inherits the project-root working directory, so omit `project-root`. Stage context: -- `boundaries` — normalize the supplied findings into the reference set, generalize each family into a saturated source and sink boundary, and seed the rule units from it +- `intake` — turn this mode's input into the run's families: the swept frontier in onboarding, the diff or spec in discovery, the normalized reference set in enactment +- `boundaries` — generalize each family into one universal source and one universal sink, and seed the rule units from them - `sources` — author the seeded source units' rules and wire the joins -- `sinks` — author the seeded sink units' rules and wire the joins - `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier -- `crossref` — judge each supplied finding against the latest scan and refresh the coverage manifest +- `sinks` — author the seeded sink units' rules and wire the joins - `triage` — classify the latest findings and refresh the vulnerability report +- `crossref` — judge a reference set against the latest scan and refresh its coverage manifest - `poc` — reproduce confirmed findings and add the outcomes to the report - `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure diff --git a/skills-templates/appsec-agent/sections/handoff.md b/skills-templates/appsec-agent/sections/handoff.md deleted file mode 100644 index 8607b82bd..000000000 --- a/skills-templates/appsec-agent/sections/handoff.md +++ /dev/null @@ -1,10 +0,0 @@ -Load the chosen skill in this same session and follow it from its setup: - -``` -assessment → assessment-agent -enactment → enactment-agent -``` - -Not a subagent. MAIN must own the long build and every full-project scan, so the pipeline continues as this session, with the choice above already settled and the toolchain and nesting checks already done. It runs the rest of its own setup — language, levels, bootstrap — and everything after that is its document, not this one. - -Tell the user which pipeline you picked and why, in one line, before you hand off. diff --git a/skills-templates/enactment-agent/sections/key-constraints.md b/skills-templates/appsec-agent/sections/key-constraints.md similarity index 59% rename from skills-templates/enactment-agent/sections/key-constraints.md rename to skills-templates/appsec-agent/sections/key-constraints.md index 8f0d45e70..bec6b049b 100644 --- a/skills-templates/enactment-agent/sections/key-constraints.md +++ b/skills-templates/appsec-agent/sections/key-constraints.md @@ -1,6 +1,7 @@ - read pipeline state through `/scripts/get_status.py`, not by hand — don't re-derive it with glob/grep/`python3 -c`/yaml scans over `.opentaint/tracking`, `results`, or the `*.yaml`, nor open finding/unit/reference/SARIF files just to review progress. If its output doesn't settle the question, re-run it with `--full` before opening any file - don't author or edit stage-owned artifacts or tracking; MAIN writes only `model_commit`, `build_jdk`, and `max_memory` in `state.yaml` - keep one generated project model for the run; never hand-edit or replace it mid-analysis — fix the build and rebuild before starting a new run -- source and sink rules come before approximation work; an approximation never compensates for a boundary that was never authored -- coverage is counted by unique finding identity, never by rule id or raw SARIF result count, and a result counts as a reproduction only when its trace carries the finding's own attack path +- source and sink boundaries come before model work; a model never compensates for a boundary that was never authored +- run one mode per pass, and never switch `mode` mid-pass: the intake behind the current families would no longer be the one on disk +- where the tree carries a reference set, coverage is counted by unique finding identity, never by rule id or raw SARIF result count, and a result counts as a reproduction only when its trace carries the finding's own attack path - never drop a supplied finding as unsuitable for taint analysis diff --git a/skills-templates/appsec-agent/sections/modes.md b/skills-templates/appsec-agent/sections/modes.md new file mode 100644 index 000000000..770938759 --- /dev/null +++ b/skills-templates/appsec-agent/sections/modes.md @@ -0,0 +1,46 @@ +### What each mode takes in + +- **onboarding** — no input beyond the project itself. The frontier sweep flags the dependencies that can carry untrusted data, partitions the members the project actually calls, and verdicts each one: a trust boundary, an effect, or neither. It is the widest intake there is, and its output — a classified frontier, universal rules for the stack, and the models behind them — is what makes every later pass cheap +- **discovery** — a diff, a spec, a ticket, or a sentence about what the project does and what would be bad. Ask for whatever the user has and record its path; with nothing supplied the scope is the whole project. Intake reads it, resolves it to code, and groups that code into families +- **enactment** — the supplied findings, as a manifest, SARIF, scanner report, or a directory of finding documents. Ask for the path when it isn't given. If the user only described the findings in conversation, write them to a file first and use that; the pipeline resumes from disk, not from this thread + +### Onboarding runs once + +The frontier sweep is the expensive pass, and its corpus is durable: the classification ledger, the universal rules, and the models stay on disk and apply to every scan afterwards, whichever mode ran it. So onboard a project once and then work in discovery or enactment. Bootstrap refuses a second onboarding pass over a tree that already had one and says so; a genuinely new dependency stack is a new tree. + +### The modes compose + +They are not alternatives, and picking one is not a commitment. One `.opentaint/` tree accumulates the artifacts of every pass over it, in any order and as many times as the project needs: + +- **onboarding, then anything** — the natural start. A discovery or enactment pass over an onboarded tree finds most of its boundaries already ruled and modeled, and spends its work on what its own input names +- **enactment, then discovery** — reproduce the supplied report first, then hunt with the rules it produced. Boundaries derived from real findings are exactly the ones a discovery pass would otherwise have to argue for +- **discovery, then enactment** — assess the project, then measure a report against the corpus that pass built. What the report names but the scan missed is now a rule or modeling gap you can point at +- **any of them, again on a later commit** — the tree is long-lived. A new HEAD makes the model stale, so the pass rebuilds and rescans, and every rule, model, and verdict carries over. That's how a run becomes a regression check rather than a one-off + +So `mode` in `state.yaml` is the intake of the *current pass*, not a property of the tree. Switching it is normal, needs no fresh tree, and strands nothing. + +### Read what the tree already holds + +If `.opentaint/tracking/state.yaml` exists, find out where the project stands before choosing: + +```bash +uv run /scripts/get_status.py --full +``` + +Its header prints the current `mode`, the run's levels, the tracked finding set or spec if there is one, and — once the tree has more than one pass — the `passes:` chain. The phase lines say whether that pass is finished or mid-flight. + +- mid-flight pass — resume it rather than starting a different one over the top of it +- finished pass, and the user wants more — that's a new pass, and the choice below applies again +- tell the user what's there either way, in one line: which pass, where it stands, what carried over + +### Choose this pass + +Decide from what the user brought, then confirm it with them before bootstrapping: + +- **enactment** — they supplied findings and want them reproduced, validated, converted into reusable rules, or cross-checked against OpenTaint. "Does OpenTaint catch these?", "reproduce this report", "turn these findings into rules" +- **discovery** — no finding set to measure against; the goal is what the project is vulnerable to, in the whole project or in what a diff or spec names. "Find vulnerabilities", "scan this app", "did this PR introduce anything?" +- **onboarding** — the tree has never been onboarded and the user wants the project's own rule and model corpus built before anything is measured. Recommend it on a cold start when the run is not urgent; it is the pass that makes the others accurate + +The signal for enactment is whether a finding set exists to be measured against, not the vocabulary. A user who says "audit this against last year's pentest" and has the pentest is enactment; a user who says "reproduce the bug I think is in here" and has only a hunch is discovery. + +When the user wants both — reproduce the report *and* find what it missed — say that it is two passes over one tree, recommend enactment first so the discovery pass inherits its boundaries, and run them one at a time. Never try to drive two modes in a single pass. diff --git a/skills-templates/appsec-agent/sections/preamble.md b/skills-templates/appsec-agent/sections/preamble.md index 55e43b55f..b450b9eea 100644 --- a/skills-templates/appsec-agent/sections/preamble.md +++ b/skills-templates/appsec-agent/sections/preamble.md @@ -1,12 +1,13 @@ # AppSec Agent -The entry point for OpenTaint application-security work. Confirm the environment, decide which of the two pipelines the request needs, and hand off to it in this same session. +Run an end-to-end OpenTaint security analysis. Keep the long project build and every full-project scan in this main session; delegate each bounded intake, boundary, rule, approximation, triage, and PoC stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. -OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. Both pipelines run the same machine — MAIN owns the long build and every full-project scan, `orchestrate-stage` subagents own the bounded stages, and all durable state lives under one self-contained `.opentaint/` directory at the project root. They differ only in where the source and sink rules come from: +OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. A run produces confirmed vulnerabilities plus the project's own universal rules and the models behind them — the passThrough and dataflow approximations that carry taint through library code, which the pipeline's `approximations` phase builds. Everything lands under one self-contained `.opentaint/` directory at the project root. -- **assessment** (`assessment-agent`) — find vulnerabilities the project was not known to have. Source and sink rules come from discovering the project's dependency attack surface -- **enactment** (`enactment-agent`) — reproduce a finding set the user supplies, as verified rules. Source and sink rules come from generalizing those findings into reusable boundaries +This is the only entry point. It runs in one of three modes, which differ in what the run takes as its input — and therefore in where the universal rules come from — and in nothing else: -The two compose rather than compete: a project can run one after the other, in either order, and again on later commits, all over one accumulating `.opentaint/` tree. Each such run is a *pass*, and choosing a pipeline chooses this pass, not the project's fate. +- **onboarding** — the external-method frontier: every dependency member the project's own code calls, taken as a trust boundary until a leaf verdicts it. Run once per project, to build the universal rule and model corpus the later passes inherit +- **discovery** — the project, a diff, or an informal spec of what changed or what matters; the code it names becomes the boundary evidence +- **enactment** — a finding set the user supplies: a report, pentest results, or source-to-sink traces, reproduced as verified rules -This skill does no analysis of its own and writes nothing except by handing off. Don't bootstrap the tree here — each pipeline's own setup does that. +Whatever the input, intake groups it into families, the boundaries stage generalizes each family into one universal source and one universal sink, and everything downstream is the same pipeline in the same order. diff --git a/skills-templates/appsec-agent/sections/setup.md b/skills-templates/appsec-agent/sections/setup.md index a34182f60..bea87168f 100644 --- a/skills-templates/appsec-agent/sections/setup.md +++ b/skills-templates/appsec-agent/sections/setup.md @@ -9,6 +9,40 @@ After installing, run `opentaint health` to confirm everything's resolved. ### 2. Confirm agent nesting -Both pipelines require two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. +This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. -These two checks are the only setup steps the pipeline you hand off to may skip. +### 3. Choose the mode and locate its input + +Pick `onboarding`, `discovery`, or `enactment` per Modes, confirm the choice with the user, and get the path its intake needs — the supplied findings for enactment, the diff or spec for discovery when there is one. Onboarding needs no input beyond the project. + +### 4. Determine the language + +Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. When a repo carries build markers for more than one, ask the user which to analyze. + +### 5. Choose the workflow + +Ask the user for the knobs this mode has, together: + +1. Scan level — `lite` · `normal` · `deep`, **discovery mode only** + - lite — build + scan (expected, when there are already existing artifacts) + - normal — build + scan + custom models + - deep — build + scan + custom models + custom universal rules + - recommend by what's on disk: a cold start (no `.opentaint` artifacts) → deep; a prior pass's artifacts already present → lite + - onboarding and enactment are always deep: sweeping the frontier and reproducing a finding set both need the full rule and model toolbox, so there is no level to ask for +2. Triage level — `static` · `dynamic` + - static — classify findings from the model, no running app + - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option + +### 6. Bootstrap + +Seed the run state and the working tree: + +```bash +uv run /scripts/generate.py init --mode \ + --triage-level --language \ + [--scan-level ] [--findings ] [--spec ] +``` + +`--scan-level` is discovery's; `--findings` is enactment's and required the first time; `--spec` is discovery's and optional. It writes `state.yaml`, appends this pass to `history.yaml`, and creates the `.opentaint/` tree — plus `tracking/reference/` in enactment mode. + +Over a tree an earlier pass already built, it prints what carried over and keeps all of it — that pass's rules, models, boundary specs, and verdicts are this pass's starting corpus, and `get_status.py` will report their phases `DONE` rather than redoing them. It refuses a second onboarding pass over an already-onboarded tree. diff --git a/skills-templates/appsec-agent/sections/state-resumption.md b/skills-templates/appsec-agent/sections/state-resumption.md new file mode 100644 index 000000000..6e5afea2f --- /dev/null +++ b/skills-templates/appsec-agent/sections/state-resumption.md @@ -0,0 +1,36 @@ +Use this ownership map to route work and scan errors: + +``` +.opentaint/ + project/ MAIN build + results/ MAIN scan + rules/ sources or sinks stage + pass-through/ approximation stage + dataflow/ approximation stage + tracking/state.yaml MAIN run knobs + tracking/scope.yaml intake stage (onboarding, discovery) + tracking/reference/ intake stage writes, crossref stage judges (enactment) + tracking/boundaries/ boundaries stage + tracking/ stage agents, leaves, and join scripts otherwise + enactment.md crossref stage + vulnerabilities.md triage / PoC stage + issues/ escalation stage +``` + +The tree is long-lived and outlives this pass. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and models apply to every scan, whichever pass created them. Never delete or rewrite an artifact because this pass didn't produce it — an onboarding pass's classification ledger, a discovery pass's boundary specs, and an enactment pass's reference set are all as durable as your own. + +`state.yaml` shape — `mode` is this pass's intake, not a property of the tree, so a later pass in another mode simply rewrites it and keeps everything else: + +```yaml +mode: enactment +scan_level: deep +triage_level: dynamic +language: java +findings: reports/pentest-2026-07.md +spec: null +model_commit: 0123456789abcdef0123456789abcdef01234567 +build_jdk: null +max_memory: null +``` + +`findings` is the supplied set an enactment pass is measured against and `spec` is what a discovery pass was scoped by; both stay in `state.yaml` across passes in other modes, so a later pass in that mode resumes the same input. Neither is edited by hand mid-pass — pointing an in-flight pass at a different file strands the intake built from the old one. A genuinely different input is a new pass, bootstrapped with a new `--findings` or `--spec`. diff --git a/skills-templates/appsec-agent/sections/workflow.md b/skills-templates/appsec-agent/sections/workflow.md new file mode 100644 index 000000000..8aabceaa0 --- /dev/null +++ b/skills-templates/appsec-agent/sections/workflow.md @@ -0,0 +1,40 @@ +The run is one fixed pipeline, the same in every mode; the mode decides what intake works from, and the selected levels decide which phases are in scope. Use `uv run /scripts/get_status.py` to choose the next action: + +``` +build → MAIN: build +intake → stage subagent: intake +boundaries → stage subagent: boundaries +source_rules → stage subagent: sources +scan → MAIN: scan +approximations → stage subagent: approx-round, then MAIN: rescan; repeat +sink_rules → stage subagent: sinks, then MAIN: rescan +triage → stage subagent: triage +poc → stage subagent: poc +crossref → stage subagent: crossref (whenever the tree carries a reference set) +``` + +The spine is fixed — build, sources, scan, models, sinks, triage — and it is fixed for a reason. Both boundary sides are known before the first scan, because that scan is what proves them and names the taint frontier the model work then answers; the sink rules are authored against that frontier rather than guessed ahead of it. Never let a model stand in for a boundary that was never authored: an approximation carries taint through a carrier, it does not decide what is untrusted or what is dangerous. + +`crossref` appears whenever the tree carries a reference set — the pass that supplied it need not be this one. This pass's rescans changed what those findings reproduce, so re-judging them and refreshing `.opentaint/enactment.md` is part of finishing, not optional cleanup. + +### Build in MAIN + +When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. + +Record the returned `build_jdk` in `.opentaint/tracking/state.yaml`. Record `model_commit` as the full HEAD only when no source file is uncommitted, otherwise set it to null. Build non-convergence blocks the run because no later phase can proceed without the model. Keep one untouched model for the whole pass. + +### Scan in MAIN + +When status reports `scan`, or a stage returns with a rescan pending, load and follow the `run-scan` skill in this main session. Start the scan with the harness's main-session background-command facility, keep the engine's self-timeout, add a 1200-second outer backstop, and wait for the process completion event. + +A valid `.opentaint/results/report.sarif` means the scan completed, including exit 254 after an engine timeout. Record `max_memory: 16G` when the scan had to bump memory and reuse it on later scans. If no SARIF exists after the allowed retry/backstop, follow the repair path below for a malformed rule/model; otherwise dispatch `orchestrate-stage` with `stage: escalation` and the scan `setup` to write the scan-wide resource issue, then stop. + +When a scan or later stage reports a malformed model, unloadable created rule, ineffective join, or a created rule's false positive/negative, route the exact diagnosis and artifact path/id to the responsible stage agent per Dispatching, then scan again in MAIN. + +After every build, scan, or stage return, run `uv run /scripts/get_status.py` once to choose the next action. Use `--full` at run start, on resume, or when the brief output does not settle the question. + +### Iterating + +The pipeline loops by design, and status is what closes it. A rescan makes every cross-reference pending again; a trace stopped at an opaque carrier sends the run back to a model round; a rule blamed for a miss goes back to the stage that authored it, so status returns there before closing again. Follow status through each loop rather than declaring the run finished early — an earlier phase reading `IN_PROGRESS` after a later one ran is the loop working, not a regression. + +The run is complete when status reports `run complete`: `.opentaint/vulnerabilities.md` current, and — where the tree carries a reference set — every supplied finding either reproduced or recorded with the blocker that stopped it, in `.opentaint/enactment.md`. Report the outcome keeping raw SARIF results, validated findings, and unique vulnerability identities as separate counts. diff --git a/skills-templates/assessment-agent/main.md.j2 b/skills-templates/assessment-agent/main.md.j2 deleted file mode 100644 index 5f23c50a7..000000000 --- a/skills-templates/assessment-agent/main.md.j2 +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: assessment-agent -description: Assess a project for unknown vulnerabilities with OpenTaint, owning the long project build and scans and delegating each other pipeline stage. Use when the user asks to find vulnerabilities or scan an application for security issues, with no finding set supplied to reproduce -license: Apache-2.0 -metadata: - author: opentaint - version: "{{ version }}" ---- - -{% include "sections/preamble.md" %} - -## Setup - -{% include "sections/setup.md" %} - -## Workflow - -{% include "sections/workflow.md" %} - -## Dispatching - -{% include "sections/dispatching.md" %} - -## State and resumption - -{% include "sections/state-resumption.md" %} - -## Key constraints - -{% include "sections/key-constraints.md" %} diff --git a/skills-templates/assessment-agent/scripts b/skills-templates/assessment-agent/scripts deleted file mode 120000 index aca7e2caf..000000000 --- a/skills-templates/assessment-agent/scripts +++ /dev/null @@ -1 +0,0 @@ -../shared/scripts \ No newline at end of file diff --git a/skills-templates/assessment-agent/sections/dispatching.md b/skills-templates/assessment-agent/sections/dispatching.md deleted file mode 100644 index 04179a73c..000000000 --- a/skills-templates/assessment-agent/sections/dispatching.md +++ /dev/null @@ -1,23 +0,0 @@ -Dispatch exactly one stage-orchestrator subagent for each stage invocation: - -``` -Invoke the Skill orchestrate-stage first, then follow its instructions precisely -Inputs: - stage: -``` - -For a `deep` approximation round, also pass `sinks: true`. A subagent inherits the project-root working directory, so omit `project-root`. - -Stage context: - -- `sources` — discover dependency sources, author their rules, and wire the joins -- `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier -- `sinks` — author classified sink rules and wire the joins -- `triage` — classify the latest findings and refresh the vulnerability report -- `crossref` — re-judge a reference set an earlier enactment pass left, and refresh its coverage manifest -- `poc` — reproduce confirmed findings and add the outcomes to the report -- `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure - -Keep each agent id until the next scan validates its artifacts. On a stage-owned error, resume that agent with `stage: escalation`, the exact error, and the artifact path/id. If its thread is unavailable, start a re-entrant `orchestrate-stage` agent with that diagnosis. - -Dispatch each subagent fresh, don't fork context into it. Then wait for it natively, don't monitor or poll every minute. If the harness forces a wait timeout, set it to ~1h and re-wait when it returns. diff --git a/skills-templates/assessment-agent/sections/key-constraints.md b/skills-templates/assessment-agent/sections/key-constraints.md deleted file mode 100644 index 66d68e7d7..000000000 --- a/skills-templates/assessment-agent/sections/key-constraints.md +++ /dev/null @@ -1,3 +0,0 @@ -- read pipeline state through `/scripts/get_status.py`, not by hand — don't re-derive it with glob/grep/`python3 -c`/yaml scans over `.opentaint/tracking`, `results`, or the `*.yaml`, nor open finding/unit/SARIF files just to review progress. If its output doesn't settle the question, re-run it with `--full` before opening any file -- don't author or edit stage-owned artifacts or tracking; MAIN writes only `model_commit`, `build_jdk`, and `max_memory` in `state.yaml` -- keep one generated project model for the run; never hand-edit or replace it mid-analysis — fix the build and rebuild before starting a new run diff --git a/skills-templates/assessment-agent/sections/preamble.md b/skills-templates/assessment-agent/sections/preamble.md deleted file mode 100644 index 4419264a2..000000000 --- a/skills-templates/assessment-agent/sections/preamble.md +++ /dev/null @@ -1,9 +0,0 @@ -# Assessment Agent - -Assess a project for vulnerabilities it was not already known to have. Keep the long project build and every full-project scan in this main session; delegate each bounded source, approximation, sink, triage, and PoC stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. - -This is the assessment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `enactment-agent`'s, which reproduces a finding set the user supplies. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface here, generalized from the supplied findings there. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. - -This run is one *pass* over a tree that outlives it. The pass may follow an enactment pass, in which case its boundaries are already on disk as rules and this pass hunts with them; it may be followed by one; and it may run again on a later commit as a regression check. So leave the tree richer than you found it, and don't treat an artifact you didn't create as debris. If the tree carries a reference set from an enactment pass, your rescans change what it reproduces, and `get_status.py` keeps the cross-reference in scope so its coverage manifest stays true. - -OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. The run produces confirmed vulnerabilities plus reusable project-specific rules and approximations under one self-contained `.opentaint/` directory at the project root. diff --git a/skills-templates/assessment-agent/sections/setup.md b/skills-templates/assessment-agent/sections/setup.md deleted file mode 100644 index 2a60de3ee..000000000 --- a/skills-templates/assessment-agent/sections/setup.md +++ /dev/null @@ -1,43 +0,0 @@ -Skip only what `appsec-agent` already did when it handed off — the toolchain and nesting checks. Everything from step 3 on is this pipeline's own, including the bootstrap. - -### 1. Confirm the toolchain - -Confirm `opentaint` is on PATH with `opentaint -v`. If it's missing, don't proceed silently — tell the user and offer the install command for their platform, run an install only on explicit confirmation: - -- macOS / Linux, in order: `brew install --cask seqra/tap/opentaint` · `npm install -g @seqra/opentaint` -- Windows: `npm install -g @seqra/opentaint` - -After installing, run `opentaint health` to confirm everything's resolved. - -### 2. Confirm agent nesting - -This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. - -### 3. Determine the language - -Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. When a repo carries build markers for more than one, ask the user which to analyze. - -### 4. Choose the workflow - -Ask the user for both knobs together: - -1. Scan level — `lite` · `normal` · `deep` - - lite — build + scan (expected, when there are already existing artifacts) - - normal — build + scan + custom approximations - - deep — build + scan + custom approximations + custom rules - - recommend by what's on disk: a cold start (no `.opentaint` artifacts) → deep; a prior run's artifacts already present → lite -2. Triage level — `static` · `dynamic` - - static — classify findings from the model, no running app - - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option - -### 5. Bootstrap - -Seed the run state and the working tree with the chosen levels and language: - -```bash -uv run /scripts/generate.py init --scan-level --triage-level --language -``` - -It writes `state.yaml` with `mode: assessment`, appends this pass to `history.yaml`, and creates the `.opentaint/` tree. - -Over a tree an earlier pass already built, it prints what carried over and keeps all of it — an enactment pass's boundary-derived rules, its approximations, and its verdicts are this pass's starting corpus, and `get_status.py` will report their phases `DONE` rather than redoing them. diff --git a/skills-templates/assessment-agent/sections/state-resumption.md b/skills-templates/assessment-agent/sections/state-resumption.md deleted file mode 100644 index 3b73589d5..000000000 --- a/skills-templates/assessment-agent/sections/state-resumption.md +++ /dev/null @@ -1,28 +0,0 @@ -Use this ownership map to route work and scan errors: - -``` -.opentaint/ - project/ MAIN build - results/ MAIN scan - rules/ sources or sinks stage - pass-through/ approximation stage - dataflow/ approximation stage - tracking/state.yaml MAIN run knobs - tracking/ stage agents, leaves, and join scripts otherwise - vulnerabilities.md triage / PoC stage - issues/ escalation stage -``` - -The tree is long-lived and outlives this pass. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan, whichever pass created them. Never delete or rewrite an artifact because this pass didn't produce it — an enactment pass's boundary rules, reference set, and coverage manifest are as durable as your own. - -`state.yaml` shape — `mode` is this pass's pipeline, not a property of the tree, so a later enactment pass simply rewrites it and keeps everything else: - -```yaml -mode: assessment -scan_level: deep -triage_level: dynamic -language: java -model_commit: 0123456789abcdef0123456789abcdef01234567 -build_jdk: null -max_memory: null -``` diff --git a/skills-templates/assessment-agent/sections/workflow.md b/skills-templates/assessment-agent/sections/workflow.md deleted file mode 100644 index 2498ef595..000000000 --- a/skills-templates/assessment-agent/sections/workflow.md +++ /dev/null @@ -1,30 +0,0 @@ -The run is one fixed pipeline; the selected levels determine which phases are in scope. Use `uv run /scripts/get_status.py` to choose the next action: - -``` -build → MAIN: build -discover / source_rules → stage subagent: sources -scan → MAIN: scan -approximations → stage subagent: approx-round, then MAIN: rescan; repeat -sink_rules → stage subagent: sinks, then MAIN: rescan -triage → stage subagent: triage -poc → stage subagent: poc -crossref → stage subagent: crossref (only if an enactment pass left a reference set) -``` - -`crossref` appears only when a previous enactment pass over this tree left a reference set. This pass's rescans changed what those supplied findings reproduce, so re-judging them and refreshing `.opentaint/enactment.md` is part of finishing — not optional cleanup. - -### Build in MAIN - -When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. - -Record the returned `build_jdk` in `.opentaint/tracking/state.yaml`. Record `model_commit` as the full HEAD only when no source file is uncommitted, otherwise set it to null. Build non-convergence blocks the run because no later phase can proceed without the model. - -### Scan in MAIN - -When status reports `scan`, or a stage returns with a rescan pending, load and follow the `run-scan` skill in this main session. Start the scan with the harness's main-session background-command facility, keep the engine's self-timeout, add a 1200-second outer backstop, and wait for the process completion event. - -A valid `.opentaint/results/report.sarif` means the scan completed, including exit 254 after an engine timeout. Record `max_memory: 16G` when the scan had to bump memory and reuse it on later scans. If no SARIF exists after the allowed retry/backstop, follow the repair path below for a malformed rule/approximation; otherwise dispatch `orchestrate-stage` with `stage: escalation` and the scan `setup` to write the scan-wide resource issue, then stop. - -When a scan or later stage reports a malformed approximation, unloadable created rule, ineffective join, or a created rule's false positive/negative, route the exact diagnosis and artifact path/id to the responsible stage agent per Dispatching, then scan again in MAIN. - -After every build, scan, or stage return, run `uv run /scripts/get_status.py` once to choose the next action. Use `--full` at run start, on resume, or when the brief output does not settle the question. diff --git a/skills-templates/discover-attack-surface/main.md.j2 b/skills-templates/discover-attack-surface/main.md.j2 index 4d8142730..26d5d54ef 100644 --- a/skills-templates/discover-attack-surface/main.md.j2 +++ b/skills-templates/discover-attack-surface/main.md.j2 @@ -1,6 +1,6 @@ --- name: discover-attack-surface -description: Classify project-used dependency members and record the taint sources among them not covered by the built-in rules. Use for the source-discovery depth pass +description: Classify a slice of the external-method frontier — the dependency members the project calls — as trust boundaries or not, and record the taint sources among them that the built-in rules don't cover. Use for the frontier sweep of an onboarding pass, or wherever a scoped attack surface has to be classified license: Apache-2.0 metadata: author: opentaint diff --git a/skills-templates/discover-attack-surface/sections/preamble.md b/skills-templates/discover-attack-surface/sections/preamble.md index 85162b006..320f248af 100644 --- a/skills-templates/discover-attack-surface/sections/preamble.md +++ b/skills-templates/discover-attack-surface/sections/preamble.md @@ -1,3 +1,3 @@ # Skill: Discover Attack Surface -Work one assignment of project-used dependency members and pick out the taint sources among them — the methods where untrusted data first enters. The concrete inspection commands and value formats are language-specific — read `references/.md` per Inputs and follow its numbered steps, which key to the ones below +Work one assignment of the external-method frontier — the dependency members the project's own code calls — and pick out the trust boundaries among them: the methods where untrusted data first enters. Every member on the plan arrives as a candidate boundary and leaves with a verdict, so a member you leave unjudged is a gap in the sweep, not a default-safe. The concrete inspection commands and value formats are language-specific — read `references/.md` per Inputs and follow its numbered steps, which key to the ones below diff --git a/skills-templates/discover-universal-boundaries/main.md.j2 b/skills-templates/discover-universal-boundaries/main.md.j2 index a2f1e0eb4..1ee6665b7 100644 --- a/skills-templates/discover-universal-boundaries/main.md.j2 +++ b/skills-templates/discover-universal-boundaries/main.md.j2 @@ -1,6 +1,6 @@ --- name: discover-universal-boundaries -description: Generalize a family of known finding traces into one reusable source and one reusable sink boundary, saturated against the whole family. Use before rule authoring when reproducing supplied findings, consolidating finding-specific rules, or replacing an incidental source such as a map access or an arbitrary method call +description: Generalize a family of attack-surface evidence — finding traces, swept frontier members, or the code a diff or spec names — into one reusable source and one reusable sink boundary, saturated against the whole family. Use before rule authoring when reproducing supplied findings, turning a discovered surface into universal rules, consolidating case-specific rules, or replacing an incidental source such as a map access or an arbitrary method call license: Apache-2.0 metadata: author: opentaint diff --git a/skills-templates/discover-universal-boundaries/sections/constraints.md b/skills-templates/discover-universal-boundaries/sections/constraints.md index 33fbd8c22..38043d2dc 100644 --- a/skills-templates/discover-universal-boundaries/sections/constraints.md +++ b/skills-templates/discover-universal-boundaries/sections/constraints.md @@ -1,7 +1,7 @@ -- Work only the assigned finding ids, your family's spec, and the reference `family` field on a split -- Universal does not mean untyped or unconstrained — generalize the boundary, then recover precision with context restrictions and sanitizers, never by re-narrowing the boundary to the findings -- Never propose all methods, all map values, or all getters as a source, and never a `pattern-either` with one branch per finding +- Work only the assigned evidence, your family's spec, and the reference `family` field on a split +- Universal does not mean untyped or unconstrained — generalize the boundary, then recover precision with context restrictions and sanitizers, never by re-narrowing the boundary to the evidence +- Never propose all methods, all map values, or all getters as a source, and never a `pattern-either` with one branch per evidence item - Never mark the spec saturated on a round that widened, broke, or split anything -- Never propose a sanitizer that would suppress a trace the reference set says is real, and never record a validator as a sanitizer without the invariant it enforces -- Record a poor taint fit as an explicit pseudo-boundary; never drop the finding +- Never propose a sanitizer that would suppress a trace the evidence says is real, and never record a validator as a sanitizer without the invariant it enforces +- Record a poor taint fit as an explicit pseudo-boundary; never drop the evidence item - Don't create or recommend approximations here — note the opaque carriers and stop diff --git a/skills-templates/discover-universal-boundaries/sections/input.md b/skills-templates/discover-universal-boundaries/sections/input.md index f1ebc55a7..bfae2bbd8 100644 --- a/skills-templates/discover-universal-boundaries/sections/input.md +++ b/skills-templates/discover-universal-boundaries/sections/input.md @@ -2,6 +2,6 @@ {% include "shared/inputs/project-root.md" %} {% include "shared/inputs/language.md" %} -- `findings` (required) — path to the supplied finding manifest or report the reference set was normalized from -- `finding-ids` (required) — the reference finding ids assigned to this family. Their normalized files are `.opentaint/tracking/reference/.yaml` - `family` (required) — kebab-case name of the family; also the name of its spec and of the rule units seeded from it +- `evidence` (required) — the ids assigned to this family. Reference finding ids when the pass has a reference set, whose normalized files are `.opentaint/tracking/reference/.yaml`; otherwise the members or code areas `.opentaint/tracking/scope.yaml` recorded for the family +- `findings` (optional) — path to the supplied finding manifest or report the reference set was normalized from, when there is one. Read it for detail a normalized file doesn't carry diff --git a/skills-templates/discover-universal-boundaries/sections/output.md b/skills-templates/discover-universal-boundaries/sections/output.md index 810530794..a3a31e6eb 100644 --- a/skills-templates/discover-universal-boundaries/sections/output.md +++ b/skills-templates/discover-universal-boundaries/sections/output.md @@ -3,8 +3,8 @@ Return every spec path written (one per family or subfamily) and, in a few lines - the universal source, and which more incidental candidates you rejected; - the universal sink, and the vulnerability-class scope it stays inside; - how many saturation rounds it took and what the last round changed; -- the assigned finding ids by factorization status — `covered`, `needs-restriction`, `unfactored`; -- any family split, with the ids that moved and the reference files you rewrote; +- the assigned evidence by factorization status — `covered`, `needs-restriction`, `unfactored`; +- any family split, with the evidence that moved and — when it is a reference finding — the files you rewrote; - the sanitizers, negative patterns, and context restrictions listed separately from the boundaries; and - unresolved boundary evidence, as the `open_questions` entries. diff --git a/skills-templates/discover-universal-boundaries/sections/preamble.md b/skills-templates/discover-universal-boundaries/sections/preamble.md index c54bd38e7..909f0eb46 100644 --- a/skills-templates/discover-universal-boundaries/sections/preamble.md +++ b/skills-templates/discover-universal-boundaries/sections/preamble.md @@ -1,7 +1,7 @@ # Discover universal boundaries -Generalize one family of known finding traces into rule-ready boundaries: a single source and a single sink that every finding in the family factors through. The boundary vocabulary — how a member is named, what carries a usage condition, which packages realize each primitive effect — is language-specific: read `references/.md` per Inputs and follow its numbered steps, which key to the ones below. +Generalize one family of known attack-surface evidence into rule-ready boundaries: a single source and a single sink that every item in the family factors through. The evidence differs with the pass that produced it — a reference finding's trace, a frontier member a sweep verdicted, a code area a diff or spec named — and the reasoning is the same for all three. The boundary vocabulary — how a member is named, what carries a usage condition, which packages realize each primitive effect — is language-specific: read `references/.md` per Inputs and follow its numbered steps, which key to the ones below. -The goal is not the broadest syntax that happens to match every trace. It is the most primitive semantic boundary that stays inside the vulnerability class — general enough that the family needs one rule per side, specific enough that the rule still means something. Precision is recovered afterwards with context restrictions and sanitizers, listed separately, never by narrowing the boundary back down to the findings you started from. +The goal is not the broadest syntax that happens to match every trace. It is the most primitive semantic boundary that stays inside the vulnerability class — general enough that the family needs one rule per side, specific enough that the rule still means something. Precision is recovered afterwards with context restrictions and sanitizers, listed separately, never by narrowing the boundary back down to the evidence you started from. A boundary is only universal once it has been saturated: widened, re-checked against the whole family, and left unchanged by a full round. diff --git a/skills-templates/discover-universal-boundaries/sections/tracking.md b/skills-templates/discover-universal-boundaries/sections/tracking.md index 7ff626490..4958025d8 100644 --- a/skills-templates/discover-universal-boundaries/sections/tracking.md +++ b/skills-templates/discover-universal-boundaries/sections/tracking.md @@ -1,4 +1,4 @@ -This skill writes the boundary spec for its family, and — only when a split moves a finding — the `family` field on the reference files it moved. Leave `stages.units_seeded` pending: seeding the rule units is the calling stage's step. Touch no other reference field, no rule unit, and no rule. +This skill writes the boundary spec for its family, and — only when a split moves a reference finding — the `family` field on the reference files it moved. Leave `stages.units_seeded` pending: seeding the rule units is the calling stage's step. Touch no other reference field, no scope file, no rule unit, and no rule. {% include "shared/tracking/boundary-spec.md" %} diff --git a/skills-templates/discover-universal-boundaries/sections/workflow.md b/skills-templates/discover-universal-boundaries/sections/workflow.md index 439d528fb..d3364f05d 100644 --- a/skills-templates/discover-universal-boundaries/sections/workflow.md +++ b/skills-templates/discover-universal-boundaries/sections/workflow.md @@ -1,6 +1,6 @@ ### 1. Reconstruct every trace -For each assigned finding, read its reference file and then the project source it points at, and record: +For each assigned evidence item, read what the pass recorded for it — a reference finding's normalized file, or the member or code area `scope.yaml` named — and then the project source it points at, and record: 1. the attacker or untrusted authority; 2. the first project-visible ingress; @@ -13,7 +13,7 @@ Read enough surrounding source to tell the real boundary from incidental syntax ### 2. Propose the source -Move backward from the finding-specific expressions until you reach the earliest reusable trust-boundary value the family shares. Prefer, in order when applicable: +Move backward from the evidence's own expressions until you reach the earliest reusable trust-boundary value the family shares. Prefer, in order when applicable: - request body, parameter, path, query, header, cookie, or multipart value; - message, frame, packet, event, webhook, or callback payload; @@ -29,7 +29,7 @@ Express narrow usage conditions separately, as typed patterns, `pattern-inside`, ### 3. Propose the sink -Move forward from the finding-specific service calls to the most primitive operation that realizes the vulnerability, while staying specific to its class. Prefer boundaries such as: +Move forward from the evidence's own service calls to the most primitive operation that realizes the vulnerability, while staying specific to its class. Prefer boundaries such as: - network connect, request, send, or download for SSRF; - process, script, expression, template, query, or deserialization execution for injection; @@ -50,20 +50,20 @@ A boundary proposed from a few traces is a guess until it survives the whole fam Each round: -1. Factor every assigned finding — not only the new ones — through the current boundaries: +1. Factor every assigned evidence item — not only the new ones — through the current boundaries: ```text - universal source -> finding-specific context -> propagation -> universal sink + universal source -> item-specific context -> propagation -> universal sink ``` -2. For each finding that does not factor, generalize the offending side by exactly one step toward a more primitive boundary — never by adding a second alternative that merely spells out that finding's syntax. A `pattern-either` listing one branch per finding is the failure this skill exists to prevent. -3. Re-check the findings that already factored. A widening that breaks an earlier factorization is a widening too far: back it out and split instead. +2. For each item that does not factor, generalize the offending side by exactly one step toward a more primitive boundary — never by adding a second alternative that merely spells out that item's syntax. A `pattern-either` listing one branch per evidence item is the failure this skill exists to prevent. +3. Re-check the items that already factored. A widening that breaks an earlier factorization is a widening too far: back it out and split instead. 4. Challenge the widened boundary in both directions — if the sink now admits a different vulnerability class, narrow it back to the primitive effect or add a class-specific context restriction; if the source now admits trusted values, record what separates them as a context restriction rather than shrinking the boundary. 5. Record the round: what changed, and which factorization statuses moved. -The family is saturated when a full round widened nothing, broke nothing, and left every assigned finding either `covered` or `needs-restriction` with a named restriction. Stop and split — or record the finding `unfactored` with the evidence in `open_questions` — rather than looping a fourth time on the same finding. +The family is saturated when a full round widened nothing, broke nothing, and left every assigned item either `covered` or `needs-restriction` with a named restriction. Stop and split — or record the item `unfactored`, with what blocked it in `open_questions` — rather than looping a fourth time on the same one. -When the only boundary the whole family shares is arbitrary syntax, the family was wrong: split it. Each subfamily gets its own spec named `-` and its own saturation loop, every assigned finding lands in exactly one subfamily, and each moved finding's reference file has its `family` rewritten to the subfamily that now owns it. +When the only boundary the whole family shares is arbitrary syntax, the family was wrong: split it. Each subfamily gets its own spec named `-` and its own saturation loop, and every assigned item lands in exactly one subfamily. A moved reference finding has its own file's `family` rewritten to the subfamily that now owns it; other evidence moves with the spec it is listed in, and the calling stage reconciles the family list to the specs you return. Keep independently triggerable paths distinct in the factorization even when they share both boundaries. @@ -81,4 +81,4 @@ Real sanitization looks like resolved-IP private-range rejection for SSRF, canon ### 6. Write the specification -Write one spec per family or subfamily. `candidate_patterns` must be concrete enough for `create-rule` to test, in the member shape that language's rule units use — the language reference gives that shape and the dependency identity to record with it. Record opaque carriers under `approximation_candidates`, and stop there: no approximation is created or recommended until a rule-first scan proves a trace stops at one. +Write one spec per family or subfamily, listing the assigned ids under `evidence`. `candidate_patterns` must be concrete enough for `create-rule` to test, in the member shape that language's rule units use — the language reference gives that shape and the dependency identity to record with it. Record opaque carriers under `approximation_candidates`, and stop there: no approximation is created or recommended until a rule-first scan proves a trace stops at one. diff --git a/skills-templates/enactment-agent/main.md.j2 b/skills-templates/enactment-agent/main.md.j2 deleted file mode 100644 index a78eb7b46..000000000 --- a/skills-templates/enactment-agent/main.md.j2 +++ /dev/null @@ -1,30 +0,0 @@ ---- -name: enactment-agent -description: Reproduce a supplied finding set as verified OpenTaint rules, owning the long project build and scans and delegating each other pipeline stage. Use when the user supplies findings, a scanner report, or source-to-sink traces to reproduce, validate, or convert into reusable rules -license: Apache-2.0 -metadata: - author: opentaint - version: "{{ version }}" ---- - -{% include "sections/preamble.md" %} - -## Setup - -{% include "sections/setup.md" %} - -## Workflow - -{% include "sections/workflow.md" %} - -## Dispatching - -{% include "sections/dispatching.md" %} - -## State and resumption - -{% include "sections/state-resumption.md" %} - -## Key constraints - -{% include "sections/key-constraints.md" %} diff --git a/skills-templates/enactment-agent/scripts b/skills-templates/enactment-agent/scripts deleted file mode 120000 index aca7e2caf..000000000 --- a/skills-templates/enactment-agent/scripts +++ /dev/null @@ -1 +0,0 @@ -../shared/scripts \ No newline at end of file diff --git a/skills-templates/enactment-agent/sections/preamble.md b/skills-templates/enactment-agent/sections/preamble.md deleted file mode 100644 index d6d8eba7c..000000000 --- a/skills-templates/enactment-agent/sections/preamble.md +++ /dev/null @@ -1,11 +0,0 @@ -# Enactment Agent - -Reproduce a supplied set of findings with OpenTaint. Every finding ends the run either matched by a verified OpenTaint result whose trace carries the finding's own identity, or recorded with the exact rule, modeling, or engine limitation that stopped it. Keep the long project build and every full-project scan in this main session; delegate each bounded stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. - -This is the enactment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `assessment-agent`'s, which searches the project for vulnerabilities it was not known to have. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface there, generalized from the supplied findings here, so both sides exist before the first scan and that scan is rule-first. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. - -This run is one *pass* over a tree that outlives it. The pass may follow an assessment pass, inheriting its rules, approximations, and verdicts, and an assessment pass may follow this one to hunt with the boundaries it derived. Either can run again on a later commit. So reproduce this pass's findings and leave the tree richer than you found it; don't treat an artifact you didn't create as debris. - -You may be loaded directly, or handed off by `appsec-agent` once it identified the request as enactment. - -No finding is dropped for being a poor fit for taint analysis. Authorization, integrity, configuration, hard-coded-secret, and structural-control findings are modeled as explicit pseudo-taint boundaries. diff --git a/skills-templates/enactment-agent/sections/setup.md b/skills-templates/enactment-agent/sections/setup.md deleted file mode 100644 index be0702367..000000000 --- a/skills-templates/enactment-agent/sections/setup.md +++ /dev/null @@ -1,44 +0,0 @@ -Skip only what `appsec-agent` already did when it handed off — the toolchain and nesting checks. Everything from step 3 on is this pipeline's own, including the bootstrap. - -### 1. Confirm the toolchain - -Confirm `opentaint` is on PATH with `opentaint -v`. If it's missing, don't proceed silently — tell the user and offer the install command for their platform, run an install only on explicit confirmation: - -- macOS / Linux, in order: `brew install --cask seqra/tap/opentaint` · `npm install -g @seqra/opentaint` -- Windows: `npm install -g @seqra/opentaint` - -After installing, run `opentaint health` to confirm everything's resolved. - -### 2. Confirm agent nesting - -This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. - -### 3. Locate the findings - -The supplied findings are this run's input and the only thing it is measured against. Ask the user for their path when it isn't already given — a manifest, SARIF, scanner report, or a directory of finding documents. If the user has only described the findings in conversation, write them to a file first and use that; the pipeline resumes from disk, not from this thread. - -### 4. Determine the language - -Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. When a repo carries build markers for more than one, ask the user which to analyze. - -### 5. Choose the workflow - -Ask the user for the triage level: - -1. Triage level — `static` · `dynamic` - - static — classify findings from the model, no running app - - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option - -There is no scan-level question here: reproducing a finding set always needs the full rule and approximation toolbox, so enactment is always deep. - -### 6. Bootstrap - -Seed the run state and the working tree: - -```bash -uv run /scripts/generate.py init --mode enactment --triage-level --language --findings -``` - -It writes `state.yaml` with `mode: enactment`, appends this pass to `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. - -Over a tree an earlier pass already built, it prints what carried over and keeps all of it — an assessment pass's rules, approximations, and verdicts are this pass's starting corpus, and `get_status.py` will report their phases `DONE` rather than redoing them. `--findings` is required only the first time; a later enactment pass inherits the tracked set unless you pass a new one. diff --git a/skills-templates/enactment-agent/sections/state-resumption.md b/skills-templates/enactment-agent/sections/state-resumption.md deleted file mode 100644 index cf1c2dcdf..000000000 --- a/skills-templates/enactment-agent/sections/state-resumption.md +++ /dev/null @@ -1,34 +0,0 @@ -Use this ownership map to route work and scan errors: - -``` -.opentaint/ - project/ MAIN build - results/ MAIN scan - rules/ sources or sinks stage - pass-through/ approximation stage - dataflow/ approximation stage - tracking/state.yaml MAIN run knobs - tracking/reference/ boundaries stage writes, crossref stage judges - tracking/boundaries/ boundaries stage - tracking/ stage agents, leaves, and join scripts otherwise - enactment.md crossref stage - vulnerabilities.md triage / PoC stage - issues/ escalation stage -``` - -The tree is long-lived and outlives this pass. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan, whichever pass created them. Never delete or rewrite an artifact because this pass didn't produce it — an assessment pass's discovered source units, approximations, and verdicts are as durable as your own. - -`state.yaml` shape — `mode` is this pass's pipeline, not a property of the tree, so a later assessment pass simply rewrites it and keeps everything else: - -```yaml -mode: enactment -scan_level: deep -triage_level: static -language: java -findings: reports/pentest-2026-07.md -model_commit: 0123456789abcdef0123456789abcdef01234567 -build_jdk: null -max_memory: null -``` - -`mode` is what selects this pipeline for this pass; `findings` is the supplied set the pass is measured against, and it stays in `state.yaml` across an assessment pass so a later enactment pass resumes the same set. Neither is edited by hand mid-pass — pointing an in-flight pass at a different finding file strands its reference set. A genuinely different finding set is a new pass, bootstrapped with a new `--findings`. diff --git a/skills-templates/enactment-agent/sections/workflow.md b/skills-templates/enactment-agent/sections/workflow.md deleted file mode 100644 index 27faee3d5..000000000 --- a/skills-templates/enactment-agent/sections/workflow.md +++ /dev/null @@ -1,39 +0,0 @@ -The run is one fixed pipeline. Use `uv run /scripts/get_status.py` to choose the next action: - -``` -build → MAIN: build -reference_set / boundaries → stage subagent: boundaries -source_rules → stage subagent: sources -sink_rules → stage subagent: sinks -scan → MAIN: scan -approximations → stage subagent: approx-round, then MAIN: rescan; repeat -triage → stage subagent: triage -poc → stage subagent: poc -crossref → stage subagent: crossref -``` - -Both rule sides are authored before the first scan on purpose: the scan that follows is the one that proves the boundaries, and its results are what later stages are allowed to react to. Never let an approximation stand in for a missing source or sink rule. - -The cross-reference closes the run rather than steering it: it judges what the finished rule set, its approximations, and its verdicts actually reproduced. - -### Build in MAIN - -When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. - -Record the returned `build_jdk` in `.opentaint/tracking/state.yaml`. Record `model_commit` as the full HEAD only when no source file is uncommitted, otherwise set it to null. Build non-convergence blocks the run because no later phase can proceed without the model. Keep one untouched model for the entire enactment run. - -### Scan in MAIN - -When status reports `scan`, or a stage returns with a rescan pending, load and follow the `run-scan` skill in this main session. Start the scan with the harness's main-session background-command facility, keep the engine's self-timeout, add a 1200-second outer backstop, and wait for the process completion event. - -A valid `.opentaint/results/report.sarif` means the scan completed, including exit 254 after an engine timeout. Record `max_memory: 16G` when the scan had to bump memory and reuse it on later scans. If no SARIF exists after the allowed retry/backstop, follow the repair path below for a malformed rule/approximation; otherwise dispatch `orchestrate-stage` with `stage: escalation` and the scan `setup` to write the scan-wide resource issue, then stop. - -When a scan or later stage reports a malformed approximation, unloadable created rule, ineffective join, or a created rule's false positive/negative, route the exact diagnosis and artifact path/id to the responsible stage agent per Dispatching, then scan again in MAIN. - -After every build, scan, or stage return, run `uv run /scripts/get_status.py` once to choose the next action. Use `--full` at run start, on resume, or when the brief output does not settle the question. - -### Iterating to coverage - -The pipeline loops by design, and status is what closes it. A rescan makes every cross-reference pending again; a cross-reference that finds a trace stopped at an opaque carrier sends the run back to an approximation round; one that blames a rule sends it back to the stage that authored it, so status returns there before closing again. Follow status through each loop rather than declaring the run finished early — an earlier phase reading `IN_PROGRESS` after a later one ran is the loop working, not a regression. - -The run is complete when status reports `run complete` — every supplied finding reproduced or recorded with its blocker, and `.opentaint/enactment.md` current. Report the coverage manifest, keeping raw SARIF results, validated findings, and unique vulnerability identities as separate counts. diff --git a/skills-templates/orchestrate-stage/references/boundaries.md.j2 b/skills-templates/orchestrate-stage/references/boundaries.md.j2 index f3ab8694a..eaeffbb3d 100644 --- a/skills-templates/orchestrate-stage/references/boundaries.md.j2 +++ b/skills-templates/orchestrate-stage/references/boundaries.md.j2 @@ -1,18 +1,8 @@ -# Reference set + universal boundaries +# Universal boundaries -Turn the supplied findings into a normalized reference set, generalize each finding family into one saturated source and sink boundary, and seed the source and sink units those boundaries imply. Enactment mode only — it replaces dependency discovery, and everything it writes feeds the ordinary rule-authoring stages. +Generalize each family intake produced into one saturated universal source and one universal sink, and seed the source and sink units those boundaries imply. Every mode runs this stage: the families differ — a swept frontier, a diff or spec, a supplied finding set — but what comes out is the same universal rule material, and everything it writes feeds the ordinary rule-authoring stages. -## Normalize the reference set - -`state.yaml` names the supplied findings under `findings` — a manifest, SARIF, report, or directory of finding documents. Write one `.opentaint/tracking/reference/.yaml` per supplied finding. - -{% include "shared/tracking/reference-finding.md" %} - -Give each finding a stable id of its own — the supplied one when it has one. Preserve separately triggerable attack paths as separate findings even when they share a sink. Never drop a finding for being a poor fit for taint analysis: an authorization, integrity, configuration, hard-coded-secret, or structural-control finding gets a reference file like any other, and becomes an explicit pseudo-boundary later. - -Then group the findings into families and set `family` on each. Partition by vulnerability class or by a cohesive finding family — never by file batches or arbitrary count. A family is the set of findings you expect to share one source and one sink. - -Fan out this normalization when the supplied set is large: one leaf per slice of the supplied report, each writing its own reference files. Assign the families yourself once every file exists, since that decision needs the whole set. +Read the family list from `get_status.py`, which takes it from `.opentaint/tracking/scope.yaml` in onboarding and discovery mode, and from the reference findings' own `family` field in enactment mode. ## Discover the boundaries @@ -20,15 +10,15 @@ Fan out discover-universal-boundaries, one leaf per family. Inputs each: - `language` -- `findings` — the path `state.yaml` names -- `finding-ids` — the ids assigned to this family - `family` +- `evidence` — the ids the family carries: reference finding ids in enactment mode, the members or code areas `scope.yaml` recorded otherwise +- `findings` (enactment mode) — the supplied-findings path `state.yaml` names, so a leaf can read a finding beyond its normalized file -Expect back — `.opentaint/tracking/boundaries/.yaml` with `saturation.status: saturated`, one `factorization` entry per assigned finding, and the controls listed separately from the positive boundaries. A leaf that splits its family writes one spec per subfamily and rewrites `family` on each reference finding it moved, so every finding still points at the spec that owns it. +Expect back — `.opentaint/tracking/boundaries/.yaml` with `saturation.status: saturated`, one `factorization` entry per assigned evidence item, and the controls listed separately from the positive boundaries. A leaf that splits its family writes one spec per subfamily; in enactment mode it also rewrites `family` on each reference finding it moved, and otherwise the split is recorded in the specs themselves — reconcile `scope.yaml` to the specs that came back so the family list and the specs still agree. {% include "shared/tracking/boundary-spec.md" %} -A spec returning with an `unfactored` finding is not a failure to retry blindly — read its `open_questions`, and either re-dispatch the leaf with the finding split out as its own family or accept the pseudo-boundary it proposes. +A spec returning with an `unfactored` evidence item is not a failure to retry blindly — read its `open_questions`, and either re-dispatch the leaf with that item split out as its own family or accept the pseudo-boundary it proposes. ## Seed the rule units @@ -40,6 +30,8 @@ For each saturated spec, write its `candidate_patterns` into the family's source Copy `method`, `signature`, and `note` from the spec's `candidate_patterns` (plus `vuln_class` on the sink side), leave `rule_id: null` and the `stages` pending, and fill `dependencies` with the dependency each pattern's package comes from — empty when the boundary is a project member, as a structural pseudo-boundary usually is. Do not carry `context_restrictions`, `sanitizers`, or `negative_patterns` into the units: the boundary is the positive pattern, and the controls stay listed in the spec. +Both sides are seeded here, and only the source side is authored before the first scan. That is deliberate: the sink boundary is decided now, on the evidence, and authored later against the frontier that scan names — never invented by a model round that found a carrier. + ## Stage gate -`get_status.py` drives `reference_set` then `boundaries`, naming findings without a family, families without a spec, unsaturated specs, unfactored findings, and unseeded specs. Finish when both are `DONE`, or when the next step it reports is source rules. +`get_status.py` drives `boundaries`, naming families without a spec, unsaturated specs, evidence with no factorization, and unseeded specs. Finish when it reads `DONE`, or when the next step it reports is source rules. diff --git a/skills-templates/orchestrate-stage/references/crossref.md.j2 b/skills-templates/orchestrate-stage/references/crossref.md.j2 index 14b5a8328..8ef28084e 100644 --- a/skills-templates/orchestrate-stage/references/crossref.md.j2 +++ b/skills-templates/orchestrate-stage/references/crossref.md.j2 @@ -1,8 +1,8 @@ # Cross-reference — scan results against the reference set -Judge, per supplied finding, whether the latest scan reproduced it, and assemble the coverage manifest. This is the stage that closes an enactment pass: every rule, approximation, and verdict is already in place, so what the scan shows now is what the pass delivered. Nothing else may set a reference finding's `status`. +Judge, per supplied finding, whether the latest scan reproduced it, and assemble the coverage manifest. This is the stage that closes an enactment pass: every rule, model, and verdict is already in place, so what the scan shows now is what the pass delivered. Nothing else may set a reference finding's `status`. -It also runs at the end of an assessment pass over a tree that already carries a reference set. That pass's new rules and rescans changed what those findings reproduce — usually for the better, occasionally by losing one — so re-judge them all and refresh the manifest. The work is identical; only the pass that triggered it differs. +It runs in any mode over a tree that carries a reference set an earlier enactment pass left. That pass's new rules and rescans changed what those findings reproduce — usually for the better, occasionally by losing one — so re-judge them all and refresh the manifest. The work is identical; only the pass that triggered it differs. It is also the stage that decides what the run still owes. A judgement here can send the pipeline back — an unmodeled carrier to an approximation round, a rule-caused miss to the stage that authored the rule — and status will report that earlier phase as current again. That is the loop working; re-enter this stage after the rescan rather than closing on stale results. diff --git a/skills-templates/orchestrate-stage/references/intake.md.j2 b/skills-templates/orchestrate-stage/references/intake.md.j2 new file mode 100644 index 000000000..e78629ab9 --- /dev/null +++ b/skills-templates/orchestrate-stage/references/intake.md.j2 @@ -0,0 +1,75 @@ +# Intake — this mode's input, as the run's families + +Turn what the pass was given into the families the boundaries stage generalizes. The three modes start from different material and converge on the same handoff: a named family per group, each carrying the evidence it was grouped from. Nothing here authors a rule, proposes a boundary, or touches a model. + +Read `mode` from `get_status.py --full` and follow that mode's section. A family is the set of evidence you expect to share one universal source and one universal sink — partition by vulnerability class or by a cohesive attack surface, never by file batches or arbitrary count. + +## Onboarding — the external-method frontier + +Every dependency member the project's own code calls is a trust boundary until a leaf says otherwise. That over-approximation is the point: the sweep classifies the whole frontier once, and every later pass inherits the verdicts. + +### 1. Triage the dependencies + +Dispatch triage-dependencies when status names it. + +Expect back — `.opentaint/tracking/coverage.yaml` written with the flagged packages; status advances to the frontier partition. + +### 2. Partition the frontier + +Run: + +```bash +uv run /scripts/generate.py partition frontier +``` + +It writes balanced `.opentaint/tracking/rules/plans/lib-NNN.yaml` plans over the flagged packages' members the project actually calls, one disjoint slice per leaf. On re-entry it partitions only members no prior pass verdicted. + +### 3. Sweep it + +Fan out discover-attack-surface, one per plan. + +Inputs each: +- `language` +- `plan` + +At the join run: + +```bash +uv run /scripts/generate.py mark-safe +``` + +Expect back — each leaf records the trust boundaries it finds into its plan and writes any source unit(s); the join folds source/safe verdicts into `classification.yaml` and prunes the consumed plans. The sweep is a single fan-out pass, not a loop. + +### 4. Group the frontier into families + +Read the verdicted boundaries back from the ledger and the source units the sweep wrote, and group them into families — usually one per attack surface a set of members shares (a framework's request accessors, a client library's egress calls), not one per dependency. Write them to `.opentaint/tracking/scope.yaml`, evidence being the members themselves. + +{% include "shared/tracking/scope.md" %} + +## Discovery — the project, a diff, or a spec + +`state.yaml` names the scoping document under `spec` when there is one; with none, the scope is the whole project. + +Resolve the input to code before grouping it: read the diff or spec, then the project source it points at — the endpoints, handlers, jobs, and consumers it touches, and the calls they make into dependencies. An informal spec ("this service takes uploads from partners and renders them") is scoped the same way: name the surfaces it implies and read them. + +Then group what you read into families and write `.opentaint/tracking/scope.yaml`, evidence being the members, endpoints, or code areas each family was grouped from — concrete enough that the boundary leaf can go straight to the source. + +Fan this reading out when the scope is large: one leaf per slice, each reporting the surfaces it found. Assign the families yourself once every slice has reported, since that decision needs the whole scope. + +{% include "shared/tracking/scope.md" %} + +## Enactment — the supplied finding set + +`state.yaml` names the supplied findings under `findings` — a manifest, SARIF, report, or directory of finding documents. Write one `.opentaint/tracking/reference/.yaml` per supplied finding. + +{% include "shared/tracking/reference-finding.md" %} + +Give each finding a stable id of its own — the supplied one when it has one. Preserve separately triggerable attack paths as separate findings even when they share a sink. Never drop a finding for being a poor fit for taint analysis: an authorization, integrity, configuration, hard-coded-secret, or structural-control finding gets a reference file like any other, and becomes an explicit pseudo-boundary later. + +Then group the findings into families and set `family` on each. Enactment's families live on the reference files themselves rather than in `scope.yaml`, because the assignment has to move with the finding when a family splits. + +Fan out this normalization when the supplied set is large: one leaf per slice of the supplied report, each writing its own reference files. Assign the families yourself once every file exists, since that decision needs the whole set. + +## Stage gate + +`get_status.py` drives `intake`, naming the mode's next step — the dependency triage, the frontier plans, the scope file, or the findings still without a family. Finish when it reads `DONE`, or when the next step it reports is the boundaries stage. diff --git a/skills-templates/orchestrate-stage/references/sinks.md b/skills-templates/orchestrate-stage/references/sinks.md index 338becd36..7cfeeabfa 100644 --- a/skills-templates/orchestrate-stage/references/sinks.md +++ b/skills-templates/orchestrate-stage/references/sinks.md @@ -1,6 +1,6 @@ # Author sinks + assemble -Author the sink rules flagged into per-package units during approximation classification and join them to every relevant source. The final project rescan follows to surface findings. +Author the sink units this run seeded — the boundaries stage's sink side, plus the sinks flagged into per-package units during model classification — and join them to every relevant source. The final project rescan follows to surface findings. ## Sink lib rules diff --git a/skills-templates/orchestrate-stage/references/sources.md b/skills-templates/orchestrate-stage/references/sources.md index 961bb7003..822c7c698 100644 --- a/skills-templates/orchestrate-stage/references/sources.md +++ b/skills-templates/orchestrate-stage/references/sources.md @@ -1,36 +1,8 @@ -# Discover sources + source rules +# Author sources + assemble -Discover the project's used dependency members that are taint sources, author source rules, and wire them to the built-in sinks. The first project scan follows and names the later taint frontier. On re-entry, partition plans only members no prior run verdicted. +Author the source rules the boundaries stage seeded into per-family units, and wire them to every relevant sink. The first project scan follows: it is what proves the boundaries and names the taint frontier the later stages work from. -## Triage dependencies - -Dispatch triage-dependencies when status names it. - -Expect back — `.opentaint/tracking/coverage.yaml` written; status advances to source discovery. - -## Discover sources - -Run: - -```bash -uv run /scripts/generate.py partition discover -``` - -It writes balanced `.opentaint/tracking/rules/plans/lib-NNN.yaml` plans, one disjoint slice per leaf. Fan out discover-attack-surface, one per plan. - -Inputs each: -- `language` -- `plan` - -At the join run: - -```bash -uv run /scripts/generate.py mark-safe -``` - -Expect back — each agent records the sources it finds into its plan and writes any source unit(s); the join folds source/safe verdicts into `classification.yaml` and prunes the consumed plans. Discovery is a single fan-out pass, not a loop. - -Run `uv run /scripts/get_status.py` to confirm `discover` `DONE`. +On re-entry, work only the units `get_status.py` still lists as pending — a unit that already passes is a prior pass's result, not work to redo. ## Source lib rules @@ -66,4 +38,4 @@ Expect back — existing tag-expanded joins reused where they already cover the ## Stage gate -`get_status.py` drives `discover` then `source_rules`, naming the current sub-step and units. Finish when both are `DONE`, or when the next step it reports is the project scan. +`get_status.py` drives `source_rules`, naming the current sub-step and units. Finish when it reads `DONE`, or when the next step it reports is the project scan. diff --git a/skills-templates/orchestrate-stage/sections/workflow.md b/skills-templates/orchestrate-stage/sections/workflow.md index 6b0253760..c69c24032 100644 --- a/skills-templates/orchestrate-stage/sections/workflow.md +++ b/skills-templates/orchestrate-stage/sections/workflow.md @@ -1,16 +1,19 @@ Match the stage keyword to its reference and read it fully: ``` +intake → /references/intake.md +boundaries → /references/boundaries.md sources → /references/sources.md approx-round → /references/approx-round.md sinks → /references/sinks.md triage → /references/triage.md poc → /references/poc.md +crossref → /references/crossref.md (a tree carrying a reference set) escalation → /references/escalation.md -boundaries → /references/boundaries.md (enactment mode) -crossref → /references/crossref.md (enactment mode) ``` +`intake` is the only stage whose work depends on the run's `mode`; its reference carries a section per mode. Everything after it reads the same whichever mode produced the families. + Run the bundled script to get the setup overview before proceeding to the reference's instructions: ```bash diff --git a/skills-templates/shared/scripts/_common.py b/skills-templates/shared/scripts/_common.py index cd0873805..9c2ad0b8e 100644 --- a/skills-templates/shared/scripts/_common.py +++ b/skills-templates/shared/scripts/_common.py @@ -21,8 +21,9 @@ SINKS_TR = RULES_TR / "sinks" JOINS_TR = RULES_TR / "joins" FINDINGS_TR = TRACKING / "findings" +SCOPE = TRACKING / "scope.yaml" # every mode: what intake scoped, as the family list REFERENCE_TR = TRACKING / "reference" # enactment mode: the supplied findings, normalized -BOUNDARIES_TR = TRACKING / "boundaries" # enactment mode: per-family boundary specs +BOUNDARIES_TR = TRACKING / "boundaries" # every mode: per-family universal boundary specs RESULTS = ROOT / "results" DROPPED = RESULTS / "dropped-external-methods.yaml" SARIF = RESULTS / "report.sarif" @@ -99,6 +100,22 @@ def git_head(): return None +# ---- intake scope ---- + +def scope_families(): + """(name, evidence) per family the intake stage scoped, in scope.yaml order. + + One shape for every mode: the evidence items are reference finding ids in enactment mode, + and the members or code areas intake settled on in onboarding and discovery mode. The + boundaries stage generalizes one family per entry, whichever mode wrote it.""" + out = [] + for f in (load_yaml(SCOPE, {}) or {}).get("families") or []: + name = strip_quotes((f or {}).get("name", "")) if isinstance(f, dict) else "" + if name: + out.append((name, [strip_quotes(str(e)) for e in (f.get("evidence") or [])])) + return out + + # ---- approximation batch readers (shared by coverage + partition) ---- # a method counts classified once it sits in any batch classification bucket or in build.done diff --git a/skills-templates/shared/scripts/generate.py b/skills-templates/shared/scripts/generate.py index 5e204b3c5..51e1d1339 100644 --- a/skills-templates/shared/scripts/generate.py +++ b/skills-templates/shared/scripts/generate.py @@ -9,8 +9,8 @@ init bootstrap the .opentaint tree + state.yaml from the workflow flags partition analyze dropped external methods -> per-root batch plans (approximations) - partition discover coverage.yaml's used members -> balanced discover plans - mark-safe discover plans' verdicts -> classification.yaml ledger (+prune plans) + partition frontier coverage.yaml's used members -> balanced frontier plans + mark-safe frontier plans' verdicts -> classification.yaml ledger (+prune plans) merge-skipped batch skipped/engine_issues -> approximations/skipped.yaml (+prune plans) findings results/report.sarif -> per-rule finding tracking files (idempotent) """ @@ -35,9 +35,9 @@ ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch ROOT_DEPTH = 2 # library root = first 2 dotted segments -DISCOVER_TARGET, DISCOVER_BAND = 50, 15 # project-used members per discover plan (~50, loose) +FRONTIER_TARGET, FRONTIER_BAND = 50, 15 # project-used members per frontier plan (~50, loose) -DISCOVER_PLANS = RULES_TR / "plans" +FRONTIER_PLANS = RULES_TR / "plans" APPROX_PLANS = APPROX / "plans" @@ -46,24 +46,33 @@ # the durable directories a run writes into; the leaves/scripts mkdir on write, but seeding # them up front gives every stage a place to land and makes the empty tree self-describing. INIT_DIRS = [TRACKING, APPROX, SOURCES_TR, SINKS_TR, JOINS_TR, FINDINGS_TR, - RESULTS, RULES, PASS_THROUGH, DATAFLOW] -ENACTMENT_DIRS = [REFERENCE_TR, BOUNDARIES_TR] # enactment mode only + BOUNDARIES_TR, RESULTS, RULES, PASS_THROUGH, DATAFLOW] +ENACTMENT_DIRS = [REFERENCE_TR] # the normalized supplied findings STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves def carried_over(): """What a pass inherits from the passes before it, as (label, count) — everything durable - the tree already holds. Both pipelines write into one tree, so a pass never starts empty + the tree already holds. Every mode writes into one tree, so a pass never starts empty unless the tree is.""" def n(paths): return sum(1 for _ in paths) return [("rule unit", n(SOURCES_TR.glob("*.yaml")) + n(SINKS_TR.glob("*.yaml"))), ("created rule", n(p for p in RULES.rglob("*.yaml") if p.is_file())), + ("boundary spec", n(BOUNDARIES_TR.glob("*.yaml")) if BOUNDARIES_TR.is_dir() else 0), ("approximation batch", len(batch_files())), ("reference finding", n(REFERENCE_TR.glob("*.yaml")) if REFERENCE_TR.is_dir() else 0), ("triaged finding", n(FINDINGS_TR.glob("*.yaml")))] +def onboarded(runs): + """Onboarding sweeps the whole external-method frontier and its corpus is durable, so it is + a one-time pass. Re-running init in onboarding mode is a resume while the tree is still in + that pass; once a later mode took over, the sweep is done and asking for it again is a + mistake worth naming.""" + return any(str((r or {}).get("type", "")).startswith("onboarding/") for r in runs) + + def cmd_init(args): state_path = TRACKING / "state.yaml" prior = load_yaml(state_path, {}) or {} @@ -73,20 +82,28 @@ def cmd_init(args): findings = args.findings or prior.get("findings") if enactment and not findings: raise SystemExit("init --mode enactment requires --findings ") - if not enactment and not args.scan_level: - raise SystemExit("init --mode assessment requires --scan-level") - # enactment reproduces a supplied finding set, which always needs the full rule + approximation - # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. - scan_level = "deep" if enactment else args.scan_level + if args.mode == "discovery" and not args.scan_level: + raise SystemExit("init --mode discovery requires --scan-level") + runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] + if args.mode == "onboarding" and onboarded(runs) and prior.get("mode") != "onboarding": + raise SystemExit("this tree is already onboarded — its frontier sweep, universal rules, " + "and models are on disk and every later pass builds on them; run " + "discovery or enactment instead") + # onboarding sweeps the frontier and enactment reproduces a supplied finding set: both always + # need the full rule + approximation toolbox, so their level is fixed rather than asked for. + scan_level = args.scan_level if args.mode == "discovery" else "deep" for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) - # `mode` is this pass's pipeline, not a permanent property of the tree: the two compose, in - # either order and repeatedly across commits, and every artifact below is shared between them. + # `mode` is this pass's intake, not a permanent property of the tree: the modes compose, in + # any order and repeatedly across commits, and every artifact below is shared between them. state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} - if findings: # kept even on an assessment pass, so a later one resumes it + if findings: # kept across other modes, so a later enactment pass resumes it state["findings"] = findings + spec = args.spec or prior.get("spec") + if spec: + state["spec"] = spec for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") @@ -94,7 +111,6 @@ def cmd_init(args): # history: one entry per pass. Re-running init with the same knobs on the same commit is a # resume of the current pass, not a new one — a different mode, level, or commit starts one. hist_path = TRACKING / "history.yaml" - runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] entry = {"commit": git_head(), "type": f"{args.mode}/{scan_level}/{args.triage_level}"} new_pass = not runs or runs[-1] != entry @@ -115,6 +131,8 @@ def cmd_init(args): f"triage_level={state['triage_level']} language={state['language']}") if findings: print(f"findings={findings}") + if spec: + print(f"spec={spec}") if prior: kept = ", ".join(f"{c} {label}{'' if c == 1 else 's'}" for label, c in carried_over() if c) print(f"carried over: {kept or 'nothing yet'}") @@ -209,7 +227,7 @@ def write_plans(plans, out_dir, prefix_id): if isinstance(x, dict) else x) for p, v in sorted(scopes.items())} path = out_dir / f"{pid}.yaml" - # source: null is the unprocessed sentinel — a discover agent overwrites it with the + # source: null is the unprocessed sentinel — a frontier agent overwrites it with the # list of sources it found (an empty list when it finds none). mark-safe folds only # plans whose sentinel was replaced, so an un-returned plan is never marked safe. path.write_text(dump_yaml({"id": pid, "scopes": norm, "source": None}), encoding="utf-8") @@ -273,7 +291,7 @@ def cmd_analyze(args): return 0 -# ---- partition discover ---- +# ---- partition frontier ---- def yaml_modules(model_yaml): # each module in project.yaml as (packages, moduleClasses); only `packages` says which of a @@ -345,8 +363,8 @@ def pending_packages(): return tuple(p for p in (cov.get("packages") or []) if isinstance(p, str) and p) -def cmd_discover(args): - regen_plans(DISCOVER_PLANS) +def cmd_frontier(args): + regen_plans(FRONTIER_PLANS) packages = pending_packages() if not packages: print("nothing to plan — no pending package in coverage.yaml", file=sys.stderr) @@ -360,26 +378,26 @@ def cmd_discover(args): if not todo: print("nothing to plan — every used member already verdicted", file=sys.stderr) return 0 - cap = DISCOVER_TARGET + DISCOVER_BAND - plans = pack(atomize(todo, cap), DISCOVER_TARGET, cap) + cap = FRONTIER_TARGET + FRONTIER_BAND + plans = pack(atomize(todo, cap), FRONTIER_TARGET, cap) rows = {f: [{"method": f, "signature": s} for s in sorted(sigs[f])] for f in sigs} plans = [{pkg: [r for f in members for r in rows[f]] for pkg, members in plan.items()} for plan in plans] - for p in write_plans(plans, DISCOVER_PLANS, "lib"): + for p in write_plans(plans, FRONTIER_PLANS, "lib"): print(p) return 0 def cmd_partition(args): - return cmd_analyze(args) if args.kind == "analyze" else cmd_discover(args) + return cmd_analyze(args) if args.kind == "analyze" else cmd_frontier(args) -# ---- mark-safe (discover join) ---- +# ---- mark-safe (frontier join) ---- def cmd_mark_safe(args): - plans = sorted(glob.glob(str(DISCOVER_PLANS / "lib-*.yaml"))) + plans = sorted(glob.glob(str(FRONTIER_PLANS / "lib-*.yaml"))) if not plans: - print("no discover plans to reconcile", file=sys.stderr) + print("no frontier plans to reconcile", file=sys.stderr) return 0 ledger = RULES_TR / "classification.yaml" doc = load_yaml(ledger, {}) or {} @@ -389,7 +407,7 @@ def cmd_mark_safe(args): for p in plans: pdoc = load_yaml(p, {}) or {} raw = pdoc.get("source") - if raw is None: # sentinel intact — no discover agent returned for it + if raw is None: # sentinel intact — no frontier agent returned for it unprocessed.append(p) continue members = {member_key(m) for v in (pdoc.get("scopes") or {}).values() for m in v} @@ -399,7 +417,7 @@ def cmd_mark_safe(args): processed.append(p) print(f"{Path(p).name}: {len(srcs)} sources, {len(members - srcs)} safe") if not processed: - print("no processed discover plans (every plan still carries source: null) — " + print("no processed frontier plans (every plan still carries source: null) — " "fan out discover-attack-surface first", file=sys.stderr) return 0 safe -= source @@ -409,7 +427,7 @@ def cmd_mark_safe(args): if not args.keep: for p in processed: Path(p).unlink() - print(f"pruned {len(processed)} reconciled discover plan(s)") + print(f"pruned {len(processed)} reconciled frontier plan(s)") if unprocessed: print(f"left {len(unprocessed)} unprocessed plan(s) (source: null) for re-dispatch: " + ", ".join(Path(p).name for p in unprocessed)) @@ -605,22 +623,27 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--mode", default="assessment", choices=["assessment", "enactment"], - help="assessment: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--mode", required=True, choices=["onboarding", "discovery", "enactment"], + help="onboarding: sweep the external-method frontier once. discovery: work " + "from the project, a diff, or an informal spec. enactment: reproduce a " + "supplied finding set") i.add_argument("--scan-level", choices=["lite", "normal", "deep"], - help="assessment mode only; enactment is always deep") + help="discovery mode only; onboarding and enactment are always deep") i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) i.add_argument("--language", default=None, help="target language, determined by the orchestrator") i.add_argument("--findings", default=None, help="enactment mode: path to the supplied finding manifest/report/directory") + i.add_argument("--spec", default=None, + help="discovery mode: path to the diff, spec, or note that scopes the pass; " + "omit to scope the pass to the whole project") i.set_defaults(func=cmd_init) p = sub.add_parser("partition", help="split classification work into per-agent plans") - p.add_argument("kind", choices=["analyze", "discover"]) + p.add_argument("kind", choices=["analyze", "frontier"]) p.set_defaults(func=cmd_partition) - m = sub.add_parser("mark-safe", help="merge discover plans into classification.yaml") - m.add_argument("--keep", action="store_true", help="keep the reconciled discover plans") + m = sub.add_parser("mark-safe", help="merge frontier plans into classification.yaml") + m.add_argument("--keep", action="store_true", help="keep the reconciled frontier plans") m.set_defaults(func=cmd_mark_safe) s = sub.add_parser("merge-skipped", help="rebuild approximations/skipped.yaml from batches") diff --git a/skills-templates/shared/scripts/get_status.py b/skills-templates/shared/scripts/get_status.py index e8114ebdd..e8411bb26 100644 --- a/skills-templates/shared/scripts/get_status.py +++ b/skills-templates/shared/scripts/get_status.py @@ -23,17 +23,17 @@ from pathlib import Path from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, + PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SCOPE, SINKS_TR, SOURCES_TR, TRACKING, build_done_keys, classified_keys, dropped_entries, - git_head, load_yaml, member_key, modeled_entries, skipped_keys, - strip_quotes) + git_head, load_yaml, member_key, modeled_entries, scope_families, + skipped_keys, strip_quotes) STATE = load_yaml(TRACKING / "state.yaml", {}) or {} -MODE = STATE.get("mode") or "assessment" +MODE = STATE.get("mode") or "discovery" SCAN_LEVEL = STATE.get("scan_level") TRIAGE_LEVEL = STATE.get("triage_level") -DISCOVER_PLANS = RULES_TR / "plans" +FRONTIER_PLANS = RULES_TR / "plans" APPROX_PLANS = APPROX / "plans" VULN = ROOT / "vulnerabilities.md" ENACTMENT = ROOT / "enactment.md" @@ -165,10 +165,13 @@ def ph_build(): "dispatch build-project"], None -def ph_discover(): +def ph_frontier(): + """Onboarding's intake: the external-method frontier, swept as one trust boundary. Every + dependency member the project's own code calls is a candidate boundary until a leaf verdicts + it, and the ledger is what makes that verdict durable for every later pass.""" if not (TRACKING / "coverage.yaml").is_file(): return False, ["dispatch triage-dependencies"], None - leftover = sorted(glob.glob(str(DISCOVER_PLANS / "*.yaml"))) + leftover = sorted(glob.glob(str(FRONTIER_PLANS / "*.yaml"))) units = load_units(SOURCES_TR) ledger = load_yaml(RULES_TR / "classification.yaml", {}) or {} if leftover: @@ -177,7 +180,7 @@ def ph_discover(): tasks.append("then run `scripts/generate.py mark-safe` to reconcile the plans") return False, tasks, None if not ledger and not units: - return False, ["run `scripts/generate.py partition discover` to plan the used members"], None + return False, ["run `scripts/generate.py partition frontier` to plan the called members"], None return True, [], None @@ -288,9 +291,12 @@ def ph_poc(): return True, [], None -# ---- enactment-mode phases ---- +# ---- intake: one contract, three mode-specific derivations ---- def ph_reference_set(): + """Enactment's intake: the supplied findings, normalized and grouped. The `family` field on + each reference file is the assignment — it moves with the finding when a family splits, so + the families are read back from the files rather than from a separate list.""" docs = load_docs(REFERENCE_TR) if not docs: src = STATE.get("findings") or "state.yaml findings unset" @@ -303,31 +309,65 @@ def ph_reference_set(): return True, [], None +def ph_scope(): + """Onboarding's and discovery's intake join: the families their evidence groups into, written + to scope.yaml. Enactment carries the same information on its reference files instead.""" + fams = scope_families() + if not fams: + src = STATE.get("spec") or "the whole project" + what = ("the frontier the sweep verdicted as sources and effects" if MODE == "onboarding" + else f"the code {src} names") + return False, [f"group {what} into families and write {SCOPE}"], None + empty = [n for n, ev in fams if not ev] + if empty: + return False, ["families in scope.yaml with no evidence recorded:"] \ + + [f" {n}" for n in empty], None + return True, [], None + + +def ph_intake(): + if MODE == "enactment": + return ph_reference_set() + if MODE == "onboarding": + done, tasks, note = ph_frontier() + if not done: + return done, tasks, note + return ph_scope() + + def families(): - return sorted({strip_quotes(d.get("family", "")) for _, d in load_docs(REFERENCE_TR) - if strip_quotes(d.get("family", ""))}) + """(family, evidence ids) for this pass, whichever mode scoped it — reference finding ids in + enactment mode, the members or areas intake recorded otherwise. The boundaries stage + generalizes one universal source and sink per entry.""" + if MODE == "enactment": + out = {} + for path, doc in load_docs(REFERENCE_TR): + fam = strip_quotes(doc.get("family", "")) + if fam: + out.setdefault(fam, []).append(path.stem) + return sorted(out.items()) + return scope_families() def ph_boundaries(): specs = {p.stem: d for p, d in load_docs(BOUNDARIES_TR)} fams = families() - missing = [f for f in fams if f not in specs] + missing = [f for f, _ in fams if f not in specs] if missing: return False, ["dispatch discover-universal-boundaries, one per family:"] \ + [f" {f}" for f in missing], None - # a split renames the family on its reference findings, so every spec here owns its findings - unsaturated = [f for f in fams + # a split rewrites the family on the evidence it moves, so every spec here owns its evidence + unsaturated = [f for f, _ in fams if str((specs[f].get("saturation") or {}).get("status", "")).strip() != "saturated"] if unsaturated: return False, ["boundary specs not saturated:"] + [f" {f}" for f in unsaturated], None - unfactored = sorted(p.stem for p, d in load_docs(REFERENCE_TR) - if p.stem not in (specs.get(strip_quotes(d.get("family", "")), {}) - .get("factorization") or {})) + unfactored = sorted(f"{f}: {e}" for f, evidence in fams for e in evidence + if e not in (specs[f].get("factorization") or {})) if unfactored: - return False, ["reference findings with no factorization in their spec:"] \ + return False, ["evidence with no factorization in its spec:"] \ + [f" {r}" for r in unfactored], None - unseeded = [f for f in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] + unseeded = [f for f, _ in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] if unseeded: return False, ["seed the source and sink units from these specs' candidate_patterns:"] \ + [f" {f}" for f in unseeded], None @@ -359,14 +399,21 @@ def ph_crossref(): def has_reference_set(): """A reference set outlives the pass that created it. Any later pass that rescans changes what - it reproduces, so the cross-reference stays in scope — otherwise an assessment pass would leave - a coverage manifest that silently describes an older scan.""" + it reproduces, so the cross-reference stays in scope — otherwise an onboarding or discovery + pass would leave a coverage manifest that silently describes an older scan.""" return REFERENCE_TR.is_dir() and any(REFERENCE_TR.glob("*.yaml")) -ASSESSMENT_PHASES = [ +# One pipeline, whatever the mode brought to it. Intake and its universal boundaries differ — +# the swept frontier, the diff or spec, the supplied findings — but from the boundary specs on, +# every mode runs the same stages in the same order: sources before the scan that proves them, +# approximations against the frontier that scan names, sinks after it, then triage. Only the +# closing phases are conditional, on the triage level, the controls knob, and whether the tree +# carries a reference set to re-judge. +PHASES = [ ("build", ph_build, lambda: True), - ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), + ("intake", ph_intake, lambda: SCAN_LEVEL == "deep"), + ("boundaries", ph_boundaries, lambda: SCAN_LEVEL == "deep"), ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), ("scan", ph_scan, lambda: True), ("approximations", ph_approximations, lambda: SCAN_LEVEL in ("normal", "deep")), @@ -376,24 +423,7 @@ def has_reference_set(): ("crossref", ph_crossref, has_reference_set), ] -# enactment reproduces a supplied finding set: the reference set and its saturated boundaries -# replace dependency discovery, and both rule sides are authored before the first scan so that -# scan is rule-first. The cross-reference closes the run — it judges what the finished rule set, -# its approximations, its verdicts and its controls actually reproduced. -ENACTMENT_PHASES = [ - ("build", ph_build, lambda: True), - ("reference_set", ph_reference_set, lambda: True), - ("boundaries", ph_boundaries, lambda: True), - ("source_rules", ph_source_rules, lambda: True), - ("sink_rules", ph_sink_rules, lambda: True), - ("scan", ph_scan, lambda: True), - ("approximations", ph_approximations, lambda: True), - ("triage", ph_triage, lambda: True), - ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), - ("crossref", ph_crossref, lambda: True), -] -PHASES = ENACTMENT_PHASES if MODE == "enactment" else ASSESSMENT_PHASES # ---- caps ---- @@ -436,8 +466,10 @@ def cmd_full(): print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " f"language={STATE.get('language')} commit={commit} " f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") - if STATE.get("findings"): # printed on an assessment pass too — the set is still tracked + if STATE.get("findings"): # printed in every mode — the set stays tracked print(f"findings={STATE.get('findings')}") + if STATE.get("spec"): + print(f"spec={STATE.get('spec')}") runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] if len(runs) > 1: print("passes: " + " -> ".join(str(r.get("type", "?")) for r in runs)) diff --git a/skills-templates/shared/tracking/boundary-spec.md b/skills-templates/shared/tracking/boundary-spec.md index db55b8a5e..003ba8012 100644 --- a/skills-templates/shared/tracking/boundary-spec.md +++ b/skills-templates/shared/tracking/boundary-spec.md @@ -1,8 +1,8 @@ -`.opentaint/tracking/boundaries/.yaml` — one boundary family: the single source and single sink every finding in the family factors through, plus the controls that recover precision. `candidate_patterns` are concrete enough for `create-rule` to test, and seed the family's source and sink units verbatim. `factorization` carries one entry per assigned reference finding, `status` one of `covered` (factors through both boundaries as-is), `needs-restriction` (factors only under a named `context_restrictions` entry), or `unfactored` (does not factor — explain in `open_questions` and split or add a pseudo-boundary). `saturation` records the widen-and-recheck rounds and only reads `saturated` once a full round changed neither the boundaries nor any factorization. `approximation_candidates` are opaque carriers noted for later — never acted on before a scan proves the trace stops there. Keep it clear from comments +`.opentaint/tracking/boundaries/.yaml` — one boundary family: the single universal source and single universal sink every piece of the family's evidence factors through, plus the controls that recover precision. `evidence` lists what the family was grouped from — reference finding ids in enactment mode, the members or code areas `scope.yaml` recorded otherwise. `candidate_patterns` are concrete enough for `create-rule` to test, and seed the family's source and sink units verbatim. `factorization` carries one entry per evidence item, `status` one of `covered` (factors through both boundaries as-is), `needs-restriction` (factors only under a named `context_restrictions` entry), or `unfactored` (does not factor — explain in `open_questions` and split or add a pseudo-boundary). `saturation` records the widen-and-recheck rounds and only reads `saturated` once a full round changed neither the boundaries nor any factorization. `approximation_candidates` are opaque carriers noted for later — never acted on before a scan proves the trace stops there. Keep it clear from comments ```yaml family: ssrf -findings: [DSC-014, DSC-021] +evidence: [DSC-014, DSC-021] source: semantic_boundary: external request value entering a controller candidate_patterns: diff --git a/skills-templates/shared/tracking/scope.md b/skills-templates/shared/tracking/scope.md new file mode 100644 index 000000000..4aff24f08 --- /dev/null +++ b/skills-templates/shared/tracking/scope.md @@ -0,0 +1,14 @@ +`.opentaint/tracking/scope.yaml` — what this pass's intake scoped, and the families the boundaries stage generalizes one at a time. `scope` is one line on what the pass was given, so a later reader knows what the family list came from. Each family carries the `evidence` it was grouped from: the frontier members a sweep verdicted, or the members, endpoints, and code areas a diff or spec resolved to. Enactment mode writes no scope file — its families live on the reference findings, which carry the same assignment on the file that moves with them. Keep it clear from comments + +```yaml +mode: discovery +scope: docs/2026-07-webhooks.md — partner webhook registration and delivery +families: + - name: ssrf-webhook + evidence: + - com.acme.webhook.WebhookController#register + - com.acme.webhook.WebhookService#dispatch + - name: upload-path + evidence: + - com.acme.upload.UploadController#store +``` diff --git a/skills/appsec-agent/SKILL.md b/skills/appsec-agent/SKILL.md index 7fb7710e1..bb9fc4731 100644 --- a/skills/appsec-agent/SKILL.md +++ b/skills/appsec-agent/SKILL.md @@ -1,6 +1,6 @@ --- name: appsec-agent -description: Entry point for OpenTaint application-security work — confirms the toolchain, picks the pipeline the request needs, and hands off to it. Use when the user asks to find vulnerabilities, scan an application for security issues, reproduce or validate a supplied finding set, or continue an OpenTaint run +description: Run an end-to-end OpenTaint application-security analysis in one of three modes — onboarding a project's dependency frontier, discovering vulnerabilities from the project, a diff, or a spec, or enacting a supplied finding set — owning the long project build and scans and delegating each other pipeline stage. Use when the user asks to find vulnerabilities, scan an application for security issues, reproduce or validate a supplied finding set, or continue an OpenTaint run license: Apache-2.0 metadata: author: opentaint @@ -9,16 +9,66 @@ metadata: # AppSec Agent -The entry point for OpenTaint application-security work. Confirm the environment, decide which of the two pipelines the request needs, and hand off to it in this same session. +Run an end-to-end OpenTaint security analysis. Keep the long project build and every full-project scan in this main session; delegate each bounded intake, boundary, rule, approximation, triage, and PoC stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. -OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. Both pipelines run the same machine — MAIN owns the long build and every full-project scan, `orchestrate-stage` subagents own the bounded stages, and all durable state lives under one self-contained `.opentaint/` directory at the project root. They differ only in where the source and sink rules come from: +OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. A run produces confirmed vulnerabilities plus the project's own universal rules and the models behind them — the passThrough and dataflow approximations that carry taint through library code, which the pipeline's `approximations` phase builds. Everything lands under one self-contained `.opentaint/` directory at the project root. -- **assessment** (`assessment-agent`) — find vulnerabilities the project was not known to have. Source and sink rules come from discovering the project's dependency attack surface -- **enactment** (`enactment-agent`) — reproduce a finding set the user supplies, as verified rules. Source and sink rules come from generalizing those findings into reusable boundaries +This is the only entry point. It runs in one of three modes, which differ in what the run takes as its input — and therefore in where the universal rules come from — and in nothing else: -The two compose rather than compete: a project can run one after the other, in either order, and again on later commits, all over one accumulating `.opentaint/` tree. Each such run is a *pass*, and choosing a pipeline chooses this pass, not the project's fate. +- **onboarding** — the external-method frontier: every dependency member the project's own code calls, taken as a trust boundary until a leaf verdicts it. Run once per project, to build the universal rule and model corpus the later passes inherit +- **discovery** — the project, a diff, or an informal spec of what changed or what matters; the code it names becomes the boundary evidence +- **enactment** — a finding set the user supplies: a report, pentest results, or source-to-sink traces, reproduced as verified rules -This skill does no analysis of its own and writes nothing except by handing off. Don't bootstrap the tree here — each pipeline's own setup does that. +Whatever the input, intake groups it into families, the boundaries stage generalizes each family into one universal source and one universal sink, and everything downstream is the same pipeline in the same order. + +## Modes + +### What each mode takes in + +- **onboarding** — no input beyond the project itself. The frontier sweep flags the dependencies that can carry untrusted data, partitions the members the project actually calls, and verdicts each one: a trust boundary, an effect, or neither. It is the widest intake there is, and its output — a classified frontier, universal rules for the stack, and the models behind them — is what makes every later pass cheap +- **discovery** — a diff, a spec, a ticket, or a sentence about what the project does and what would be bad. Ask for whatever the user has and record its path; with nothing supplied the scope is the whole project. Intake reads it, resolves it to code, and groups that code into families +- **enactment** — the supplied findings, as a manifest, SARIF, scanner report, or a directory of finding documents. Ask for the path when it isn't given. If the user only described the findings in conversation, write them to a file first and use that; the pipeline resumes from disk, not from this thread + +### Onboarding runs once + +The frontier sweep is the expensive pass, and its corpus is durable: the classification ledger, the universal rules, and the models stay on disk and apply to every scan afterwards, whichever mode ran it. So onboard a project once and then work in discovery or enactment. Bootstrap refuses a second onboarding pass over a tree that already had one and says so; a genuinely new dependency stack is a new tree. + +### The modes compose + +They are not alternatives, and picking one is not a commitment. One `.opentaint/` tree accumulates the artifacts of every pass over it, in any order and as many times as the project needs: + +- **onboarding, then anything** — the natural start. A discovery or enactment pass over an onboarded tree finds most of its boundaries already ruled and modeled, and spends its work on what its own input names +- **enactment, then discovery** — reproduce the supplied report first, then hunt with the rules it produced. Boundaries derived from real findings are exactly the ones a discovery pass would otherwise have to argue for +- **discovery, then enactment** — assess the project, then measure a report against the corpus that pass built. What the report names but the scan missed is now a rule or modeling gap you can point at +- **any of them, again on a later commit** — the tree is long-lived. A new HEAD makes the model stale, so the pass rebuilds and rescans, and every rule, model, and verdict carries over. That's how a run becomes a regression check rather than a one-off + +So `mode` in `state.yaml` is the intake of the *current pass*, not a property of the tree. Switching it is normal, needs no fresh tree, and strands nothing. + +### Read what the tree already holds + +If `.opentaint/tracking/state.yaml` exists, find out where the project stands before choosing: + +```bash +uv run /scripts/get_status.py --full +``` + +Its header prints the current `mode`, the run's levels, the tracked finding set or spec if there is one, and — once the tree has more than one pass — the `passes:` chain. The phase lines say whether that pass is finished or mid-flight. + +- mid-flight pass — resume it rather than starting a different one over the top of it +- finished pass, and the user wants more — that's a new pass, and the choice below applies again +- tell the user what's there either way, in one line: which pass, where it stands, what carried over + +### Choose this pass + +Decide from what the user brought, then confirm it with them before bootstrapping: + +- **enactment** — they supplied findings and want them reproduced, validated, converted into reusable rules, or cross-checked against OpenTaint. "Does OpenTaint catch these?", "reproduce this report", "turn these findings into rules" +- **discovery** — no finding set to measure against; the goal is what the project is vulnerable to, in the whole project or in what a diff or spec names. "Find vulnerabilities", "scan this app", "did this PR introduce anything?" +- **onboarding** — the tree has never been onboarded and the user wants the project's own rule and model corpus built before anything is measured. Recommend it on a cold start when the run is not urgent; it is the pass that makes the others accurate + +The signal for enactment is whether a finding set exists to be measured against, not the vocabulary. A user who says "audit this against last year's pentest" and has the pentest is enactment; a user who says "reproduce the bug I think is in here" and has only a hunch is discovery. + +When the user wants both — reproduce the report *and* find what it missed — say that it is two passes over one tree, recommend enactment first so the discovery pass inherits its boundaries, and run them one at a time. Never try to drive two modes in a single pass. ## Setup @@ -33,56 +83,160 @@ After installing, run `opentaint health` to confirm everything's resolved. ### 2. Confirm agent nesting -Both pipelines require two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. +This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. -These two checks are the only setup steps the pipeline you hand off to may skip. +### 3. Choose the mode and locate its input -## Choose the pipeline +Pick `onboarding`, `discovery`, or `enactment` per Modes, confirm the choice with the user, and get the path its intake needs — the supplied findings for enactment, the diff or spec for discovery when there is one. Onboarding needs no input beyond the project. -### The pipelines compose +### 4. Determine the language -They are not alternatives, and picking one is not a commitment. One `.opentaint/` tree accumulates the artifacts of every pass over it, in either order and as many times as the project needs: +Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. When a repo carries build markers for more than one, ask the user which to analyze. -- **enactment, then assessment** — reproduce the supplied report first, then hunt with the rules it produced. The boundaries derived from real findings are exactly the sources and sinks the assessment pass would otherwise have to discover -- **assessment, then enactment** — assess the project, then measure a report against the corpus that pass built. What the report names but the scan missed is now a rule or modeling gap you can point at -- **either, again on a later commit** — the tree is long-lived. A new HEAD makes the model stale, so the pass rebuilds and rescans, and every rule, approximation, and verdict carries over. That's how a run becomes a regression check rather than a one-off +### 5. Choose the workflow -So `mode` in `state.yaml` is the pipeline of the *current pass*, not a property of the tree. Switching it is normal, needs no fresh tree, and strands nothing. +Ask the user for the knobs this mode has, together: -### Read what the tree already holds +1. Scan level — `lite` · `normal` · `deep`, **discovery mode only** + - lite — build + scan (expected, when there are already existing artifacts) + - normal — build + scan + custom models + - deep — build + scan + custom models + custom universal rules + - recommend by what's on disk: a cold start (no `.opentaint` artifacts) → deep; a prior pass's artifacts already present → lite + - onboarding and enactment are always deep: sweeping the frontier and reproducing a finding set both need the full rule and model toolbox, so there is no level to ask for +2. Triage level — `static` · `dynamic` + - static — classify findings from the model, no running app + - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option -If `.opentaint/tracking/state.yaml` exists, find out where the project stands before choosing: +### 6. Bootstrap + +Seed the run state and the working tree: ```bash -uv run /scripts/get_status.py --full +uv run /scripts/generate.py init --mode \ + --triage-level --language \ + [--scan-level ] [--findings ] [--spec ] ``` -Its header prints the current `mode`, the run's levels, the tracked finding set if there is one, and — once the tree has more than one pass — the `passes:` chain. The phase lines say whether that pass is finished or mid-flight. +`--scan-level` is discovery's; `--findings` is enactment's and required the first time; `--spec` is discovery's and optional. It writes `state.yaml`, appends this pass to `history.yaml`, and creates the `.opentaint/` tree — plus `tracking/reference/` in enactment mode. -- mid-flight pass — resume it: hand off to `assessment-agent` for `mode=assessment`, `enactment-agent` for `mode=enactment`. Don't start a different pass over the top of an unfinished one -- finished pass, and the user wants more — that's a new pass, and the choice below applies again -- tell the user what's there either way, in one line: which pass, where it stands, what carried over +Over a tree an earlier pass already built, it prints what carried over and keeps all of it — that pass's rules, models, boundary specs, and verdicts are this pass's starting corpus, and `get_status.py` will report their phases `DONE` rather than redoing them. It refuses a second onboarding pass over an already-onboarded tree. -### Choose this pass +## Workflow + +The run is one fixed pipeline, the same in every mode; the mode decides what intake works from, and the selected levels decide which phases are in scope. Use `uv run /scripts/get_status.py` to choose the next action: + +``` +build → MAIN: build +intake → stage subagent: intake +boundaries → stage subagent: boundaries +source_rules → stage subagent: sources +scan → MAIN: scan +approximations → stage subagent: approx-round, then MAIN: rescan; repeat +sink_rules → stage subagent: sinks, then MAIN: rescan +triage → stage subagent: triage +poc → stage subagent: poc +crossref → stage subagent: crossref (whenever the tree carries a reference set) +``` + +The spine is fixed — build, sources, scan, models, sinks, triage — and it is fixed for a reason. Both boundary sides are known before the first scan, because that scan is what proves them and names the taint frontier the model work then answers; the sink rules are authored against that frontier rather than guessed ahead of it. Never let a model stand in for a boundary that was never authored: an approximation carries taint through a carrier, it does not decide what is untrusted or what is dangerous. + +`crossref` appears whenever the tree carries a reference set — the pass that supplied it need not be this one. This pass's rescans changed what those findings reproduce, so re-judging them and refreshing `.opentaint/enactment.md` is part of finishing, not optional cleanup. + +### Build in MAIN + +When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. + +Record the returned `build_jdk` in `.opentaint/tracking/state.yaml`. Record `model_commit` as the full HEAD only when no source file is uncommitted, otherwise set it to null. Build non-convergence blocks the run because no later phase can proceed without the model. Keep one untouched model for the whole pass. + +### Scan in MAIN + +When status reports `scan`, or a stage returns with a rescan pending, load and follow the `run-scan` skill in this main session. Start the scan with the harness's main-session background-command facility, keep the engine's self-timeout, add a 1200-second outer backstop, and wait for the process completion event. + +A valid `.opentaint/results/report.sarif` means the scan completed, including exit 254 after an engine timeout. Record `max_memory: 16G` when the scan had to bump memory and reuse it on later scans. If no SARIF exists after the allowed retry/backstop, follow the repair path below for a malformed rule/model; otherwise dispatch `orchestrate-stage` with `stage: escalation` and the scan `setup` to write the scan-wide resource issue, then stop. + +When a scan or later stage reports a malformed model, unloadable created rule, ineffective join, or a created rule's false positive/negative, route the exact diagnosis and artifact path/id to the responsible stage agent per Dispatching, then scan again in MAIN. -Decide from what the user brought, then confirm it with them before handing off: +After every build, scan, or stage return, run `uv run /scripts/get_status.py` once to choose the next action. Use `--full` at run start, on resume, or when the brief output does not settle the question. -- **enactment** — they supplied findings, a scanner report, penetration-test results, or source-to-sink traces, and want them reproduced, validated, converted into reusable rules, or cross-checked against OpenTaint. "Does OpenTaint catch these?", "reproduce this report", "turn these findings into rules" -- **assessment** — no finding set to measure against; the goal is what the project is vulnerable to. "Find vulnerabilities", "scan this app", "is this endpoint exploitable?" +### Iterating -The signal is whether a finding set exists to be measured against, not the vocabulary. A user who says "audit this against last year's pentest" and has the pentest is enactment; a user who says "reproduce the bug I think is in here" and has only a hunch is assessment. +The pipeline loops by design, and status is what closes it. A rescan makes every cross-reference pending again; a trace stopped at an opaque carrier sends the run back to a model round; a rule blamed for a miss goes back to the stage that authored it, so status returns there before closing again. Follow status through each loop rather than declaring the run finished early — an earlier phase reading `IN_PROGRESS` after a later one ran is the loop working, not a regression. -When the user wants both — reproduce the report *and* find what it missed — say that it is two passes over one tree, recommend enactment first so the assessment inherits its boundaries, and run them one at a time. Never try to drive both in a single pass. +The run is complete when status reports `run complete`: `.opentaint/vulnerabilities.md` current, and — where the tree carries a reference set — every supplied finding either reproduced or recorded with the blocker that stopped it, in `.opentaint/enactment.md`. Report the outcome keeping raw SARIF results, validated findings, and unique vulnerability identities as separate counts. -## Hand off +## Dispatching -Load the chosen skill in this same session and follow it from its setup: +Dispatch exactly one stage-orchestrator subagent for each stage invocation: ``` -assessment → assessment-agent -enactment → enactment-agent +Invoke the Skill orchestrate-stage first, then follow its instructions precisely +Inputs: + stage: ``` -Not a subagent. MAIN must own the long build and every full-project scan, so the pipeline continues as this session, with the choice above already settled and the toolchain and nesting checks already done. It runs the rest of its own setup — language, levels, bootstrap — and everything after that is its document, not this one. +For a `deep` model round, also pass `sinks: true`. A subagent inherits the project-root working directory, so omit `project-root`. + +Stage context: + +- `intake` — turn this mode's input into the run's families: the swept frontier in onboarding, the diff or spec in discovery, the normalized reference set in enactment +- `boundaries` — generalize each family into one universal source and one universal sink, and seed the rule units from them +- `sources` — author the seeded source units' rules and wire the joins +- `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier +- `sinks` — author the seeded sink units' rules and wire the joins +- `triage` — classify the latest findings and refresh the vulnerability report +- `crossref` — judge a reference set against the latest scan and refresh its coverage manifest +- `poc` — reproduce confirmed findings and add the outcomes to the report +- `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure + +Keep each agent id until the next scan validates its artifacts. On a stage-owned error, resume that agent with `stage: escalation`, the exact error, and the artifact path/id. If its thread is unavailable, start a re-entrant `orchestrate-stage` agent with that diagnosis. + +Dispatch each subagent fresh, don't fork context into it. Then wait for it natively, don't monitor or poll every minute. If the harness forces a wait timeout, set it to ~1h and re-wait when it returns. + +## State and resumption + +Use this ownership map to route work and scan errors: + +``` +.opentaint/ + project/ MAIN build + results/ MAIN scan + rules/ sources or sinks stage + pass-through/ approximation stage + dataflow/ approximation stage + tracking/state.yaml MAIN run knobs + tracking/scope.yaml intake stage (onboarding, discovery) + tracking/reference/ intake stage writes, crossref stage judges (enactment) + tracking/boundaries/ boundaries stage + tracking/ stage agents, leaves, and join scripts otherwise + enactment.md crossref stage + vulnerabilities.md triage / PoC stage + issues/ escalation stage +``` + +The tree is long-lived and outlives this pass. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and models apply to every scan, whichever pass created them. Never delete or rewrite an artifact because this pass didn't produce it — an onboarding pass's classification ledger, a discovery pass's boundary specs, and an enactment pass's reference set are all as durable as your own. + +`state.yaml` shape — `mode` is this pass's intake, not a property of the tree, so a later pass in another mode simply rewrites it and keeps everything else: + +```yaml +mode: enactment +scan_level: deep +triage_level: dynamic +language: java +findings: reports/pentest-2026-07.md +spec: null +model_commit: 0123456789abcdef0123456789abcdef01234567 +build_jdk: null +max_memory: null +``` + +`findings` is the supplied set an enactment pass is measured against and `spec` is what a discovery pass was scoped by; both stay in `state.yaml` across passes in other modes, so a later pass in that mode resumes the same input. Neither is edited by hand mid-pass — pointing an in-flight pass at a different file strands the intake built from the old one. A genuinely different input is a new pass, bootstrapped with a new `--findings` or `--spec`. + +## Key constraints -Tell the user which pipeline you picked and why, in one line, before you hand off. +- read pipeline state through `/scripts/get_status.py`, not by hand — don't re-derive it with glob/grep/`python3 -c`/yaml scans over `.opentaint/tracking`, `results`, or the `*.yaml`, nor open finding/unit/reference/SARIF files just to review progress. If its output doesn't settle the question, re-run it with `--full` before opening any file +- don't author or edit stage-owned artifacts or tracking; MAIN writes only `model_commit`, `build_jdk`, and `max_memory` in `state.yaml` +- keep one generated project model for the run; never hand-edit or replace it mid-analysis — fix the build and rebuild before starting a new run +- source and sink boundaries come before model work; a model never compensates for a boundary that was never authored +- run one mode per pass, and never switch `mode` mid-pass: the intake behind the current families would no longer be the one on disk +- where the tree carries a reference set, coverage is counted by unique finding identity, never by rule id or raw SARIF result count, and a result counts as a reproduction only when its trace carries the finding's own attack path +- never drop a supplied finding as unsuitable for taint analysis diff --git a/skills/appsec-agent/scripts/_common.py b/skills/appsec-agent/scripts/_common.py index cd0873805..9c2ad0b8e 100644 --- a/skills/appsec-agent/scripts/_common.py +++ b/skills/appsec-agent/scripts/_common.py @@ -21,8 +21,9 @@ SINKS_TR = RULES_TR / "sinks" JOINS_TR = RULES_TR / "joins" FINDINGS_TR = TRACKING / "findings" +SCOPE = TRACKING / "scope.yaml" # every mode: what intake scoped, as the family list REFERENCE_TR = TRACKING / "reference" # enactment mode: the supplied findings, normalized -BOUNDARIES_TR = TRACKING / "boundaries" # enactment mode: per-family boundary specs +BOUNDARIES_TR = TRACKING / "boundaries" # every mode: per-family universal boundary specs RESULTS = ROOT / "results" DROPPED = RESULTS / "dropped-external-methods.yaml" SARIF = RESULTS / "report.sarif" @@ -99,6 +100,22 @@ def git_head(): return None +# ---- intake scope ---- + +def scope_families(): + """(name, evidence) per family the intake stage scoped, in scope.yaml order. + + One shape for every mode: the evidence items are reference finding ids in enactment mode, + and the members or code areas intake settled on in onboarding and discovery mode. The + boundaries stage generalizes one family per entry, whichever mode wrote it.""" + out = [] + for f in (load_yaml(SCOPE, {}) or {}).get("families") or []: + name = strip_quotes((f or {}).get("name", "")) if isinstance(f, dict) else "" + if name: + out.append((name, [strip_quotes(str(e)) for e in (f.get("evidence") or [])])) + return out + + # ---- approximation batch readers (shared by coverage + partition) ---- # a method counts classified once it sits in any batch classification bucket or in build.done diff --git a/skills/appsec-agent/scripts/generate.py b/skills/appsec-agent/scripts/generate.py index 5e204b3c5..51e1d1339 100644 --- a/skills/appsec-agent/scripts/generate.py +++ b/skills/appsec-agent/scripts/generate.py @@ -9,8 +9,8 @@ init bootstrap the .opentaint tree + state.yaml from the workflow flags partition analyze dropped external methods -> per-root batch plans (approximations) - partition discover coverage.yaml's used members -> balanced discover plans - mark-safe discover plans' verdicts -> classification.yaml ledger (+prune plans) + partition frontier coverage.yaml's used members -> balanced frontier plans + mark-safe frontier plans' verdicts -> classification.yaml ledger (+prune plans) merge-skipped batch skipped/engine_issues -> approximations/skipped.yaml (+prune plans) findings results/report.sarif -> per-rule finding tracking files (idempotent) """ @@ -35,9 +35,9 @@ ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch ROOT_DEPTH = 2 # library root = first 2 dotted segments -DISCOVER_TARGET, DISCOVER_BAND = 50, 15 # project-used members per discover plan (~50, loose) +FRONTIER_TARGET, FRONTIER_BAND = 50, 15 # project-used members per frontier plan (~50, loose) -DISCOVER_PLANS = RULES_TR / "plans" +FRONTIER_PLANS = RULES_TR / "plans" APPROX_PLANS = APPROX / "plans" @@ -46,24 +46,33 @@ # the durable directories a run writes into; the leaves/scripts mkdir on write, but seeding # them up front gives every stage a place to land and makes the empty tree self-describing. INIT_DIRS = [TRACKING, APPROX, SOURCES_TR, SINKS_TR, JOINS_TR, FINDINGS_TR, - RESULTS, RULES, PASS_THROUGH, DATAFLOW] -ENACTMENT_DIRS = [REFERENCE_TR, BOUNDARIES_TR] # enactment mode only + BOUNDARIES_TR, RESULTS, RULES, PASS_THROUGH, DATAFLOW] +ENACTMENT_DIRS = [REFERENCE_TR] # the normalized supplied findings STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves def carried_over(): """What a pass inherits from the passes before it, as (label, count) — everything durable - the tree already holds. Both pipelines write into one tree, so a pass never starts empty + the tree already holds. Every mode writes into one tree, so a pass never starts empty unless the tree is.""" def n(paths): return sum(1 for _ in paths) return [("rule unit", n(SOURCES_TR.glob("*.yaml")) + n(SINKS_TR.glob("*.yaml"))), ("created rule", n(p for p in RULES.rglob("*.yaml") if p.is_file())), + ("boundary spec", n(BOUNDARIES_TR.glob("*.yaml")) if BOUNDARIES_TR.is_dir() else 0), ("approximation batch", len(batch_files())), ("reference finding", n(REFERENCE_TR.glob("*.yaml")) if REFERENCE_TR.is_dir() else 0), ("triaged finding", n(FINDINGS_TR.glob("*.yaml")))] +def onboarded(runs): + """Onboarding sweeps the whole external-method frontier and its corpus is durable, so it is + a one-time pass. Re-running init in onboarding mode is a resume while the tree is still in + that pass; once a later mode took over, the sweep is done and asking for it again is a + mistake worth naming.""" + return any(str((r or {}).get("type", "")).startswith("onboarding/") for r in runs) + + def cmd_init(args): state_path = TRACKING / "state.yaml" prior = load_yaml(state_path, {}) or {} @@ -73,20 +82,28 @@ def cmd_init(args): findings = args.findings or prior.get("findings") if enactment and not findings: raise SystemExit("init --mode enactment requires --findings ") - if not enactment and not args.scan_level: - raise SystemExit("init --mode assessment requires --scan-level") - # enactment reproduces a supplied finding set, which always needs the full rule + approximation - # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. - scan_level = "deep" if enactment else args.scan_level + if args.mode == "discovery" and not args.scan_level: + raise SystemExit("init --mode discovery requires --scan-level") + runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] + if args.mode == "onboarding" and onboarded(runs) and prior.get("mode") != "onboarding": + raise SystemExit("this tree is already onboarded — its frontier sweep, universal rules, " + "and models are on disk and every later pass builds on them; run " + "discovery or enactment instead") + # onboarding sweeps the frontier and enactment reproduces a supplied finding set: both always + # need the full rule + approximation toolbox, so their level is fixed rather than asked for. + scan_level = args.scan_level if args.mode == "discovery" else "deep" for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) - # `mode` is this pass's pipeline, not a permanent property of the tree: the two compose, in - # either order and repeatedly across commits, and every artifact below is shared between them. + # `mode` is this pass's intake, not a permanent property of the tree: the modes compose, in + # any order and repeatedly across commits, and every artifact below is shared between them. state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} - if findings: # kept even on an assessment pass, so a later one resumes it + if findings: # kept across other modes, so a later enactment pass resumes it state["findings"] = findings + spec = args.spec or prior.get("spec") + if spec: + state["spec"] = spec for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") @@ -94,7 +111,6 @@ def cmd_init(args): # history: one entry per pass. Re-running init with the same knobs on the same commit is a # resume of the current pass, not a new one — a different mode, level, or commit starts one. hist_path = TRACKING / "history.yaml" - runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] entry = {"commit": git_head(), "type": f"{args.mode}/{scan_level}/{args.triage_level}"} new_pass = not runs or runs[-1] != entry @@ -115,6 +131,8 @@ def cmd_init(args): f"triage_level={state['triage_level']} language={state['language']}") if findings: print(f"findings={findings}") + if spec: + print(f"spec={spec}") if prior: kept = ", ".join(f"{c} {label}{'' if c == 1 else 's'}" for label, c in carried_over() if c) print(f"carried over: {kept or 'nothing yet'}") @@ -209,7 +227,7 @@ def write_plans(plans, out_dir, prefix_id): if isinstance(x, dict) else x) for p, v in sorted(scopes.items())} path = out_dir / f"{pid}.yaml" - # source: null is the unprocessed sentinel — a discover agent overwrites it with the + # source: null is the unprocessed sentinel — a frontier agent overwrites it with the # list of sources it found (an empty list when it finds none). mark-safe folds only # plans whose sentinel was replaced, so an un-returned plan is never marked safe. path.write_text(dump_yaml({"id": pid, "scopes": norm, "source": None}), encoding="utf-8") @@ -273,7 +291,7 @@ def cmd_analyze(args): return 0 -# ---- partition discover ---- +# ---- partition frontier ---- def yaml_modules(model_yaml): # each module in project.yaml as (packages, moduleClasses); only `packages` says which of a @@ -345,8 +363,8 @@ def pending_packages(): return tuple(p for p in (cov.get("packages") or []) if isinstance(p, str) and p) -def cmd_discover(args): - regen_plans(DISCOVER_PLANS) +def cmd_frontier(args): + regen_plans(FRONTIER_PLANS) packages = pending_packages() if not packages: print("nothing to plan — no pending package in coverage.yaml", file=sys.stderr) @@ -360,26 +378,26 @@ def cmd_discover(args): if not todo: print("nothing to plan — every used member already verdicted", file=sys.stderr) return 0 - cap = DISCOVER_TARGET + DISCOVER_BAND - plans = pack(atomize(todo, cap), DISCOVER_TARGET, cap) + cap = FRONTIER_TARGET + FRONTIER_BAND + plans = pack(atomize(todo, cap), FRONTIER_TARGET, cap) rows = {f: [{"method": f, "signature": s} for s in sorted(sigs[f])] for f in sigs} plans = [{pkg: [r for f in members for r in rows[f]] for pkg, members in plan.items()} for plan in plans] - for p in write_plans(plans, DISCOVER_PLANS, "lib"): + for p in write_plans(plans, FRONTIER_PLANS, "lib"): print(p) return 0 def cmd_partition(args): - return cmd_analyze(args) if args.kind == "analyze" else cmd_discover(args) + return cmd_analyze(args) if args.kind == "analyze" else cmd_frontier(args) -# ---- mark-safe (discover join) ---- +# ---- mark-safe (frontier join) ---- def cmd_mark_safe(args): - plans = sorted(glob.glob(str(DISCOVER_PLANS / "lib-*.yaml"))) + plans = sorted(glob.glob(str(FRONTIER_PLANS / "lib-*.yaml"))) if not plans: - print("no discover plans to reconcile", file=sys.stderr) + print("no frontier plans to reconcile", file=sys.stderr) return 0 ledger = RULES_TR / "classification.yaml" doc = load_yaml(ledger, {}) or {} @@ -389,7 +407,7 @@ def cmd_mark_safe(args): for p in plans: pdoc = load_yaml(p, {}) or {} raw = pdoc.get("source") - if raw is None: # sentinel intact — no discover agent returned for it + if raw is None: # sentinel intact — no frontier agent returned for it unprocessed.append(p) continue members = {member_key(m) for v in (pdoc.get("scopes") or {}).values() for m in v} @@ -399,7 +417,7 @@ def cmd_mark_safe(args): processed.append(p) print(f"{Path(p).name}: {len(srcs)} sources, {len(members - srcs)} safe") if not processed: - print("no processed discover plans (every plan still carries source: null) — " + print("no processed frontier plans (every plan still carries source: null) — " "fan out discover-attack-surface first", file=sys.stderr) return 0 safe -= source @@ -409,7 +427,7 @@ def cmd_mark_safe(args): if not args.keep: for p in processed: Path(p).unlink() - print(f"pruned {len(processed)} reconciled discover plan(s)") + print(f"pruned {len(processed)} reconciled frontier plan(s)") if unprocessed: print(f"left {len(unprocessed)} unprocessed plan(s) (source: null) for re-dispatch: " + ", ".join(Path(p).name for p in unprocessed)) @@ -605,22 +623,27 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--mode", default="assessment", choices=["assessment", "enactment"], - help="assessment: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--mode", required=True, choices=["onboarding", "discovery", "enactment"], + help="onboarding: sweep the external-method frontier once. discovery: work " + "from the project, a diff, or an informal spec. enactment: reproduce a " + "supplied finding set") i.add_argument("--scan-level", choices=["lite", "normal", "deep"], - help="assessment mode only; enactment is always deep") + help="discovery mode only; onboarding and enactment are always deep") i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) i.add_argument("--language", default=None, help="target language, determined by the orchestrator") i.add_argument("--findings", default=None, help="enactment mode: path to the supplied finding manifest/report/directory") + i.add_argument("--spec", default=None, + help="discovery mode: path to the diff, spec, or note that scopes the pass; " + "omit to scope the pass to the whole project") i.set_defaults(func=cmd_init) p = sub.add_parser("partition", help="split classification work into per-agent plans") - p.add_argument("kind", choices=["analyze", "discover"]) + p.add_argument("kind", choices=["analyze", "frontier"]) p.set_defaults(func=cmd_partition) - m = sub.add_parser("mark-safe", help="merge discover plans into classification.yaml") - m.add_argument("--keep", action="store_true", help="keep the reconciled discover plans") + m = sub.add_parser("mark-safe", help="merge frontier plans into classification.yaml") + m.add_argument("--keep", action="store_true", help="keep the reconciled frontier plans") m.set_defaults(func=cmd_mark_safe) s = sub.add_parser("merge-skipped", help="rebuild approximations/skipped.yaml from batches") diff --git a/skills/appsec-agent/scripts/get_status.py b/skills/appsec-agent/scripts/get_status.py index e8114ebdd..e8411bb26 100644 --- a/skills/appsec-agent/scripts/get_status.py +++ b/skills/appsec-agent/scripts/get_status.py @@ -23,17 +23,17 @@ from pathlib import Path from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, + PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SCOPE, SINKS_TR, SOURCES_TR, TRACKING, build_done_keys, classified_keys, dropped_entries, - git_head, load_yaml, member_key, modeled_entries, skipped_keys, - strip_quotes) + git_head, load_yaml, member_key, modeled_entries, scope_families, + skipped_keys, strip_quotes) STATE = load_yaml(TRACKING / "state.yaml", {}) or {} -MODE = STATE.get("mode") or "assessment" +MODE = STATE.get("mode") or "discovery" SCAN_LEVEL = STATE.get("scan_level") TRIAGE_LEVEL = STATE.get("triage_level") -DISCOVER_PLANS = RULES_TR / "plans" +FRONTIER_PLANS = RULES_TR / "plans" APPROX_PLANS = APPROX / "plans" VULN = ROOT / "vulnerabilities.md" ENACTMENT = ROOT / "enactment.md" @@ -165,10 +165,13 @@ def ph_build(): "dispatch build-project"], None -def ph_discover(): +def ph_frontier(): + """Onboarding's intake: the external-method frontier, swept as one trust boundary. Every + dependency member the project's own code calls is a candidate boundary until a leaf verdicts + it, and the ledger is what makes that verdict durable for every later pass.""" if not (TRACKING / "coverage.yaml").is_file(): return False, ["dispatch triage-dependencies"], None - leftover = sorted(glob.glob(str(DISCOVER_PLANS / "*.yaml"))) + leftover = sorted(glob.glob(str(FRONTIER_PLANS / "*.yaml"))) units = load_units(SOURCES_TR) ledger = load_yaml(RULES_TR / "classification.yaml", {}) or {} if leftover: @@ -177,7 +180,7 @@ def ph_discover(): tasks.append("then run `scripts/generate.py mark-safe` to reconcile the plans") return False, tasks, None if not ledger and not units: - return False, ["run `scripts/generate.py partition discover` to plan the used members"], None + return False, ["run `scripts/generate.py partition frontier` to plan the called members"], None return True, [], None @@ -288,9 +291,12 @@ def ph_poc(): return True, [], None -# ---- enactment-mode phases ---- +# ---- intake: one contract, three mode-specific derivations ---- def ph_reference_set(): + """Enactment's intake: the supplied findings, normalized and grouped. The `family` field on + each reference file is the assignment — it moves with the finding when a family splits, so + the families are read back from the files rather than from a separate list.""" docs = load_docs(REFERENCE_TR) if not docs: src = STATE.get("findings") or "state.yaml findings unset" @@ -303,31 +309,65 @@ def ph_reference_set(): return True, [], None +def ph_scope(): + """Onboarding's and discovery's intake join: the families their evidence groups into, written + to scope.yaml. Enactment carries the same information on its reference files instead.""" + fams = scope_families() + if not fams: + src = STATE.get("spec") or "the whole project" + what = ("the frontier the sweep verdicted as sources and effects" if MODE == "onboarding" + else f"the code {src} names") + return False, [f"group {what} into families and write {SCOPE}"], None + empty = [n for n, ev in fams if not ev] + if empty: + return False, ["families in scope.yaml with no evidence recorded:"] \ + + [f" {n}" for n in empty], None + return True, [], None + + +def ph_intake(): + if MODE == "enactment": + return ph_reference_set() + if MODE == "onboarding": + done, tasks, note = ph_frontier() + if not done: + return done, tasks, note + return ph_scope() + + def families(): - return sorted({strip_quotes(d.get("family", "")) for _, d in load_docs(REFERENCE_TR) - if strip_quotes(d.get("family", ""))}) + """(family, evidence ids) for this pass, whichever mode scoped it — reference finding ids in + enactment mode, the members or areas intake recorded otherwise. The boundaries stage + generalizes one universal source and sink per entry.""" + if MODE == "enactment": + out = {} + for path, doc in load_docs(REFERENCE_TR): + fam = strip_quotes(doc.get("family", "")) + if fam: + out.setdefault(fam, []).append(path.stem) + return sorted(out.items()) + return scope_families() def ph_boundaries(): specs = {p.stem: d for p, d in load_docs(BOUNDARIES_TR)} fams = families() - missing = [f for f in fams if f not in specs] + missing = [f for f, _ in fams if f not in specs] if missing: return False, ["dispatch discover-universal-boundaries, one per family:"] \ + [f" {f}" for f in missing], None - # a split renames the family on its reference findings, so every spec here owns its findings - unsaturated = [f for f in fams + # a split rewrites the family on the evidence it moves, so every spec here owns its evidence + unsaturated = [f for f, _ in fams if str((specs[f].get("saturation") or {}).get("status", "")).strip() != "saturated"] if unsaturated: return False, ["boundary specs not saturated:"] + [f" {f}" for f in unsaturated], None - unfactored = sorted(p.stem for p, d in load_docs(REFERENCE_TR) - if p.stem not in (specs.get(strip_quotes(d.get("family", "")), {}) - .get("factorization") or {})) + unfactored = sorted(f"{f}: {e}" for f, evidence in fams for e in evidence + if e not in (specs[f].get("factorization") or {})) if unfactored: - return False, ["reference findings with no factorization in their spec:"] \ + return False, ["evidence with no factorization in its spec:"] \ + [f" {r}" for r in unfactored], None - unseeded = [f for f in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] + unseeded = [f for f, _ in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] if unseeded: return False, ["seed the source and sink units from these specs' candidate_patterns:"] \ + [f" {f}" for f in unseeded], None @@ -359,14 +399,21 @@ def ph_crossref(): def has_reference_set(): """A reference set outlives the pass that created it. Any later pass that rescans changes what - it reproduces, so the cross-reference stays in scope — otherwise an assessment pass would leave - a coverage manifest that silently describes an older scan.""" + it reproduces, so the cross-reference stays in scope — otherwise an onboarding or discovery + pass would leave a coverage manifest that silently describes an older scan.""" return REFERENCE_TR.is_dir() and any(REFERENCE_TR.glob("*.yaml")) -ASSESSMENT_PHASES = [ +# One pipeline, whatever the mode brought to it. Intake and its universal boundaries differ — +# the swept frontier, the diff or spec, the supplied findings — but from the boundary specs on, +# every mode runs the same stages in the same order: sources before the scan that proves them, +# approximations against the frontier that scan names, sinks after it, then triage. Only the +# closing phases are conditional, on the triage level, the controls knob, and whether the tree +# carries a reference set to re-judge. +PHASES = [ ("build", ph_build, lambda: True), - ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), + ("intake", ph_intake, lambda: SCAN_LEVEL == "deep"), + ("boundaries", ph_boundaries, lambda: SCAN_LEVEL == "deep"), ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), ("scan", ph_scan, lambda: True), ("approximations", ph_approximations, lambda: SCAN_LEVEL in ("normal", "deep")), @@ -376,24 +423,7 @@ def has_reference_set(): ("crossref", ph_crossref, has_reference_set), ] -# enactment reproduces a supplied finding set: the reference set and its saturated boundaries -# replace dependency discovery, and both rule sides are authored before the first scan so that -# scan is rule-first. The cross-reference closes the run — it judges what the finished rule set, -# its approximations, its verdicts and its controls actually reproduced. -ENACTMENT_PHASES = [ - ("build", ph_build, lambda: True), - ("reference_set", ph_reference_set, lambda: True), - ("boundaries", ph_boundaries, lambda: True), - ("source_rules", ph_source_rules, lambda: True), - ("sink_rules", ph_sink_rules, lambda: True), - ("scan", ph_scan, lambda: True), - ("approximations", ph_approximations, lambda: True), - ("triage", ph_triage, lambda: True), - ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), - ("crossref", ph_crossref, lambda: True), -] -PHASES = ENACTMENT_PHASES if MODE == "enactment" else ASSESSMENT_PHASES # ---- caps ---- @@ -436,8 +466,10 @@ def cmd_full(): print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " f"language={STATE.get('language')} commit={commit} " f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") - if STATE.get("findings"): # printed on an assessment pass too — the set is still tracked + if STATE.get("findings"): # printed in every mode — the set stays tracked print(f"findings={STATE.get('findings')}") + if STATE.get("spec"): + print(f"spec={STATE.get('spec')}") runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] if len(runs) > 1: print("passes: " + " -> ".join(str(r.get("type", "?")) for r in runs)) diff --git a/skills/assessment-agent/SKILL.md b/skills/assessment-agent/SKILL.md deleted file mode 100644 index ad32f515f..000000000 --- a/skills/assessment-agent/SKILL.md +++ /dev/null @@ -1,160 +0,0 @@ ---- -name: assessment-agent -description: Assess a project for unknown vulnerabilities with OpenTaint, owning the long project build and scans and delegating each other pipeline stage. Use when the user asks to find vulnerabilities or scan an application for security issues, with no finding set supplied to reproduce -license: Apache-2.0 -metadata: - author: opentaint - version: "0.3.0" ---- - -# Assessment Agent - -Assess a project for vulnerabilities it was not already known to have. Keep the long project build and every full-project scan in this main session; delegate each bounded source, approximation, sink, triage, and PoC stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. - -This is the assessment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `enactment-agent`'s, which reproduces a finding set the user supplies. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface here, generalized from the supplied findings there. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. - -This run is one *pass* over a tree that outlives it. The pass may follow an enactment pass, in which case its boundaries are already on disk as rules and this pass hunts with them; it may be followed by one; and it may run again on a later commit as a regression check. So leave the tree richer than you found it, and don't treat an artifact you didn't create as debris. If the tree carries a reference set from an enactment pass, your rescans change what it reproduces, and `get_status.py` keeps the cross-reference in scope so its coverage manifest stays true. - -OpenTaint is a whole-program, interprocedural, field-sensitive alias analysis SAST. The run produces confirmed vulnerabilities plus reusable project-specific rules and approximations under one self-contained `.opentaint/` directory at the project root. - -## Setup - -Skip only what `appsec-agent` already did when it handed off — the toolchain and nesting checks. Everything from step 3 on is this pipeline's own, including the bootstrap. - -### 1. Confirm the toolchain - -Confirm `opentaint` is on PATH with `opentaint -v`. If it's missing, don't proceed silently — tell the user and offer the install command for their platform, run an install only on explicit confirmation: - -- macOS / Linux, in order: `brew install --cask seqra/tap/opentaint` · `npm install -g @seqra/opentaint` -- Windows: `npm install -g @seqra/opentaint` - -After installing, run `opentaint health` to confirm everything's resolved. - -### 2. Confirm agent nesting - -This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. - -### 3. Determine the language - -Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. When a repo carries build markers for more than one, ask the user which to analyze. - -### 4. Choose the workflow - -Ask the user for both knobs together: - -1. Scan level — `lite` · `normal` · `deep` - - lite — build + scan (expected, when there are already existing artifacts) - - normal — build + scan + custom approximations - - deep — build + scan + custom approximations + custom rules - - recommend by what's on disk: a cold start (no `.opentaint` artifacts) → deep; a prior run's artifacts already present → lite -2. Triage level — `static` · `dynamic` - - static — classify findings from the model, no running app - - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option - -### 5. Bootstrap - -Seed the run state and the working tree with the chosen levels and language: - -```bash -uv run /scripts/generate.py init --scan-level --triage-level --language -``` - -It writes `state.yaml` with `mode: assessment`, appends this pass to `history.yaml`, and creates the `.opentaint/` tree. - -Over a tree an earlier pass already built, it prints what carried over and keeps all of it — an enactment pass's boundary-derived rules, its approximations, and its verdicts are this pass's starting corpus, and `get_status.py` will report their phases `DONE` rather than redoing them. - -## Workflow - -The run is one fixed pipeline; the selected levels determine which phases are in scope. Use `uv run /scripts/get_status.py` to choose the next action: - -``` -build → MAIN: build -discover / source_rules → stage subagent: sources -scan → MAIN: scan -approximations → stage subagent: approx-round, then MAIN: rescan; repeat -sink_rules → stage subagent: sinks, then MAIN: rescan -triage → stage subagent: triage -poc → stage subagent: poc -crossref → stage subagent: crossref (only if an enactment pass left a reference set) -``` - -`crossref` appears only when a previous enactment pass over this tree left a reference set. This pass's rescans changed what those supplied findings reproduce, so re-judging them and refreshing `.opentaint/enactment.md` is part of finishing — not optional cleanup. - -### Build in MAIN - -When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. - -Record the returned `build_jdk` in `.opentaint/tracking/state.yaml`. Record `model_commit` as the full HEAD only when no source file is uncommitted, otherwise set it to null. Build non-convergence blocks the run because no later phase can proceed without the model. - -### Scan in MAIN - -When status reports `scan`, or a stage returns with a rescan pending, load and follow the `run-scan` skill in this main session. Start the scan with the harness's main-session background-command facility, keep the engine's self-timeout, add a 1200-second outer backstop, and wait for the process completion event. - -A valid `.opentaint/results/report.sarif` means the scan completed, including exit 254 after an engine timeout. Record `max_memory: 16G` when the scan had to bump memory and reuse it on later scans. If no SARIF exists after the allowed retry/backstop, follow the repair path below for a malformed rule/approximation; otherwise dispatch `orchestrate-stage` with `stage: escalation` and the scan `setup` to write the scan-wide resource issue, then stop. - -When a scan or later stage reports a malformed approximation, unloadable created rule, ineffective join, or a created rule's false positive/negative, route the exact diagnosis and artifact path/id to the responsible stage agent per Dispatching, then scan again in MAIN. - -After every build, scan, or stage return, run `uv run /scripts/get_status.py` once to choose the next action. Use `--full` at run start, on resume, or when the brief output does not settle the question. - -## Dispatching - -Dispatch exactly one stage-orchestrator subagent for each stage invocation: - -``` -Invoke the Skill orchestrate-stage first, then follow its instructions precisely -Inputs: - stage: -``` - -For a `deep` approximation round, also pass `sinks: true`. A subagent inherits the project-root working directory, so omit `project-root`. - -Stage context: - -- `sources` — discover dependency sources, author their rules, and wire the joins -- `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier -- `sinks` — author classified sink rules and wire the joins -- `triage` — classify the latest findings and refresh the vulnerability report -- `crossref` — re-judge a reference set an earlier enactment pass left, and refresh its coverage manifest -- `poc` — reproduce confirmed findings and add the outcomes to the report -- `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure - -Keep each agent id until the next scan validates its artifacts. On a stage-owned error, resume that agent with `stage: escalation`, the exact error, and the artifact path/id. If its thread is unavailable, start a re-entrant `orchestrate-stage` agent with that diagnosis. - -Dispatch each subagent fresh, don't fork context into it. Then wait for it natively, don't monitor or poll every minute. If the harness forces a wait timeout, set it to ~1h and re-wait when it returns. - -## State and resumption - -Use this ownership map to route work and scan errors: - -``` -.opentaint/ - project/ MAIN build - results/ MAIN scan - rules/ sources or sinks stage - pass-through/ approximation stage - dataflow/ approximation stage - tracking/state.yaml MAIN run knobs - tracking/ stage agents, leaves, and join scripts otherwise - vulnerabilities.md triage / PoC stage - issues/ escalation stage -``` - -The tree is long-lived and outlives this pass. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan, whichever pass created them. Never delete or rewrite an artifact because this pass didn't produce it — an enactment pass's boundary rules, reference set, and coverage manifest are as durable as your own. - -`state.yaml` shape — `mode` is this pass's pipeline, not a property of the tree, so a later enactment pass simply rewrites it and keeps everything else: - -```yaml -mode: assessment -scan_level: deep -triage_level: dynamic -language: java -model_commit: 0123456789abcdef0123456789abcdef01234567 -build_jdk: null -max_memory: null -``` - -## Key constraints - -- read pipeline state through `/scripts/get_status.py`, not by hand — don't re-derive it with glob/grep/`python3 -c`/yaml scans over `.opentaint/tracking`, `results`, or the `*.yaml`, nor open finding/unit/SARIF files just to review progress. If its output doesn't settle the question, re-run it with `--full` before opening any file -- don't author or edit stage-owned artifacts or tracking; MAIN writes only `model_commit`, `build_jdk`, and `max_memory` in `state.yaml` -- keep one generated project model for the run; never hand-edit or replace it mid-analysis — fix the build and rebuild before starting a new run diff --git a/skills/assessment-agent/scripts/_common.py b/skills/assessment-agent/scripts/_common.py deleted file mode 100644 index cd0873805..000000000 --- a/skills/assessment-agent/scripts/_common.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Shared helpers for the OpenTaint pipeline orchestrator scripts. - -Not a runnable script — imported by the PEP723 entry points (get_status.py, generate.py), -which carry the pyyaml dependency. Every path resolves under the fixed -/.opentaint/ tree relative to the current directory, so run the entry -scripts from the project root. -""" -import glob -import re -import subprocess -from pathlib import Path - -import yaml - -ROOT = Path(".opentaint") -MODEL = ROOT / "project" -TRACKING = ROOT / "tracking" -APPROX = TRACKING / "approximations" -RULES_TR = TRACKING / "rules" -SOURCES_TR = RULES_TR / "sources" -SINKS_TR = RULES_TR / "sinks" -JOINS_TR = RULES_TR / "joins" -FINDINGS_TR = TRACKING / "findings" -REFERENCE_TR = TRACKING / "reference" # enactment mode: the supplied findings, normalized -BOUNDARIES_TR = TRACKING / "boundaries" # enactment mode: per-family boundary specs -RESULTS = ROOT / "results" -DROPPED = RESULTS / "dropped-external-methods.yaml" -SARIF = RESULTS / "report.sarif" -RULES = ROOT / "rules" -PASS_THROUGH = ROOT / "pass-through" -DATAFLOW = ROOT / "dataflow" - - -# ---- yaml io ---- - -def load_yaml(path, default=None): - p = Path(path) - if not p.is_file(): - return default - try: - return yaml.safe_load(p.read_text(encoding="utf-8")) or default - except yaml.YAMLError as e: - raise SystemExit(f"{p}: invalid YAML — {e}\n" - " a JVM signature containing '[' must be quoted in flow style " - "(signature: \"([BLjava/lang/String;)V\")") - - -def dump_yaml(obj): - return yaml.safe_dump(obj, sort_keys=False, default_flow_style=False, allow_unicode=True) - - -# ---- fqn / member normalization ---- - -def strip_quotes(s): - return str(s).strip().strip('"').strip("'") - - -def fqn_base(s): - """The method fqn without its signature/params — `a.b.C#m`.""" - s = strip_quotes(s) - i = s.find("(") - return (s[:i] if i != -1 else s).strip() - - -def member_of(item): - """A bucket/plan entry (dict or str) normalized to {method, signature?}.""" - if isinstance(item, dict): - m = strip_quotes(item.get("method", "")) - sig = str(item.get("signature", "")).strip() - return {"method": m, "signature": sig} if sig else {"method": m} - return {"method": strip_quotes(item)} - - -def member_key(item): - """Overload-precise key: method + signature. Matches the classification ledger.""" - if isinstance(item, dict): - return f"{strip_quotes(item.get('method', ''))}{str(item.get('signature', '')).strip()}" - return strip_quotes(item) - - -def class_of(fqn): - return fqn_base(fqn).split("#", 1)[0].strip() - - -def package_of(fqn): - cls = class_of(fqn) - return cls.rsplit(".", 1)[0] if "." in cls else "" - - -# ---- git ---- - -def git_head(): - """HEAD commit of the project tree, or None when there's no repo.""" - try: - out = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, - text=True, check=True) - return out.stdout.strip() or None - except (OSError, subprocess.CalledProcessError): - return None - - -# ---- approximation batch readers (shared by coverage + partition) ---- - -# a method counts classified once it sits in any batch classification bucket or in build.done -CLASSIFIED_BUCKETS = ("passthrough", "dataflow", "skipped", "engine_issues") -MODELED_BUCKETS = ("passthrough", "dataflow") - - -def batch_files(): - """Every approximation batch file (skipped.yaml is the merged view, not a batch).""" - return [Path(p) for p in sorted(glob.glob(str(APPROX / "*.yaml"))) - if Path(p).name != "skipped.yaml"] - - -def dropped_entries(): - """The dropped external methods as {method, signature?} rows (order preserved).""" - rows = [] - for e in load_yaml(DROPPED, []) or []: - if isinstance(e, dict) and e.get("method"): - row = {"method": strip_quotes(e["method"])} - if e.get("signature"): - row["signature"] = str(e["signature"]).strip() - rows.append(row) - return rows - - -def classified_keys(): - """method+signature keys of every method already classified across the batch files. - Overload-precise (matches the ledger, build.done, unbuilt and stuck checks): a method with - one overload classified does not mask a differently-propagating overload still dropped.""" - out = set() - for p in batch_files(): - doc = load_yaml(p, {}) or {} - for key in CLASSIFIED_BUCKETS: - for item in doc.get(key, []) or []: - if str(item).strip(): - out.add(member_key(item)) - for item in (doc.get("build") or {}).get("done", []) or []: - if str(item).strip(): - out.add(member_key(item)) - return out - - -def skipped_keys(): - """method+signature keys classified terminal — the `skipped` and `engine_issues` buckets of - every batch. Terminal means the method will never build a working carrier, so it must not hold - the approximations phase pending even if it is still modeled and still dropped by the scan. - (skipped.yaml is the merged view of these same buckets, so reading the batches alone suffices.)""" - out = set() - for p in batch_files(): - doc = load_yaml(p, {}) or {} - for bucket in ("skipped", "engine_issues"): - for item in doc.get(bucket, []) or []: - if str(item).strip(): - out.add(member_key(item)) - return out - - -def modeled_entries(): - """Every passthrough/dataflow entry across batches, tagged with its batch file.""" - out = [] - for p in batch_files(): - doc = load_yaml(p, {}) or {} - for kind in MODELED_BUCKETS: - for item in doc.get(kind, []) or []: - if str(item).strip(): - out.append((p, kind, member_of(item))) - return out - - -def build_done_keys(): - """Overload-precise keys of every built approximation across batches.""" - keys = set() - for p in batch_files(): - doc = load_yaml(p, {}) or {} - for item in (doc.get("build") or {}).get("done", []) or []: - if str(item).strip(): - keys.add(member_key(item)) - return keys - - -# ---- finding files ---- - -RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) -VERDICT_RE = re.compile(r'^verdict:\s*(.+?)\s*$', re.M) - - -def ledger_verdicted_keys(): - """method+signature keys already verdicted in classification.yaml (source ∪ safe).""" - doc = load_yaml(RULES_TR / "classification.yaml", {}) or {} - return {strip_quotes(x) for key in ("source", "safe") for x in (doc.get(key) or [])} diff --git a/skills/assessment-agent/scripts/generate.py b/skills/assessment-agent/scripts/generate.py deleted file mode 100644 index 5e204b3c5..000000000 --- a/skills/assessment-agent/scripts/generate.py +++ /dev/null @@ -1,639 +0,0 @@ -# /// script -# requires-python = ">=3.9" -# dependencies = ["pyyaml==6.0.3"] -# /// -""" -generate.py — the orchestrator's writing helper. Every subcommand mutates durable -state at a fan-out join; none is read-only (use get_status.py for checks). Run with uv -from the project root: `uv run scripts/generate.py `. - - init bootstrap the .opentaint tree + state.yaml from the workflow flags - partition analyze dropped external methods -> per-root batch plans (approximations) - partition discover coverage.yaml's used members -> balanced discover plans - mark-safe discover plans' verdicts -> classification.yaml ledger (+prune plans) - merge-skipped batch skipped/engine_issues -> approximations/skipped.yaml (+prune plans) - findings results/report.sarif -> per-rule finding tracking files (idempotent) -""" -import argparse -import glob -import hashlib -import json -import math -import re -import subprocess -import sys -from pathlib import Path - -import yaml - -from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, - SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, - batch_files, dropped_entries, dump_yaml, fqn_base, git_head, - ledger_verdicted_keys, load_yaml, member_key, package_of, strip_quotes) - -ANALYZE_BUDGET = 20 # methods per approximation batch -ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch -ROOT_DEPTH = 2 # library root = first 2 dotted segments -DISCOVER_TARGET, DISCOVER_BAND = 50, 15 # project-used members per discover plan (~50, loose) - -DISCOVER_PLANS = RULES_TR / "plans" -APPROX_PLANS = APPROX / "plans" - - -# ---- init: bootstrap the working tree + state.yaml ---- - -# the durable directories a run writes into; the leaves/scripts mkdir on write, but seeding -# them up front gives every stage a place to land and makes the empty tree self-describing. -INIT_DIRS = [TRACKING, APPROX, SOURCES_TR, SINKS_TR, JOINS_TR, FINDINGS_TR, - RESULTS, RULES, PASS_THROUGH, DATAFLOW] -ENACTMENT_DIRS = [REFERENCE_TR, BOUNDARIES_TR] # enactment mode only -STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves - - -def carried_over(): - """What a pass inherits from the passes before it, as (label, count) — everything durable - the tree already holds. Both pipelines write into one tree, so a pass never starts empty - unless the tree is.""" - def n(paths): - return sum(1 for _ in paths) - return [("rule unit", n(SOURCES_TR.glob("*.yaml")) + n(SINKS_TR.glob("*.yaml"))), - ("created rule", n(p for p in RULES.rglob("*.yaml") if p.is_file())), - ("approximation batch", len(batch_files())), - ("reference finding", n(REFERENCE_TR.glob("*.yaml")) if REFERENCE_TR.is_dir() else 0), - ("triaged finding", n(FINDINGS_TR.glob("*.yaml")))] - - -def cmd_init(args): - state_path = TRACKING / "state.yaml" - prior = load_yaml(state_path, {}) or {} - enactment = args.mode == "enactment" - # the finding set is a property of the tree, not of one pass — an enactment pass that follows - # an earlier one inherits it, so --findings is required only the first time - findings = args.findings or prior.get("findings") - if enactment and not findings: - raise SystemExit("init --mode enactment requires --findings ") - if not enactment and not args.scan_level: - raise SystemExit("init --mode assessment requires --scan-level") - # enactment reproduces a supplied finding set, which always needs the full rule + approximation - # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. - scan_level = "deep" if enactment else args.scan_level - - for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): - d.mkdir(parents=True, exist_ok=True) - # `mode` is this pass's pipeline, not a permanent property of the tree: the two compose, in - # either order and repeatedly across commits, and every artifact below is shared between them. - state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, - "language": args.language or prior.get("language")} - if findings: # kept even on an assessment pass, so a later one resumes it - state["findings"] = findings - for k in STATE_DERIVED: # never clobber what build/scan already learned - state[k] = prior.get(k) - state_path.write_text(dump_yaml(state), encoding="utf-8") - - # history: one entry per pass. Re-running init with the same knobs on the same commit is a - # resume of the current pass, not a new one — a different mode, level, or commit starts one. - hist_path = TRACKING / "history.yaml" - runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] - entry = {"commit": git_head(), - "type": f"{args.mode}/{scan_level}/{args.triage_level}"} - new_pass = not runs or runs[-1] != entry - if new_pass: - runs.append(entry) - hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") - - prior_mode = prior.get("mode") - if not prior: - how = "fresh tree" - elif prior_mode and prior_mode != args.mode: - how = f"new {args.mode} pass over the existing {prior_mode} tree" - elif new_pass: - how = f"new {args.mode} pass (pass {len(runs)})" - else: - how = "resumed (derived knobs preserved)" - print(f"init {how}: mode={args.mode} scan_level={scan_level} " - f"triage_level={state['triage_level']} language={state['language']}") - if findings: - print(f"findings={findings}") - if prior: - kept = ", ".join(f"{c} {label}{'' if c == 1 else 's'}" for label, c in carried_over() if c) - print(f"carried over: {kept or 'nothing yet'}") - print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " - "directories under .opentaint/") - print("next: uv run scripts/get_status.py --full") - return 0 - - -def regen_plans(out_dir): - # partition regenerates the whole plan set from the current unclassified state, so drop any - # stale plans first — otherwise a re-partition leaves already-consumed plans as leftover cruft. - out_dir.mkdir(parents=True, exist_ok=True) - for p in out_dir.glob("*.yaml"): - p.unlink() - - -# ---- partition: shared bin-packing ---- - -def root_of(fqn, depth=ROOT_DEPTH): - segs = class_of(fqn).split(".") - return ".".join(segs[:depth]) if len(segs) >= depth else class_of(fqn) - - -def in_packages(cls, prefixes): - # dotted-boundary match: `a.b.collect` never matches a sibling `a.b.collectX` - return any(cls == p or cls.startswith(p + ".") for p in prefixes) - - -def atomize(fqns, cap): - # split into atomic scopes (prefix, [fqns]); each scope is a whole (sub)package (or a subtree - # under cap) — a package is NEVER split across scopes, so it lands in exactly one bin and no two - # agents ever share a package's per-package unit. A package over cap stays oversized. - scopes = [] - - def recurse(prefix, items): - if len(items) <= cap: - scopes.append((prefix, items)) - return - depth = len(prefix.split(".")) - buckets, leaf = {}, [] - for f in items: - pkg = package_of(f) - segs = pkg.split(".") if pkg else [] - if pkg == prefix or len(segs) <= depth: - leaf.append(f) - else: - child = ".".join(segs[: depth + 1]) - buckets.setdefault(child, []).append(f) - if leaf: - scopes.append((prefix, leaf)) - for child, cf in buckets.items(): - recurse(child, cf) - - top = {} - for f in fqns: - pkg = package_of(f) - top.setdefault(pkg.split(".")[0] if pkg else class_of(f), []).append(f) - for seg0, items in top.items(): - recurse(seg0, items) - return scopes - - -def pack(scopes, target, cap): - # longest-processing-time bin-packing. An atomic scope larger than cap (a package that can't - # be split) gets its own bin instead of forcing the whole set to one-scope-per-bin. - plans = [{p: v} for p, v in scopes if len(v) > cap] - items = sorted((s for s in scopes if len(s[1]) <= cap), key=lambda s: len(s[1]), reverse=True) - if items: - total = sum(len(v) for _, v in items) - k = max(1, math.ceil(total / cap), round(total / target)) - while True: - loads = [0] * k - bins = [{} for _ in range(k)] - for prefix, v in items: - i = min(range(k), key=lambda j: loads[j]) - bins[i][prefix] = v - loads[i] += len(v) - if max(loads) <= cap or k >= len(items): - break - k += 1 - plans += [b for b in bins if b] - return plans - - -def write_plans(plans, out_dir, prefix_id): - out_dir.mkdir(parents=True, exist_ok=True) - paths = [] - for i, scopes in enumerate(plans, 1): - pid = f"{prefix_id}-{i:03d}" - norm = {p.replace(".", "-"): sorted(v, key=lambda x: (x["method"], x.get("signature", "")) - if isinstance(x, dict) else x) - for p, v in sorted(scopes.items())} - path = out_dir / f"{pid}.yaml" - # source: null is the unprocessed sentinel — a discover agent overwrites it with the - # list of sources it found (an empty list when it finds none). mark-safe folds only - # plans whose sentinel was replaced, so an un-returned plan is never marked safe. - path.write_text(dump_yaml({"id": pid, "scopes": norm, "source": None}), encoding="utf-8") - paths.append(str(path)) - return paths - - -# ---- partition analyze ---- - -def _root_next_index(prefix): - # additive numbering: a re-partition round must never reuse an id an existing batch already - # owns, or the new plan's analyze agent would overwrite that batch. Continue past the highest - # index any existing batch OR leftover plan already claims for this root. - mx = 0 - for d in (APPROX, APPROX_PLANS): - for p in glob.glob(str(d / f"{prefix}-*.yaml")): - m = re.match(rf"^{re.escape(prefix)}-(\d+)\.yaml$", Path(p).name) - if m: - mx = max(mx, int(m.group(1))) - return mx + 1 - - -def cmd_analyze(args): - regen_plans(APPROX_PLANS) - classified = classified_keys() - rows = [r for r in dropped_entries() if member_key(r) not in classified] - if not rows: - print("nothing to plan — every dropped method already classified", file=sys.stderr) - return 0 - - by_root = {} - for r in rows: - by_root.setdefault(root_of(r["method"]), []).append(r) - count = lambda rs: len({fqn_base(r["method"]) for r in rs}) - misc = [] - for root in [k for k, rs in by_root.items() if count(rs) <= ANALYZE_MISC]: - misc += by_root.pop(root) - if misc: - by_root["misc"] = misc - - paths = [] - for root in sorted(by_root): - by_fqn = {} - for r in by_root[root]: - by_fqn.setdefault(fqn_base(r["method"]), []).append(r) - bins = pack(atomize(sorted(by_fqn), ANALYZE_BUDGET), ANALYZE_BUDGET, ANALYZE_BUDGET) - prefix = root.replace(".", "-") - start = _root_next_index(prefix) - for i, b in enumerate(bins): - scopes = {} - for f in {f for v in b.values() for f in v}: # re-group the batch by class - scopes.setdefault(class_of(f), []).extend(by_fqn[f]) - norm = {cls: sorted(v, key=lambda x: (x["method"], x.get("signature", ""))) - for cls, v in sorted(scopes.items())} - pid = f"{prefix}-{start + i:03d}" - path = APPROX_PLANS / f"{pid}.yaml" - path.write_text(dump_yaml({"scopes": norm}), encoding="utf-8") - paths.append(str(path)) - for p in paths: - print(p) - return 0 - - -# ---- partition discover ---- - -def yaml_modules(model_yaml): - # each module in project.yaml as (packages, moduleClasses); only `packages` says which of a - # classpath-mode model's moduleClasses (dependency jars included) is project code - doc = load_yaml(model_yaml, {}) or {} - mods = [] - - def walk(node): - if isinstance(node, dict): - if isinstance(node.get("moduleClasses"), list): - mods.append(([str(p) for p in (node.get("packages") or [])], - [str(c) for c in node["moduleClasses"]])) - for v in node.values(): - walk(v) - elif isinstance(node, list): - for x in node: - walk(x) - - walk(doc) - return mods - - -def is_project_class(cls, packages): - # mirrors the engine's ProjectClasses.isModuleClass; empty packages = a project-only module - return not packages or any(cls.startswith(p) for p in packages) - - -CALL_RE = re.compile(r"//\s*(?:Interface)?Method\s+(\S+?)\.(?):(\S+)") - - -def extract_usages(): - # disassemble project classes, collect // Method / // InterfaceMethod call sites with their - # JVM descriptor; returns (fqn, signature) pairs so an overloaded member stays disambiguated - fqns = set() - for packages, module_classes in yaml_modules(MODEL / "project.yaml"): - for entry in module_classes: - p = MODEL / entry - if p.is_dir(): - classes = [str(c.relative_to(p))[:-6].replace("/", ".") for c in p.rglob("*.class")] - elif p.is_file(): - try: - listing = subprocess.run(["jar", "tf", str(p)], capture_output=True, - text=True, check=True).stdout - except (OSError, subprocess.CalledProcessError): - continue - if not packages: - print(f"warning: {entry} is a jar in a module with no declared `packages` — its " - f"plans will cover the library's own calls, not the project's", file=sys.stderr) - classes = [c[:-6].replace("/", ".") for c in listing.splitlines() - if c.endswith(".class")] - else: - continue - classes = [c for c in classes if is_project_class(c, packages)] - cp = str(p) - for i in range(0, len(classes), 200): # batch to keep argv under the limit - batch = classes[i:i + 200] - try: - out = subprocess.run(["javap", "-c", "-p", "-classpath", cp, *batch], - capture_output=True, text=True).stdout - except OSError: - continue - for owner, method, sig in CALL_RE.findall(out): - fqns.add((f"{owner.replace('/', '.')}#{method}", sig)) - return fqns - - -def pending_packages(): - cov = load_yaml(TRACKING / "coverage.yaml", {}) or {} - return tuple(p for p in (cov.get("packages") or []) if isinstance(p, str) and p) - - -def cmd_discover(args): - regen_plans(DISCOVER_PLANS) - packages = pending_packages() - if not packages: - print("nothing to plan — no pending package in coverage.yaml", file=sys.stderr) - return 0 - verdicted = ledger_verdicted_keys() # method+signature keys - sigs = {} # bare member fqn -> its pending signatures - for f, sig in extract_usages(): - if in_packages(class_of(f), packages) and f + sig not in verdicted: - sigs.setdefault(f, set()).add(sig) - todo = sorted(sigs) - if not todo: - print("nothing to plan — every used member already verdicted", file=sys.stderr) - return 0 - cap = DISCOVER_TARGET + DISCOVER_BAND - plans = pack(atomize(todo, cap), DISCOVER_TARGET, cap) - rows = {f: [{"method": f, "signature": s} for s in sorted(sigs[f])] for f in sigs} - plans = [{pkg: [r for f in members for r in rows[f]] for pkg, members in plan.items()} - for plan in plans] - for p in write_plans(plans, DISCOVER_PLANS, "lib"): - print(p) - return 0 - - -def cmd_partition(args): - return cmd_analyze(args) if args.kind == "analyze" else cmd_discover(args) - - -# ---- mark-safe (discover join) ---- - -def cmd_mark_safe(args): - plans = sorted(glob.glob(str(DISCOVER_PLANS / "lib-*.yaml"))) - if not plans: - print("no discover plans to reconcile", file=sys.stderr) - return 0 - ledger = RULES_TR / "classification.yaml" - doc = load_yaml(ledger, {}) or {} - source = {member_key(x) for x in (doc.get("source") or [])} - safe = {member_key(x) for x in (doc.get("safe") or [])} - processed, unprocessed = [], [] - for p in plans: - pdoc = load_yaml(p, {}) or {} - raw = pdoc.get("source") - if raw is None: # sentinel intact — no discover agent returned for it - unprocessed.append(p) - continue - members = {member_key(m) for v in (pdoc.get("scopes") or {}).values() for m in v} - srcs = {member_key(x) for x in raw} - source |= srcs - safe |= members - srcs - processed.append(p) - print(f"{Path(p).name}: {len(srcs)} sources, {len(members - srcs)} safe") - if not processed: - print("no processed discover plans (every plan still carries source: null) — " - "fan out discover-attack-surface first", file=sys.stderr) - return 0 - safe -= source - ledger.parent.mkdir(parents=True, exist_ok=True) - ledger.write_text(dump_yaml({"source": sorted(source), "safe": sorted(safe)}), encoding="utf-8") - print(f"classification.yaml: {len(source)} source, {len(safe)} safe total") - if not args.keep: - for p in processed: - Path(p).unlink() - print(f"pruned {len(processed)} reconciled discover plan(s)") - if unprocessed: - print(f"left {len(unprocessed)} unprocessed plan(s) (source: null) for re-dispatch: " - + ", ".join(Path(p).name for p in unprocessed)) - return 0 - - -# ---- merge-skipped (analyze join) ---- - -def _skip_member(item): - # normalize a skipped/engine_issues entry to {method, signature?}, dropping the reason - if isinstance(item, dict): - m = strip_quotes(item.get("method", "")) - sig = str(item.get("signature", "")).strip() - return {"method": m, "signature": sig} if sig else {"method": m} - return {"method": strip_quotes(item)} - - -def _collect(docs, bucket): - seen = {} - for doc in docs: - for item in doc.get(bucket, []) or []: - m = _skip_member(item) - if m["method"]: - seen[(m["method"], m.get("signature", ""))] = m - return [seen[k] for k in sorted(seen)] - - -def cmd_merge_skipped(args): - # collects the `skipped` and `engine_issues` buckets of every batch into skipped.yaml, keeping - # them as two separate groups — regular skips under `methods`, engine issues under `engine_issues`. - docs = [load_yaml(p, {}) or {} for p in - (Path(x) for x in sorted(glob.glob(str(APPROX / "*.yaml")))) - if p.name != "skipped.yaml"] - out = {"methods": _collect(docs, "skipped"), "engine_issues": _collect(docs, "engine_issues")} - (APPROX / "skipped.yaml").write_text(dump_yaml(out), encoding="utf-8") - print(f"skipped.yaml: {len(out['methods'])} methods, {len(out['engine_issues'])} engine_issues") - if not args.keep and APPROX_PLANS.is_dir(): - pruned = [p for p in glob.glob(str(APPROX_PLANS / "*.yaml"))] - for p in pruned: - Path(p).unlink() - if pruned: - print(f"pruned {len(pruned)} consumed approximation plan(s)") - return 0 - - -# ---- findings (SARIF -> per-rule tracking files) ---- - -ADJ = ["brave", "calm", "eager", "fuzzy", "gentle", "jolly", "keen", "lucid", - "merry", "noble", "proud", "quiet", "rapid", "sly", "tidy", "vivid", - "witty", "zesty", "amber", "bold"] -NOUN = ["hopper", "eagle", "otter", "falcon", "maple", "comet", "harbor", - "willow", "pixel", "river", "ember", "cobra", "lotus", "raven", - "quartz", "badger", "cedar", "drake", "finch", "gull"] - -_FP_PREFERENCE = ("vulnerabilitySourceSinkHash", "vulnerabilityWithTraceHash") - -HASHES_RE = re.compile(r'^sarif_hashes:\s*\[(.*)\]\s*$', re.M) -HASHES_BLOCK_RE = re.compile(r'^sarif_hashes:\s*\n((?:[ \t]+-[^\n]*\n?)+)', re.M) - - -def docker_name(seed, taken): - h = int(hashlib.sha1(seed.encode()).hexdigest(), 16) - base = f"{ADJ[h % len(ADJ)]}-{NOUN[(h // len(ADJ)) % len(NOUN)]}" - name, n = base, 2 - while name in taken: - name, n = f"{base}-{n}", n + 1 - return name - - -def result_hash(res): - fp = res.get("fingerprints") or res.get("partialFingerprints") - if isinstance(fp, dict) and fp: - for pref in _FP_PREFERENCE: - for k, v in fp.items(): - if k.startswith(pref): - return str(v)[:16] - return str(sorted(fp.values())[0])[:16] - parts = [res.get("ruleId", "")] - locs = list(res.get("locations", [])) - for cf in res.get("codeFlows", []): - for tf in cf.get("threadFlows", []): - locs += [st.get("location", {}) for st in tf.get("locations", [])] - for loc in locs: - pl = loc.get("physicalLocation", {}) - parts.append(pl.get("artifactLocation", {}).get("uri", "")) - parts.append(json.dumps(pl.get("region", {}), sort_keys=True)) - return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16] - - -def scan_results(sarif): - out = {} - for run in sarif.get("runs") or []: - for res in run.get("results") or []: - rid = res.get("ruleId") or "unknown" - msg = (res.get("message", {}) or {}).get("text", "").strip() - out.setdefault(rid, {})[result_hash(res)] = msg - return out - - -def fmt_list(hashes): - return "[" + ", ".join(hashes) + "]" - - -def parse_hashes(text): - m = HASHES_RE.search(text) - if m: - return [h.strip() for h in m.group(1).split(",") if h.strip()] - m = HASHES_BLOCK_RE.search(text) - if m: - return [ln.strip().lstrip("-").strip() - for ln in m.group(1).splitlines() if ln.strip().lstrip("-").strip()] - return [] - - -def replace_hashes(text, merged): - line = "sarif_hashes: " + fmt_list(merged) - if HASHES_RE.search(text): - return HASHES_RE.sub(lambda m: line, text, count=1) - if HASHES_BLOCK_RE.search(text): - return HASHES_BLOCK_RE.sub(line + "\n", text, count=1) - return line + "\n" + text - - -def new_file_text(rid, hashes, notes): - body = "\n".join(" " + ln for ln in (notes or "(no analyzer message)").splitlines()) - return (f"sarif_hashes: {fmt_list(hashes)}\n" - f"rule_id: {rid}\n" - f"verdict: pending\n" - f"notes: >\n{body}\n" - f"poc: pending\n") - - -def cmd_findings(args): - sarif = json.loads(SARIF.read_text(encoding="utf-8")) - by_rule = scan_results(sarif) - out = FINDINGS_TR - out.mkdir(parents=True, exist_ok=True) - - existing = {} - taken = set() - for p in sorted(glob.glob(str(out / "*.yaml"))): - text = Path(p).read_text(encoding="utf-8") - rid = RULE_RE.search(text) - verdict = VERDICT_RE.search(text) - taken.add(Path(p).stem) - if rid: - existing.setdefault(rid.group(1).strip(), []).append( - (Path(p), parse_hashes(text), verdict.group(1).strip() if verdict else "pending")) - - created = updated = unchanged = reconcile = 0 - for rid, hashmap in sorted(by_rule.items()): - scanned = set(hashmap) - files = existing.get(rid) - if not files: - name = docker_name(rid, taken) - taken.add(name) - notes = "\n".join(sorted({m for m in hashmap.values() if m})) - (out / f"{name}.yaml").write_text(new_file_text(rid, sorted(scanned), notes), - encoding="utf-8") - created += 1 - continue - already = set().union(*(set(h) for _, h, _ in files)) - new = sorted(scanned - already) - if not new: - unchanged += 1 - continue - pending = next(((p, h) for p, h, v in files if v == "pending"), None) - if pending: - path, hashes = pending - text = replace_hashes(path.read_text(encoding="utf-8"), sorted(set(hashes) | set(new))) - text = re.sub(r'^verdict:\s*.+$', "verdict: pending", text, count=1, flags=re.M) - path.write_text(text, encoding="utf-8") - updated += 1 - continue - name = docker_name(rid, taken) - taken.add(name) - msgs = sorted({hashmap[h] for h in new if hashmap.get(h)}) - notes = ("reconcile: new results under a rule whose findings are already triaged — " - "match each against this rule's triaged findings by flow before judging; if the " - "vulnerability is the same, merge its hashes into that finding and inherit its " - "verdict instead of re-triaging\n" + "\n".join(msgs)) - (out / f"{name}.yaml").write_text(new_file_text(rid, sorted(new), notes), encoding="utf-8") - reconcile += 1 - - print(f"findings: {created} created, {updated} updated, {unchanged} unchanged, " - f"{reconcile} to reconcile ({len(by_rule)} rules in scan)") - return 0 - - -def main(): - ap = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - sub = ap.add_subparsers(dest="cmd", required=True) - - i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--mode", default="assessment", choices=["assessment", "enactment"], - help="assessment: find vulnerabilities. enactment: reproduce supplied findings") - i.add_argument("--scan-level", choices=["lite", "normal", "deep"], - help="assessment mode only; enactment is always deep") - i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) - i.add_argument("--language", default=None, help="target language, determined by the orchestrator") - i.add_argument("--findings", default=None, - help="enactment mode: path to the supplied finding manifest/report/directory") - i.set_defaults(func=cmd_init) - - p = sub.add_parser("partition", help="split classification work into per-agent plans") - p.add_argument("kind", choices=["analyze", "discover"]) - p.set_defaults(func=cmd_partition) - - m = sub.add_parser("mark-safe", help="merge discover plans into classification.yaml") - m.add_argument("--keep", action="store_true", help="keep the reconciled discover plans") - m.set_defaults(func=cmd_mark_safe) - - s = sub.add_parser("merge-skipped", help="rebuild approximations/skipped.yaml from batches") - s.add_argument("--keep", action="store_true", help="keep the consumed approximation plans") - s.set_defaults(func=cmd_merge_skipped) - - f = sub.add_parser("findings", help="seed per-rule finding files from results/report.sarif") - f.set_defaults(func=cmd_findings) - - - args = ap.parse_args() - return args.func(args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/assessment-agent/scripts/get_status.py b/skills/assessment-agent/scripts/get_status.py deleted file mode 100644 index e8114ebdd..000000000 --- a/skills/assessment-agent/scripts/get_status.py +++ /dev/null @@ -1,481 +0,0 @@ -# /// script -# requires-python = ">=3.9" -# dependencies = ["pyyaml==6.0.3"] -# /// -""" -get_status.py — the orchestrator's status source. Derives, from the .opentaint tree, -which pipeline phase is current and the exact orchestrator tasks for it; writes nothing -(use generate.py to mutate). Run with uv from the project root: - - uv run scripts/get_status.py current stage + its orchestrator tasks - uv run scripts/get_status.py --full every in-scope phase as DONE/IN_PROGRESS/PENDING - -Call it at each stage boundary to decide the next move, and --full at run start / on -resume. It lists every pending plan, batch, unit, and finding to hand out — dispatch what -it names rather than re-deriving state by hand. -""" -import argparse -import glob -import os -import re -import subprocess -import sys -from pathlib import Path - -from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, - SOURCES_TR, TRACKING, build_done_keys, classified_keys, dropped_entries, - git_head, load_yaml, member_key, modeled_entries, skipped_keys, - strip_quotes) - -STATE = load_yaml(TRACKING / "state.yaml", {}) or {} -MODE = STATE.get("mode") or "assessment" -SCAN_LEVEL = STATE.get("scan_level") -TRIAGE_LEVEL = STATE.get("triage_level") - -DISCOVER_PLANS = RULES_TR / "plans" -APPROX_PLANS = APPROX / "plans" -VULN = ROOT / "vulnerabilities.md" -ENACTMENT = ROOT / "enactment.md" -GLOBAL_CAP = 10 - - -def short(c): - return str(c)[:8] if c else c - - -# ---- tree readers ---- - -def load_units(d): - return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ - if Path(d).is_dir() else [] - - -def load_docs(d): - """(path, doc) for every tracking file in a directory — reference, boundary, control.""" - return [(p, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ - if Path(d).is_dir() else [] - - -def load_joins(): - return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(JOINS_TR.glob("*.yaml"))] \ - if JOINS_TR.is_dir() else [] - - -def load_findings(): - out = [] - if FINDINGS_TR.is_dir(): - for p in sorted(FINDINGS_TR.glob("*.yaml")): - doc = load_yaml(p, {}) or {} - notes = str(doc.get("notes") or "") - out.append({"path": str(p), "name": p.stem, - "verdict": str(doc.get("verdict", "pending")).strip(), - "poc": str(doc.get("poc", "pending")).strip(), - "reconcile": notes.lstrip().startswith("reconcile")}) - return out - - -def newest_mtime(paths): - m = 0.0 - for p in paths: - try: - m = max(m, p.stat().st_mtime) - except OSError: - pass - return m - - -def approx_dirty(): - # scan older than the newest applied approximation artifact -> a rescan is pending - if not SARIF.is_file(): - return False - arts = list(PASS_THROUGH.rglob("*")) + list(DATAFLOW.rglob("*")) - return newest_mtime([p for p in arts if p.is_file()]) > SARIF.stat().st_mtime - - -def rules_dirty(): - if not SARIF.is_file(): - return False - return newest_mtime([p for p in RULES.rglob("*.yaml") if p.is_file()]) > SARIF.stat().st_mtime - - -def scan_stale(): - # SARIF older than the model -> a rebuild happened, rescan before trusting it - manifest = MODEL / "project.yaml" - if not SARIF.is_file() or not manifest.is_file(): - return False - return manifest.stat().st_mtime > SARIF.stat().st_mtime - - -def unit_next(doc, kind, side): - # the next dispatch step for a not-yet-passing rule unit - if (doc.get("stages") or {}).get("test_project") != "done": - return f"create-test-project type {kind}" - return f"create-rule side {side}" - - -def _join_source_refs(): - return {str(s).strip() for _, doc in load_joins() - for s in (doc.get("sources") or []) if str(s).strip()} - - -def _join_sink_refs(): - return {str(j["sink"]).strip() for _, doc in load_joins() - for j in (doc.get("joins") or []) if isinstance(j, dict) and j.get("sink")} - - -def _created_refs(units, field): - """rule_ids on the units that resolve to a rule file under .opentaint/rules (created, not - a built-in ref, which is indistinguishable by path but never sits on disk here).""" - refs = set() - for _, doc in units: - for e in doc.get(field) or []: - rid = str(e.get("rule_id", "")).strip() if isinstance(e, dict) else "" - if rid and (RULES / re.split(r"[:#]", rid, 1)[0]).is_file(): - refs.add(rid) - return refs - - -def _pending_units(units, kind, side): - # units not passing and not settled by a blocker; each tagged with its next step - out = [] - for name, doc in units: - st = doc.get("stages") or {} - if st.get("tests_passing") != "done" and not (doc.get("blocker") or st.get("blocker")): - out.append(f" {name} {unit_next(doc, kind, side)}") - return out - - -# ---- phase derivations: each returns (done, tasks, note) ---- -# done: the phase is complete. tasks: the brief lines when it is the current stage. -# note: a short suffix shown only on the --full line (e.g. build from a dirty tree). - -def ph_build(): - if not (MODEL / "project.yaml").is_file(): - return False, ["determine project language and write to state.yaml.language", - "dispatch build-project"], None - head, mc = git_head(), STATE.get("model_commit") - if head is None: - return True, [], "no git — taken as current" - if mc is None: - return True, [], "from dirty tree" - if head == mc: - return True, [], None - return False, [f"model stale: HEAD {short(head)} != model_commit {short(mc)}", - "dispatch build-project"], None - - -def ph_discover(): - if not (TRACKING / "coverage.yaml").is_file(): - return False, ["dispatch triage-dependencies"], None - leftover = sorted(glob.glob(str(DISCOVER_PLANS / "*.yaml"))) - units = load_units(SOURCES_TR) - ledger = load_yaml(RULES_TR / "classification.yaml", {}) or {} - if leftover: - tasks = [f"dispatch discover-attack-surface, one per plan (cap {GLOBAL_CAP}):"] - tasks += [f" {p}" for p in leftover] - tasks.append("then run `scripts/generate.py mark-safe` to reconcile the plans") - return False, tasks, None - if not ledger and not units: - return False, ["run `scripts/generate.py partition discover` to plan the used members"], None - return True, [], None - - -def ph_source_rules(): - units = load_units(SOURCES_TR) - if not units: - return True, [], "built-in covered" - pend = _pending_units(units, "rule-source", "sources") - if pend: - return False, ["pending units:"] + pend, None - missing = sorted(_created_refs(units, "sources") - _join_source_refs()) - if missing: - return False, ["created sources not wired to a join", "dispatch assemble-lib-rules"], None - return True, [], None - - -def ph_scan(): - if not SARIF.is_file() or scan_stale(): - return False, ["dispatch run-scan"], None - return True, [], None - - -def ph_approximations(): - if not SARIF.is_file(): - return False, ["dispatch run-scan"], None - classified = classified_keys() - uncovered = [e for e in dropped_entries() if member_key(e) not in classified] - if uncovered: - plans = sorted(glob.glob(str(APPROX_PLANS / "*.yaml"))) - if plans: - tasks = [f"dispatch analyze-external-methods, one per plan (cap {GLOBAL_CAP}):"] - tasks += [f" {p}" for p in plans] - tasks.append("then run `scripts/generate.py merge-skipped` to merge the batches") - return False, tasks, None - n = len({e["method"] for e in uncovered}) - return False, [f"{n} methods unclassified", - "run `scripts/generate.py partition analyze` to split them into batch plans"], None - done = build_done_keys() - terminal = skipped_keys() # skipped/engine-issue carriers never build — don't hold the gate - unbuilt = [(p, kind) for p, kind, m in modeled_entries() - if member_key(m) not in done and member_key(m) not in terminal] - if unbuilt: - by_kind = {} - for p, kind in unbuilt: - by_kind.setdefault(kind, set()).add(p.stem) - tasks = ["build unbuilt batches:"] - if by_kind.get("passthrough"): - tasks.append(" passthrough create-pass-through-approximation: " - + ", ".join(sorted(by_kind["passthrough"]))) - if by_kind.get("dataflow"): - tasks.append(" dataflow create-test-project type dataflow, then " - "create-dataflow-approximation: " + ", ".join(sorted(by_kind["dataflow"]))) - return False, tasks, None - if approx_dirty(): - return False, ["approximations built after the last scan", "dispatch run-scan"], None - stuck = sorted({e["method"] for e in dropped_entries() - if member_key(e) in done and member_key(e) not in terminal}) - if stuck: - return False, [f"built but still dropped ({len(stuck)}), escalate:"] \ - + [f" {m}" for m in stuck], None - return True, [], None - - -def ph_sink_rules(): - units = load_units(SINKS_TR) - pend = _pending_units(units, "rule-sink", "sinks") - if pend: - return False, ["pending units:"] + pend, None - refs = _join_sink_refs() - missing = sorted({e["rule_id"] for _, doc in units for e in (doc.get("sinks") or []) - if isinstance(e, dict) and e.get("rule_id") - and str(e["rule_id"]).strip() not in refs}) - if missing: - return False, ["sink rules not wired to a join", "dispatch assemble-lib-rules"], None - if rules_dirty(): - return False, ["rules changed after the last scan", "dispatch run-scan"], None - return True, [], None - - -def ph_triage(): - findings = load_findings() - if not findings: - if not SARIF.is_file(): - return False, ["dispatch run-scan"], None - return False, ["run `scripts/generate.py findings` to seed the finding files"], None - pend = [f" {f['path']}" + (" (reconcile)" if f["reconcile"] else "") - for f in findings if f["verdict"] == "pending"] - if pend: - return False, ["dispatch analyze-findings over pending findings:"] + pend, None - tp = sum(1 for f in findings if f["verdict"] == "TP") - stale = newest_mtime([Path(f["path"]) for f in findings]) > (VULN.stat().st_mtime - if VULN.is_file() else 0) - if not VULN.is_file() or stale: - return False, [f"rewrite .opentaint/vulnerabilities.md from the TP findings ({tp} TP)"], None - return True, [], None - - -def ph_poc(): - findings = load_findings() - tps = [f for f in findings if f["verdict"] == "TP"] - pend = [f" {f['path']}" for f in tps if f["poc"] == "pending"] - if pend: - return False, ["generate-poc serially over TP findings without a PoC:"] + pend, None - servers = (load_yaml(TRACKING / "poc-servers.yaml", {}) or {}).get("servers") or [] - if servers: - return False, ["tear down the instances in poc-servers.yaml and clear the registry", - "refresh .opentaint/vulnerabilities.md"], None - return True, [], None - - -# ---- enactment-mode phases ---- - -def ph_reference_set(): - docs = load_docs(REFERENCE_TR) - if not docs: - src = STATE.get("findings") or "state.yaml findings unset" - return False, [f"normalize the supplied findings ({src}) into " - ".opentaint/tracking/reference/.yaml"], None - missing = sorted(p.stem for p, d in docs if not strip_quotes(d.get("family", ""))) - if missing: - return False, ["reference findings not assigned to a boundary family:"] \ - + [f" {m}" for m in missing], None - return True, [], None - - -def families(): - return sorted({strip_quotes(d.get("family", "")) for _, d in load_docs(REFERENCE_TR) - if strip_quotes(d.get("family", ""))}) - - -def ph_boundaries(): - specs = {p.stem: d for p, d in load_docs(BOUNDARIES_TR)} - fams = families() - missing = [f for f in fams if f not in specs] - if missing: - return False, ["dispatch discover-universal-boundaries, one per family:"] \ - + [f" {f}" for f in missing], None - # a split renames the family on its reference findings, so every spec here owns its findings - unsaturated = [f for f in fams - if str((specs[f].get("saturation") or {}).get("status", "")).strip() - != "saturated"] - if unsaturated: - return False, ["boundary specs not saturated:"] + [f" {f}" for f in unsaturated], None - unfactored = sorted(p.stem for p, d in load_docs(REFERENCE_TR) - if p.stem not in (specs.get(strip_quotes(d.get("family", "")), {}) - .get("factorization") or {})) - if unfactored: - return False, ["reference findings with no factorization in their spec:"] \ - + [f" {r}" for r in unfactored], None - unseeded = [f for f in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] - if unseeded: - return False, ["seed the source and sink units from these specs' candidate_patterns:"] \ - + [f" {f}" for f in unseeded], None - return True, [], None - - -def ph_crossref(): - if not SARIF.is_file(): - return False, ["dispatch run-scan"], None - docs = load_docs(REFERENCE_TR) - scanned = SARIF.stat().st_mtime - pend = [p for p, d in docs - if str(d.get("crossref", "pending")).strip() != "done" or p.stat().st_mtime < scanned] - if pend: - return False, [f"cross-reference the scan against {len(pend)} reference finding(s):"] \ - + [f" {p}" for p in pend], None - blocked = sorted({str(m) for _, d in docs for m in (d.get("blocked_at") or [])}) - if blocked: - return False, ["expected traces stop at unmodeled carriers:"] + [f" {m}" for m in blocked] \ - + ["model them in an approximation round, rescan, then cross-reference again"], None - rep = sum(1 for _, d in docs if str(d.get("status", "")).strip() == "reproduced") - stale = newest_mtime([p for p, _ in docs]) > (ENACTMENT.stat().st_mtime - if ENACTMENT.is_file() else 0) - if not ENACTMENT.is_file() or stale: - return False, [f"rewrite .opentaint/enactment.md coverage manifest " - f"({rep}/{len(docs)} reproduced)"], None - return True, [], None - - -def has_reference_set(): - """A reference set outlives the pass that created it. Any later pass that rescans changes what - it reproduces, so the cross-reference stays in scope — otherwise an assessment pass would leave - a coverage manifest that silently describes an older scan.""" - return REFERENCE_TR.is_dir() and any(REFERENCE_TR.glob("*.yaml")) - - -ASSESSMENT_PHASES = [ - ("build", ph_build, lambda: True), - ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), - ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), - ("scan", ph_scan, lambda: True), - ("approximations", ph_approximations, lambda: SCAN_LEVEL in ("normal", "deep")), - ("sink_rules", ph_sink_rules, lambda: SCAN_LEVEL == "deep"), - ("triage", ph_triage, lambda: True), - ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), - ("crossref", ph_crossref, has_reference_set), -] - -# enactment reproduces a supplied finding set: the reference set and its saturated boundaries -# replace dependency discovery, and both rule sides are authored before the first scan so that -# scan is rule-first. The cross-reference closes the run — it judges what the finished rule set, -# its approximations, its verdicts and its controls actually reproduced. -ENACTMENT_PHASES = [ - ("build", ph_build, lambda: True), - ("reference_set", ph_reference_set, lambda: True), - ("boundaries", ph_boundaries, lambda: True), - ("source_rules", ph_source_rules, lambda: True), - ("sink_rules", ph_sink_rules, lambda: True), - ("scan", ph_scan, lambda: True), - ("approximations", ph_approximations, lambda: True), - ("triage", ph_triage, lambda: True), - ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), - ("crossref", ph_crossref, lambda: True), -] - -PHASES = ENACTMENT_PHASES if MODE == "enactment" else ASSESSMENT_PHASES - - -# ---- caps ---- - -def free_gb(): - mi = Path("/proc/meminfo") - if mi.is_file(): - for ln in mi.read_text().splitlines(): - if ln.startswith("MemAvailable:"): - return int(ln.split()[1]) // (1024 * 1024) - try: - total = int(subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, - text=True, check=True).stdout.strip()) - return total // (1024 ** 3) - except (OSError, subprocess.CalledProcessError, ValueError): - return None - - -def heavy_cap(): - cores = os.cpu_count() or 1 - fg = free_gb() - return max(1, min(cores, (fg // 2 if fg else cores), GLOBAL_CAP)) - - -# ---- output ---- - -def in_scope(): - return [(name, fn) for name, fn, gate in PHASES if gate()] - - -def evaluate(): - """Every in-scope phase as (name, done, tasks, note), with the current stage marked.""" - rows = [(name,) + fn() for name, fn in in_scope()] - current = next((i for i, r in enumerate(rows) if not r[1]), None) - return rows, current - - -def cmd_full(): - commit = short(STATE.get("model_commit")) or "none" - print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " - f"language={STATE.get('language')} commit={commit} " - f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") - if STATE.get("findings"): # printed on an assessment pass too — the set is still tracked - print(f"findings={STATE.get('findings')}") - runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] - if len(runs) > 1: - print("passes: " + " -> ".join(str(r.get("type", "?")) for r in runs)) - rows, current = evaluate() - # a phase downstream of the current stage that vacuously satisfies its own check is not - # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. - for i, (name, done, tasks, note) in enumerate(rows): - if current is None or i < current: - state = "DONE" - elif i == current: - state = "IN_PROGRESS" - else: - state = "PENDING" - suffix = f" ({note})" if note and state == "DONE" else "" - print(f"{name:<15} {state}{suffix}") - return 0 - - -def cmd_brief(): - rows, current = evaluate() - if current is None: - print("run complete") - return 0 - name, _done, tasks, _note = rows[current] - print(f"{name} IN_PROGRESS") - for t in tasks: - print(f" {t}") - return 0 - - -def main(): - ap = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument("--full", action="store_true", - help="every in-scope phase as DONE/IN_PROGRESS/PENDING") - args = ap.parse_args() - return cmd_full() if args.full else cmd_brief() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/discover-attack-surface/SKILL.md b/skills/discover-attack-surface/SKILL.md index 84af32d47..73d9ded34 100644 --- a/skills/discover-attack-surface/SKILL.md +++ b/skills/discover-attack-surface/SKILL.md @@ -1,6 +1,6 @@ --- name: discover-attack-surface -description: Classify project-used dependency members and record the taint sources among them not covered by the built-in rules. Use for the source-discovery depth pass +description: Classify a slice of the external-method frontier — the dependency members the project calls — as trust boundaries or not, and record the taint sources among them that the built-in rules don't cover. Use for the frontier sweep of an onboarding pass, or wherever a scoped attack surface has to be classified license: Apache-2.0 metadata: author: opentaint @@ -9,7 +9,7 @@ metadata: # Skill: Discover Attack Surface -Work one assignment of project-used dependency members and pick out the taint sources among them — the methods where untrusted data first enters. The concrete inspection commands and value formats are language-specific — read `references/.md` per Inputs and follow its numbered steps, which key to the ones below +Work one assignment of the external-method frontier — the dependency members the project's own code calls — and pick out the trust boundaries among them: the methods where untrusted data first enters. Every member on the plan arrives as a candidate boundary and leaves with a verdict, so a member you leave unjudged is a gap in the sweep, not a default-safe. The concrete inspection commands and value formats are language-specific — read `references/.md` per Inputs and follow its numbered steps, which key to the ones below ## Inputs diff --git a/skills/discover-universal-boundaries/SKILL.md b/skills/discover-universal-boundaries/SKILL.md index 76655350c..73ff6f582 100644 --- a/skills/discover-universal-boundaries/SKILL.md +++ b/skills/discover-universal-boundaries/SKILL.md @@ -1,6 +1,6 @@ --- name: discover-universal-boundaries -description: Generalize a family of known finding traces into one reusable source and one reusable sink boundary, saturated against the whole family. Use before rule authoring when reproducing supplied findings, consolidating finding-specific rules, or replacing an incidental source such as a map access or an arbitrary method call +description: Generalize a family of attack-surface evidence — finding traces, swept frontier members, or the code a diff or spec names — into one reusable source and one reusable sink boundary, saturated against the whole family. Use before rule authoring when reproducing supplied findings, turning a discovered surface into universal rules, consolidating case-specific rules, or replacing an incidental source such as a map access or an arbitrary method call license: Apache-2.0 metadata: author: opentaint @@ -9,9 +9,9 @@ metadata: # Discover universal boundaries -Generalize one family of known finding traces into rule-ready boundaries: a single source and a single sink that every finding in the family factors through. The boundary vocabulary — how a member is named, what carries a usage condition, which packages realize each primitive effect — is language-specific: read `references/.md` per Inputs and follow its numbered steps, which key to the ones below. +Generalize one family of known attack-surface evidence into rule-ready boundaries: a single source and a single sink that every item in the family factors through. The evidence differs with the pass that produced it — a reference finding's trace, a frontier member a sweep verdicted, a code area a diff or spec named — and the reasoning is the same for all three. The boundary vocabulary — how a member is named, what carries a usage condition, which packages realize each primitive effect — is language-specific: read `references/.md` per Inputs and follow its numbered steps, which key to the ones below. -The goal is not the broadest syntax that happens to match every trace. It is the most primitive semantic boundary that stays inside the vulnerability class — general enough that the family needs one rule per side, specific enough that the rule still means something. Precision is recovered afterwards with context restrictions and sanitizers, listed separately, never by narrowing the boundary back down to the findings you started from. +The goal is not the broadest syntax that happens to match every trace. It is the most primitive semantic boundary that stays inside the vulnerability class — general enough that the family needs one rule per side, specific enough that the rule still means something. Precision is recovered afterwards with context restrictions and sanitizers, listed separately, never by narrowing the boundary back down to the evidence you started from. A boundary is only universal once it has been saturated: widened, re-checked against the whole family, and left unchanged by a full round. @@ -21,15 +21,15 @@ Provided by the caller, fall back to the default value when omitted. Ask back on - `project-root` (optional) — root of the target project. Opentaint keeps all analysis artifacts under the fixed `/.opentaint/` directory, so every `.opentaint/...` path below resolves there. Default: current directory - `language` (required) — target language for this project and language-specific instructions -- `findings` (required) — path to the supplied finding manifest or report the reference set was normalized from -- `finding-ids` (required) — the reference finding ids assigned to this family. Their normalized files are `.opentaint/tracking/reference/.yaml` - `family` (required) — kebab-case name of the family; also the name of its spec and of the rule units seeded from it +- `evidence` (required) — the ids assigned to this family. Reference finding ids when the pass has a reference set, whose normalized files are `.opentaint/tracking/reference/.yaml`; otherwise the members or code areas `.opentaint/tracking/scope.yaml` recorded for the family +- `findings` (optional) — path to the supplied finding manifest or report the reference set was normalized from, when there is one. Read it for detail a normalized file doesn't carry ## Workflow ### 1. Reconstruct every trace -For each assigned finding, read its reference file and then the project source it points at, and record: +For each assigned evidence item, read what the pass recorded for it — a reference finding's normalized file, or the member or code area `scope.yaml` named — and then the project source it points at, and record: 1. the attacker or untrusted authority; 2. the first project-visible ingress; @@ -42,7 +42,7 @@ Read enough surrounding source to tell the real boundary from incidental syntax ### 2. Propose the source -Move backward from the finding-specific expressions until you reach the earliest reusable trust-boundary value the family shares. Prefer, in order when applicable: +Move backward from the evidence's own expressions until you reach the earliest reusable trust-boundary value the family shares. Prefer, in order when applicable: - request body, parameter, path, query, header, cookie, or multipart value; - message, frame, packet, event, webhook, or callback payload; @@ -58,7 +58,7 @@ Express narrow usage conditions separately, as typed patterns, `pattern-inside`, ### 3. Propose the sink -Move forward from the finding-specific service calls to the most primitive operation that realizes the vulnerability, while staying specific to its class. Prefer boundaries such as: +Move forward from the evidence's own service calls to the most primitive operation that realizes the vulnerability, while staying specific to its class. Prefer boundaries such as: - network connect, request, send, or download for SSRF; - process, script, expression, template, query, or deserialization execution for injection; @@ -79,20 +79,20 @@ A boundary proposed from a few traces is a guess until it survives the whole fam Each round: -1. Factor every assigned finding — not only the new ones — through the current boundaries: +1. Factor every assigned evidence item — not only the new ones — through the current boundaries: ```text - universal source -> finding-specific context -> propagation -> universal sink + universal source -> item-specific context -> propagation -> universal sink ``` -2. For each finding that does not factor, generalize the offending side by exactly one step toward a more primitive boundary — never by adding a second alternative that merely spells out that finding's syntax. A `pattern-either` listing one branch per finding is the failure this skill exists to prevent. -3. Re-check the findings that already factored. A widening that breaks an earlier factorization is a widening too far: back it out and split instead. +2. For each item that does not factor, generalize the offending side by exactly one step toward a more primitive boundary — never by adding a second alternative that merely spells out that item's syntax. A `pattern-either` listing one branch per evidence item is the failure this skill exists to prevent. +3. Re-check the items that already factored. A widening that breaks an earlier factorization is a widening too far: back it out and split instead. 4. Challenge the widened boundary in both directions — if the sink now admits a different vulnerability class, narrow it back to the primitive effect or add a class-specific context restriction; if the source now admits trusted values, record what separates them as a context restriction rather than shrinking the boundary. 5. Record the round: what changed, and which factorization statuses moved. -The family is saturated when a full round widened nothing, broke nothing, and left every assigned finding either `covered` or `needs-restriction` with a named restriction. Stop and split — or record the finding `unfactored` with the evidence in `open_questions` — rather than looping a fourth time on the same finding. +The family is saturated when a full round widened nothing, broke nothing, and left every assigned item either `covered` or `needs-restriction` with a named restriction. Stop and split — or record the item `unfactored`, with what blocked it in `open_questions` — rather than looping a fourth time on the same one. -When the only boundary the whole family shares is arbitrary syntax, the family was wrong: split it. Each subfamily gets its own spec named `-` and its own saturation loop, every assigned finding lands in exactly one subfamily, and each moved finding's reference file has its `family` rewritten to the subfamily that now owns it. +When the only boundary the whole family shares is arbitrary syntax, the family was wrong: split it. Each subfamily gets its own spec named `-` and its own saturation loop, and every assigned item lands in exactly one subfamily. A moved reference finding has its own file's `family` rewritten to the subfamily that now owns it; other evidence moves with the spec it is listed in, and the calling stage reconciles the family list to the specs you return. Keep independently triggerable paths distinct in the factorization even when they share both boundaries. @@ -110,7 +110,7 @@ Real sanitization looks like resolved-IP private-range rejection for SSRF, canon ### 6. Write the specification -Write one spec per family or subfamily. `candidate_patterns` must be concrete enough for `create-rule` to test, in the member shape that language's rule units use — the language reference gives that shape and the dependency identity to record with it. Record opaque carriers under `approximation_candidates`, and stop there: no approximation is created or recommended until a rule-first scan proves a trace stops at one. +Write one spec per family or subfamily, listing the assigned ids under `evidence`. `candidate_patterns` must be concrete enough for `create-rule` to test, in the member shape that language's rule units use — the language reference gives that shape and the dependency identity to record with it. Record opaque carriers under `approximation_candidates`, and stop there: no approximation is created or recommended until a rule-first scan proves a trace stops at one. ## Output @@ -119,8 +119,8 @@ Return every spec path written (one per family or subfamily) and, in a few lines - the universal source, and which more incidental candidates you rejected; - the universal sink, and the vulnerability-class scope it stays inside; - how many saturation rounds it took and what the last round changed; -- the assigned finding ids by factorization status — `covered`, `needs-restriction`, `unfactored`; -- any family split, with the ids that moved and the reference files you rewrote; +- the assigned evidence by factorization status — `covered`, `needs-restriction`, `unfactored`; +- any family split, with the evidence that moved and — when it is a reference finding — the files you rewrote; - the sanitizers, negative patterns, and context restrictions listed separately from the boundaries; and - unresolved boundary evidence, as the `open_questions` entries. @@ -128,13 +128,13 @@ Never paste file contents. ## Tracking -This skill writes the boundary spec for its family, and — only when a split moves a finding — the `family` field on the reference files it moved. Leave `stages.units_seeded` pending: seeding the rule units is the calling stage's step. Touch no other reference field, no rule unit, and no rule. +This skill writes the boundary spec for its family, and — only when a split moves a reference finding — the `family` field on the reference files it moved. Leave `stages.units_seeded` pending: seeding the rule units is the calling stage's step. Touch no other reference field, no scope file, no rule unit, and no rule. -`.opentaint/tracking/boundaries/.yaml` — one boundary family: the single source and single sink every finding in the family factors through, plus the controls that recover precision. `candidate_patterns` are concrete enough for `create-rule` to test, and seed the family's source and sink units verbatim. `factorization` carries one entry per assigned reference finding, `status` one of `covered` (factors through both boundaries as-is), `needs-restriction` (factors only under a named `context_restrictions` entry), or `unfactored` (does not factor — explain in `open_questions` and split or add a pseudo-boundary). `saturation` records the widen-and-recheck rounds and only reads `saturated` once a full round changed neither the boundaries nor any factorization. `approximation_candidates` are opaque carriers noted for later — never acted on before a scan proves the trace stops there. Keep it clear from comments +`.opentaint/tracking/boundaries/.yaml` — one boundary family: the single universal source and single universal sink every piece of the family's evidence factors through, plus the controls that recover precision. `evidence` lists what the family was grouped from — reference finding ids in enactment mode, the members or code areas `scope.yaml` recorded otherwise. `candidate_patterns` are concrete enough for `create-rule` to test, and seed the family's source and sink units verbatim. `factorization` carries one entry per evidence item, `status` one of `covered` (factors through both boundaries as-is), `needs-restriction` (factors only under a named `context_restrictions` entry), or `unfactored` (does not factor — explain in `open_questions` and split or add a pseudo-boundary). `saturation` records the widen-and-recheck rounds and only reads `saturated` once a full round changed neither the boundaries nor any factorization. `approximation_candidates` are opaque carriers noted for later — never acted on before a scan proves the trace stops there. Keep it clear from comments ```yaml family: ssrf -findings: [DSC-014, DSC-021] +evidence: [DSC-014, DSC-021] source: semantic_boundary: external request value entering a controller candidate_patterns: @@ -183,10 +183,10 @@ notes: > ## Constraints -- Work only the assigned finding ids, your family's spec, and the reference `family` field on a split -- Universal does not mean untyped or unconstrained — generalize the boundary, then recover precision with context restrictions and sanitizers, never by re-narrowing the boundary to the findings -- Never propose all methods, all map values, or all getters as a source, and never a `pattern-either` with one branch per finding +- Work only the assigned evidence, your family's spec, and the reference `family` field on a split +- Universal does not mean untyped or unconstrained — generalize the boundary, then recover precision with context restrictions and sanitizers, never by re-narrowing the boundary to the evidence +- Never propose all methods, all map values, or all getters as a source, and never a `pattern-either` with one branch per evidence item - Never mark the spec saturated on a round that widened, broke, or split anything -- Never propose a sanitizer that would suppress a trace the reference set says is real, and never record a validator as a sanitizer without the invariant it enforces -- Record a poor taint fit as an explicit pseudo-boundary; never drop the finding +- Never propose a sanitizer that would suppress a trace the evidence says is real, and never record a validator as a sanitizer without the invariant it enforces +- Record a poor taint fit as an explicit pseudo-boundary; never drop the evidence item - Don't create or recommend approximations here — note the opaque carriers and stop diff --git a/skills/enactment-agent/SKILL.md b/skills/enactment-agent/SKILL.md deleted file mode 100644 index 9063b12b4..000000000 --- a/skills/enactment-agent/SKILL.md +++ /dev/null @@ -1,182 +0,0 @@ ---- -name: enactment-agent -description: Reproduce a supplied finding set as verified OpenTaint rules, owning the long project build and scans and delegating each other pipeline stage. Use when the user supplies findings, a scanner report, or source-to-sink traces to reproduce, validate, or convert into reusable rules -license: Apache-2.0 -metadata: - author: opentaint - version: "0.3.0" ---- - -# Enactment Agent - -Reproduce a supplied set of findings with OpenTaint. Every finding ends the run either matched by a verified OpenTaint result whose trace carries the finding's own identity, or recorded with the exact rule, modeling, or engine limitation that stopped it. Keep the long project build and every full-project scan in this main session; delegate each bounded stage to an `orchestrate-stage` subagent, which owns its leaf fan-out and joins. - -This is the enactment pipeline — one of the two that share the OpenTaint machine and the `.opentaint/` tree. The other is `assessment-agent`'s, which searches the project for vulnerabilities it was not known to have. They differ in where the source and sink rules come from: discovered from the project's dependency attack surface there, generalized from the supplied findings here, so both sides exist before the first scan and that scan is rule-first. Everything downstream is shared, and `appsec-agent` is the entry point that picks between them. - -This run is one *pass* over a tree that outlives it. The pass may follow an assessment pass, inheriting its rules, approximations, and verdicts, and an assessment pass may follow this one to hunt with the boundaries it derived. Either can run again on a later commit. So reproduce this pass's findings and leave the tree richer than you found it; don't treat an artifact you didn't create as debris. - -You may be loaded directly, or handed off by `appsec-agent` once it identified the request as enactment. - -No finding is dropped for being a poor fit for taint analysis. Authorization, integrity, configuration, hard-coded-secret, and structural-control findings are modeled as explicit pseudo-taint boundaries. - -## Setup - -Skip only what `appsec-agent` already did when it handed off — the toolchain and nesting checks. Everything from step 3 on is this pipeline's own, including the bootstrap. - -### 1. Confirm the toolchain - -Confirm `opentaint` is on PATH with `opentaint -v`. If it's missing, don't proceed silently — tell the user and offer the install command for their platform, run an install only on explicit confirmation: - -- macOS / Linux, in order: `brew install --cask seqra/tap/opentaint` · `npm install -g @seqra/opentaint` -- Windows: `npm install -g @seqra/opentaint` - -After installing, run `opentaint health` to confirm everything's resolved. - -### 2. Confirm agent nesting - -This workflow requires two subagent levels: MAIN → stage orchestrator → leaf. Confirm the harness permits depth 2 before starting; otherwise ask the user to enable it. - -### 3. Locate the findings - -The supplied findings are this run's input and the only thing it is measured against. Ask the user for their path when it isn't already given — a manifest, SARIF, scanner report, or a directory of finding documents. If the user has only described the findings in conversation, write them to a file first and use that; the pipeline resumes from disk, not from this thread. - -### 4. Determine the language - -Read the project's build files to fix the target language — Maven/Gradle → java, `go.mod` → go, and so on. Record it at bootstrap; stage orchestrators pass it to language-coupled leaves. When a repo carries build markers for more than one, ask the user which to analyze. - -### 5. Choose the workflow - -Ask the user for the triage level: - -1. Triage level — `static` · `dynamic` - - static — classify findings from the model, no running app - - dynamic — static + PoC per confirmed TP. This launches a few test services on the user's machine (local instances and ports), torn down at the end of the run. Make that clear in the option - -There is no scan-level question here: reproducing a finding set always needs the full rule and approximation toolbox, so enactment is always deep. - -### 6. Bootstrap - -Seed the run state and the working tree: - -```bash -uv run /scripts/generate.py init --mode enactment --triage-level --language --findings -``` - -It writes `state.yaml` with `mode: enactment`, appends this pass to `history.yaml`, and creates the `.opentaint/` tree including `tracking/reference/` and `tracking/boundaries/`. - -Over a tree an earlier pass already built, it prints what carried over and keeps all of it — an assessment pass's rules, approximations, and verdicts are this pass's starting corpus, and `get_status.py` will report their phases `DONE` rather than redoing them. `--findings` is required only the first time; a later enactment pass inherits the tracked set unless you pass a new one. - -## Workflow - -The run is one fixed pipeline. Use `uv run /scripts/get_status.py` to choose the next action: - -``` -build → MAIN: build -reference_set / boundaries → stage subagent: boundaries -source_rules → stage subagent: sources -sink_rules → stage subagent: sinks -scan → MAIN: scan -approximations → stage subagent: approx-round, then MAIN: rescan; repeat -triage → stage subagent: triage -poc → stage subagent: poc -crossref → stage subagent: crossref -``` - -Both rule sides are authored before the first scan on purpose: the scan that follows is the one that proves the boundaries, and its results are what later stages are allowed to react to. Never let an approximation stand in for a missing source or sink rule. - -The cross-reference closes the run rather than steering it: it judges what the finished rule set, its approximations, and its verdicts actually reproduced. - -### Build in MAIN - -When status reports `build`, load and follow the `build-project` skill in this main session. Run its long build command through the harness's main-session background-command facility and wait for its completion event. - -Record the returned `build_jdk` in `.opentaint/tracking/state.yaml`. Record `model_commit` as the full HEAD only when no source file is uncommitted, otherwise set it to null. Build non-convergence blocks the run because no later phase can proceed without the model. Keep one untouched model for the entire enactment run. - -### Scan in MAIN - -When status reports `scan`, or a stage returns with a rescan pending, load and follow the `run-scan` skill in this main session. Start the scan with the harness's main-session background-command facility, keep the engine's self-timeout, add a 1200-second outer backstop, and wait for the process completion event. - -A valid `.opentaint/results/report.sarif` means the scan completed, including exit 254 after an engine timeout. Record `max_memory: 16G` when the scan had to bump memory and reuse it on later scans. If no SARIF exists after the allowed retry/backstop, follow the repair path below for a malformed rule/approximation; otherwise dispatch `orchestrate-stage` with `stage: escalation` and the scan `setup` to write the scan-wide resource issue, then stop. - -When a scan or later stage reports a malformed approximation, unloadable created rule, ineffective join, or a created rule's false positive/negative, route the exact diagnosis and artifact path/id to the responsible stage agent per Dispatching, then scan again in MAIN. - -After every build, scan, or stage return, run `uv run /scripts/get_status.py` once to choose the next action. Use `--full` at run start, on resume, or when the brief output does not settle the question. - -### Iterating to coverage - -The pipeline loops by design, and status is what closes it. A rescan makes every cross-reference pending again; a cross-reference that finds a trace stopped at an opaque carrier sends the run back to an approximation round; one that blames a rule sends it back to the stage that authored it, so status returns there before closing again. Follow status through each loop rather than declaring the run finished early — an earlier phase reading `IN_PROGRESS` after a later one ran is the loop working, not a regression. - -The run is complete when status reports `run complete` — every supplied finding reproduced or recorded with its blocker, and `.opentaint/enactment.md` current. Report the coverage manifest, keeping raw SARIF results, validated findings, and unique vulnerability identities as separate counts. - -## Dispatching - -Dispatch exactly one stage-orchestrator subagent for each stage invocation: - -``` -Invoke the Skill orchestrate-stage first, then follow its instructions precisely -Inputs: - stage: -``` - -A subagent inherits the project-root working directory, so omit `project-root`. - -Stage context: - -- `boundaries` — normalize the supplied findings into the reference set, generalize each family into a saturated source and sink boundary, and seed the rule units from it -- `sources` — author the seeded source units' rules and wire the joins -- `sinks` — author the seeded sink units' rules and wire the joins -- `approx-round` — classify and build one dropped-method frontier; use a fresh agent for each new frontier -- `crossref` — judge each supplied finding against the latest scan and refresh the coverage manifest -- `triage` — classify the latest findings and refresh the vulnerability report -- `poc` — reproduce confirmed findings and add the outcomes to the report -- `escalation` — repair or settle a stage artifact, or report a scan-wide no-SARIF failure - -Keep each agent id until the next scan validates its artifacts. On a stage-owned error, resume that agent with `stage: escalation`, the exact error, and the artifact path/id. If its thread is unavailable, start a re-entrant `orchestrate-stage` agent with that diagnosis. - -Dispatch each subagent fresh, don't fork context into it. Then wait for it natively, don't monitor or poll every minute. If the harness forces a wait timeout, set it to ~1h and re-wait when it returns. - -## State and resumption - -Use this ownership map to route work and scan errors: - -``` -.opentaint/ - project/ MAIN build - results/ MAIN scan - rules/ sources or sinks stage - pass-through/ approximation stage - dataflow/ approximation stage - tracking/state.yaml MAIN run knobs - tracking/reference/ boundaries stage writes, crossref stage judges - tracking/boundaries/ boundaries stage - tracking/ stage agents, leaves, and join scripts otherwise - enactment.md crossref stage - vulnerabilities.md triage / PoC stage - issues/ escalation stage -``` - -The tree is long-lived and outlives this pass. On resume, reuse `DONE` artifacts; `get_status.py` derives the next phase from disk. Existing rules and approximations apply to every scan, whichever pass created them. Never delete or rewrite an artifact because this pass didn't produce it — an assessment pass's discovered source units, approximations, and verdicts are as durable as your own. - -`state.yaml` shape — `mode` is this pass's pipeline, not a property of the tree, so a later assessment pass simply rewrites it and keeps everything else: - -```yaml -mode: enactment -scan_level: deep -triage_level: static -language: java -findings: reports/pentest-2026-07.md -model_commit: 0123456789abcdef0123456789abcdef01234567 -build_jdk: null -max_memory: null -``` - -`mode` is what selects this pipeline for this pass; `findings` is the supplied set the pass is measured against, and it stays in `state.yaml` across an assessment pass so a later enactment pass resumes the same set. Neither is edited by hand mid-pass — pointing an in-flight pass at a different finding file strands its reference set. A genuinely different finding set is a new pass, bootstrapped with a new `--findings`. - -## Key constraints - -- read pipeline state through `/scripts/get_status.py`, not by hand — don't re-derive it with glob/grep/`python3 -c`/yaml scans over `.opentaint/tracking`, `results`, or the `*.yaml`, nor open finding/unit/reference/SARIF files just to review progress. If its output doesn't settle the question, re-run it with `--full` before opening any file -- don't author or edit stage-owned artifacts or tracking; MAIN writes only `model_commit`, `build_jdk`, and `max_memory` in `state.yaml` -- keep one generated project model for the run; never hand-edit or replace it mid-analysis — fix the build and rebuild before starting a new run -- source and sink rules come before approximation work; an approximation never compensates for a boundary that was never authored -- coverage is counted by unique finding identity, never by rule id or raw SARIF result count, and a result counts as a reproduction only when its trace carries the finding's own attack path -- never drop a supplied finding as unsuitable for taint analysis diff --git a/skills/enactment-agent/scripts/_common.py b/skills/enactment-agent/scripts/_common.py deleted file mode 100644 index cd0873805..000000000 --- a/skills/enactment-agent/scripts/_common.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Shared helpers for the OpenTaint pipeline orchestrator scripts. - -Not a runnable script — imported by the PEP723 entry points (get_status.py, generate.py), -which carry the pyyaml dependency. Every path resolves under the fixed -/.opentaint/ tree relative to the current directory, so run the entry -scripts from the project root. -""" -import glob -import re -import subprocess -from pathlib import Path - -import yaml - -ROOT = Path(".opentaint") -MODEL = ROOT / "project" -TRACKING = ROOT / "tracking" -APPROX = TRACKING / "approximations" -RULES_TR = TRACKING / "rules" -SOURCES_TR = RULES_TR / "sources" -SINKS_TR = RULES_TR / "sinks" -JOINS_TR = RULES_TR / "joins" -FINDINGS_TR = TRACKING / "findings" -REFERENCE_TR = TRACKING / "reference" # enactment mode: the supplied findings, normalized -BOUNDARIES_TR = TRACKING / "boundaries" # enactment mode: per-family boundary specs -RESULTS = ROOT / "results" -DROPPED = RESULTS / "dropped-external-methods.yaml" -SARIF = RESULTS / "report.sarif" -RULES = ROOT / "rules" -PASS_THROUGH = ROOT / "pass-through" -DATAFLOW = ROOT / "dataflow" - - -# ---- yaml io ---- - -def load_yaml(path, default=None): - p = Path(path) - if not p.is_file(): - return default - try: - return yaml.safe_load(p.read_text(encoding="utf-8")) or default - except yaml.YAMLError as e: - raise SystemExit(f"{p}: invalid YAML — {e}\n" - " a JVM signature containing '[' must be quoted in flow style " - "(signature: \"([BLjava/lang/String;)V\")") - - -def dump_yaml(obj): - return yaml.safe_dump(obj, sort_keys=False, default_flow_style=False, allow_unicode=True) - - -# ---- fqn / member normalization ---- - -def strip_quotes(s): - return str(s).strip().strip('"').strip("'") - - -def fqn_base(s): - """The method fqn without its signature/params — `a.b.C#m`.""" - s = strip_quotes(s) - i = s.find("(") - return (s[:i] if i != -1 else s).strip() - - -def member_of(item): - """A bucket/plan entry (dict or str) normalized to {method, signature?}.""" - if isinstance(item, dict): - m = strip_quotes(item.get("method", "")) - sig = str(item.get("signature", "")).strip() - return {"method": m, "signature": sig} if sig else {"method": m} - return {"method": strip_quotes(item)} - - -def member_key(item): - """Overload-precise key: method + signature. Matches the classification ledger.""" - if isinstance(item, dict): - return f"{strip_quotes(item.get('method', ''))}{str(item.get('signature', '')).strip()}" - return strip_quotes(item) - - -def class_of(fqn): - return fqn_base(fqn).split("#", 1)[0].strip() - - -def package_of(fqn): - cls = class_of(fqn) - return cls.rsplit(".", 1)[0] if "." in cls else "" - - -# ---- git ---- - -def git_head(): - """HEAD commit of the project tree, or None when there's no repo.""" - try: - out = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, - text=True, check=True) - return out.stdout.strip() or None - except (OSError, subprocess.CalledProcessError): - return None - - -# ---- approximation batch readers (shared by coverage + partition) ---- - -# a method counts classified once it sits in any batch classification bucket or in build.done -CLASSIFIED_BUCKETS = ("passthrough", "dataflow", "skipped", "engine_issues") -MODELED_BUCKETS = ("passthrough", "dataflow") - - -def batch_files(): - """Every approximation batch file (skipped.yaml is the merged view, not a batch).""" - return [Path(p) for p in sorted(glob.glob(str(APPROX / "*.yaml"))) - if Path(p).name != "skipped.yaml"] - - -def dropped_entries(): - """The dropped external methods as {method, signature?} rows (order preserved).""" - rows = [] - for e in load_yaml(DROPPED, []) or []: - if isinstance(e, dict) and e.get("method"): - row = {"method": strip_quotes(e["method"])} - if e.get("signature"): - row["signature"] = str(e["signature"]).strip() - rows.append(row) - return rows - - -def classified_keys(): - """method+signature keys of every method already classified across the batch files. - Overload-precise (matches the ledger, build.done, unbuilt and stuck checks): a method with - one overload classified does not mask a differently-propagating overload still dropped.""" - out = set() - for p in batch_files(): - doc = load_yaml(p, {}) or {} - for key in CLASSIFIED_BUCKETS: - for item in doc.get(key, []) or []: - if str(item).strip(): - out.add(member_key(item)) - for item in (doc.get("build") or {}).get("done", []) or []: - if str(item).strip(): - out.add(member_key(item)) - return out - - -def skipped_keys(): - """method+signature keys classified terminal — the `skipped` and `engine_issues` buckets of - every batch. Terminal means the method will never build a working carrier, so it must not hold - the approximations phase pending even if it is still modeled and still dropped by the scan. - (skipped.yaml is the merged view of these same buckets, so reading the batches alone suffices.)""" - out = set() - for p in batch_files(): - doc = load_yaml(p, {}) or {} - for bucket in ("skipped", "engine_issues"): - for item in doc.get(bucket, []) or []: - if str(item).strip(): - out.add(member_key(item)) - return out - - -def modeled_entries(): - """Every passthrough/dataflow entry across batches, tagged with its batch file.""" - out = [] - for p in batch_files(): - doc = load_yaml(p, {}) or {} - for kind in MODELED_BUCKETS: - for item in doc.get(kind, []) or []: - if str(item).strip(): - out.append((p, kind, member_of(item))) - return out - - -def build_done_keys(): - """Overload-precise keys of every built approximation across batches.""" - keys = set() - for p in batch_files(): - doc = load_yaml(p, {}) or {} - for item in (doc.get("build") or {}).get("done", []) or []: - if str(item).strip(): - keys.add(member_key(item)) - return keys - - -# ---- finding files ---- - -RULE_RE = re.compile(r'^rule_id:\s*(.+?)\s*$', re.M) -VERDICT_RE = re.compile(r'^verdict:\s*(.+?)\s*$', re.M) - - -def ledger_verdicted_keys(): - """method+signature keys already verdicted in classification.yaml (source ∪ safe).""" - doc = load_yaml(RULES_TR / "classification.yaml", {}) or {} - return {strip_quotes(x) for key in ("source", "safe") for x in (doc.get(key) or [])} diff --git a/skills/enactment-agent/scripts/generate.py b/skills/enactment-agent/scripts/generate.py deleted file mode 100644 index 5e204b3c5..000000000 --- a/skills/enactment-agent/scripts/generate.py +++ /dev/null @@ -1,639 +0,0 @@ -# /// script -# requires-python = ">=3.9" -# dependencies = ["pyyaml==6.0.3"] -# /// -""" -generate.py — the orchestrator's writing helper. Every subcommand mutates durable -state at a fan-out join; none is read-only (use get_status.py for checks). Run with uv -from the project root: `uv run scripts/generate.py `. - - init bootstrap the .opentaint tree + state.yaml from the workflow flags - partition analyze dropped external methods -> per-root batch plans (approximations) - partition discover coverage.yaml's used members -> balanced discover plans - mark-safe discover plans' verdicts -> classification.yaml ledger (+prune plans) - merge-skipped batch skipped/engine_issues -> approximations/skipped.yaml (+prune plans) - findings results/report.sarif -> per-rule finding tracking files (idempotent) -""" -import argparse -import glob -import hashlib -import json -import math -import re -import subprocess -import sys -from pathlib import Path - -import yaml - -from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, DROPPED, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, REFERENCE_TR, RESULTS, RULES, RULES_TR, SARIF, SINKS_TR, - SOURCES_TR, RULE_RE, TRACKING, VERDICT_RE, class_of, classified_keys, - batch_files, dropped_entries, dump_yaml, fqn_base, git_head, - ledger_verdicted_keys, load_yaml, member_key, package_of, strip_quotes) - -ANALYZE_BUDGET = 20 # methods per approximation batch -ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch -ROOT_DEPTH = 2 # library root = first 2 dotted segments -DISCOVER_TARGET, DISCOVER_BAND = 50, 15 # project-used members per discover plan (~50, loose) - -DISCOVER_PLANS = RULES_TR / "plans" -APPROX_PLANS = APPROX / "plans" - - -# ---- init: bootstrap the working tree + state.yaml ---- - -# the durable directories a run writes into; the leaves/scripts mkdir on write, but seeding -# them up front gives every stage a place to land and makes the empty tree self-describing. -INIT_DIRS = [TRACKING, APPROX, SOURCES_TR, SINKS_TR, JOINS_TR, FINDINGS_TR, - RESULTS, RULES, PASS_THROUGH, DATAFLOW] -ENACTMENT_DIRS = [REFERENCE_TR, BOUNDARIES_TR] # enactment mode only -STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves - - -def carried_over(): - """What a pass inherits from the passes before it, as (label, count) — everything durable - the tree already holds. Both pipelines write into one tree, so a pass never starts empty - unless the tree is.""" - def n(paths): - return sum(1 for _ in paths) - return [("rule unit", n(SOURCES_TR.glob("*.yaml")) + n(SINKS_TR.glob("*.yaml"))), - ("created rule", n(p for p in RULES.rglob("*.yaml") if p.is_file())), - ("approximation batch", len(batch_files())), - ("reference finding", n(REFERENCE_TR.glob("*.yaml")) if REFERENCE_TR.is_dir() else 0), - ("triaged finding", n(FINDINGS_TR.glob("*.yaml")))] - - -def cmd_init(args): - state_path = TRACKING / "state.yaml" - prior = load_yaml(state_path, {}) or {} - enactment = args.mode == "enactment" - # the finding set is a property of the tree, not of one pass — an enactment pass that follows - # an earlier one inherits it, so --findings is required only the first time - findings = args.findings or prior.get("findings") - if enactment and not findings: - raise SystemExit("init --mode enactment requires --findings ") - if not enactment and not args.scan_level: - raise SystemExit("init --mode assessment requires --scan-level") - # enactment reproduces a supplied finding set, which always needs the full rule + approximation - # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. - scan_level = "deep" if enactment else args.scan_level - - for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): - d.mkdir(parents=True, exist_ok=True) - # `mode` is this pass's pipeline, not a permanent property of the tree: the two compose, in - # either order and repeatedly across commits, and every artifact below is shared between them. - state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, - "language": args.language or prior.get("language")} - if findings: # kept even on an assessment pass, so a later one resumes it - state["findings"] = findings - for k in STATE_DERIVED: # never clobber what build/scan already learned - state[k] = prior.get(k) - state_path.write_text(dump_yaml(state), encoding="utf-8") - - # history: one entry per pass. Re-running init with the same knobs on the same commit is a - # resume of the current pass, not a new one — a different mode, level, or commit starts one. - hist_path = TRACKING / "history.yaml" - runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] - entry = {"commit": git_head(), - "type": f"{args.mode}/{scan_level}/{args.triage_level}"} - new_pass = not runs or runs[-1] != entry - if new_pass: - runs.append(entry) - hist_path.write_text(dump_yaml({"runs": runs}), encoding="utf-8") - - prior_mode = prior.get("mode") - if not prior: - how = "fresh tree" - elif prior_mode and prior_mode != args.mode: - how = f"new {args.mode} pass over the existing {prior_mode} tree" - elif new_pass: - how = f"new {args.mode} pass (pass {len(runs)})" - else: - how = "resumed (derived knobs preserved)" - print(f"init {how}: mode={args.mode} scan_level={scan_level} " - f"triage_level={state['triage_level']} language={state['language']}") - if findings: - print(f"findings={findings}") - if prior: - kept = ", ".join(f"{c} {label}{'' if c == 1 else 's'}" for label, c in carried_over() if c) - print(f"carried over: {kept or 'nothing yet'}") - print(f"seeded {len(INIT_DIRS) + (len(ENACTMENT_DIRS) if enactment else 0)} " - "directories under .opentaint/") - print("next: uv run scripts/get_status.py --full") - return 0 - - -def regen_plans(out_dir): - # partition regenerates the whole plan set from the current unclassified state, so drop any - # stale plans first — otherwise a re-partition leaves already-consumed plans as leftover cruft. - out_dir.mkdir(parents=True, exist_ok=True) - for p in out_dir.glob("*.yaml"): - p.unlink() - - -# ---- partition: shared bin-packing ---- - -def root_of(fqn, depth=ROOT_DEPTH): - segs = class_of(fqn).split(".") - return ".".join(segs[:depth]) if len(segs) >= depth else class_of(fqn) - - -def in_packages(cls, prefixes): - # dotted-boundary match: `a.b.collect` never matches a sibling `a.b.collectX` - return any(cls == p or cls.startswith(p + ".") for p in prefixes) - - -def atomize(fqns, cap): - # split into atomic scopes (prefix, [fqns]); each scope is a whole (sub)package (or a subtree - # under cap) — a package is NEVER split across scopes, so it lands in exactly one bin and no two - # agents ever share a package's per-package unit. A package over cap stays oversized. - scopes = [] - - def recurse(prefix, items): - if len(items) <= cap: - scopes.append((prefix, items)) - return - depth = len(prefix.split(".")) - buckets, leaf = {}, [] - for f in items: - pkg = package_of(f) - segs = pkg.split(".") if pkg else [] - if pkg == prefix or len(segs) <= depth: - leaf.append(f) - else: - child = ".".join(segs[: depth + 1]) - buckets.setdefault(child, []).append(f) - if leaf: - scopes.append((prefix, leaf)) - for child, cf in buckets.items(): - recurse(child, cf) - - top = {} - for f in fqns: - pkg = package_of(f) - top.setdefault(pkg.split(".")[0] if pkg else class_of(f), []).append(f) - for seg0, items in top.items(): - recurse(seg0, items) - return scopes - - -def pack(scopes, target, cap): - # longest-processing-time bin-packing. An atomic scope larger than cap (a package that can't - # be split) gets its own bin instead of forcing the whole set to one-scope-per-bin. - plans = [{p: v} for p, v in scopes if len(v) > cap] - items = sorted((s for s in scopes if len(s[1]) <= cap), key=lambda s: len(s[1]), reverse=True) - if items: - total = sum(len(v) for _, v in items) - k = max(1, math.ceil(total / cap), round(total / target)) - while True: - loads = [0] * k - bins = [{} for _ in range(k)] - for prefix, v in items: - i = min(range(k), key=lambda j: loads[j]) - bins[i][prefix] = v - loads[i] += len(v) - if max(loads) <= cap or k >= len(items): - break - k += 1 - plans += [b for b in bins if b] - return plans - - -def write_plans(plans, out_dir, prefix_id): - out_dir.mkdir(parents=True, exist_ok=True) - paths = [] - for i, scopes in enumerate(plans, 1): - pid = f"{prefix_id}-{i:03d}" - norm = {p.replace(".", "-"): sorted(v, key=lambda x: (x["method"], x.get("signature", "")) - if isinstance(x, dict) else x) - for p, v in sorted(scopes.items())} - path = out_dir / f"{pid}.yaml" - # source: null is the unprocessed sentinel — a discover agent overwrites it with the - # list of sources it found (an empty list when it finds none). mark-safe folds only - # plans whose sentinel was replaced, so an un-returned plan is never marked safe. - path.write_text(dump_yaml({"id": pid, "scopes": norm, "source": None}), encoding="utf-8") - paths.append(str(path)) - return paths - - -# ---- partition analyze ---- - -def _root_next_index(prefix): - # additive numbering: a re-partition round must never reuse an id an existing batch already - # owns, or the new plan's analyze agent would overwrite that batch. Continue past the highest - # index any existing batch OR leftover plan already claims for this root. - mx = 0 - for d in (APPROX, APPROX_PLANS): - for p in glob.glob(str(d / f"{prefix}-*.yaml")): - m = re.match(rf"^{re.escape(prefix)}-(\d+)\.yaml$", Path(p).name) - if m: - mx = max(mx, int(m.group(1))) - return mx + 1 - - -def cmd_analyze(args): - regen_plans(APPROX_PLANS) - classified = classified_keys() - rows = [r for r in dropped_entries() if member_key(r) not in classified] - if not rows: - print("nothing to plan — every dropped method already classified", file=sys.stderr) - return 0 - - by_root = {} - for r in rows: - by_root.setdefault(root_of(r["method"]), []).append(r) - count = lambda rs: len({fqn_base(r["method"]) for r in rs}) - misc = [] - for root in [k for k, rs in by_root.items() if count(rs) <= ANALYZE_MISC]: - misc += by_root.pop(root) - if misc: - by_root["misc"] = misc - - paths = [] - for root in sorted(by_root): - by_fqn = {} - for r in by_root[root]: - by_fqn.setdefault(fqn_base(r["method"]), []).append(r) - bins = pack(atomize(sorted(by_fqn), ANALYZE_BUDGET), ANALYZE_BUDGET, ANALYZE_BUDGET) - prefix = root.replace(".", "-") - start = _root_next_index(prefix) - for i, b in enumerate(bins): - scopes = {} - for f in {f for v in b.values() for f in v}: # re-group the batch by class - scopes.setdefault(class_of(f), []).extend(by_fqn[f]) - norm = {cls: sorted(v, key=lambda x: (x["method"], x.get("signature", ""))) - for cls, v in sorted(scopes.items())} - pid = f"{prefix}-{start + i:03d}" - path = APPROX_PLANS / f"{pid}.yaml" - path.write_text(dump_yaml({"scopes": norm}), encoding="utf-8") - paths.append(str(path)) - for p in paths: - print(p) - return 0 - - -# ---- partition discover ---- - -def yaml_modules(model_yaml): - # each module in project.yaml as (packages, moduleClasses); only `packages` says which of a - # classpath-mode model's moduleClasses (dependency jars included) is project code - doc = load_yaml(model_yaml, {}) or {} - mods = [] - - def walk(node): - if isinstance(node, dict): - if isinstance(node.get("moduleClasses"), list): - mods.append(([str(p) for p in (node.get("packages") or [])], - [str(c) for c in node["moduleClasses"]])) - for v in node.values(): - walk(v) - elif isinstance(node, list): - for x in node: - walk(x) - - walk(doc) - return mods - - -def is_project_class(cls, packages): - # mirrors the engine's ProjectClasses.isModuleClass; empty packages = a project-only module - return not packages or any(cls.startswith(p) for p in packages) - - -CALL_RE = re.compile(r"//\s*(?:Interface)?Method\s+(\S+?)\.(?):(\S+)") - - -def extract_usages(): - # disassemble project classes, collect // Method / // InterfaceMethod call sites with their - # JVM descriptor; returns (fqn, signature) pairs so an overloaded member stays disambiguated - fqns = set() - for packages, module_classes in yaml_modules(MODEL / "project.yaml"): - for entry in module_classes: - p = MODEL / entry - if p.is_dir(): - classes = [str(c.relative_to(p))[:-6].replace("/", ".") for c in p.rglob("*.class")] - elif p.is_file(): - try: - listing = subprocess.run(["jar", "tf", str(p)], capture_output=True, - text=True, check=True).stdout - except (OSError, subprocess.CalledProcessError): - continue - if not packages: - print(f"warning: {entry} is a jar in a module with no declared `packages` — its " - f"plans will cover the library's own calls, not the project's", file=sys.stderr) - classes = [c[:-6].replace("/", ".") for c in listing.splitlines() - if c.endswith(".class")] - else: - continue - classes = [c for c in classes if is_project_class(c, packages)] - cp = str(p) - for i in range(0, len(classes), 200): # batch to keep argv under the limit - batch = classes[i:i + 200] - try: - out = subprocess.run(["javap", "-c", "-p", "-classpath", cp, *batch], - capture_output=True, text=True).stdout - except OSError: - continue - for owner, method, sig in CALL_RE.findall(out): - fqns.add((f"{owner.replace('/', '.')}#{method}", sig)) - return fqns - - -def pending_packages(): - cov = load_yaml(TRACKING / "coverage.yaml", {}) or {} - return tuple(p for p in (cov.get("packages") or []) if isinstance(p, str) and p) - - -def cmd_discover(args): - regen_plans(DISCOVER_PLANS) - packages = pending_packages() - if not packages: - print("nothing to plan — no pending package in coverage.yaml", file=sys.stderr) - return 0 - verdicted = ledger_verdicted_keys() # method+signature keys - sigs = {} # bare member fqn -> its pending signatures - for f, sig in extract_usages(): - if in_packages(class_of(f), packages) and f + sig not in verdicted: - sigs.setdefault(f, set()).add(sig) - todo = sorted(sigs) - if not todo: - print("nothing to plan — every used member already verdicted", file=sys.stderr) - return 0 - cap = DISCOVER_TARGET + DISCOVER_BAND - plans = pack(atomize(todo, cap), DISCOVER_TARGET, cap) - rows = {f: [{"method": f, "signature": s} for s in sorted(sigs[f])] for f in sigs} - plans = [{pkg: [r for f in members for r in rows[f]] for pkg, members in plan.items()} - for plan in plans] - for p in write_plans(plans, DISCOVER_PLANS, "lib"): - print(p) - return 0 - - -def cmd_partition(args): - return cmd_analyze(args) if args.kind == "analyze" else cmd_discover(args) - - -# ---- mark-safe (discover join) ---- - -def cmd_mark_safe(args): - plans = sorted(glob.glob(str(DISCOVER_PLANS / "lib-*.yaml"))) - if not plans: - print("no discover plans to reconcile", file=sys.stderr) - return 0 - ledger = RULES_TR / "classification.yaml" - doc = load_yaml(ledger, {}) or {} - source = {member_key(x) for x in (doc.get("source") or [])} - safe = {member_key(x) for x in (doc.get("safe") or [])} - processed, unprocessed = [], [] - for p in plans: - pdoc = load_yaml(p, {}) or {} - raw = pdoc.get("source") - if raw is None: # sentinel intact — no discover agent returned for it - unprocessed.append(p) - continue - members = {member_key(m) for v in (pdoc.get("scopes") or {}).values() for m in v} - srcs = {member_key(x) for x in raw} - source |= srcs - safe |= members - srcs - processed.append(p) - print(f"{Path(p).name}: {len(srcs)} sources, {len(members - srcs)} safe") - if not processed: - print("no processed discover plans (every plan still carries source: null) — " - "fan out discover-attack-surface first", file=sys.stderr) - return 0 - safe -= source - ledger.parent.mkdir(parents=True, exist_ok=True) - ledger.write_text(dump_yaml({"source": sorted(source), "safe": sorted(safe)}), encoding="utf-8") - print(f"classification.yaml: {len(source)} source, {len(safe)} safe total") - if not args.keep: - for p in processed: - Path(p).unlink() - print(f"pruned {len(processed)} reconciled discover plan(s)") - if unprocessed: - print(f"left {len(unprocessed)} unprocessed plan(s) (source: null) for re-dispatch: " - + ", ".join(Path(p).name for p in unprocessed)) - return 0 - - -# ---- merge-skipped (analyze join) ---- - -def _skip_member(item): - # normalize a skipped/engine_issues entry to {method, signature?}, dropping the reason - if isinstance(item, dict): - m = strip_quotes(item.get("method", "")) - sig = str(item.get("signature", "")).strip() - return {"method": m, "signature": sig} if sig else {"method": m} - return {"method": strip_quotes(item)} - - -def _collect(docs, bucket): - seen = {} - for doc in docs: - for item in doc.get(bucket, []) or []: - m = _skip_member(item) - if m["method"]: - seen[(m["method"], m.get("signature", ""))] = m - return [seen[k] for k in sorted(seen)] - - -def cmd_merge_skipped(args): - # collects the `skipped` and `engine_issues` buckets of every batch into skipped.yaml, keeping - # them as two separate groups — regular skips under `methods`, engine issues under `engine_issues`. - docs = [load_yaml(p, {}) or {} for p in - (Path(x) for x in sorted(glob.glob(str(APPROX / "*.yaml")))) - if p.name != "skipped.yaml"] - out = {"methods": _collect(docs, "skipped"), "engine_issues": _collect(docs, "engine_issues")} - (APPROX / "skipped.yaml").write_text(dump_yaml(out), encoding="utf-8") - print(f"skipped.yaml: {len(out['methods'])} methods, {len(out['engine_issues'])} engine_issues") - if not args.keep and APPROX_PLANS.is_dir(): - pruned = [p for p in glob.glob(str(APPROX_PLANS / "*.yaml"))] - for p in pruned: - Path(p).unlink() - if pruned: - print(f"pruned {len(pruned)} consumed approximation plan(s)") - return 0 - - -# ---- findings (SARIF -> per-rule tracking files) ---- - -ADJ = ["brave", "calm", "eager", "fuzzy", "gentle", "jolly", "keen", "lucid", - "merry", "noble", "proud", "quiet", "rapid", "sly", "tidy", "vivid", - "witty", "zesty", "amber", "bold"] -NOUN = ["hopper", "eagle", "otter", "falcon", "maple", "comet", "harbor", - "willow", "pixel", "river", "ember", "cobra", "lotus", "raven", - "quartz", "badger", "cedar", "drake", "finch", "gull"] - -_FP_PREFERENCE = ("vulnerabilitySourceSinkHash", "vulnerabilityWithTraceHash") - -HASHES_RE = re.compile(r'^sarif_hashes:\s*\[(.*)\]\s*$', re.M) -HASHES_BLOCK_RE = re.compile(r'^sarif_hashes:\s*\n((?:[ \t]+-[^\n]*\n?)+)', re.M) - - -def docker_name(seed, taken): - h = int(hashlib.sha1(seed.encode()).hexdigest(), 16) - base = f"{ADJ[h % len(ADJ)]}-{NOUN[(h // len(ADJ)) % len(NOUN)]}" - name, n = base, 2 - while name in taken: - name, n = f"{base}-{n}", n + 1 - return name - - -def result_hash(res): - fp = res.get("fingerprints") or res.get("partialFingerprints") - if isinstance(fp, dict) and fp: - for pref in _FP_PREFERENCE: - for k, v in fp.items(): - if k.startswith(pref): - return str(v)[:16] - return str(sorted(fp.values())[0])[:16] - parts = [res.get("ruleId", "")] - locs = list(res.get("locations", [])) - for cf in res.get("codeFlows", []): - for tf in cf.get("threadFlows", []): - locs += [st.get("location", {}) for st in tf.get("locations", [])] - for loc in locs: - pl = loc.get("physicalLocation", {}) - parts.append(pl.get("artifactLocation", {}).get("uri", "")) - parts.append(json.dumps(pl.get("region", {}), sort_keys=True)) - return hashlib.sha1("|".join(parts).encode()).hexdigest()[:16] - - -def scan_results(sarif): - out = {} - for run in sarif.get("runs") or []: - for res in run.get("results") or []: - rid = res.get("ruleId") or "unknown" - msg = (res.get("message", {}) or {}).get("text", "").strip() - out.setdefault(rid, {})[result_hash(res)] = msg - return out - - -def fmt_list(hashes): - return "[" + ", ".join(hashes) + "]" - - -def parse_hashes(text): - m = HASHES_RE.search(text) - if m: - return [h.strip() for h in m.group(1).split(",") if h.strip()] - m = HASHES_BLOCK_RE.search(text) - if m: - return [ln.strip().lstrip("-").strip() - for ln in m.group(1).splitlines() if ln.strip().lstrip("-").strip()] - return [] - - -def replace_hashes(text, merged): - line = "sarif_hashes: " + fmt_list(merged) - if HASHES_RE.search(text): - return HASHES_RE.sub(lambda m: line, text, count=1) - if HASHES_BLOCK_RE.search(text): - return HASHES_BLOCK_RE.sub(line + "\n", text, count=1) - return line + "\n" + text - - -def new_file_text(rid, hashes, notes): - body = "\n".join(" " + ln for ln in (notes or "(no analyzer message)").splitlines()) - return (f"sarif_hashes: {fmt_list(hashes)}\n" - f"rule_id: {rid}\n" - f"verdict: pending\n" - f"notes: >\n{body}\n" - f"poc: pending\n") - - -def cmd_findings(args): - sarif = json.loads(SARIF.read_text(encoding="utf-8")) - by_rule = scan_results(sarif) - out = FINDINGS_TR - out.mkdir(parents=True, exist_ok=True) - - existing = {} - taken = set() - for p in sorted(glob.glob(str(out / "*.yaml"))): - text = Path(p).read_text(encoding="utf-8") - rid = RULE_RE.search(text) - verdict = VERDICT_RE.search(text) - taken.add(Path(p).stem) - if rid: - existing.setdefault(rid.group(1).strip(), []).append( - (Path(p), parse_hashes(text), verdict.group(1).strip() if verdict else "pending")) - - created = updated = unchanged = reconcile = 0 - for rid, hashmap in sorted(by_rule.items()): - scanned = set(hashmap) - files = existing.get(rid) - if not files: - name = docker_name(rid, taken) - taken.add(name) - notes = "\n".join(sorted({m for m in hashmap.values() if m})) - (out / f"{name}.yaml").write_text(new_file_text(rid, sorted(scanned), notes), - encoding="utf-8") - created += 1 - continue - already = set().union(*(set(h) for _, h, _ in files)) - new = sorted(scanned - already) - if not new: - unchanged += 1 - continue - pending = next(((p, h) for p, h, v in files if v == "pending"), None) - if pending: - path, hashes = pending - text = replace_hashes(path.read_text(encoding="utf-8"), sorted(set(hashes) | set(new))) - text = re.sub(r'^verdict:\s*.+$', "verdict: pending", text, count=1, flags=re.M) - path.write_text(text, encoding="utf-8") - updated += 1 - continue - name = docker_name(rid, taken) - taken.add(name) - msgs = sorted({hashmap[h] for h in new if hashmap.get(h)}) - notes = ("reconcile: new results under a rule whose findings are already triaged — " - "match each against this rule's triaged findings by flow before judging; if the " - "vulnerability is the same, merge its hashes into that finding and inherit its " - "verdict instead of re-triaging\n" + "\n".join(msgs)) - (out / f"{name}.yaml").write_text(new_file_text(rid, sorted(new), notes), encoding="utf-8") - reconcile += 1 - - print(f"findings: {created} created, {updated} updated, {unchanged} unchanged, " - f"{reconcile} to reconcile ({len(by_rule)} rules in scan)") - return 0 - - -def main(): - ap = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - sub = ap.add_subparsers(dest="cmd", required=True) - - i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--mode", default="assessment", choices=["assessment", "enactment"], - help="assessment: find vulnerabilities. enactment: reproduce supplied findings") - i.add_argument("--scan-level", choices=["lite", "normal", "deep"], - help="assessment mode only; enactment is always deep") - i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) - i.add_argument("--language", default=None, help="target language, determined by the orchestrator") - i.add_argument("--findings", default=None, - help="enactment mode: path to the supplied finding manifest/report/directory") - i.set_defaults(func=cmd_init) - - p = sub.add_parser("partition", help="split classification work into per-agent plans") - p.add_argument("kind", choices=["analyze", "discover"]) - p.set_defaults(func=cmd_partition) - - m = sub.add_parser("mark-safe", help="merge discover plans into classification.yaml") - m.add_argument("--keep", action="store_true", help="keep the reconciled discover plans") - m.set_defaults(func=cmd_mark_safe) - - s = sub.add_parser("merge-skipped", help="rebuild approximations/skipped.yaml from batches") - s.add_argument("--keep", action="store_true", help="keep the consumed approximation plans") - s.set_defaults(func=cmd_merge_skipped) - - f = sub.add_parser("findings", help="seed per-rule finding files from results/report.sarif") - f.set_defaults(func=cmd_findings) - - - args = ap.parse_args() - return args.func(args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/enactment-agent/scripts/get_status.py b/skills/enactment-agent/scripts/get_status.py deleted file mode 100644 index e8114ebdd..000000000 --- a/skills/enactment-agent/scripts/get_status.py +++ /dev/null @@ -1,481 +0,0 @@ -# /// script -# requires-python = ">=3.9" -# dependencies = ["pyyaml==6.0.3"] -# /// -""" -get_status.py — the orchestrator's status source. Derives, from the .opentaint tree, -which pipeline phase is current and the exact orchestrator tasks for it; writes nothing -(use generate.py to mutate). Run with uv from the project root: - - uv run scripts/get_status.py current stage + its orchestrator tasks - uv run scripts/get_status.py --full every in-scope phase as DONE/IN_PROGRESS/PENDING - -Call it at each stage boundary to decide the next move, and --full at run start / on -resume. It lists every pending plan, batch, unit, and finding to hand out — dispatch what -it names rather than re-deriving state by hand. -""" -import argparse -import glob -import os -import re -import subprocess -import sys -from pathlib import Path - -from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, - SOURCES_TR, TRACKING, build_done_keys, classified_keys, dropped_entries, - git_head, load_yaml, member_key, modeled_entries, skipped_keys, - strip_quotes) - -STATE = load_yaml(TRACKING / "state.yaml", {}) or {} -MODE = STATE.get("mode") or "assessment" -SCAN_LEVEL = STATE.get("scan_level") -TRIAGE_LEVEL = STATE.get("triage_level") - -DISCOVER_PLANS = RULES_TR / "plans" -APPROX_PLANS = APPROX / "plans" -VULN = ROOT / "vulnerabilities.md" -ENACTMENT = ROOT / "enactment.md" -GLOBAL_CAP = 10 - - -def short(c): - return str(c)[:8] if c else c - - -# ---- tree readers ---- - -def load_units(d): - return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ - if Path(d).is_dir() else [] - - -def load_docs(d): - """(path, doc) for every tracking file in a directory — reference, boundary, control.""" - return [(p, load_yaml(p, {}) or {}) for p in sorted(Path(d).glob("*.yaml"))] \ - if Path(d).is_dir() else [] - - -def load_joins(): - return [(p.stem, load_yaml(p, {}) or {}) for p in sorted(JOINS_TR.glob("*.yaml"))] \ - if JOINS_TR.is_dir() else [] - - -def load_findings(): - out = [] - if FINDINGS_TR.is_dir(): - for p in sorted(FINDINGS_TR.glob("*.yaml")): - doc = load_yaml(p, {}) or {} - notes = str(doc.get("notes") or "") - out.append({"path": str(p), "name": p.stem, - "verdict": str(doc.get("verdict", "pending")).strip(), - "poc": str(doc.get("poc", "pending")).strip(), - "reconcile": notes.lstrip().startswith("reconcile")}) - return out - - -def newest_mtime(paths): - m = 0.0 - for p in paths: - try: - m = max(m, p.stat().st_mtime) - except OSError: - pass - return m - - -def approx_dirty(): - # scan older than the newest applied approximation artifact -> a rescan is pending - if not SARIF.is_file(): - return False - arts = list(PASS_THROUGH.rglob("*")) + list(DATAFLOW.rglob("*")) - return newest_mtime([p for p in arts if p.is_file()]) > SARIF.stat().st_mtime - - -def rules_dirty(): - if not SARIF.is_file(): - return False - return newest_mtime([p for p in RULES.rglob("*.yaml") if p.is_file()]) > SARIF.stat().st_mtime - - -def scan_stale(): - # SARIF older than the model -> a rebuild happened, rescan before trusting it - manifest = MODEL / "project.yaml" - if not SARIF.is_file() or not manifest.is_file(): - return False - return manifest.stat().st_mtime > SARIF.stat().st_mtime - - -def unit_next(doc, kind, side): - # the next dispatch step for a not-yet-passing rule unit - if (doc.get("stages") or {}).get("test_project") != "done": - return f"create-test-project type {kind}" - return f"create-rule side {side}" - - -def _join_source_refs(): - return {str(s).strip() for _, doc in load_joins() - for s in (doc.get("sources") or []) if str(s).strip()} - - -def _join_sink_refs(): - return {str(j["sink"]).strip() for _, doc in load_joins() - for j in (doc.get("joins") or []) if isinstance(j, dict) and j.get("sink")} - - -def _created_refs(units, field): - """rule_ids on the units that resolve to a rule file under .opentaint/rules (created, not - a built-in ref, which is indistinguishable by path but never sits on disk here).""" - refs = set() - for _, doc in units: - for e in doc.get(field) or []: - rid = str(e.get("rule_id", "")).strip() if isinstance(e, dict) else "" - if rid and (RULES / re.split(r"[:#]", rid, 1)[0]).is_file(): - refs.add(rid) - return refs - - -def _pending_units(units, kind, side): - # units not passing and not settled by a blocker; each tagged with its next step - out = [] - for name, doc in units: - st = doc.get("stages") or {} - if st.get("tests_passing") != "done" and not (doc.get("blocker") or st.get("blocker")): - out.append(f" {name} {unit_next(doc, kind, side)}") - return out - - -# ---- phase derivations: each returns (done, tasks, note) ---- -# done: the phase is complete. tasks: the brief lines when it is the current stage. -# note: a short suffix shown only on the --full line (e.g. build from a dirty tree). - -def ph_build(): - if not (MODEL / "project.yaml").is_file(): - return False, ["determine project language and write to state.yaml.language", - "dispatch build-project"], None - head, mc = git_head(), STATE.get("model_commit") - if head is None: - return True, [], "no git — taken as current" - if mc is None: - return True, [], "from dirty tree" - if head == mc: - return True, [], None - return False, [f"model stale: HEAD {short(head)} != model_commit {short(mc)}", - "dispatch build-project"], None - - -def ph_discover(): - if not (TRACKING / "coverage.yaml").is_file(): - return False, ["dispatch triage-dependencies"], None - leftover = sorted(glob.glob(str(DISCOVER_PLANS / "*.yaml"))) - units = load_units(SOURCES_TR) - ledger = load_yaml(RULES_TR / "classification.yaml", {}) or {} - if leftover: - tasks = [f"dispatch discover-attack-surface, one per plan (cap {GLOBAL_CAP}):"] - tasks += [f" {p}" for p in leftover] - tasks.append("then run `scripts/generate.py mark-safe` to reconcile the plans") - return False, tasks, None - if not ledger and not units: - return False, ["run `scripts/generate.py partition discover` to plan the used members"], None - return True, [], None - - -def ph_source_rules(): - units = load_units(SOURCES_TR) - if not units: - return True, [], "built-in covered" - pend = _pending_units(units, "rule-source", "sources") - if pend: - return False, ["pending units:"] + pend, None - missing = sorted(_created_refs(units, "sources") - _join_source_refs()) - if missing: - return False, ["created sources not wired to a join", "dispatch assemble-lib-rules"], None - return True, [], None - - -def ph_scan(): - if not SARIF.is_file() or scan_stale(): - return False, ["dispatch run-scan"], None - return True, [], None - - -def ph_approximations(): - if not SARIF.is_file(): - return False, ["dispatch run-scan"], None - classified = classified_keys() - uncovered = [e for e in dropped_entries() if member_key(e) not in classified] - if uncovered: - plans = sorted(glob.glob(str(APPROX_PLANS / "*.yaml"))) - if plans: - tasks = [f"dispatch analyze-external-methods, one per plan (cap {GLOBAL_CAP}):"] - tasks += [f" {p}" for p in plans] - tasks.append("then run `scripts/generate.py merge-skipped` to merge the batches") - return False, tasks, None - n = len({e["method"] for e in uncovered}) - return False, [f"{n} methods unclassified", - "run `scripts/generate.py partition analyze` to split them into batch plans"], None - done = build_done_keys() - terminal = skipped_keys() # skipped/engine-issue carriers never build — don't hold the gate - unbuilt = [(p, kind) for p, kind, m in modeled_entries() - if member_key(m) not in done and member_key(m) not in terminal] - if unbuilt: - by_kind = {} - for p, kind in unbuilt: - by_kind.setdefault(kind, set()).add(p.stem) - tasks = ["build unbuilt batches:"] - if by_kind.get("passthrough"): - tasks.append(" passthrough create-pass-through-approximation: " - + ", ".join(sorted(by_kind["passthrough"]))) - if by_kind.get("dataflow"): - tasks.append(" dataflow create-test-project type dataflow, then " - "create-dataflow-approximation: " + ", ".join(sorted(by_kind["dataflow"]))) - return False, tasks, None - if approx_dirty(): - return False, ["approximations built after the last scan", "dispatch run-scan"], None - stuck = sorted({e["method"] for e in dropped_entries() - if member_key(e) in done and member_key(e) not in terminal}) - if stuck: - return False, [f"built but still dropped ({len(stuck)}), escalate:"] \ - + [f" {m}" for m in stuck], None - return True, [], None - - -def ph_sink_rules(): - units = load_units(SINKS_TR) - pend = _pending_units(units, "rule-sink", "sinks") - if pend: - return False, ["pending units:"] + pend, None - refs = _join_sink_refs() - missing = sorted({e["rule_id"] for _, doc in units for e in (doc.get("sinks") or []) - if isinstance(e, dict) and e.get("rule_id") - and str(e["rule_id"]).strip() not in refs}) - if missing: - return False, ["sink rules not wired to a join", "dispatch assemble-lib-rules"], None - if rules_dirty(): - return False, ["rules changed after the last scan", "dispatch run-scan"], None - return True, [], None - - -def ph_triage(): - findings = load_findings() - if not findings: - if not SARIF.is_file(): - return False, ["dispatch run-scan"], None - return False, ["run `scripts/generate.py findings` to seed the finding files"], None - pend = [f" {f['path']}" + (" (reconcile)" if f["reconcile"] else "") - for f in findings if f["verdict"] == "pending"] - if pend: - return False, ["dispatch analyze-findings over pending findings:"] + pend, None - tp = sum(1 for f in findings if f["verdict"] == "TP") - stale = newest_mtime([Path(f["path"]) for f in findings]) > (VULN.stat().st_mtime - if VULN.is_file() else 0) - if not VULN.is_file() or stale: - return False, [f"rewrite .opentaint/vulnerabilities.md from the TP findings ({tp} TP)"], None - return True, [], None - - -def ph_poc(): - findings = load_findings() - tps = [f for f in findings if f["verdict"] == "TP"] - pend = [f" {f['path']}" for f in tps if f["poc"] == "pending"] - if pend: - return False, ["generate-poc serially over TP findings without a PoC:"] + pend, None - servers = (load_yaml(TRACKING / "poc-servers.yaml", {}) or {}).get("servers") or [] - if servers: - return False, ["tear down the instances in poc-servers.yaml and clear the registry", - "refresh .opentaint/vulnerabilities.md"], None - return True, [], None - - -# ---- enactment-mode phases ---- - -def ph_reference_set(): - docs = load_docs(REFERENCE_TR) - if not docs: - src = STATE.get("findings") or "state.yaml findings unset" - return False, [f"normalize the supplied findings ({src}) into " - ".opentaint/tracking/reference/.yaml"], None - missing = sorted(p.stem for p, d in docs if not strip_quotes(d.get("family", ""))) - if missing: - return False, ["reference findings not assigned to a boundary family:"] \ - + [f" {m}" for m in missing], None - return True, [], None - - -def families(): - return sorted({strip_quotes(d.get("family", "")) for _, d in load_docs(REFERENCE_TR) - if strip_quotes(d.get("family", ""))}) - - -def ph_boundaries(): - specs = {p.stem: d for p, d in load_docs(BOUNDARIES_TR)} - fams = families() - missing = [f for f in fams if f not in specs] - if missing: - return False, ["dispatch discover-universal-boundaries, one per family:"] \ - + [f" {f}" for f in missing], None - # a split renames the family on its reference findings, so every spec here owns its findings - unsaturated = [f for f in fams - if str((specs[f].get("saturation") or {}).get("status", "")).strip() - != "saturated"] - if unsaturated: - return False, ["boundary specs not saturated:"] + [f" {f}" for f in unsaturated], None - unfactored = sorted(p.stem for p, d in load_docs(REFERENCE_TR) - if p.stem not in (specs.get(strip_quotes(d.get("family", "")), {}) - .get("factorization") or {})) - if unfactored: - return False, ["reference findings with no factorization in their spec:"] \ - + [f" {r}" for r in unfactored], None - unseeded = [f for f in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] - if unseeded: - return False, ["seed the source and sink units from these specs' candidate_patterns:"] \ - + [f" {f}" for f in unseeded], None - return True, [], None - - -def ph_crossref(): - if not SARIF.is_file(): - return False, ["dispatch run-scan"], None - docs = load_docs(REFERENCE_TR) - scanned = SARIF.stat().st_mtime - pend = [p for p, d in docs - if str(d.get("crossref", "pending")).strip() != "done" or p.stat().st_mtime < scanned] - if pend: - return False, [f"cross-reference the scan against {len(pend)} reference finding(s):"] \ - + [f" {p}" for p in pend], None - blocked = sorted({str(m) for _, d in docs for m in (d.get("blocked_at") or [])}) - if blocked: - return False, ["expected traces stop at unmodeled carriers:"] + [f" {m}" for m in blocked] \ - + ["model them in an approximation round, rescan, then cross-reference again"], None - rep = sum(1 for _, d in docs if str(d.get("status", "")).strip() == "reproduced") - stale = newest_mtime([p for p, _ in docs]) > (ENACTMENT.stat().st_mtime - if ENACTMENT.is_file() else 0) - if not ENACTMENT.is_file() or stale: - return False, [f"rewrite .opentaint/enactment.md coverage manifest " - f"({rep}/{len(docs)} reproduced)"], None - return True, [], None - - -def has_reference_set(): - """A reference set outlives the pass that created it. Any later pass that rescans changes what - it reproduces, so the cross-reference stays in scope — otherwise an assessment pass would leave - a coverage manifest that silently describes an older scan.""" - return REFERENCE_TR.is_dir() and any(REFERENCE_TR.glob("*.yaml")) - - -ASSESSMENT_PHASES = [ - ("build", ph_build, lambda: True), - ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), - ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), - ("scan", ph_scan, lambda: True), - ("approximations", ph_approximations, lambda: SCAN_LEVEL in ("normal", "deep")), - ("sink_rules", ph_sink_rules, lambda: SCAN_LEVEL == "deep"), - ("triage", ph_triage, lambda: True), - ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), - ("crossref", ph_crossref, has_reference_set), -] - -# enactment reproduces a supplied finding set: the reference set and its saturated boundaries -# replace dependency discovery, and both rule sides are authored before the first scan so that -# scan is rule-first. The cross-reference closes the run — it judges what the finished rule set, -# its approximations, its verdicts and its controls actually reproduced. -ENACTMENT_PHASES = [ - ("build", ph_build, lambda: True), - ("reference_set", ph_reference_set, lambda: True), - ("boundaries", ph_boundaries, lambda: True), - ("source_rules", ph_source_rules, lambda: True), - ("sink_rules", ph_sink_rules, lambda: True), - ("scan", ph_scan, lambda: True), - ("approximations", ph_approximations, lambda: True), - ("triage", ph_triage, lambda: True), - ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), - ("crossref", ph_crossref, lambda: True), -] - -PHASES = ENACTMENT_PHASES if MODE == "enactment" else ASSESSMENT_PHASES - - -# ---- caps ---- - -def free_gb(): - mi = Path("/proc/meminfo") - if mi.is_file(): - for ln in mi.read_text().splitlines(): - if ln.startswith("MemAvailable:"): - return int(ln.split()[1]) // (1024 * 1024) - try: - total = int(subprocess.run(["sysctl", "-n", "hw.memsize"], capture_output=True, - text=True, check=True).stdout.strip()) - return total // (1024 ** 3) - except (OSError, subprocess.CalledProcessError, ValueError): - return None - - -def heavy_cap(): - cores = os.cpu_count() or 1 - fg = free_gb() - return max(1, min(cores, (fg // 2 if fg else cores), GLOBAL_CAP)) - - -# ---- output ---- - -def in_scope(): - return [(name, fn) for name, fn, gate in PHASES if gate()] - - -def evaluate(): - """Every in-scope phase as (name, done, tasks, note), with the current stage marked.""" - rows = [(name,) + fn() for name, fn in in_scope()] - current = next((i for i, r in enumerate(rows) if not r[1]), None) - return rows, current - - -def cmd_full(): - commit = short(STATE.get("model_commit")) or "none" - print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " - f"language={STATE.get('language')} commit={commit} " - f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") - if STATE.get("findings"): # printed on an assessment pass too — the set is still tracked - print(f"findings={STATE.get('findings')}") - runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] - if len(runs) > 1: - print("passes: " + " -> ".join(str(r.get("type", "?")) for r in runs)) - rows, current = evaluate() - # a phase downstream of the current stage that vacuously satisfies its own check is not - # actually done — its producing stage hasn't run — so it reads PENDING, never DONE. - for i, (name, done, tasks, note) in enumerate(rows): - if current is None or i < current: - state = "DONE" - elif i == current: - state = "IN_PROGRESS" - else: - state = "PENDING" - suffix = f" ({note})" if note and state == "DONE" else "" - print(f"{name:<15} {state}{suffix}") - return 0 - - -def cmd_brief(): - rows, current = evaluate() - if current is None: - print("run complete") - return 0 - name, _done, tasks, _note = rows[current] - print(f"{name} IN_PROGRESS") - for t in tasks: - print(f" {t}") - return 0 - - -def main(): - ap = argparse.ArgumentParser(description=__doc__, - formatter_class=argparse.RawDescriptionHelpFormatter) - ap.add_argument("--full", action="store_true", - help="every in-scope phase as DONE/IN_PROGRESS/PENDING") - args = ap.parse_args() - return cmd_full() if args.full else cmd_brief() - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/orchestrate-stage/SKILL.md b/skills/orchestrate-stage/SKILL.md index adf9e1726..6a700174b 100644 --- a/skills/orchestrate-stage/SKILL.md +++ b/skills/orchestrate-stage/SKILL.md @@ -16,16 +16,19 @@ Complete one bounded stage of the OpenTaint security workflow. Dispatch its inde Match the stage keyword to its reference and read it fully: ``` +intake → /references/intake.md +boundaries → /references/boundaries.md sources → /references/sources.md approx-round → /references/approx-round.md sinks → /references/sinks.md triage → /references/triage.md poc → /references/poc.md +crossref → /references/crossref.md (a tree carrying a reference set) escalation → /references/escalation.md -boundaries → /references/boundaries.md (enactment mode) -crossref → /references/crossref.md (enactment mode) ``` +`intake` is the only stage whose work depends on the run's `mode`; its reference carries a section per mode. Everything after it reads the same whichever mode produced the families. + Run the bundled script to get the setup overview before proceeding to the reference's instructions: ```bash diff --git a/skills/orchestrate-stage/references/boundaries.md b/skills/orchestrate-stage/references/boundaries.md index 715b19bcb..82f7542a9 100644 --- a/skills/orchestrate-stage/references/boundaries.md +++ b/skills/orchestrate-stage/references/boundaries.md @@ -1,38 +1,8 @@ -# Reference set + universal boundaries +# Universal boundaries -Turn the supplied findings into a normalized reference set, generalize each finding family into one saturated source and sink boundary, and seed the source and sink units those boundaries imply. Enactment mode only — it replaces dependency discovery, and everything it writes feeds the ordinary rule-authoring stages. +Generalize each family intake produced into one saturated universal source and one universal sink, and seed the source and sink units those boundaries imply. Every mode runs this stage: the families differ — a swept frontier, a diff or spec, a supplied finding set — but what comes out is the same universal rule material, and everything it writes feeds the ordinary rule-authoring stages. -## Normalize the reference set - -`state.yaml` names the supplied findings under `findings` — a manifest, SARIF, report, or directory of finding documents. Write one `.opentaint/tracking/reference/.yaml` per supplied finding. - -`.opentaint/tracking/reference/.yaml` — one supplied finding, normalized to a stable identity and carried through boundary discovery and the cross-reference. The file is named for the finding's own id. `family` ties it to its boundary spec and is rewritten when a family splits. `status` is `pending` until the cross-reference judges it, then `reproduced` or `unreproduced`; `cause` explains an `unreproduced` one so the pipeline knows who owns it — `rule` (a boundary, restriction, or sanitizer is wrong), `approximation` (an opaque carrier breaks the path), or `engine` (a modeling limit, paired with `blocker`). `blocked_at` lists the carriers still to model and is cleared once they are modeled or judged terminal. `matched_hashes` are the SARIF result hashes whose trace carries this finding's identity — never a rule-id match alone. Keep it clear from comments - -```yaml -id: DSC-014 -vuln_class: ssrf -family: ssrf -source: request body field `callbackUrl` on POST /api/webhook/register -propagation: WebhookReqVO -> WebhookDO -> WebhookService#dispatch -sink: RestTemplate#getForObject in WebhookService#dispatch -expected_location: yudao-module-infra/.../WebhookService.java:88 -guards: URL parsed with new URI(...), no private-range rejection -status: reproduced -cause: null -blocker: null -blocked_at: [] -matched_hashes: [a1b2c3d4e5f6a7b8] -crossref: done -notes: > - crossref: join ssrf-webhook-ext fired at WebhookService#dispatch:88 with the trace entering at - the registration body — same attack path as the reference finding -``` - -Give each finding a stable id of its own — the supplied one when it has one. Preserve separately triggerable attack paths as separate findings even when they share a sink. Never drop a finding for being a poor fit for taint analysis: an authorization, integrity, configuration, hard-coded-secret, or structural-control finding gets a reference file like any other, and becomes an explicit pseudo-boundary later. - -Then group the findings into families and set `family` on each. Partition by vulnerability class or by a cohesive finding family — never by file batches or arbitrary count. A family is the set of findings you expect to share one source and one sink. - -Fan out this normalization when the supplied set is large: one leaf per slice of the supplied report, each writing its own reference files. Assign the families yourself once every file exists, since that decision needs the whole set. +Read the family list from `get_status.py`, which takes it from `.opentaint/tracking/scope.yaml` in onboarding and discovery mode, and from the reference findings' own `family` field in enactment mode. ## Discover the boundaries @@ -40,17 +10,17 @@ Fan out discover-universal-boundaries, one leaf per family. Inputs each: - `language` -- `findings` — the path `state.yaml` names -- `finding-ids` — the ids assigned to this family - `family` +- `evidence` — the ids the family carries: reference finding ids in enactment mode, the members or code areas `scope.yaml` recorded otherwise +- `findings` (enactment mode) — the supplied-findings path `state.yaml` names, so a leaf can read a finding beyond its normalized file -Expect back — `.opentaint/tracking/boundaries/.yaml` with `saturation.status: saturated`, one `factorization` entry per assigned finding, and the controls listed separately from the positive boundaries. A leaf that splits its family writes one spec per subfamily and rewrites `family` on each reference finding it moved, so every finding still points at the spec that owns it. +Expect back — `.opentaint/tracking/boundaries/.yaml` with `saturation.status: saturated`, one `factorization` entry per assigned evidence item, and the controls listed separately from the positive boundaries. A leaf that splits its family writes one spec per subfamily; in enactment mode it also rewrites `family` on each reference finding it moved, and otherwise the split is recorded in the specs themselves — reconcile `scope.yaml` to the specs that came back so the family list and the specs still agree. -`.opentaint/tracking/boundaries/.yaml` — one boundary family: the single source and single sink every finding in the family factors through, plus the controls that recover precision. `candidate_patterns` are concrete enough for `create-rule` to test, and seed the family's source and sink units verbatim. `factorization` carries one entry per assigned reference finding, `status` one of `covered` (factors through both boundaries as-is), `needs-restriction` (factors only under a named `context_restrictions` entry), or `unfactored` (does not factor — explain in `open_questions` and split or add a pseudo-boundary). `saturation` records the widen-and-recheck rounds and only reads `saturated` once a full round changed neither the boundaries nor any factorization. `approximation_candidates` are opaque carriers noted for later — never acted on before a scan proves the trace stops there. Keep it clear from comments +`.opentaint/tracking/boundaries/.yaml` — one boundary family: the single universal source and single universal sink every piece of the family's evidence factors through, plus the controls that recover precision. `evidence` lists what the family was grouped from — reference finding ids in enactment mode, the members or code areas `scope.yaml` recorded otherwise. `candidate_patterns` are concrete enough for `create-rule` to test, and seed the family's source and sink units verbatim. `factorization` carries one entry per evidence item, `status` one of `covered` (factors through both boundaries as-is), `needs-restriction` (factors only under a named `context_restrictions` entry), or `unfactored` (does not factor — explain in `open_questions` and split or add a pseudo-boundary). `saturation` records the widen-and-recheck rounds and only reads `saturated` once a full round changed neither the boundaries nor any factorization. `approximation_candidates` are opaque carriers noted for later — never acted on before a scan proves the trace stops there. Keep it clear from comments ```yaml family: ssrf -findings: [DSC-014, DSC-021] +evidence: [DSC-014, DSC-021] source: semantic_boundary: external request value entering a controller candidate_patterns: @@ -75,7 +45,7 @@ stages: units_seeded: done ``` -A spec returning with an `unfactored` finding is not a failure to retry blindly — read its `open_questions`, and either re-dispatch the leaf with the finding split out as its own family or accept the pseudo-boundary it proposes. +A spec returning with an `unfactored` evidence item is not a failure to retry blindly — read its `open_questions`, and either re-dispatch the leaf with that item split out as its own family or accept the pseudo-boundary it proposes. ## Seed the rule units @@ -107,6 +77,8 @@ stages: Copy `method`, `signature`, and `note` from the spec's `candidate_patterns` (plus `vuln_class` on the sink side), leave `rule_id: null` and the `stages` pending, and fill `dependencies` with the dependency each pattern's package comes from — empty when the boundary is a project member, as a structural pseudo-boundary usually is. Do not carry `context_restrictions`, `sanitizers`, or `negative_patterns` into the units: the boundary is the positive pattern, and the controls stay listed in the spec. +Both sides are seeded here, and only the source side is authored before the first scan. That is deliberate: the sink boundary is decided now, on the evidence, and authored later against the frontier that scan names — never invented by a model round that found a carrier. + ## Stage gate -`get_status.py` drives `reference_set` then `boundaries`, naming findings without a family, families without a spec, unsaturated specs, unfactored findings, and unseeded specs. Finish when both are `DONE`, or when the next step it reports is source rules. +`get_status.py` drives `boundaries`, naming families without a spec, unsaturated specs, evidence with no factorization, and unseeded specs. Finish when it reads `DONE`, or when the next step it reports is source rules. diff --git a/skills/orchestrate-stage/references/crossref.md b/skills/orchestrate-stage/references/crossref.md index 4b8703c79..961383877 100644 --- a/skills/orchestrate-stage/references/crossref.md +++ b/skills/orchestrate-stage/references/crossref.md @@ -1,8 +1,8 @@ # Cross-reference — scan results against the reference set -Judge, per supplied finding, whether the latest scan reproduced it, and assemble the coverage manifest. This is the stage that closes an enactment pass: every rule, approximation, and verdict is already in place, so what the scan shows now is what the pass delivered. Nothing else may set a reference finding's `status`. +Judge, per supplied finding, whether the latest scan reproduced it, and assemble the coverage manifest. This is the stage that closes an enactment pass: every rule, model, and verdict is already in place, so what the scan shows now is what the pass delivered. Nothing else may set a reference finding's `status`. -It also runs at the end of an assessment pass over a tree that already carries a reference set. That pass's new rules and rescans changed what those findings reproduce — usually for the better, occasionally by losing one — so re-judge them all and refresh the manifest. The work is identical; only the pass that triggered it differs. +It runs in any mode over a tree that carries a reference set an earlier enactment pass left. That pass's new rules and rescans changed what those findings reproduce — usually for the better, occasionally by losing one — so re-judge them all and refresh the manifest. The work is identical; only the pass that triggered it differs. It is also the stage that decides what the run still owes. A judgement here can send the pipeline back — an unmodeled carrier to an approximation round, a rule-caused miss to the stage that authored the rule — and status will report that earlier phase as current again. That is the loop working; re-enter this stage after the rescan rather than closing on stale results. diff --git a/skills/orchestrate-stage/references/intake.md b/skills/orchestrate-stage/references/intake.md new file mode 100644 index 000000000..baafc8bec --- /dev/null +++ b/skills/orchestrate-stage/references/intake.md @@ -0,0 +1,121 @@ +# Intake — this mode's input, as the run's families + +Turn what the pass was given into the families the boundaries stage generalizes. The three modes start from different material and converge on the same handoff: a named family per group, each carrying the evidence it was grouped from. Nothing here authors a rule, proposes a boundary, or touches a model. + +Read `mode` from `get_status.py --full` and follow that mode's section. A family is the set of evidence you expect to share one universal source and one universal sink — partition by vulnerability class or by a cohesive attack surface, never by file batches or arbitrary count. + +## Onboarding — the external-method frontier + +Every dependency member the project's own code calls is a trust boundary until a leaf says otherwise. That over-approximation is the point: the sweep classifies the whole frontier once, and every later pass inherits the verdicts. + +### 1. Triage the dependencies + +Dispatch triage-dependencies when status names it. + +Expect back — `.opentaint/tracking/coverage.yaml` written with the flagged packages; status advances to the frontier partition. + +### 2. Partition the frontier + +Run: + +```bash +uv run /scripts/generate.py partition frontier +``` + +It writes balanced `.opentaint/tracking/rules/plans/lib-NNN.yaml` plans over the flagged packages' members the project actually calls, one disjoint slice per leaf. On re-entry it partitions only members no prior pass verdicted. + +### 3. Sweep it + +Fan out discover-attack-surface, one per plan. + +Inputs each: +- `language` +- `plan` + +At the join run: + +```bash +uv run /scripts/generate.py mark-safe +``` + +Expect back — each leaf records the trust boundaries it finds into its plan and writes any source unit(s); the join folds source/safe verdicts into `classification.yaml` and prunes the consumed plans. The sweep is a single fan-out pass, not a loop. + +### 4. Group the frontier into families + +Read the verdicted boundaries back from the ledger and the source units the sweep wrote, and group them into families — usually one per attack surface a set of members shares (a framework's request accessors, a client library's egress calls), not one per dependency. Write them to `.opentaint/tracking/scope.yaml`, evidence being the members themselves. + +`.opentaint/tracking/scope.yaml` — what this pass's intake scoped, and the families the boundaries stage generalizes one at a time. `scope` is one line on what the pass was given, so a later reader knows what the family list came from. Each family carries the `evidence` it was grouped from: the frontier members a sweep verdicted, or the members, endpoints, and code areas a diff or spec resolved to. Enactment mode writes no scope file — its families live on the reference findings, which carry the same assignment on the file that moves with them. Keep it clear from comments + +```yaml +mode: discovery +scope: docs/2026-07-webhooks.md — partner webhook registration and delivery +families: + - name: ssrf-webhook + evidence: + - com.acme.webhook.WebhookController#register + - com.acme.webhook.WebhookService#dispatch + - name: upload-path + evidence: + - com.acme.upload.UploadController#store +``` + +## Discovery — the project, a diff, or a spec + +`state.yaml` names the scoping document under `spec` when there is one; with none, the scope is the whole project. + +Resolve the input to code before grouping it: read the diff or spec, then the project source it points at — the endpoints, handlers, jobs, and consumers it touches, and the calls they make into dependencies. An informal spec ("this service takes uploads from partners and renders them") is scoped the same way: name the surfaces it implies and read them. + +Then group what you read into families and write `.opentaint/tracking/scope.yaml`, evidence being the members, endpoints, or code areas each family was grouped from — concrete enough that the boundary leaf can go straight to the source. + +Fan this reading out when the scope is large: one leaf per slice, each reporting the surfaces it found. Assign the families yourself once every slice has reported, since that decision needs the whole scope. + +`.opentaint/tracking/scope.yaml` — what this pass's intake scoped, and the families the boundaries stage generalizes one at a time. `scope` is one line on what the pass was given, so a later reader knows what the family list came from. Each family carries the `evidence` it was grouped from: the frontier members a sweep verdicted, or the members, endpoints, and code areas a diff or spec resolved to. Enactment mode writes no scope file — its families live on the reference findings, which carry the same assignment on the file that moves with them. Keep it clear from comments + +```yaml +mode: discovery +scope: docs/2026-07-webhooks.md — partner webhook registration and delivery +families: + - name: ssrf-webhook + evidence: + - com.acme.webhook.WebhookController#register + - com.acme.webhook.WebhookService#dispatch + - name: upload-path + evidence: + - com.acme.upload.UploadController#store +``` + +## Enactment — the supplied finding set + +`state.yaml` names the supplied findings under `findings` — a manifest, SARIF, report, or directory of finding documents. Write one `.opentaint/tracking/reference/.yaml` per supplied finding. + +`.opentaint/tracking/reference/.yaml` — one supplied finding, normalized to a stable identity and carried through boundary discovery and the cross-reference. The file is named for the finding's own id. `family` ties it to its boundary spec and is rewritten when a family splits. `status` is `pending` until the cross-reference judges it, then `reproduced` or `unreproduced`; `cause` explains an `unreproduced` one so the pipeline knows who owns it — `rule` (a boundary, restriction, or sanitizer is wrong), `approximation` (an opaque carrier breaks the path), or `engine` (a modeling limit, paired with `blocker`). `blocked_at` lists the carriers still to model and is cleared once they are modeled or judged terminal. `matched_hashes` are the SARIF result hashes whose trace carries this finding's identity — never a rule-id match alone. Keep it clear from comments + +```yaml +id: DSC-014 +vuln_class: ssrf +family: ssrf +source: request body field `callbackUrl` on POST /api/webhook/register +propagation: WebhookReqVO -> WebhookDO -> WebhookService#dispatch +sink: RestTemplate#getForObject in WebhookService#dispatch +expected_location: yudao-module-infra/.../WebhookService.java:88 +guards: URL parsed with new URI(...), no private-range rejection +status: reproduced +cause: null +blocker: null +blocked_at: [] +matched_hashes: [a1b2c3d4e5f6a7b8] +crossref: done +notes: > + crossref: join ssrf-webhook-ext fired at WebhookService#dispatch:88 with the trace entering at + the registration body — same attack path as the reference finding +``` + +Give each finding a stable id of its own — the supplied one when it has one. Preserve separately triggerable attack paths as separate findings even when they share a sink. Never drop a finding for being a poor fit for taint analysis: an authorization, integrity, configuration, hard-coded-secret, or structural-control finding gets a reference file like any other, and becomes an explicit pseudo-boundary later. + +Then group the findings into families and set `family` on each. Enactment's families live on the reference files themselves rather than in `scope.yaml`, because the assignment has to move with the finding when a family splits. + +Fan out this normalization when the supplied set is large: one leaf per slice of the supplied report, each writing its own reference files. Assign the families yourself once every file exists, since that decision needs the whole set. + +## Stage gate + +`get_status.py` drives `intake`, naming the mode's next step — the dependency triage, the frontier plans, the scope file, or the findings still without a family. Finish when it reads `DONE`, or when the next step it reports is the boundaries stage. diff --git a/skills/orchestrate-stage/references/sinks.md b/skills/orchestrate-stage/references/sinks.md index 338becd36..7cfeeabfa 100644 --- a/skills/orchestrate-stage/references/sinks.md +++ b/skills/orchestrate-stage/references/sinks.md @@ -1,6 +1,6 @@ # Author sinks + assemble -Author the sink rules flagged into per-package units during approximation classification and join them to every relevant source. The final project rescan follows to surface findings. +Author the sink units this run seeded — the boundaries stage's sink side, plus the sinks flagged into per-package units during model classification — and join them to every relevant source. The final project rescan follows to surface findings. ## Sink lib rules diff --git a/skills/orchestrate-stage/references/sources.md b/skills/orchestrate-stage/references/sources.md index 961bb7003..822c7c698 100644 --- a/skills/orchestrate-stage/references/sources.md +++ b/skills/orchestrate-stage/references/sources.md @@ -1,36 +1,8 @@ -# Discover sources + source rules +# Author sources + assemble -Discover the project's used dependency members that are taint sources, author source rules, and wire them to the built-in sinks. The first project scan follows and names the later taint frontier. On re-entry, partition plans only members no prior run verdicted. +Author the source rules the boundaries stage seeded into per-family units, and wire them to every relevant sink. The first project scan follows: it is what proves the boundaries and names the taint frontier the later stages work from. -## Triage dependencies - -Dispatch triage-dependencies when status names it. - -Expect back — `.opentaint/tracking/coverage.yaml` written; status advances to source discovery. - -## Discover sources - -Run: - -```bash -uv run /scripts/generate.py partition discover -``` - -It writes balanced `.opentaint/tracking/rules/plans/lib-NNN.yaml` plans, one disjoint slice per leaf. Fan out discover-attack-surface, one per plan. - -Inputs each: -- `language` -- `plan` - -At the join run: - -```bash -uv run /scripts/generate.py mark-safe -``` - -Expect back — each agent records the sources it finds into its plan and writes any source unit(s); the join folds source/safe verdicts into `classification.yaml` and prunes the consumed plans. Discovery is a single fan-out pass, not a loop. - -Run `uv run /scripts/get_status.py` to confirm `discover` `DONE`. +On re-entry, work only the units `get_status.py` still lists as pending — a unit that already passes is a prior pass's result, not work to redo. ## Source lib rules @@ -66,4 +38,4 @@ Expect back — existing tag-expanded joins reused where they already cover the ## Stage gate -`get_status.py` drives `discover` then `source_rules`, naming the current sub-step and units. Finish when both are `DONE`, or when the next step it reports is the project scan. +`get_status.py` drives `source_rules`, naming the current sub-step and units. Finish when it reads `DONE`, or when the next step it reports is the project scan. diff --git a/skills/orchestrate-stage/scripts/_common.py b/skills/orchestrate-stage/scripts/_common.py index cd0873805..9c2ad0b8e 100644 --- a/skills/orchestrate-stage/scripts/_common.py +++ b/skills/orchestrate-stage/scripts/_common.py @@ -21,8 +21,9 @@ SINKS_TR = RULES_TR / "sinks" JOINS_TR = RULES_TR / "joins" FINDINGS_TR = TRACKING / "findings" +SCOPE = TRACKING / "scope.yaml" # every mode: what intake scoped, as the family list REFERENCE_TR = TRACKING / "reference" # enactment mode: the supplied findings, normalized -BOUNDARIES_TR = TRACKING / "boundaries" # enactment mode: per-family boundary specs +BOUNDARIES_TR = TRACKING / "boundaries" # every mode: per-family universal boundary specs RESULTS = ROOT / "results" DROPPED = RESULTS / "dropped-external-methods.yaml" SARIF = RESULTS / "report.sarif" @@ -99,6 +100,22 @@ def git_head(): return None +# ---- intake scope ---- + +def scope_families(): + """(name, evidence) per family the intake stage scoped, in scope.yaml order. + + One shape for every mode: the evidence items are reference finding ids in enactment mode, + and the members or code areas intake settled on in onboarding and discovery mode. The + boundaries stage generalizes one family per entry, whichever mode wrote it.""" + out = [] + for f in (load_yaml(SCOPE, {}) or {}).get("families") or []: + name = strip_quotes((f or {}).get("name", "")) if isinstance(f, dict) else "" + if name: + out.append((name, [strip_quotes(str(e)) for e in (f.get("evidence") or [])])) + return out + + # ---- approximation batch readers (shared by coverage + partition) ---- # a method counts classified once it sits in any batch classification bucket or in build.done diff --git a/skills/orchestrate-stage/scripts/generate.py b/skills/orchestrate-stage/scripts/generate.py index 5e204b3c5..51e1d1339 100644 --- a/skills/orchestrate-stage/scripts/generate.py +++ b/skills/orchestrate-stage/scripts/generate.py @@ -9,8 +9,8 @@ init bootstrap the .opentaint tree + state.yaml from the workflow flags partition analyze dropped external methods -> per-root batch plans (approximations) - partition discover coverage.yaml's used members -> balanced discover plans - mark-safe discover plans' verdicts -> classification.yaml ledger (+prune plans) + partition frontier coverage.yaml's used members -> balanced frontier plans + mark-safe frontier plans' verdicts -> classification.yaml ledger (+prune plans) merge-skipped batch skipped/engine_issues -> approximations/skipped.yaml (+prune plans) findings results/report.sarif -> per-rule finding tracking files (idempotent) """ @@ -35,9 +35,9 @@ ANALYZE_BUDGET = 20 # methods per approximation batch ANALYZE_MISC = 6 # roots with <= this many methods pool into one misc batch ROOT_DEPTH = 2 # library root = first 2 dotted segments -DISCOVER_TARGET, DISCOVER_BAND = 50, 15 # project-used members per discover plan (~50, loose) +FRONTIER_TARGET, FRONTIER_BAND = 50, 15 # project-used members per frontier plan (~50, loose) -DISCOVER_PLANS = RULES_TR / "plans" +FRONTIER_PLANS = RULES_TR / "plans" APPROX_PLANS = APPROX / "plans" @@ -46,24 +46,33 @@ # the durable directories a run writes into; the leaves/scripts mkdir on write, but seeding # them up front gives every stage a place to land and makes the empty tree self-describing. INIT_DIRS = [TRACKING, APPROX, SOURCES_TR, SINKS_TR, JOINS_TR, FINDINGS_TR, - RESULTS, RULES, PASS_THROUGH, DATAFLOW] -ENACTMENT_DIRS = [REFERENCE_TR, BOUNDARIES_TR] # enactment mode only + BOUNDARIES_TR, RESULTS, RULES, PASS_THROUGH, DATAFLOW] +ENACTMENT_DIRS = [REFERENCE_TR] # the normalized supplied findings STATE_DERIVED = ("model_commit", "build_jdk", "max_memory") # build/scan fill these, init preserves def carried_over(): """What a pass inherits from the passes before it, as (label, count) — everything durable - the tree already holds. Both pipelines write into one tree, so a pass never starts empty + the tree already holds. Every mode writes into one tree, so a pass never starts empty unless the tree is.""" def n(paths): return sum(1 for _ in paths) return [("rule unit", n(SOURCES_TR.glob("*.yaml")) + n(SINKS_TR.glob("*.yaml"))), ("created rule", n(p for p in RULES.rglob("*.yaml") if p.is_file())), + ("boundary spec", n(BOUNDARIES_TR.glob("*.yaml")) if BOUNDARIES_TR.is_dir() else 0), ("approximation batch", len(batch_files())), ("reference finding", n(REFERENCE_TR.glob("*.yaml")) if REFERENCE_TR.is_dir() else 0), ("triaged finding", n(FINDINGS_TR.glob("*.yaml")))] +def onboarded(runs): + """Onboarding sweeps the whole external-method frontier and its corpus is durable, so it is + a one-time pass. Re-running init in onboarding mode is a resume while the tree is still in + that pass; once a later mode took over, the sweep is done and asking for it again is a + mistake worth naming.""" + return any(str((r or {}).get("type", "")).startswith("onboarding/") for r in runs) + + def cmd_init(args): state_path = TRACKING / "state.yaml" prior = load_yaml(state_path, {}) or {} @@ -73,20 +82,28 @@ def cmd_init(args): findings = args.findings or prior.get("findings") if enactment and not findings: raise SystemExit("init --mode enactment requires --findings ") - if not enactment and not args.scan_level: - raise SystemExit("init --mode assessment requires --scan-level") - # enactment reproduces a supplied finding set, which always needs the full rule + approximation - # toolbox — there is no lite/normal variant of it, so the level is fixed rather than asked for. - scan_level = "deep" if enactment else args.scan_level + if args.mode == "discovery" and not args.scan_level: + raise SystemExit("init --mode discovery requires --scan-level") + runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] + if args.mode == "onboarding" and onboarded(runs) and prior.get("mode") != "onboarding": + raise SystemExit("this tree is already onboarded — its frontier sweep, universal rules, " + "and models are on disk and every later pass builds on them; run " + "discovery or enactment instead") + # onboarding sweeps the frontier and enactment reproduces a supplied finding set: both always + # need the full rule + approximation toolbox, so their level is fixed rather than asked for. + scan_level = args.scan_level if args.mode == "discovery" else "deep" for d in INIT_DIRS + (ENACTMENT_DIRS if enactment else []): d.mkdir(parents=True, exist_ok=True) - # `mode` is this pass's pipeline, not a permanent property of the tree: the two compose, in - # either order and repeatedly across commits, and every artifact below is shared between them. + # `mode` is this pass's intake, not a permanent property of the tree: the modes compose, in + # any order and repeatedly across commits, and every artifact below is shared between them. state = {"mode": args.mode, "scan_level": scan_level, "triage_level": args.triage_level, "language": args.language or prior.get("language")} - if findings: # kept even on an assessment pass, so a later one resumes it + if findings: # kept across other modes, so a later enactment pass resumes it state["findings"] = findings + spec = args.spec or prior.get("spec") + if spec: + state["spec"] = spec for k in STATE_DERIVED: # never clobber what build/scan already learned state[k] = prior.get(k) state_path.write_text(dump_yaml(state), encoding="utf-8") @@ -94,7 +111,6 @@ def cmd_init(args): # history: one entry per pass. Re-running init with the same knobs on the same commit is a # resume of the current pass, not a new one — a different mode, level, or commit starts one. hist_path = TRACKING / "history.yaml" - runs = (load_yaml(hist_path, {}) or {}).get("runs") or [] entry = {"commit": git_head(), "type": f"{args.mode}/{scan_level}/{args.triage_level}"} new_pass = not runs or runs[-1] != entry @@ -115,6 +131,8 @@ def cmd_init(args): f"triage_level={state['triage_level']} language={state['language']}") if findings: print(f"findings={findings}") + if spec: + print(f"spec={spec}") if prior: kept = ", ".join(f"{c} {label}{'' if c == 1 else 's'}" for label, c in carried_over() if c) print(f"carried over: {kept or 'nothing yet'}") @@ -209,7 +227,7 @@ def write_plans(plans, out_dir, prefix_id): if isinstance(x, dict) else x) for p, v in sorted(scopes.items())} path = out_dir / f"{pid}.yaml" - # source: null is the unprocessed sentinel — a discover agent overwrites it with the + # source: null is the unprocessed sentinel — a frontier agent overwrites it with the # list of sources it found (an empty list when it finds none). mark-safe folds only # plans whose sentinel was replaced, so an un-returned plan is never marked safe. path.write_text(dump_yaml({"id": pid, "scopes": norm, "source": None}), encoding="utf-8") @@ -273,7 +291,7 @@ def cmd_analyze(args): return 0 -# ---- partition discover ---- +# ---- partition frontier ---- def yaml_modules(model_yaml): # each module in project.yaml as (packages, moduleClasses); only `packages` says which of a @@ -345,8 +363,8 @@ def pending_packages(): return tuple(p for p in (cov.get("packages") or []) if isinstance(p, str) and p) -def cmd_discover(args): - regen_plans(DISCOVER_PLANS) +def cmd_frontier(args): + regen_plans(FRONTIER_PLANS) packages = pending_packages() if not packages: print("nothing to plan — no pending package in coverage.yaml", file=sys.stderr) @@ -360,26 +378,26 @@ def cmd_discover(args): if not todo: print("nothing to plan — every used member already verdicted", file=sys.stderr) return 0 - cap = DISCOVER_TARGET + DISCOVER_BAND - plans = pack(atomize(todo, cap), DISCOVER_TARGET, cap) + cap = FRONTIER_TARGET + FRONTIER_BAND + plans = pack(atomize(todo, cap), FRONTIER_TARGET, cap) rows = {f: [{"method": f, "signature": s} for s in sorted(sigs[f])] for f in sigs} plans = [{pkg: [r for f in members for r in rows[f]] for pkg, members in plan.items()} for plan in plans] - for p in write_plans(plans, DISCOVER_PLANS, "lib"): + for p in write_plans(plans, FRONTIER_PLANS, "lib"): print(p) return 0 def cmd_partition(args): - return cmd_analyze(args) if args.kind == "analyze" else cmd_discover(args) + return cmd_analyze(args) if args.kind == "analyze" else cmd_frontier(args) -# ---- mark-safe (discover join) ---- +# ---- mark-safe (frontier join) ---- def cmd_mark_safe(args): - plans = sorted(glob.glob(str(DISCOVER_PLANS / "lib-*.yaml"))) + plans = sorted(glob.glob(str(FRONTIER_PLANS / "lib-*.yaml"))) if not plans: - print("no discover plans to reconcile", file=sys.stderr) + print("no frontier plans to reconcile", file=sys.stderr) return 0 ledger = RULES_TR / "classification.yaml" doc = load_yaml(ledger, {}) or {} @@ -389,7 +407,7 @@ def cmd_mark_safe(args): for p in plans: pdoc = load_yaml(p, {}) or {} raw = pdoc.get("source") - if raw is None: # sentinel intact — no discover agent returned for it + if raw is None: # sentinel intact — no frontier agent returned for it unprocessed.append(p) continue members = {member_key(m) for v in (pdoc.get("scopes") or {}).values() for m in v} @@ -399,7 +417,7 @@ def cmd_mark_safe(args): processed.append(p) print(f"{Path(p).name}: {len(srcs)} sources, {len(members - srcs)} safe") if not processed: - print("no processed discover plans (every plan still carries source: null) — " + print("no processed frontier plans (every plan still carries source: null) — " "fan out discover-attack-surface first", file=sys.stderr) return 0 safe -= source @@ -409,7 +427,7 @@ def cmd_mark_safe(args): if not args.keep: for p in processed: Path(p).unlink() - print(f"pruned {len(processed)} reconciled discover plan(s)") + print(f"pruned {len(processed)} reconciled frontier plan(s)") if unprocessed: print(f"left {len(unprocessed)} unprocessed plan(s) (source: null) for re-dispatch: " + ", ".join(Path(p).name for p in unprocessed)) @@ -605,22 +623,27 @@ def main(): sub = ap.add_subparsers(dest="cmd", required=True) i = sub.add_parser("init", help="bootstrap the .opentaint tree + state.yaml from workflow flags") - i.add_argument("--mode", default="assessment", choices=["assessment", "enactment"], - help="assessment: find vulnerabilities. enactment: reproduce supplied findings") + i.add_argument("--mode", required=True, choices=["onboarding", "discovery", "enactment"], + help="onboarding: sweep the external-method frontier once. discovery: work " + "from the project, a diff, or an informal spec. enactment: reproduce a " + "supplied finding set") i.add_argument("--scan-level", choices=["lite", "normal", "deep"], - help="assessment mode only; enactment is always deep") + help="discovery mode only; onboarding and enactment are always deep") i.add_argument("--triage-level", required=True, choices=["static", "dynamic"]) i.add_argument("--language", default=None, help="target language, determined by the orchestrator") i.add_argument("--findings", default=None, help="enactment mode: path to the supplied finding manifest/report/directory") + i.add_argument("--spec", default=None, + help="discovery mode: path to the diff, spec, or note that scopes the pass; " + "omit to scope the pass to the whole project") i.set_defaults(func=cmd_init) p = sub.add_parser("partition", help="split classification work into per-agent plans") - p.add_argument("kind", choices=["analyze", "discover"]) + p.add_argument("kind", choices=["analyze", "frontier"]) p.set_defaults(func=cmd_partition) - m = sub.add_parser("mark-safe", help="merge discover plans into classification.yaml") - m.add_argument("--keep", action="store_true", help="keep the reconciled discover plans") + m = sub.add_parser("mark-safe", help="merge frontier plans into classification.yaml") + m.add_argument("--keep", action="store_true", help="keep the reconciled frontier plans") m.set_defaults(func=cmd_mark_safe) s = sub.add_parser("merge-skipped", help="rebuild approximations/skipped.yaml from batches") diff --git a/skills/orchestrate-stage/scripts/get_status.py b/skills/orchestrate-stage/scripts/get_status.py index e8114ebdd..e8411bb26 100644 --- a/skills/orchestrate-stage/scripts/get_status.py +++ b/skills/orchestrate-stage/scripts/get_status.py @@ -23,17 +23,17 @@ from pathlib import Path from _common import (APPROX, BOUNDARIES_TR, DATAFLOW, FINDINGS_TR, JOINS_TR, MODEL, - PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SINKS_TR, + PASS_THROUGH, REFERENCE_TR, ROOT, RULES, RULES_TR, SARIF, SCOPE, SINKS_TR, SOURCES_TR, TRACKING, build_done_keys, classified_keys, dropped_entries, - git_head, load_yaml, member_key, modeled_entries, skipped_keys, - strip_quotes) + git_head, load_yaml, member_key, modeled_entries, scope_families, + skipped_keys, strip_quotes) STATE = load_yaml(TRACKING / "state.yaml", {}) or {} -MODE = STATE.get("mode") or "assessment" +MODE = STATE.get("mode") or "discovery" SCAN_LEVEL = STATE.get("scan_level") TRIAGE_LEVEL = STATE.get("triage_level") -DISCOVER_PLANS = RULES_TR / "plans" +FRONTIER_PLANS = RULES_TR / "plans" APPROX_PLANS = APPROX / "plans" VULN = ROOT / "vulnerabilities.md" ENACTMENT = ROOT / "enactment.md" @@ -165,10 +165,13 @@ def ph_build(): "dispatch build-project"], None -def ph_discover(): +def ph_frontier(): + """Onboarding's intake: the external-method frontier, swept as one trust boundary. Every + dependency member the project's own code calls is a candidate boundary until a leaf verdicts + it, and the ledger is what makes that verdict durable for every later pass.""" if not (TRACKING / "coverage.yaml").is_file(): return False, ["dispatch triage-dependencies"], None - leftover = sorted(glob.glob(str(DISCOVER_PLANS / "*.yaml"))) + leftover = sorted(glob.glob(str(FRONTIER_PLANS / "*.yaml"))) units = load_units(SOURCES_TR) ledger = load_yaml(RULES_TR / "classification.yaml", {}) or {} if leftover: @@ -177,7 +180,7 @@ def ph_discover(): tasks.append("then run `scripts/generate.py mark-safe` to reconcile the plans") return False, tasks, None if not ledger and not units: - return False, ["run `scripts/generate.py partition discover` to plan the used members"], None + return False, ["run `scripts/generate.py partition frontier` to plan the called members"], None return True, [], None @@ -288,9 +291,12 @@ def ph_poc(): return True, [], None -# ---- enactment-mode phases ---- +# ---- intake: one contract, three mode-specific derivations ---- def ph_reference_set(): + """Enactment's intake: the supplied findings, normalized and grouped. The `family` field on + each reference file is the assignment — it moves with the finding when a family splits, so + the families are read back from the files rather than from a separate list.""" docs = load_docs(REFERENCE_TR) if not docs: src = STATE.get("findings") or "state.yaml findings unset" @@ -303,31 +309,65 @@ def ph_reference_set(): return True, [], None +def ph_scope(): + """Onboarding's and discovery's intake join: the families their evidence groups into, written + to scope.yaml. Enactment carries the same information on its reference files instead.""" + fams = scope_families() + if not fams: + src = STATE.get("spec") or "the whole project" + what = ("the frontier the sweep verdicted as sources and effects" if MODE == "onboarding" + else f"the code {src} names") + return False, [f"group {what} into families and write {SCOPE}"], None + empty = [n for n, ev in fams if not ev] + if empty: + return False, ["families in scope.yaml with no evidence recorded:"] \ + + [f" {n}" for n in empty], None + return True, [], None + + +def ph_intake(): + if MODE == "enactment": + return ph_reference_set() + if MODE == "onboarding": + done, tasks, note = ph_frontier() + if not done: + return done, tasks, note + return ph_scope() + + def families(): - return sorted({strip_quotes(d.get("family", "")) for _, d in load_docs(REFERENCE_TR) - if strip_quotes(d.get("family", ""))}) + """(family, evidence ids) for this pass, whichever mode scoped it — reference finding ids in + enactment mode, the members or areas intake recorded otherwise. The boundaries stage + generalizes one universal source and sink per entry.""" + if MODE == "enactment": + out = {} + for path, doc in load_docs(REFERENCE_TR): + fam = strip_quotes(doc.get("family", "")) + if fam: + out.setdefault(fam, []).append(path.stem) + return sorted(out.items()) + return scope_families() def ph_boundaries(): specs = {p.stem: d for p, d in load_docs(BOUNDARIES_TR)} fams = families() - missing = [f for f in fams if f not in specs] + missing = [f for f, _ in fams if f not in specs] if missing: return False, ["dispatch discover-universal-boundaries, one per family:"] \ + [f" {f}" for f in missing], None - # a split renames the family on its reference findings, so every spec here owns its findings - unsaturated = [f for f in fams + # a split rewrites the family on the evidence it moves, so every spec here owns its evidence + unsaturated = [f for f, _ in fams if str((specs[f].get("saturation") or {}).get("status", "")).strip() != "saturated"] if unsaturated: return False, ["boundary specs not saturated:"] + [f" {f}" for f in unsaturated], None - unfactored = sorted(p.stem for p, d in load_docs(REFERENCE_TR) - if p.stem not in (specs.get(strip_quotes(d.get("family", "")), {}) - .get("factorization") or {})) + unfactored = sorted(f"{f}: {e}" for f, evidence in fams for e in evidence + if e not in (specs[f].get("factorization") or {})) if unfactored: - return False, ["reference findings with no factorization in their spec:"] \ + return False, ["evidence with no factorization in its spec:"] \ + [f" {r}" for r in unfactored], None - unseeded = [f for f in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] + unseeded = [f for f, _ in fams if (specs[f].get("stages") or {}).get("units_seeded") != "done"] if unseeded: return False, ["seed the source and sink units from these specs' candidate_patterns:"] \ + [f" {f}" for f in unseeded], None @@ -359,14 +399,21 @@ def ph_crossref(): def has_reference_set(): """A reference set outlives the pass that created it. Any later pass that rescans changes what - it reproduces, so the cross-reference stays in scope — otherwise an assessment pass would leave - a coverage manifest that silently describes an older scan.""" + it reproduces, so the cross-reference stays in scope — otherwise an onboarding or discovery + pass would leave a coverage manifest that silently describes an older scan.""" return REFERENCE_TR.is_dir() and any(REFERENCE_TR.glob("*.yaml")) -ASSESSMENT_PHASES = [ +# One pipeline, whatever the mode brought to it. Intake and its universal boundaries differ — +# the swept frontier, the diff or spec, the supplied findings — but from the boundary specs on, +# every mode runs the same stages in the same order: sources before the scan that proves them, +# approximations against the frontier that scan names, sinks after it, then triage. Only the +# closing phases are conditional, on the triage level, the controls knob, and whether the tree +# carries a reference set to re-judge. +PHASES = [ ("build", ph_build, lambda: True), - ("discover", ph_discover, lambda: SCAN_LEVEL == "deep"), + ("intake", ph_intake, lambda: SCAN_LEVEL == "deep"), + ("boundaries", ph_boundaries, lambda: SCAN_LEVEL == "deep"), ("source_rules", ph_source_rules, lambda: SCAN_LEVEL == "deep"), ("scan", ph_scan, lambda: True), ("approximations", ph_approximations, lambda: SCAN_LEVEL in ("normal", "deep")), @@ -376,24 +423,7 @@ def has_reference_set(): ("crossref", ph_crossref, has_reference_set), ] -# enactment reproduces a supplied finding set: the reference set and its saturated boundaries -# replace dependency discovery, and both rule sides are authored before the first scan so that -# scan is rule-first. The cross-reference closes the run — it judges what the finished rule set, -# its approximations, its verdicts and its controls actually reproduced. -ENACTMENT_PHASES = [ - ("build", ph_build, lambda: True), - ("reference_set", ph_reference_set, lambda: True), - ("boundaries", ph_boundaries, lambda: True), - ("source_rules", ph_source_rules, lambda: True), - ("sink_rules", ph_sink_rules, lambda: True), - ("scan", ph_scan, lambda: True), - ("approximations", ph_approximations, lambda: True), - ("triage", ph_triage, lambda: True), - ("poc", ph_poc, lambda: TRIAGE_LEVEL == "dynamic"), - ("crossref", ph_crossref, lambda: True), -] -PHASES = ENACTMENT_PHASES if MODE == "enactment" else ASSESSMENT_PHASES # ---- caps ---- @@ -436,8 +466,10 @@ def cmd_full(): print(f"mode={MODE} scan={SCAN_LEVEL} triage={TRIAGE_LEVEL} " f"language={STATE.get('language')} commit={commit} " f"cap={GLOBAL_CAP} (heavy {heavy_cap()})") - if STATE.get("findings"): # printed on an assessment pass too — the set is still tracked + if STATE.get("findings"): # printed in every mode — the set stays tracked print(f"findings={STATE.get('findings')}") + if STATE.get("spec"): + print(f"spec={STATE.get('spec')}") runs = (load_yaml(TRACKING / "history.yaml", {}) or {}).get("runs") or [] if len(runs) > 1: print("passes: " + " -> ".join(str(r.get("type", "?")) for r in runs))