Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions soup-discovery/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ File| Holds| Reviewer
---|---|---
.soup-policy.yml| The parameters of WI-006-09-02, Parameters defined per project. Only what differs from the defaults.| QM, for the service-level and scope parameters
.soup-scope.yml| `include:` and `exclude:`, each entry an `id` and a `reason`. The run fails while a discovered candidate appears in neither.| Development lead
.soup-scope.yml, `built_image` on an include entry| The image a Dockerfile candidate actually produces, `${version}` substituted from the run. Without it the candidate is scanned from its FROM line, which is the *base* image: anything the Dockerfile adds afterwards is missing from the inventory and anything it deletes is still reported. Declared rather than derived — the reference lives in a compose file and a release workflow, and the naming between them is per-product. May be a single reference or a map of tier (`staging`, `candidate`, `branch`) to reference, for a product that pushes `<version>-staging` from one release workflow and `<version>` from another. A declaration that cannot be applied stops the run; a version the run does not know, or a tier the map does not name, makes the candidate a reported gap, never a silent fallback to the base image.| SOUP approver
.soup-decisions.yml| Risk acceptances, revised remediation dates and `vendor_requests` with their follow-up dates.| SOUP approver

## Configuration keys
Expand Down
24 changes: 20 additions & 4 deletions soup-discovery/examples/example.soup-scope.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,23 @@ include:
- id: values-image-cache
reason: Third-party image pinned in the deployment values and shipped as part of the stack.

# The final stage of a Dockerfile we build — this is what covers the components of our own image.
# The final stage of a Dockerfile we build. Discovery finds this candidate from the FROM line, so
# without `built_image` below the scan target is the *base image*, and everything the Dockerfile
# does afterwards is invisible: an `apk add` in the final stage is missing from the inventory, and
# a package the final stage deletes is still reported against it.
#
# `built_image` points the scan at the image this Dockerfile produces instead. `${version}` is the
# release version of the run. Declare it wherever the built image is pushed somewhere the run can
# pull it from; the action takes registry-username / registry-password for a private one.
#
# Where the reference differs per tier — a staging release pushing <version>-staging and a
# production one pushing <version> is the common case — key it by tier instead of writing one
# string. A tier the map does not name is reported as a gap, not scanned from its base image.
- id: dockerfiles-image-final
reason: Final stage of the service Dockerfile — the base of the image we ship.
reason: Final stage of the service Dockerfile — the image we ship.
built_image:
staging: ourregistry/our-service:${version}-staging
candidate: ourregistry/our-service:${version}

exclude:
# Same image as an included candidate, in a different artefact. Legitimate, and the reason is what
Expand All @@ -44,8 +58,10 @@ exclude:
# Dockerfile candidate that builds it.
- id: deployed-service--version-
reason: >-
Our own image at the release version. The tag is substituted at deploy time, so it cannot be
resolved here; its components are covered by dockerfiles-image-final, which builds it.
Our own image at the release version, referenced from the deployment manifest where the tag is
substituted at deploy time and cannot be resolved here. The same image is scanned as
dockerfiles-image-final, which declares a built_image — without that declaration this exclusion
would leave our own image uncovered, because the Dockerfile candidate scans only its base.

# Infrastructure, not shipped software. State which it is — "infrastructure" alone is not a decision.
- id: terraform-root
Expand Down
6 changes: 3 additions & 3 deletions soup-discovery/scripts/discover.sh
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ while IFS= read -r f; do
app_root="${f%%/android/*}"
[[ "$app_root" == "$f" ]] && app_root=$(dirname "$dir")
[[ -z "$app_root" || "$app_root" == "." ]] && app_root="app"
# Gradle dependency locking wins over the built artifact. Measured: syft reads ZERO
# Gradle dependency locking wins over the built artifact. syft reads ZERO
# components out of an AAB — dex bytecode carries no package metadata — so scanning the
# bundle would close the gap by claiming an empty inventory, which is worse than the gap.
# The lockfile is the resolved android closure. Enabling it is a build-config decision,
Expand Down Expand Up @@ -167,7 +167,7 @@ done <<<"$(grep -E '(^|/)pom\.xml$' <<<"$FILES" || true)"
# one. Before this, the iOS half of a mobile product was in no inventory and was not reported
# as missing either: the Android build is a candidate and can be recorded as a gap, while iOS
# was absent from the candidate list, so a document could say complete with a whole platform
# never looked at. On one real product that was 39 pods.
# never looked at.
#
# Keyed on the app root rather than the file's directory, the same as the Android candidate:
# Package.resolved sits several levels down inside Runner.xcworkspace, and an id built from
Expand Down Expand Up @@ -436,7 +436,7 @@ while IFS= read -r ref; do
# is the difference between a scope rule that can say what covers this and one that
# can only say "some templated thing in this file".
resolvable=false
note="OUR OWN IMAGE, RELEASE-VERSIONED — repository resolved from the chart values as ${img%%:*}, but the tag is the chart appVersion / --set version, so the concrete version comes from the deploy record. Its contents are covered by the Dockerfile candidate that builds it."
note="OUR OWN IMAGE, RELEASE-VERSIONED — repository resolved from the chart values as ${img%%:*}, but the tag is the chart appVersion / --set version, so the concrete version comes from the deploy record. The Dockerfile candidate that builds it covers its contents only where that candidate declares built_image; without one it scans the base image from its FROM line."
elif [[ "$img" == *'@unresolved'* || "$img" == *'{{'* || "$img" == *'<'*'>'* || "$img" == *'${'* || "$img" =~ ^[A-Z][A-Z0-9_]*$ ]]; then
resolvable=false
note="TEMPLATED REFERENCE — the concrete version is substituted at deploy time and is not knowable from the repo. Its components cannot be enumerated here; resolving it needs the deploy record."
Expand Down
82 changes: 81 additions & 1 deletion soup-discovery/scripts/resolve-scope.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,28 @@
# silently omitted from it. Both are invisible failures, and the second is the one that
# matters for CVE scope: WI-006-03 wants "a new SOUP appeared" to be a review event.
#
# An include entry may also carry `built_image:`, and that is the one thing here that changes
# *what* is scanned rather than *whether* it is scanned. A Dockerfile candidate is discovered
# from its FROM line, so the scan target is the base image — everything the Dockerfile does
# afterwards (apk add, COPY, rm -rf) is invisible: a package the final stage deletes is still
# reported against the product, and one it installs is missing from the inventory. Declaring the
# image the Dockerfile produces points the scan at the artefact that actually ships.
#
# Declared rather than derived. The reference lives in a compose file and a release workflow,
# and the naming between them is per-product convention — deriving it would be a guess, and a
# wrong guess here scans somebody else's image and calls it ours.
#
# Usage: resolve-scope.sh <candidates.json> [.soup-scope.yml]
# SOUP_VERSION=<tag> substituted into ${version} in a built_image reference
# SOUP_TIER=<tier> selects the entry when built_image is keyed by tier

set -uo pipefail

CANDIDATES="${1:-candidates.json}"
SCOPE_FILE="${2:-.soup-scope.yml}"
OUTPUT="${SCOPE_OUTPUT:-scan-plan.json}"
VERSION="${SOUP_VERSION:-}"
TIER="${SOUP_TIER:-}"

for tool in jq yq; do
command -v "$tool" >/dev/null 2>&1 || { echo "::error::$tool required" >&2; exit 1; }
Expand Down Expand Up @@ -45,6 +60,8 @@ SCOPE_JSON=$(yq -o=json '.' "$SCOPE_FILE")
PLAN=$(jq -n \
--argjson cand "$(jq -c '.candidates' "$CANDIDATES")" \
--argjson scope "$SCOPE_JSON" \
--arg version "$VERSION" \
--arg tier "$TIER" \
'
def rules(k): ($scope[k] // []);
def matches($c; $r):
Expand All @@ -63,6 +80,51 @@ PLAN=$(jq -n \
| map(select((.path // null) as $p | $p != null and ($c.markers | any(startswith($p)))))
| first);

# Redirect an included container candidate from its base image to the image the Dockerfile
# produces. Three outcomes, and none of them is "quietly keep scanning the base image":
# a misdeclaration is an error that stops the run, an unknowable version is a reported gap,
# and only a fully resolved reference actually redirects.
def apply_built($c; $inc):
($inc.built_image // null) as $decl
| (if ($decl | type) == "object" then ($decl[$tier] // null) else $decl end) as $bi
| if $decl == null then $c
elif ($c.scan_source | startswith("registry:") | not) then
$c + {built_image_error:
"built_image is only meaningful for a container candidate; this one scans \($c.scan_source)"}
# Keyed by tier, because a product does not push the same reference from every workflow:
# one repository tags <version>-staging from its staging release and <version> from its
# production one. A tier the declaration does not name is a gap for the same reason an
# unknown version is: the alternative is scanning the base image and saying nothing.
elif ($decl | type) == "object" and $bi == null then
$c + {resolvable: false,
note: ("built_image names no image for the "
+ (if $tier == "" then "unknown" else $tier end)
+ " tier, so the shipped image cannot be addressed — reported as a gap "
+ "rather than scanning the base image instead")}
elif ($bi | type) != "string" then
$c + {built_image_error:
"built_image must be an image reference or a map of tier to image reference"}
elif ($version == "" and ($bi | test("\\$\\{version\\}"))) then
# No version, no concrete image. Falling back to the base image would restore exactly
# the wrong answer this exists to remove, and it would do it silently.
$c + {resolvable: false,
note: ("built_image declared but this run knows no version, so the shipped image "
+ "cannot be addressed — reported as a gap rather than scanning the base image instead")}
else
($bi | gsub("\\$\\{version\\}"; $version)) as $resolved
| if ($resolved | test("\\$\\{")) then
$c + {built_image_error:
"built_image still carries an unsubstituted placeholder after resolution: \($resolved)"}
else
$c + {scan_source: ("registry:" + $resolved),
base_image: $c.scan_source,
built_image_declared: true,
note: ("scanning the image this Dockerfile produces (" + $resolved
+ ") rather than the base image from its FROM line"
+ (if ($c.note // "") == "" then "" else "; " + $c.note end))}
end
end;

def classify($c):
by_id($c; "include") as $inc_id
| by_id($c; "exclude") as $exc_id
Expand All @@ -75,7 +137,7 @@ PLAN=$(jq -n \
| if $inc != null and $exc != null then
$c + {decision:"conflict", reason:"matched both include and exclude at the same specificity"}
elif $inc != null then
$c + {decision:"include", reason:($inc.reason // "")}
apply_built($c + {decision:"include", reason:($inc.reason // "")}; $inc)
elif $exc != null then
$c + {decision:"exclude", reason:($exc.reason // "")}
else
Expand All @@ -94,6 +156,10 @@ PLAN=$(jq -n \
},
unclassified: ($all | map(select(.decision=="unclassified")) | map({id, ecosystem, markers, ships})),
conflicts: ($all | map(select(.decision=="conflict")) | map({id, markers})),
built_image_errors: ($all | map(select(.built_image_error != null))
| map({id, error: .built_image_error})),
redirected: ($all | map(select(.built_image_declared == true))
| map({id, base_image, scan_source})),
# Includes as well as excludes: WI-006-09: Introduce says every entry carries a reason, and an
# unexplained include is how test tooling ends up in the shipped inventory unnoticed.
missing_reason: ($all | map(select((.decision=="exclude" or .decision=="include")
Expand All @@ -120,6 +186,15 @@ if [[ "$(jq -r '.counts.conflict' "$OUTPUT")" != "0" ]]; then
STATUS=1
fi

# A built_image that cannot be applied is refused rather than ignored. Ignoring it would leave
# the candidate scanning its base image while the scope file states otherwise, which is worse
# than never having declared it: the document would carry a claim nobody checked.
if [[ "$(jq -r '.built_image_errors | length' "$OUTPUT")" != "0" ]]; then
echo "::error::built_image declarations that cannot be applied" >&2
jq -r '.built_image_errors[] | "::error:: \(.id): \(.error)"' "$OUTPUT" >&2
STATUS=1
fi

# An exclusion without a reason is the failure mode this whole file exists to prevent:
# it is indistinguishable from an oversight six months later.
if [[ "$(jq -r '.missing_reason | length' "$OUTPUT")" != "0" ]]; then
Expand All @@ -128,6 +203,11 @@ if [[ "$(jq -r '.missing_reason | length' "$OUTPUT")" != "0" ]]; then
STATUS=1
fi

# Named in the log, because a redirected scan target changes what the inventory means and
# should not be something only a diff of the BOM reveals.
jq -r '.redirected[]? | " scanning \(.id) as \(.scan_source | sub("^registry:";"")) (declared), not \(.base_image | sub("^registry:";""))"' \
"$OUTPUT" >&2

if [[ $STATUS -eq 0 ]]; then
echo "wrote $OUTPUT — every candidate has a recorded decision" >&2
fi
Expand Down
13 changes: 9 additions & 4 deletions soup-discovery/scripts/run-pipeline.sh
Original file line number Diff line number Diff line change
Expand Up @@ -63,22 +63,27 @@ DISCOVER_OUTPUT="$CAND" bash "$HERE/discover.sh" "$REPO" || die "discovery faile

# --- 2. scope ---------------------------------------------------------------
SCOPE_FILE="${SOUP_SCOPE_FILE:-$REPO/.soup-scope.yml}"
SCOPE_OUTPUT="$PLAN" bash "$HERE/resolve-scope.sh" "$CAND" "$SCOPE_FILE" \
# SOUP_VERSION feeds ${version} in a built_image declaration — the tag is the only part of a
# shipped image reference this pipeline can know, and it already has it.
SCOPE_OUTPUT="$PLAN" SOUP_VERSION="$VERSION" SOUP_TIER="${SBOM_TIER:-}" bash "$HERE/resolve-scope.sh" "$CAND" "$SCOPE_FILE" \
|| die "scope resolution failed — every candidate needs a recorded decision before a BOM can be trusted"

# --- 3. scan each in-scope target ------------------------------------------
BOMS=()
GAPS=()

while IFS=$'\t' read -r id source resolvable ecosystem markers; do
while IFS=$'\t' read -r id source resolvable ecosystem markers note; do
[[ -z "$id" ]] && continue
raw="$OUT_DIR/bom/$id.raw.json"
final="$OUT_DIR/bom/$id.cdx.json"
kind="${source%%:*}"
arg="${source#*:}"

if [[ "$resolvable" != "true" ]]; then
log " gap $id — reference is templated, concrete version not knowable from the repo"
# The candidate's own note: a templated deploy reference and a built_image on a run
# that knows no version are both unscannable for different reasons, and a gap naming
# the wrong one sends whoever reads it to the wrong file.
log " gap $id — ${note:-reference is templated, concrete version not knowable from the repo}"
GAPS+=("$id"); continue
fi

Expand Down Expand Up @@ -205,7 +210,7 @@ while IFS=$'\t' read -r id source resolvable ecosystem markers; do
|| log "::warning::$id: dependency graph could not be derived"
rm -f "$raw" "$native"
BOMS+=("$final")
done < <(jq -r '.scan[] | "\(.id)\t\(.scan_source)\t\(.resolvable)\t\(.ecosystem)\t\(.markers | join(","))"' "$PLAN")
done < <(jq -r '.scan[] | "\(.id)\t\(.scan_source)\t\(.resolvable)\t\(.ecosystem)\t\(.markers | join(","))\t\(.note // "" | gsub("\t"; " "))"' "$PLAN")

[[ ${#BOMS[@]} -gt 0 ]] || die "no target produced a BOM"

Expand Down
Loading