feat: add image --upload to push an SBOM for an image tag OD-710 - #50
Conversation
…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>
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 28 |
| Duplication | 7 |
AI Reviewer: first review requested successfully. AI can make mistakes. Always validate suggestions.
TIP This summary will be updated as you push new changes.
There was a problem hiding this comment.
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
Filewith 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/xmlmedia type. - Upload without
--tagfails 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.
-
--uploadcombined with--deleteis 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
|
|
||
| console.log( | ||
| ansis.dim( | ||
| `\nRun 'codacy image ${provider} ${organization} ${image} --tag ${opts.tag}' to see it.`, |
There was a problem hiding this comment.
🟡 MEDIUM RISK
Use sanitized values when constructing this follow-up command; do not interpolate raw image or opts.tag into terminal output.
There was a problem hiding this comment.
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
| ...(opts.environment ? { environment: opts.environment } : {}), | ||
| }); | ||
|
|
||
| spinner.succeed(`Uploaded ${path.basename(file)} for ${label}.`); |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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
| 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.`, | ||
| ), | ||
| ); | ||
| } |
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
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
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>
0ac1c31 to
01b24dc
Compare
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>
01b24dc to
e5d8b71
Compare
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>
e5d8b71 to
bc2b864
Compare
Code reviewReviewed 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: One note that is not a defect in this PR, but affects the feature it adds: 🤖 Generated by /code-review command |
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>
# Conflicts: # SPECS/README.md
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>
|
Agreed on the sequencing — the stack merges in order, #49 → #50 → #52 → #53, so It has been merged forward from #49, so it now carries the 🤖 Generated by /pr-fixup command |
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>
# Conflicts: # SPECS/README.md
The merge-base changed after approval.
# Conflicts: # README.md # SPECS/README.md # SPECS/commands/images.md # src/commands/AGENTS.md # src/commands/image.test.ts # src/commands/image.ts
The merge-base changed after approval.
PR 2 of the OD-710 stack.
images(list) andimage(list tags, show a tag,--deletescoped by--tag)--upload(uploadImageSbom)--delete --keep-latest <n>What's here
uploadImageSbomwas already in the generatedSbomService— nonpm run update-api.Decisions worth a look
--uploadis a verb,--tagis its scope — the same shape--deletealready uses, so no new flag vocabulary.--tagis required here: the API keys an upload on image and tag, with no untagged fallback. Refused by name before the file is read.File, not a bareBlob. ABlobgoes out asfilename="blob", which tells the server and anyone reading a request log nothing; aFilecarries the real name. The generated client'sisBlobaccepts both. Media type comes from the extension (.json,.xml, elseapplication/octet-stream— letting the API decide rather than guessing wrong in the request).--uploadand--deleteare 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/--repositoryare omitted when unset rather than sent as undefined form fields.uploadImageSbomisn'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 readsCODACY_PROJECT_TOKENthere), and the upload already names arepositoryName. Logged as a ranked gap inSPECS/missing-endpoints.mdfor the API owners to decide on — not assumed.Tests
9 new (
image.test.tsis now 25), 708 passing. Covers the happy path, theFilename/type/contents, optional passthroughs, XML inference, missing--tag, missing file, empty file, the--upload/--deleterefusal, 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