Skip to content

feat: add image --upload to push an SBOM for an image tag OD-710 - #50

Merged
claudiacodacy merged 15 commits into
mainfrom
feat/od-710-image-upload
Sep 22, 2026
Merged

claudiacodacy merged 15 commits into
mainfrom
feat/od-710-image-upload

Conversation

@claudiacodacy

Copy link
Copy Markdown
Contributor

PR 2 of the OD-710 stack.

Stacked on #49 — that is the base branch, so this diff shows only the upload work. Merge #49 first; this retargets to main automatically.

PR Scope State
1 images (list) and image (list tags, show a tag, --delete scoped by --tag) #49
2 --upload (uploadImageSbom) this one
3 bulk cleanup — --delete --keep-latest <n> blocked on the org-wide metrics wipe

What's here

codacy image gh my-org my-service --tag 1.2.3 --upload ./sbom.json
codacy image gh my-org my-service --tag 1.2.3 --upload ./sbom.json --environment production --repository my-repo

uploadImageSbom was already in the generated SbomService — no npm run update-api.

Decisions worth a look

  • --upload is a verb, --tag is its scope — the same shape --delete already uses, so no new flag vocabulary. --tag is required here: the API keys an upload on image and tag, with no untagged fallback. Refused by name before the file is read.
  • The file is validated locally. An unreadable path and an empty file both fail immediately with something actionable, instead of as a 400 from the other side of the network.
  • Sent as a File, not a bare Blob. A Blob goes out as filename="blob", which tells the server and anyone reading a request log nothing; a File carries the real name. The generated client's isBlob accepts both. Media type comes from the extension (.json, .xml, else application/octet-stream — letting the API decide rather than guessing wrong in the request).
  • --upload and --delete are refused together. Unlike --delete's two scopes, these are two verbs; asking for both says nothing coherent about what should happen to the SBOM. This is the one mutual-exclusion guard that earns its place.
  • --environment / --repository are omitted when unset rather than sent as undefined form fields.
  • Account token only — and that's the awkward part. uploadImageSbom isn't on the repository-token whitelist, but uploading an SBOM from a pipeline is exactly where a project token would be natural (the coverage reporter already reads CODACY_PROJECT_TOKEN there), and the upload already names a repositoryName. Logged as a ranked gap in SPECS/missing-endpoints.md for the API owners to decide on — not assumed.

Tests

9 new (image.test.ts is now 25), 708 passing. Covers the happy path, the File name/type/contents, optional passthroughs, XML inference, missing --tag, missing file, empty file, the --upload/--delete refusal, and JSON output. Docs updated: SPECS/commands/images.md, SPECS/README.md, SPECS/missing-endpoints.md, README.md, src/commands/AGENTS.md, changeset (minor).

🤖 Generated with Claude Code

claudiacodacy and others added 2 commits September 21, 2026 13:24
…r images OD-710

Adds the first of three stacked PRs for OD-710: listing and deleting.

`codacy images <provider> <org>` lists container images with SBOMs uploaded
to an organization, with a tag count per image — the number an org at the
1000-tag cap needs to see. `ImageSummary` carries no count, so it comes from
listImageTags' pagination.total with limit 1, fanned out at a bounded
concurrency of 8, degrading to a dim `-` per image on failure and opt-out-able
with -N/--no-tag-counts.

`codacy image <provider> <org> <image>` lists that image's tags and deletes
them: -t/--delete-tag <tag> for one, -D/--delete for the image and all its
SBOMs. Both confirm via the shared confirmAction (-y/--skip-confirmation for
CI; a non-TTY without it aborts) and print a notice first, because a single
SBOM delete currently zero-fills Container Scanning metrics for the whole
organization until the next nightly scan. That defect is also why bulk
cleanup (--keep-latest) is not here: it would fire the wipe once per tag.

Both commands are account-token only — no image operation is on the
repository-token whitelist — and go through sanitizeText() on every value
that arrived with the SBOM upload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… tag fan-out

Review feedback on OD-710.

`--delete-tag <tag>` is gone. `--delete` is now the only delete verb and
`--tag <tag>` scopes it, the same split `issues --ignore` makes with its
filters: the flag that narrows what is acted on is the flag that narrows what
is shown. `--delete` alone takes the image and every SBOM under it, `--tag X
--delete` takes one tag, and the mutual-exclusion guard two verbs needed is
gone with them. `--tag` without an action shows that one tag, paging the
listing and matching exactly (the tags endpoint has no per-tag filter) — the
shape `pull-request --issue <id>` already uses.

The `images` tag-count fan-out is gone too: the count belongs on
`ImageSummary`, so it is being added server-side instead of derived at one
extra request per image. `images` is one request per page again, and
`-N, --no-tag-counts` goes with it. Filed as a pending backend task. The
whole-image `--delete` keeps its own `limit: 1` count lookup, which only
feeds the confirmation prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claudiacodacy
claudiacodacy added this pull request to stack #51 September 21, 2026 12:46
@codacy-production

codacy-production Bot commented Sep 21, 2026

Copy link
Copy Markdown

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 28 complexity · 7 duplication

Metric Results
Complexity 28
Duplication 7

View in Codacy

AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.

Run reviewer

TIP This summary will be updated as you push new changes.

@codacy-production codacy-production Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

Codacy reports the PR as not up to standards. The upload success messages interpolate user-controlled values without sanitization, allowing terminal-control injection; this should be fixed before merging.

The repository-token authentication criterion is not directly covered by the provided tests, leaving a security-related behavior gap. The upload command also has elevated complexity and no independently verifiable coverage report.

About this PR

  • Add an automated test proving repository-token authentication is rejected before any upload request is made.
  • The image command and upload flow have accumulated multiple responsibilities, while production coverage cannot be independently verified from the report. Consider separating dispatch, file handling, and result rendering as follow-up maintainability work.
1 comment outside of the diff
src/commands/image.ts

line 92-159 🟡 MEDIUM RISK
Suggestion: Extract action selection and validation into focused helpers, keeping upload, delete, tag display, and list dispatch separate without changing CLI behavior.

Test suggestions

  • Successful upload sends the image, tag, and SBOM to uploadImageSbom.
  • Uploaded multipart content is a File with the original filename, JSON media type, and file contents.
  • Optional environment and repository values are passed through, while unset values are omitted.
  • XML files receive application/xml media type.
  • Upload without --tag fails before reading the file or calling the API.
  • Missing or unreadable SBOM files fail locally without calling the API.
  • Empty SBOM files fail locally without calling the API.
  • --upload combined with --delete is rejected without performing either action.
  • JSON output confirms the upload and includes optional metadata.
  • Repository-token authentication is rejected for image uploads before any request.
Prompt proposal for missing tests
Consider implementing these tests if applicable:
1. Repository-token authentication is rejected for image uploads before any request.

TIP How was this review? Give us feedback

Comment thread src/commands/image.ts Outdated

console.log(
ansis.dim(
`\nRun 'codacy image ${provider} ${organization} ${image} --tag ${opts.tag}' to see it.`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

Use sanitized values when constructing this follow-up command; do not interpolate raw image or opts.tag into terminal output.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — image and opts.tag are sanitized in the follow-up hint. The file already sanitized both everywhere else; this line and the success line above it were the exceptions.

🤖 Generated by /pr-fixup command

Comment thread src/commands/image.ts Outdated
...(opts.environment ? { environment: opts.environment } : {}),
});

spinner.succeed(`Uploaded ${path.basename(file)} for ${label}.`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

Sanitize the basename before including it in the success message, and sanitize every upload-derived value in follow-up command output to prevent terminal-control injection.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The basename is sanitized in the success line. Same reasoning as the image and tag beside it: all three are strings this process was handed rather than strings it chose.

🤖 Generated by /pr-fixup command

Comment thread src/commands/image.ts
Comment on lines +344 to +409
async function executeUpload(
provider: string,
organization: string,
image: string,
file: string,
opts: {
tag?: string;
environment?: string;
repositoryName?: string;
json: boolean;
},
): Promise<void> {
if (!opts.tag) {
throw new Error(
"--upload requires --tag <tag>: an SBOM is uploaded for one image tag.",
);
}

let contents: Buffer;
try {
contents = await fs.readFile(file);
} catch {
throw new Error(`Could not read SBOM file '${file}'.`);
}
if (contents.length === 0) {
throw new Error(`SBOM file '${file}' is empty.`);
}

const label = `${sanitizeText(image)}:${sanitizeText(opts.tag)}`;
const spinner = ora(`Uploading SBOM for ${label}...`).start();

// `File` rather than a bare `Blob` so the multipart part carries the real
// filename — a `Blob` is sent as `filename="blob"`, which tells the server
// (and anyone reading a request log) nothing. The generated client's
// `isBlob` accepts both.
const sbom = new File([contents], path.basename(file), {
type: sbomContentType(file),
});

await SbomService.uploadImageSbom(provider, organization, {
sbom,
imageName: image,
tag: opts.tag,
...(opts.repositoryName ? { repositoryName: opts.repositoryName } : {}),
...(opts.environment ? { environment: opts.environment } : {}),
});

spinner.succeed(`Uploaded ${path.basename(file)} for ${label}.`);

if (opts.json) {
printJson({
imageName: image,
tag: opts.tag,
...(opts.repositoryName ? { repositoryName: opts.repositoryName } : {}),
...(opts.environment ? { environment: opts.environment } : {}),
uploaded: true,
});
return;
}

console.log(
ansis.dim(
`\nRun 'codacy image ${provider} ${organization} ${image} --tag ${opts.tag}' to see it.`,
),
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

Suggestion: Extract SBOM file loading/File construction and upload-result rendering into focused helpers, preserving validation order, optional-field omission, spinner behavior, error messages, and tests.

See Issue in Codacy

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly. Reading and validating the SBOM file is a self-contained step with its own failure modes, so it comes out as readSbomFile — that is what took executeUpload back under the line limit, and validation order, spinner behaviour and error messages are unchanged. The result rendering stays inline: it is a printJson call and a console.log, and extracting those would add a hop without removing a decision.

🤖 Generated by /pr-fixup command

claudiacodacy and others added 2 commits September 21, 2026 22:59
Codacy flagged five complexity issues on this PR: the `images` action
callback (89 lines, CCN 17), `listTags` (66 lines, CCN 13) and `fetchTags`
(CCN 9). Each is the same shape — a page loop, a JSON projection and a table
build sharing one function — so each gets the same treatment `image.ts`
already used for `fetchTags`/`listTags`: the cursor loop and the table
rendering come out, the caller is left orchestrating.

Two sanitization gaps came out with them, both values that arrive from the
API rather than from the user's own command line:

- `showTag`'s "Delete this tag with --tag <tag>" hint interpolated
  `match.tag` raw, while the table two lines above sanitized every field.
- The metrics-wipe notice went to stdout, so `--output json` emitted prose
  ahead of the JSON document. It is a warning, so it belongs on stderr; a
  declined confirmation under `--output json` now reports itself as
  `{deleted: false, aborted: true}` rather than a prose line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claudiacodacy
claudiacodacy force-pushed the feat/od-710-image-upload branch from 0ac1c31 to 01b24dc Compare September 21, 2026 22:07
The first split left it at 55 lines against a limit of 50. The JSON projection
and the whole table-mode rendering come out as `projectImage` and
`printImages`, leaving `listImages` with the limit, the fetch and the choice
between the two modes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claudiacodacy
claudiacodacy force-pushed the feat/od-710-image-upload branch from 01b24dc to e5d8b71 Compare September 21, 2026 22:13
claudiacodacy and others added 3 commits September 21, 2026 23:17
Semgrep read `--tag <tag>` and `<image>` in help text as HTML with
interpolated variables, three times. And the reviewers flagged a missing
`sanitizeText()` on a value the user had typed on their own command line,
which is not the CWE-150 sink the API-supplied fields are — the real miss was
next to it, on a value that had come back from the API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR 2 of the OD-710 stack, on top of the listing/deleting commands.

`codacy image <provider> <org> <image> --tag <tag> --upload ./sbom.json`
uploads an SPDX or CycloneDX SBOM via the already-generated uploadImageSbom.
It fits the command's existing split — --upload is the verb, --tag is the
scope — and requires --tag, since the API keys an upload on image and tag
with no untagged fallback.

The file is validated locally first, so an unreadable path or an empty file
fails with something actionable instead of a remote 400. It goes out as a
File rather than a bare Blob so the multipart part carries the real filename
(a Blob is sent as filename="blob"), with the media type inferred from the
extension. Optional -e/--environment and -r/--repository map to the API's
environment/repositoryName and are omitted rather than sent as undefined.

--upload and --delete are refused together: unlike --delete's two scopes,
these are two verbs, and asking for both says nothing coherent.

Still account-token only. That is the awkward part — uploading from a
pipeline is exactly where a project token would be natural — so the
whitelist gap is logged in SPECS/missing-endpoints.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…choes

Codacy flagged `executeUpload` at 55 lines (limit 50). Reading and validating
the file is a self-contained step with its own failure modes, so it moves to
`readSbomFile` and the upload path is left with the request and its output.

The success line and the follow-up-command hint interpolated the filename,
image and tag raw. All three are strings this process was handed rather than
strings it chose, and the file already sanitizes image and tag everywhere
else — the exceptions were the two places they were echoed back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alerizzo

Copy link
Copy Markdown
Collaborator

Code review

Reviewed as part of the #49#50#52#53 stack, against the merged state. No merge-blocking issues found in this PR's own diff.

Checked and clear: --tag is required and refused before the file is read; --upload + --delete mutual exclusion; local validation of an unreadable and an empty file; the File construction carries the real filename and an extension-inferred media type (global File is available under the declared engines: node >=20); --environment / --repository are omitted rather than sent undefined; resolveAccountAuth runs before any request; image, tag and filename all pass through sanitizeText() before styling.

One note that is not a defect in this PR, but affects the feature it adds: --upload failures surface through the top-level handleError, so they only carry the API's real message once #53 lands. The tag-mismatch case in #53's description (SBOM tag mismatch: expected 9.9.9, found 3.20) is an upload error, so this PR's most common failure mode reads as Error: Bad Request until then. Worth keeping the stack merging in order rather than landing #50 on its own.

🤖 Generated by /code-review command

claudiacodacy and others added 4 commits September 22, 2026 11:00
confirmAction built its readline interface with `output: process.stdout`.
`process.stdin.isTTY` is true whenever stdin is a terminal — including when
stdout is a pipe — so `image ... --delete --output json | jq` sent the
question and the echoed keystroke into jq, which failed on them. The code
comment beside the abort branch asserted the opposite.

Fixed in utils/prompt.ts rather than per command: a confirmation is
interaction, not program output, so it belongs on the stream the spinners
already use, and no command has to thread its output format down into the
prompt. Every caller benefits (image --delete, issues --ignore,
tools --import); interactive runs are unchanged, both streams reaching the
same terminal. A non-TTY stdin still declines outright.

Adds utils/prompt.test.ts — the helper had none, every caller having
mocked it — pinning the stream, the y/N parsing and the non-TTY refusal.

Also replaces two images.test.ts assertions that could not fail:

- "renders a dim dash for missing values" asserted only that the image
  name appeared, so it passed with the orDash fallback deleted or
  printing the literal "undefined". It now counts the row's three dashes.
- "caps --limit at 1000" asserted a page size of 100, which is
  Math.min(limit, PAGE_SIZE) for any limit >= 100 and never exercised
  MAX_LIMIT. It now pages a cursor that never ends and asserts the loop
  stops after 10 requests, with the mock bounded at 20 so a broken clamp
  fails in milliseconds rather than hanging.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
npm run check-types runs tsc --noEmit over the tests too. The readline
mock's ReadLineOptions and SbomService's CancelablePromise return type
both need an explicit cast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@claudiacodacy

Copy link
Copy Markdown
Contributor Author

Agreed on the sequencing — the stack merges in order, #49#50#52#53, so --upload never ships without the error-message fix behind it. No changes to this PR's own diff.

It has been merged forward from #49, so it now carries the confirmAction stderr fix and picks up the rest of the stack in order.

🤖 Generated by /pr-fixup command

alerizzo
alerizzo previously approved these changes Sep 22, 2026
claudiacodacy and others added 2 commits September 22, 2026 11:30
It ran as long as the feature entry it follows up on, against a 311-char
median for the table. The reasoning is already in the commit message, the
PR reply and the code comments; the changelog needs the what and the
why-it-mattered. Test count back to this branch's own measured total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
joanasteodoro
joanasteodoro previously approved these changes Sep 22, 2026
Base automatically changed from feat/od-710-images-commands to main September 22, 2026 10:40
@claudiacodacy
claudiacodacy dismissed joanasteodoro’s stale review September 22, 2026 10:40

The merge-base changed after approval.

joanasteodoro
joanasteodoro previously approved these changes Sep 22, 2026
# Conflicts:
#	README.md
#	SPECS/README.md
#	SPECS/commands/images.md
#	src/commands/AGENTS.md
#	src/commands/image.test.ts
#	src/commands/image.ts
@claudiacodacy
claudiacodacy dismissed joanasteodoro’s stale review September 22, 2026 10:42

The merge-base changed after approval.

@claudiacodacy
claudiacodacy merged commit 4dfa3dd into main Sep 22, 2026
4 checks passed
@claudiacodacy
claudiacodacy deleted the feat/od-710-image-upload branch September 22, 2026 10:45
@github-actions github-actions Bot mentioned this pull request Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants