diff --git a/Cargo.lock b/Cargo.lock
index 82c3c8c8..42a939cc 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3998,6 +3998,7 @@ dependencies = [
"inquire",
"log",
"memmap2",
+ "nom",
"num_cpus",
"once_cell",
"parking_lot",
@@ -4284,6 +4285,7 @@ dependencies = [
"libc",
"mio 1.0.4",
"pin-project-lite",
+ "signal-hook-registry",
"socket2 0.6.0",
"tokio-macros",
"windows-sys 0.61.2",
diff --git a/Cargo.toml b/Cargo.toml
index f62f6785..a79f3f03 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ version = "0.16.0"
edition = "2024"
authors = ["Syncable Team"]
description = "A Rust-based CLI that analyzes code repositories and generates Infrastructure as Code configurations"
-license = "MIT OR Apache-2.0"
+license = "GPL-3.0"
repository = "https://github.com/syncable-dev/syncable-cli"
keywords = ["iac", "infrastructure", "docker", "terraform", "cli"]
categories = ["command-line-utilities", "development-tools"]
@@ -48,7 +48,7 @@ term_size = "0.3"
# Vulnerability checking dependencies
rustsec = "0.30"
reqwest = { version = "0.12", features = ["json", "blocking"] }
-tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "sync"] }
+tokio = { version = "1", features = ["rt", "macros", "rt-multi-thread", "sync", "process", "io-util"] }
textwrap = "0.16"
tempfile = "3"
dirs = "6"
@@ -77,6 +77,9 @@ rig-core = { version = "0.26", features = ["derive"] }
# Diff rendering for file confirmation UI
similar = "2.6"
+# Dockerfile linting (hadolint-rs)
+nom = "7" # Parser combinators for Dockerfile parsing
+
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
diff --git a/LICENSE b/LICENSE
index ab13a8b0..30ace6a8 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,674 @@
-MIT License
-
-Copyright (c) 2024 Syncable Team
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
\ No newline at end of file
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ {one line to give the program's name and a brief idea of what it does.}
+ Copyright (C) {year} {name of author}
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ {project} Copyright (C) {year} {fullname}
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
\ No newline at end of file
diff --git a/README.md b/README.md
index 9779dd5f..ecec30c2 100644
--- a/README.md
+++ b/README.md
@@ -11,7 +11,7 @@
-
+
@@ -241,7 +241,16 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines.
## 📄 License
-MIT License — see [LICENSE](LICENSE) for details.
+This project is licensed under the **GNU General Public License v3.0** (GPL-3.0).
+
+See [LICENSE](LICENSE) for the full license text.
+
+### Third-Party Attributions
+
+The Dockerfile linting functionality (`src/analyzer/hadolint/`) is a Rust translation
+of [Hadolint](https://github.com/hadolint/hadolint), originally written in Haskell by
+Lukas Martinelli and contributors. See [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)
+for full attribution details.
---
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 00000000..bdb1f680
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,75 @@
+# Third Party Notices
+
+This file contains attributions and license information for third-party software
+incorporated into Syncable-CLI.
+
+---
+
+## Hadolint
+
+The Dockerfile linting functionality in `src/analyzer/hadolint/` is a Rust
+translation of the original Hadolint project.
+
+**Original Project:** [Hadolint](https://github.com/hadolint/hadolint)
+
+**Original Authors:**
+- Lukas Martinelli (lukasmartinelli)
+- Lorenzo Bolla (lbolla)
+- And all contributors to the Hadolint project
+
+**Original License:** GNU General Public License v3.0 (GPL-3.0)
+
+**Original Copyright:**
+```
+Copyright (c) 2016-2024 Lukas Martinelli and contributors
+```
+
+**What was translated:**
+- Dockerfile parsing logic (originally in Haskell)
+- Lint rule definitions (DL3xxx, DL4xxx series)
+- Pragma/ignore directive handling
+- Configuration file format
+- Rule severity and messaging
+
+**Modifications made:**
+- Complete rewrite from Haskell to Rust
+- Integration with Syncable-CLI's agent and tool system
+- Native async support for streaming output
+- Adaptation to Rust error handling patterns
+- Additional rules and improvements specific to Syncable's use cases
+
+**License Notice:**
+This derivative work is licensed under GPL-3.0, as required by the original
+Hadolint license. See the LICENSE file in the root of this repository.
+
+The full text of the GPL-3.0 license can be found at:
+https://www.gnu.org/licenses/gpl-3.0.en.html
+
+---
+
+## ShellCheck (Rule Concepts)
+
+Some shell-related lint rules are inspired by ShellCheck.
+
+**Original Project:** [ShellCheck](https://github.com/koalaman/shellcheck)
+
+**Original Author:** Vidar Holen (koalaman)
+
+**Original License:** GNU General Public License v3.0 (GPL-3.0)
+
+**Note:** Syncable-CLI does not include ShellCheck code directly. The shell
+analysis rules are original implementations inspired by ShellCheck's rule
+concepts and documentation.
+
+---
+
+## Acknowledgments
+
+We are grateful to the open source community and the authors of Hadolint for
+creating and maintaining excellent Dockerfile linting tools. This translation
+to Rust allows native integration with Syncable-CLI while preserving the
+valuable rule definitions and linting logic developed by the original authors.
+
+If you are the author of any software mentioned here and believe the attribution
+is incorrect or incomplete, please open an issue at:
+https://github.com/syncable-dev/syncable-cli/issues
diff --git a/src/agent/mod.rs b/src/agent/mod.rs
index c9c8992f..724624bf 100644
--- a/src/agent/mod.rs
+++ b/src/agent/mod.rs
@@ -99,8 +99,12 @@ pub type AgentResult = Result;
/// Get the system prompt for the agent based on query type
fn get_system_prompt(project_path: &Path, query: Option<&str>) -> String {
- // If query suggests generation (Docker, Terraform, Helm), use DevOps prompt
if let Some(q) = query {
+ // First check if it's a code development task (highest priority)
+ if prompts::is_code_development_query(q) {
+ return prompts::get_code_development_prompt(project_path);
+ }
+ // Then check if it's DevOps generation (Docker, Terraform, Helm)
if prompts::is_generation_query(q) {
return prompts::get_devops_prompt(project_path);
}
@@ -264,6 +268,7 @@ pub async fn run_interactive(
.tool(AnalyzeTool::new(project_path_buf.clone()))
.tool(SecurityScanTool::new(project_path_buf.clone()))
.tool(VulnerabilitiesTool::new(project_path_buf.clone()))
+ .tool(HadolintTool::new(project_path_buf.clone()))
.tool(ReadFileTool::new(project_path_buf.clone()))
.tool(ListDirectoryTool::new(project_path_buf.clone()));
@@ -312,6 +317,7 @@ pub async fn run_interactive(
.tool(AnalyzeTool::new(project_path_buf.clone()))
.tool(SecurityScanTool::new(project_path_buf.clone()))
.tool(VulnerabilitiesTool::new(project_path_buf.clone()))
+ .tool(HadolintTool::new(project_path_buf.clone()))
.tool(ReadFileTool::new(project_path_buf.clone()))
.tool(ListDirectoryTool::new(project_path_buf.clone()));
@@ -777,6 +783,7 @@ pub async fn run_query(
.tool(AnalyzeTool::new(project_path_buf.clone()))
.tool(SecurityScanTool::new(project_path_buf.clone()))
.tool(VulnerabilitiesTool::new(project_path_buf.clone()))
+ .tool(HadolintTool::new(project_path_buf.clone()))
.tool(ReadFileTool::new(project_path_buf.clone()))
.tool(ListDirectoryTool::new(project_path_buf.clone()));
@@ -811,6 +818,7 @@ pub async fn run_query(
.tool(AnalyzeTool::new(project_path_buf.clone()))
.tool(SecurityScanTool::new(project_path_buf.clone()))
.tool(VulnerabilitiesTool::new(project_path_buf.clone()))
+ .tool(HadolintTool::new(project_path_buf.clone()))
.tool(ReadFileTool::new(project_path_buf.clone()))
.tool(ListDirectoryTool::new(project_path_buf.clone()));
diff --git a/src/agent/prompts/mod.rs b/src/agent/prompts/mod.rs
index 3ad07bbd..90d8eddc 100644
--- a/src/agent/prompts/mod.rs
+++ b/src/agent/prompts/mod.rs
@@ -23,8 +23,9 @@ You have access to tools to help analyze and understand the project:
1. **analyze_project** - Analyze the project to detect languages, frameworks, dependencies, and architecture
2. **security_scan** - Perform security analysis to find potential vulnerabilities and secrets
3. **check_vulnerabilities** - Check dependencies for known security vulnerabilities
-4. **read_file** - Read the contents of a file in the project
-5. **list_directory** - List files and directories in a path
+4. **hadolint** - Lint Dockerfiles for best practices (use this instead of shell hadolint)
+5. **read_file** - Read the contents of a file in the project
+6. **list_directory** - List files and directories in a path
## Guidelines
- Use the available tools to gather information before answering questions about the project
@@ -35,6 +36,77 @@ You have access to tools to help analyze and understand the project:
)
}
+/// Get the code development prompt for implementing features, translating code, etc.
+pub fn get_code_development_prompt(project_path: &std::path::Path) -> String {
+ format!(
+ r#"You are an expert software engineer helping to develop, implement, and improve code in this project.
+
+## Project Context
+You are working with a project located at: {}
+
+## Your Capabilities
+You have access to the following tools:
+
+### Analysis Tools
+1. **analyze_project** - Analyze the project structure, languages, and dependencies
+2. **read_file** - Read file contents
+3. **list_directory** - List files and directories
+
+### Development Tools
+4. **write_file** - Write or update a single file
+5. **write_files** - Write multiple files at once
+6. **shell** - Run shell commands (build, test, lint)
+
+## CRITICAL RULES - READ CAREFULLY
+
+### Rule 1: DO NOT RE-READ FILES
+- Once you read a file, DO NOT read it again in the same conversation
+- Keep track of what you've read - the content is in your context
+- If you need to reference a file you already read, use your memory
+
+### Rule 2: BIAS TOWARDS ACTION
+- After reading 3-5 key files, START WRITING CODE
+- Don't endlessly analyze - make progress by writing
+- It's better to write code and iterate than to analyze forever
+- If unsure, write a minimal first version and improve it
+
+### Rule 3: WRITE IN CHUNKS
+- For large implementations, write one file at a time
+- Don't try to write everything in one response
+- Complete one module, test it, then move to the next
+
+### Rule 4: PLAN BRIEFLY, EXECUTE QUICKLY
+- State your plan in 2-3 sentences
+- Then immediately start executing
+- Don't write long planning documents before coding
+
+## Work Protocol
+
+1. **Quick Analysis** (1-3 tool calls max):
+ - Read the most relevant existing files
+ - Understand the project structure
+
+2. **Plan** (2-3 sentences):
+ - Briefly state what you'll create
+ - Identify the files you'll write
+
+3. **Implement** (start writing immediately):
+ - Create the files using write_file or write_files
+ - Write real, working code - not pseudocode
+
+4. **Validate**:
+ - Run build/test commands with shell
+ - Fix any errors
+
+## Code Quality Standards
+- Follow the existing code style in the project
+- Add appropriate error handling
+- Include basic documentation/comments for complex logic
+- Write idiomatic code for the language being used"#,
+ project_path.display()
+ )
+}
+
/// Get the DevOps generation prompt (Docker, Terraform, Helm, K8s)
pub fn get_devops_prompt(project_path: &std::path::Path) -> String {
format!(
@@ -50,15 +122,16 @@ You have access to the following tools:
1. **analyze_project** - Analyze the project to detect languages, frameworks, dependencies, build commands, and architecture
2. **security_scan** - Perform security analysis to find potential vulnerabilities
3. **check_vulnerabilities** - Check dependencies for known security vulnerabilities
-4. **read_file** - Read the contents of a file in the project
-5. **list_directory** - List files and directories in a path
+4. **hadolint** - Native Dockerfile linter (use this instead of shell hadolint command)
+5. **read_file** - Read the contents of a file in the project
+6. **list_directory** - List files and directories in a path
### Generation Tools
-6. **write_file** - Write a single file (Dockerfile, terraform config, helm values, etc.)
-7. **write_files** - Write multiple files at once (Terraform modules, Helm charts)
+7. **write_file** - Write a single file (Dockerfile, terraform config, helm values, etc.)
+8. **write_files** - Write multiple files at once (Terraform modules, Helm charts)
### Validation Tools
-8. **shell** - Execute validation commands (docker build, terraform validate, helm lint, hadolint, etc.)
+9. **shell** - Execute validation commands (docker build, terraform validate, helm lint, etc.)
## Production-Ready Standards
@@ -97,12 +170,20 @@ You have access to the following tools:
1. **Analyze First**: Always use `analyze_project` to understand the project before generating anything
2. **Plan**: Think through what files need to be created
3. **Generate**: Use `write_file` or `write_files` to create the artifacts
-4. **Validate**: Use `shell` to validate with appropriate tools:
- - Docker: `hadolint Dockerfile && docker build -t test .`
- - Terraform: `terraform init && terraform validate`
- - Helm: `helm lint ./chart`
+4. **Validate**: Use appropriate validation tools:
+ - Docker: Use `hadolint` tool (native, no shell needed), then `shell` for `docker build -t test .`
+ - Terraform: `shell` for `terraform init && terraform validate`
+ - Helm: `shell` for `helm lint ./chart`
5. **Self-Correct**: If validation fails, read the error, fix the files, and re-validate
+**IMPORTANT**: For Dockerfile linting, ALWAYS use the native `hadolint` tool, NOT `shell hadolint`. The native tool is faster and doesn't require the hadolint binary to be installed.
+
+**CRITICAL**: If `hadolint` finds ANY errors or warnings:
+1. STOP and report ALL the issues to the user FIRST
+2. DO NOT proceed to `docker build` until the user acknowledges the issues
+3. Show each violation with its line number, rule code, and message
+4. Ask if the user wants you to fix the issues before building
+
## Error Handling
- If any validation command fails, analyze the error output
- Use `write_file` to fix the artifacts
@@ -179,7 +260,39 @@ pub fn is_generation_query(query: &str) -> bool {
"terraform", "helm", "kubernetes", "k8s",
"manifest", "chart", "module", "infrastructure",
"containerize", "containerise", "deploy", "ci/cd", "pipeline",
+ // Code development keywords
+ "implement", "translate", "port", "convert", "refactor",
+ "add feature", "new feature", "develop", "code",
];
generation_keywords.iter().any(|kw| query_lower.contains(kw))
}
+
+/// Detect if a query is specifically about code development (not DevOps)
+pub fn is_code_development_query(query: &str) -> bool {
+ let query_lower = query.to_lowercase();
+
+ // DevOps-specific terms - if these appear, it's DevOps not code dev
+ let devops_keywords = [
+ "dockerfile", "docker-compose", "docker compose",
+ "terraform", "helm", "kubernetes", "k8s",
+ "manifest", "chart", "infrastructure",
+ "containerize", "containerise", "deploy", "ci/cd", "pipeline",
+ ];
+
+ // If it's clearly DevOps, return false
+ if devops_keywords.iter().any(|kw| query_lower.contains(kw)) {
+ return false;
+ }
+
+ // Code development keywords
+ let code_keywords = [
+ "implement", "translate", "port", "convert", "refactor",
+ "add feature", "new feature", "develop", "module", "library",
+ "crate", "function", "class", "struct", "trait",
+ "rust", "python", "javascript", "typescript", "haskell",
+ "code", "rewrite", "build a", "create a",
+ ];
+
+ code_keywords.iter().any(|kw| query_lower.contains(kw))
+}
diff --git a/src/agent/tools/hadolint.rs b/src/agent/tools/hadolint.rs
new file mode 100644
index 00000000..a187419d
--- /dev/null
+++ b/src/agent/tools/hadolint.rs
@@ -0,0 +1,579 @@
+//! Hadolint tool - Native Dockerfile linting using Rig's Tool trait
+//!
+//! Provides native Dockerfile linting without requiring the external hadolint binary.
+//! Implements hadolint rules with full pragma support.
+//!
+//! Output is optimized for AI agent decision-making with:
+//! - Categorized issues (security, best-practice, maintainability, performance)
+//! - Priority rankings (critical, high, medium, low)
+//! - Actionable fix recommendations
+//! - Rule documentation links
+
+use rig::completion::ToolDefinition;
+use rig::tool::Tool;
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use std::path::PathBuf;
+
+use crate::analyzer::hadolint::{lint, lint_file, HadolintConfig, LintResult, Severity};
+
+/// Arguments for the hadolint tool
+#[derive(Debug, Deserialize)]
+pub struct HadolintArgs {
+ /// Path to Dockerfile (relative to project root) or inline content
+ #[serde(default)]
+ pub dockerfile: Option,
+
+ /// Inline Dockerfile content to lint (alternative to path)
+ #[serde(default)]
+ pub content: Option,
+
+ /// Rules to ignore (e.g., ["DL3008", "DL3013"])
+ #[serde(default)]
+ pub ignore: Vec,
+
+ /// Minimum severity threshold: "error", "warning", "info", "style"
+ #[serde(default)]
+ pub threshold: Option,
+}
+
+/// Error type for hadolint tool
+#[derive(Debug, thiserror::Error)]
+#[error("Hadolint error: {0}")]
+pub struct HadolintError(String);
+
+/// Tool to lint Dockerfiles natively
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct HadolintTool {
+ project_path: PathBuf,
+}
+
+impl HadolintTool {
+ pub fn new(project_path: PathBuf) -> Self {
+ Self { project_path }
+ }
+
+ fn parse_threshold(threshold: &str) -> Severity {
+ match threshold.to_lowercase().as_str() {
+ "error" => Severity::Error,
+ "warning" => Severity::Warning,
+ "info" => Severity::Info,
+ "style" => Severity::Style,
+ _ => Severity::Warning, // Default
+ }
+ }
+
+ /// Get the category for a rule code
+ fn get_rule_category(code: &str) -> &'static str {
+ match code {
+ // Security rules
+ "DL3000" | "DL3002" | "DL3004" | "DL3047" => "security",
+ // Best practice rules
+ "DL3003" | "DL3006" | "DL3007" | "DL3008" | "DL3009" | "DL3013" |
+ "DL3014" | "DL3015" | "DL3016" | "DL3018" | "DL3019" | "DL3020" |
+ "DL3025" | "DL3027" | "DL3028" | "DL3033" | "DL3042" | "DL3059" => "best-practice",
+ // Maintainability rules
+ "DL3005" | "DL3010" | "DL3021" | "DL3022" | "DL3023" | "DL3024" |
+ "DL3026" | "DL3029" | "DL3030" | "DL3032" | "DL3034" | "DL3035" |
+ "DL3036" | "DL3044" | "DL3045" | "DL3048" | "DL3049" | "DL3050" |
+ "DL3051" | "DL3052" | "DL3053" | "DL3054" | "DL3055" | "DL3056" |
+ "DL3057" | "DL3058" | "DL3060" | "DL3061" => "maintainability",
+ // Performance rules
+ "DL3001" | "DL3011" | "DL3017" | "DL3031" | "DL3037" | "DL3038" |
+ "DL3039" | "DL3040" | "DL3041" | "DL3046" | "DL3062" => "performance",
+ // Deprecated instructions
+ "DL4000" | "DL4001" | "DL4003" | "DL4005" | "DL4006" => "deprecated",
+ // ShellCheck rules
+ _ if code.starts_with("SC") => "shell",
+ _ => "other",
+ }
+ }
+
+ /// Get priority based on severity and category
+ fn get_priority(severity: Severity, category: &str) -> &'static str {
+ match (severity, category) {
+ (Severity::Error, "security") => "critical",
+ (Severity::Error, _) => "high",
+ (Severity::Warning, "security") => "high",
+ (Severity::Warning, "best-practice") => "medium",
+ (Severity::Warning, _) => "medium",
+ (Severity::Info, _) => "low",
+ (Severity::Style, _) => "low",
+ (Severity::Ignore, _) => "info",
+ }
+ }
+
+ /// Get actionable fix recommendation for a rule
+ fn get_fix_recommendation(code: &str) -> &'static str {
+ match code {
+ "DL3000" => "Use absolute WORKDIR paths like '/app' instead of relative paths.",
+ "DL3001" => "Remove commands that have no effect in Docker (like 'ssh', 'mount').",
+ "DL3002" => "Remove the last USER instruction setting root, or add 'USER ' at the end.",
+ "DL3003" => "Use WORKDIR to change directories instead of 'cd' in RUN commands.",
+ "DL3004" => "Remove 'sudo' from RUN commands. Docker runs as root by default, or use proper USER switching.",
+ "DL3005" => "Remove 'apt-get upgrade' or 'dist-upgrade'. Pin packages instead for reproducibility.",
+ "DL3006" => "Add explicit version tag to base image, e.g., 'FROM node:18-alpine' instead of 'FROM node'.",
+ "DL3007" => "Use specific version tag instead of ':latest', e.g., 'nginx:1.25-alpine'.",
+ "DL3008" => "Pin apt package versions: 'apt-get install package=version' or use '--no-install-recommends'.",
+ "DL3009" => "Add 'rm -rf /var/lib/apt/lists/*' after apt-get install to reduce image size.",
+ "DL3010" => "Use ADD only for extracting archives. For other files, use COPY.",
+ "DL3011" => "Use valid port numbers (0-65535) in EXPOSE.",
+ "DL3013" => "Pin pip package versions: 'pip install package==version'.",
+ "DL3014" => "Add '-y' flag to apt-get install for non-interactive mode.",
+ "DL3015" => "Add '--no-install-recommends' to apt-get install to minimize image size.",
+ "DL3016" => "Pin npm package versions: 'npm install package@version'.",
+ "DL3017" => "Remove 'apt-get upgrade'. Pin specific package versions instead.",
+ "DL3018" => "Pin apk package versions: 'apk add package=version'.",
+ "DL3019" => "Add '--no-cache' to apk add instead of separate cache cleanup.",
+ "DL3020" => "Use COPY instead of ADD for files from build context. ADD is for URLs and archives.",
+ "DL3021" => "Use COPY with --from for multi-stage builds instead of COPY from external images.",
+ "DL3022" => "Use COPY --from=stage instead of --from=image for multi-stage builds.",
+ "DL3023" => "Reference build stage by name instead of number in COPY --from.",
+ "DL3024" => "Use lowercase for 'as' in multi-stage builds: 'FROM image AS builder'.",
+ "DL3025" => "Use JSON array format for CMD/ENTRYPOINT: CMD [\"executable\", \"arg1\"].",
+ "DL3026" => "Use official Docker images when possible, or document why unofficial is needed.",
+ "DL3027" => "Remove 'apt' and use 'apt-get' for scripting in Dockerfiles.",
+ "DL3028" => "Pin gem versions: 'gem install package:version'.",
+ "DL3029" => "Specify --platform explicitly for multi-arch builds.",
+ "DL3030" => "Pin yum/dnf package versions: 'yum install package-version'.",
+ "DL3032" => "Replace 'yum clean all' with 'dnf clean all' for newer distros.",
+ "DL3033" => "Add 'yum clean all' after yum install to reduce image size.",
+ "DL3034" => "Add '--setopt=install_weak_deps=False' to dnf install.",
+ "DL3035" => "Add 'dnf clean all' after dnf install to reduce image size.",
+ "DL3036" => "Pin zypper package versions: 'zypper install package=version'.",
+ "DL3037" => "Add 'zypper clean' after zypper install.",
+ "DL3038" => "Add '--no-recommends' to zypper install.",
+ "DL3039" => "Add 'zypper clean' after zypper install.",
+ "DL3040" => "Add 'dnf clean all && rm -rf /var/cache/dnf' after dnf install.",
+ "DL3041" => "Add 'microdnf clean all' after microdnf install.",
+ "DL3042" => "Avoid pip cache in builds. Use '--no-cache-dir' or set PIP_NO_CACHE_DIR=1.",
+ "DL3044" => "Only use 'HEALTHCHECK' once per Dockerfile, or it won't work correctly.",
+ "DL3045" => "Use COPY instead of ADD for local files.",
+ "DL3046" => "Use 'useradd' instead of 'adduser' for better compatibility.",
+ "DL3047" => "Add 'wget --progress=dot:giga' or 'curl --progress-bar' to show progress during download.",
+ "DL3048" => "Prefer setting flag with 'SHELL' instruction instead of inline in RUN.",
+ "DL3049" => "Add a 'LABEL maintainer=\"name\"' for documentation.",
+ "DL3050" => "Add 'LABEL version=\"x.y\"' for versioning.",
+ "DL3051" => "Add 'LABEL description=\"...\"' for documentation.",
+ "DL3052" => "Prefer relative paths with LABEL for better portability.",
+ "DL3053" => "Remove unused LABEL instructions.",
+ "DL3054" => "Use recommended labels from OCI spec (org.opencontainers.image.*).",
+ "DL3055" => "Add 'LABEL org.opencontainers.image.created' with ISO 8601 date.",
+ "DL3056" => "Add 'LABEL org.opencontainers.image.description'.",
+ "DL3057" => "Add a HEALTHCHECK instruction for container health monitoring.",
+ "DL3058" => "Add 'LABEL org.opencontainers.image.title'.",
+ "DL3059" => "Combine consecutive RUN instructions with '&&' to reduce layers.",
+ "DL3060" => "Pin package versions in yarn add: 'yarn add package@version'.",
+ "DL3061" => "Use specific image digest or tag instead of implicit latest.",
+ "DL3062" => "Prefer single RUN with '&&' over multiple RUN for related commands.",
+ "DL4000" => "Replace MAINTAINER with 'LABEL maintainer=\"name \"'.",
+ "DL4001" => "Use wget or curl instead of ADD for downloading from URLs.",
+ "DL4003" => "Use 'ENTRYPOINT' and 'CMD' together properly for container startup.",
+ "DL4005" => "Prefer JSON notation for SHELL: SHELL [\"/bin/bash\", \"-c\"].",
+ "DL4006" => "Add 'SHELL [\"/bin/bash\", \"-o\", \"pipefail\", \"-c\"]' before RUN with pipes.",
+ _ if code.starts_with("SC") => "See ShellCheck wiki for shell scripting fix.",
+ _ => "Review the rule documentation for specific guidance.",
+ }
+ }
+
+ /// Get documentation URL for a rule
+ fn get_rule_url(code: &str) -> String {
+ if code.starts_with("DL") || code.starts_with("SC") {
+ if code.starts_with("SC") {
+ format!("https://www.shellcheck.net/wiki/{}", code)
+ } else {
+ format!("https://github.com/hadolint/hadolint/wiki/{}", code)
+ }
+ } else {
+ String::new()
+ }
+ }
+
+ /// Format result optimized for agent decision-making
+ fn format_result(result: &LintResult, filename: &str) -> String {
+ // Categorize and enrich failures
+ let enriched_failures: Vec = result.failures.iter().map(|f| {
+ let code = f.code.as_str();
+ let category = Self::get_rule_category(code);
+ let priority = Self::get_priority(f.severity, category);
+
+ json!({
+ "code": code,
+ "severity": format!("{:?}", f.severity).to_lowercase(),
+ "priority": priority,
+ "category": category,
+ "message": f.message,
+ "line": f.line,
+ "column": f.column,
+ "fix": Self::get_fix_recommendation(code),
+ "docs": Self::get_rule_url(code),
+ })
+ }).collect();
+
+ // Group by priority for agent decision ordering
+ let critical: Vec<_> = enriched_failures.iter()
+ .filter(|f| f["priority"] == "critical")
+ .cloned().collect();
+ let high: Vec<_> = enriched_failures.iter()
+ .filter(|f| f["priority"] == "high")
+ .cloned().collect();
+ let medium: Vec<_> = enriched_failures.iter()
+ .filter(|f| f["priority"] == "medium")
+ .cloned().collect();
+ let low: Vec<_> = enriched_failures.iter()
+ .filter(|f| f["priority"] == "low")
+ .cloned().collect();
+
+ // Group by category for thematic fixes
+ let mut by_category: std::collections::HashMap<&str, Vec<_>> = std::collections::HashMap::new();
+ for f in &enriched_failures {
+ let cat = f["category"].as_str().unwrap_or("other");
+ by_category.entry(cat).or_default().push(f.clone());
+ }
+
+ // Build decision context
+ let decision_context = if critical.is_empty() && high.is_empty() {
+ if medium.is_empty() && low.is_empty() {
+ "Dockerfile follows best practices. No issues found."
+ } else if medium.is_empty() {
+ "Minor improvements possible. Low priority issues only."
+ } else {
+ "Good baseline. Medium priority improvements recommended."
+ }
+ } else if !critical.is_empty() {
+ "Critical issues found. Address security/error issues first before deployment."
+ } else {
+ "High priority issues found. Review and fix before production use."
+ };
+
+ // Build agent-optimized output
+ let mut output = json!({
+ "file": filename,
+ "success": !result.has_errors(),
+ "decision_context": decision_context,
+ "summary": {
+ "total": result.failures.len(),
+ "by_priority": {
+ "critical": critical.len(),
+ "high": high.len(),
+ "medium": medium.len(),
+ "low": low.len(),
+ },
+ "by_severity": {
+ "errors": result.failures.iter().filter(|f| f.severity == Severity::Error).count(),
+ "warnings": result.failures.iter().filter(|f| f.severity == Severity::Warning).count(),
+ "info": result.failures.iter().filter(|f| f.severity == Severity::Info).count(),
+ },
+ "by_category": by_category.iter().map(|(k, v)| (k.to_string(), v.len())).collect::>(),
+ },
+ "action_plan": {
+ "critical": critical,
+ "high": high,
+ "medium": medium,
+ "low": low,
+ },
+ });
+
+ // Add quick fixes summary for agent
+ if !enriched_failures.is_empty() {
+ let quick_fixes: Vec = enriched_failures.iter()
+ .filter(|f| f["priority"] == "critical" || f["priority"] == "high")
+ .take(5)
+ .map(|f| format!("Line {}: {} - {}",
+ f["line"],
+ f["code"].as_str().unwrap_or(""),
+ f["fix"].as_str().unwrap_or("")
+ ))
+ .collect();
+
+ if !quick_fixes.is_empty() {
+ output["quick_fixes"] = json!(quick_fixes);
+ }
+ }
+
+ if !result.parse_errors.is_empty() {
+ output["parse_errors"] = json!(result.parse_errors);
+ }
+
+ serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
+ }
+}
+
+impl Tool for HadolintTool {
+ const NAME: &'static str = "hadolint";
+
+ type Error = HadolintError;
+ type Args = HadolintArgs;
+ type Output = String;
+
+ async fn definition(&self, _prompt: String) -> ToolDefinition {
+ ToolDefinition {
+ name: Self::NAME.to_string(),
+ description: "Lint Dockerfiles for best practices, security issues, and common mistakes. \
+ Returns AI-optimized JSON with issues categorized by priority (critical/high/medium/low) \
+ and type (security/best-practice/maintainability/performance/deprecated). \
+ Each issue includes an actionable fix recommendation. Use this to analyze Dockerfiles \
+ before deployment or to improve existing ones. The 'decision_context' field provides \
+ a summary for quick assessment, and 'quick_fixes' lists the most important changes."
+ .to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "dockerfile": {
+ "type": "string",
+ "description": "Path to Dockerfile relative to project root (e.g., 'Dockerfile', 'docker/Dockerfile.prod')"
+ },
+ "content": {
+ "type": "string",
+ "description": "Inline Dockerfile content to lint. Use this when you want to validate generated Dockerfile content before writing."
+ },
+ "ignore": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "List of rule codes to ignore (e.g., ['DL3008', 'DL3013'])"
+ },
+ "threshold": {
+ "type": "string",
+ "enum": ["error", "warning", "info", "style"],
+ "description": "Minimum severity to report. Default is 'warning'."
+ }
+ }
+ }),
+ }
+ }
+
+ async fn call(&self, args: Self::Args) -> Result {
+ // Build configuration
+ let mut config = HadolintConfig::default();
+
+ // Apply ignored rules
+ for rule in &args.ignore {
+ config = config.ignore(rule.as_str());
+ }
+
+ // Apply threshold
+ if let Some(threshold) = &args.threshold {
+ config = config.with_threshold(Self::parse_threshold(threshold));
+ }
+
+ // Determine source, filename, and lint
+ let (result, filename) = if let Some(content) = &args.content {
+ // Lint inline content
+ (lint(content, &config), "".to_string())
+ } else if let Some(dockerfile) = &args.dockerfile {
+ // Lint file
+ let path = self.project_path.join(dockerfile);
+ (lint_file(&path, &config), dockerfile.clone())
+ } else {
+ // Default: look for Dockerfile in project root
+ let path = self.project_path.join("Dockerfile");
+ if path.exists() {
+ (lint_file(&path, &config), "Dockerfile".to_string())
+ } else {
+ return Err(HadolintError(
+ "No Dockerfile specified and no Dockerfile found in project root".to_string(),
+ ));
+ }
+ };
+
+ // Check for parse errors
+ if !result.parse_errors.is_empty() {
+ log::warn!("Dockerfile parse errors: {:?}", result.parse_errors);
+ }
+
+ Ok(Self::format_result(&result, &filename))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::env::temp_dir;
+ use std::fs;
+
+ /// Helper to collect all issues from action_plan
+ fn collect_all_issues(parsed: &serde_json::Value) -> Vec {
+ let mut all = Vec::new();
+ for priority in ["critical", "high", "medium", "low"] {
+ if let Some(arr) = parsed["action_plan"][priority].as_array() {
+ all.extend(arr.clone());
+ }
+ }
+ all
+ }
+
+ #[tokio::test]
+ async fn test_hadolint_inline_content() {
+ let tool = HadolintTool::new(temp_dir());
+ let args = HadolintArgs {
+ dockerfile: None,
+ content: Some("FROM ubuntu:latest\nRUN sudo apt-get update".to_string()),
+ ignore: vec![],
+ threshold: None,
+ };
+
+ let result = tool.call(args).await.unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
+
+ // Should detect DL3007 (latest tag) and DL3004 (sudo)
+ assert!(!parsed["success"].as_bool().unwrap_or(true));
+ assert!(parsed["summary"]["total"].as_u64().unwrap_or(0) >= 2);
+
+ // Check new fields exist
+ assert!(parsed["decision_context"].is_string());
+ assert!(parsed["action_plan"].is_object());
+
+ // Check issues have fix recommendations
+ let issues = collect_all_issues(&parsed);
+ assert!(issues.iter().all(|i| i["fix"].is_string() && !i["fix"].as_str().unwrap().is_empty()));
+ }
+
+ #[tokio::test]
+ async fn test_hadolint_ignore_rules() {
+ let tool = HadolintTool::new(temp_dir());
+ let args = HadolintArgs {
+ dockerfile: None,
+ content: Some("FROM ubuntu:latest".to_string()),
+ ignore: vec!["DL3007".to_string()],
+ threshold: None,
+ };
+
+ let result = tool.call(args).await.unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
+
+ // DL3007 should be ignored
+ let all_issues = collect_all_issues(&parsed);
+ assert!(!all_issues.iter().any(|f| f["code"] == "DL3007"));
+ }
+
+ #[tokio::test]
+ async fn test_hadolint_threshold() {
+ let tool = HadolintTool::new(temp_dir());
+ let args = HadolintArgs {
+ dockerfile: None,
+ content: Some("FROM ubuntu\nMAINTAINER test".to_string()),
+ ignore: vec![],
+ threshold: Some("error".to_string()),
+ };
+
+ let result = tool.call(args).await.unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
+
+ // DL4000 (MAINTAINER deprecated) is Error, DL3006 (untagged) is Warning
+ // With error threshold, only errors should show
+ let all_issues = collect_all_issues(&parsed);
+ assert!(all_issues.iter().all(|f| f["severity"] == "error"));
+ }
+
+ #[tokio::test]
+ async fn test_hadolint_file() {
+ let temp = temp_dir().join("hadolint_test");
+ fs::create_dir_all(&temp).unwrap();
+ let dockerfile = temp.join("Dockerfile");
+ fs::write(&dockerfile, "FROM node:18-alpine\nWORKDIR /app\nCOPY . .\nCMD [\"node\", \"app.js\"]").unwrap();
+
+ let tool = HadolintTool::new(temp.clone());
+ let args = HadolintArgs {
+ dockerfile: Some("Dockerfile".to_string()),
+ content: None,
+ ignore: vec![],
+ threshold: None,
+ };
+
+ let result = tool.call(args).await.unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
+
+ // This is a well-formed Dockerfile, should have few/no errors
+ assert!(parsed["success"].as_bool().unwrap_or(false));
+ assert_eq!(parsed["file"], "Dockerfile");
+
+ // Cleanup
+ fs::remove_dir_all(&temp).ok();
+ }
+
+ #[tokio::test]
+ async fn test_hadolint_valid_dockerfile() {
+ let tool = HadolintTool::new(temp_dir());
+ let dockerfile = r#"
+FROM node:18-alpine AS builder
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci --only=production
+COPY . .
+RUN npm run build
+
+FROM node:18-alpine
+WORKDIR /app
+COPY --from=builder /app/dist ./dist
+USER node
+EXPOSE 3000
+CMD ["node", "dist/index.js"]
+"#;
+
+ let args = HadolintArgs {
+ dockerfile: None,
+ content: Some(dockerfile.to_string()),
+ ignore: vec![],
+ threshold: None,
+ };
+
+ let result = tool.call(args).await.unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
+
+ // Well-structured Dockerfile should pass (no errors)
+ assert!(parsed["success"].as_bool().unwrap_or(false));
+ // Should have decision context
+ assert!(parsed["decision_context"].is_string());
+ // Should not have critical or high priority issues
+ assert_eq!(parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(99), 0);
+ assert_eq!(parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(99), 0);
+ }
+
+ #[tokio::test]
+ async fn test_hadolint_priority_categorization() {
+ let tool = HadolintTool::new(temp_dir());
+ let args = HadolintArgs {
+ dockerfile: None,
+ content: Some("FROM ubuntu\nRUN sudo apt-get update\nMAINTAINER test".to_string()),
+ ignore: vec![],
+ threshold: None,
+ };
+
+ let result = tool.call(args).await.unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
+
+ // Check priority counts are present
+ assert!(parsed["summary"]["by_priority"]["critical"].is_number());
+ assert!(parsed["summary"]["by_priority"]["high"].is_number());
+ assert!(parsed["summary"]["by_priority"]["medium"].is_number());
+
+ // Check category counts
+ assert!(parsed["summary"]["by_category"].is_object());
+
+ // DL3004 (sudo) should be high priority security
+ let all_issues = collect_all_issues(&parsed);
+ let sudo_issue = all_issues.iter().find(|i| i["code"] == "DL3004");
+ assert!(sudo_issue.is_some());
+ assert_eq!(sudo_issue.unwrap()["category"], "security");
+ }
+
+ #[tokio::test]
+ async fn test_hadolint_quick_fixes() {
+ let tool = HadolintTool::new(temp_dir());
+ let args = HadolintArgs {
+ dockerfile: None,
+ content: Some("FROM ubuntu\nRUN sudo rm -rf /".to_string()),
+ ignore: vec![],
+ threshold: None,
+ };
+
+ let result = tool.call(args).await.unwrap();
+ let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
+
+ // Should have quick_fixes for high priority issues
+ if parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(0) > 0
+ || parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(0) > 0 {
+ assert!(parsed["quick_fixes"].is_array());
+ }
+ }
+}
diff --git a/src/agent/tools/mod.rs b/src/agent/tools/mod.rs
index 1945bae1..96a9b7a3 100644
--- a/src/agent/tools/mod.rs
+++ b/src/agent/tools/mod.rs
@@ -17,15 +17,20 @@
//! - `SecurityScanTool` - Security vulnerability scanning
//! - `VulnerabilitiesTool` - Dependency vulnerability checking
//!
+//! ### Linting
+//! - `HadolintTool` - Native Dockerfile linting (best practices, security)
+//!
//! ### Shell
//! - `ShellTool` - Execute validation commands (docker build, terraform validate, helm lint)
mod analyze;
mod file_ops;
+mod hadolint;
mod security;
mod shell;
pub use analyze::AnalyzeTool;
pub use file_ops::{ListDirectoryTool, ReadFileTool, WriteFileTool, WriteFilesTool};
+pub use hadolint::HadolintTool;
pub use security::{SecurityScanTool, VulnerabilitiesTool};
pub use shell::ShellTool;
diff --git a/src/agent/tools/shell.rs b/src/agent/tools/shell.rs
index 6f7cb1df..9ce46e9c 100644
--- a/src/agent/tools/shell.rs
+++ b/src/agent/tools/shell.rs
@@ -14,10 +14,11 @@ use rig::completion::ToolDefinition;
use rig::tool::Tool;
use serde::Deserialize;
use serde_json::json;
-use std::io::{BufRead, BufReader};
use std::path::PathBuf;
-use std::process::{Command, Stdio};
use std::sync::Arc;
+use tokio::io::{AsyncBufReadExt, BufReader};
+use tokio::process::Command;
+use tokio::sync::mpsc;
/// Allowed command prefixes for security
const ALLOWED_COMMANDS: &[&str] = &[
@@ -237,50 +238,92 @@ Use this to validate generated configurations:
let mut stream_display = StreamingShellOutput::new(&args.command, timeout_secs);
stream_display.render();
- // Execute command with streaming output
+ // Execute command with async streaming output
let mut child = Command::new("sh")
.arg("-c")
.arg(&args.command)
.current_dir(&working_dir)
- .stdout(Stdio::piped())
- .stderr(Stdio::piped())
+ .stdout(std::process::Stdio::piped())
+ .stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| ShellError(format!("Failed to spawn command: {}", e)))?;
- // Read stdout and stderr in parallel, streaming output
+ // Take ownership of stdout/stderr for async reading
let stdout = child.stdout.take();
let stderr = child.stderr.take();
+ // Channel for streaming output lines from both stdout and stderr
+ let (tx, mut rx) = mpsc::channel::<(String, bool)>(100); // (line, is_stderr)
+
+ // Spawn task to read stdout
+ let tx_stdout = tx.clone();
+ let stdout_handle = if let Some(stdout) = stdout {
+ Some(tokio::spawn(async move {
+ let mut reader = BufReader::new(stdout).lines();
+ let mut content = String::new();
+ while let Ok(Some(line)) = reader.next_line().await {
+ content.push_str(&line);
+ content.push('\n');
+ let _ = tx_stdout.send((line, false)).await;
+ }
+ content
+ }))
+ } else {
+ None
+ };
+
+ // Spawn task to read stderr
+ let tx_stderr = tx;
+ let stderr_handle = if let Some(stderr) = stderr {
+ Some(tokio::spawn(async move {
+ let mut reader = BufReader::new(stderr).lines();
+ let mut content = String::new();
+ while let Ok(Some(line)) = reader.next_line().await {
+ content.push_str(&line);
+ content.push('\n');
+ let _ = tx_stderr.send((line, true)).await;
+ }
+ content
+ }))
+ } else {
+ None
+ };
+
+ // Process incoming lines and update display in real-time on the main task
+ // Use tokio::select! to handle both the receiver and the reader completion
let mut stdout_content = String::new();
let mut stderr_content = String::new();
- // Read stdout
- if let Some(stdout) = stdout {
- let reader = BufReader::new(stdout);
- for line in reader.lines() {
- if let Ok(line) = line {
- stdout_content.push_str(&line);
- stdout_content.push('\n');
- stream_display.push_line(&line);
+ // Wait for readers while processing display updates
+ loop {
+ tokio::select! {
+ // Receive lines from either stdout or stderr
+ line_result = rx.recv() => {
+ match line_result {
+ Some((line, _is_stderr)) => {
+ stream_display.push_line(&line);
+ }
+ None => {
+ // Channel closed, all readers done
+ break;
+ }
+ }
}
}
}
- // Read stderr
- if let Some(stderr) = stderr {
- let reader = BufReader::new(stderr);
- for line in reader.lines() {
- if let Ok(line) = line {
- stderr_content.push_str(&line);
- stderr_content.push('\n');
- stream_display.push_line(&line);
- }
- }
+ // Collect final content from reader handles
+ if let Some(handle) = stdout_handle {
+ stdout_content = handle.await.unwrap_or_default();
+ }
+ if let Some(handle) = stderr_handle {
+ stderr_content = handle.await.unwrap_or_default();
}
// Wait for command to complete
let status = child
.wait()
+ .await
.map_err(|e| ShellError(format!("Command execution failed: {}", e)))?;
// Finalize display
diff --git a/src/agent/ui/colors.rs b/src/agent/ui/colors.rs
index 55f73d9e..216d4bd6 100644
--- a/src/agent/ui/colors.rs
+++ b/src/agent/ui/colors.rs
@@ -23,6 +23,13 @@ pub mod icons {
pub const FOLDER: &str = "📁";
pub const SECURITY: &str = "🔒";
pub const SEARCH: &str = "🔍";
+ pub const DOCKER: &str = "🐳";
+ pub const LINT: &str = "📋";
+ pub const FIX: &str = "🔧";
+ pub const CRITICAL: &str = "🔴";
+ pub const HIGH: &str = "🟠";
+ pub const MEDIUM: &str = "🟡";
+ pub const LOW: &str = "🟢";
}
/// ANSI escape codes for direct terminal control
@@ -51,6 +58,15 @@ pub mod ansi {
pub const GRAY: &str = "\x1b[38;5;245m";
pub const WHITE: &str = "\x1b[38;5;255m";
pub const SUCCESS: &str = "\x1b[38;5;114m"; // Green for success
+
+ // Hadolint/Docker specific colors (teal/docker-blue theme)
+ pub const DOCKER_BLUE: &str = "\x1b[38;5;39m"; // Docker brand blue
+ pub const TEAL: &str = "\x1b[38;5;30m"; // Teal for hadolint
+ pub const CRITICAL: &str = "\x1b[38;5;196m"; // Bright red
+ pub const HIGH: &str = "\x1b[38;5;208m"; // Orange
+ pub const MEDIUM: &str = "\x1b[38;5;220m"; // Yellow
+ pub const LOW: &str = "\x1b[38;5;114m"; // Green
+ pub const INFO_BLUE: &str = "\x1b[38;5;75m"; // Light blue for info
}
/// Format a tool name for display
diff --git a/src/agent/ui/hadolint_display.rs b/src/agent/ui/hadolint_display.rs
new file mode 100644
index 00000000..2dbd2168
--- /dev/null
+++ b/src/agent/ui/hadolint_display.rs
@@ -0,0 +1,324 @@
+//! Hadolint result display for terminal output
+//!
+//! Provides colored, formatted output for Dockerfile lint results
+//! that's visually distinct and easy to recognize.
+
+use crate::agent::ui::colors::{ansi, icons};
+use std::io::{self, Write};
+
+/// Display hadolint results in a formatted, colored terminal output
+pub struct HadolintDisplay;
+
+impl HadolintDisplay {
+ /// Format and print hadolint results from the JSON output
+ pub fn print_result(json_result: &str) {
+ if let Ok(parsed) = serde_json::from_str::(json_result) {
+ Self::print_formatted(&parsed);
+ } else {
+ // Fallback: just print the raw result
+ println!("{}", json_result);
+ }
+ }
+
+ /// Print formatted hadolint output
+ fn print_formatted(result: &serde_json::Value) {
+ let stdout = io::stdout();
+ let mut handle = stdout.lock();
+
+ // Header with Docker icon and file name
+ let file = result["file"].as_str().unwrap_or("Dockerfile");
+ let _ = writeln!(
+ handle,
+ "\n{}{}━━━ {} Hadolint: {} ━━━{}",
+ ansi::DOCKER_BLUE,
+ ansi::BOLD,
+ icons::DOCKER,
+ file,
+ ansi::RESET
+ );
+
+ // Decision context
+ if let Some(context) = result["decision_context"].as_str() {
+ let context_color = if context.contains("Critical") {
+ ansi::CRITICAL
+ } else if context.contains("High") {
+ ansi::HIGH
+ } else if context.contains("Medium") || context.contains("improvements") {
+ ansi::MEDIUM
+ } else {
+ ansi::LOW
+ };
+ let _ = writeln!(
+ handle,
+ "{} {} {}{}",
+ context_color,
+ icons::ARROW,
+ context,
+ ansi::RESET
+ );
+ }
+
+ // Summary counts
+ if let Some(summary) = result.get("summary") {
+ let total = summary["total"].as_u64().unwrap_or(0);
+ if total == 0 {
+ let _ = writeln!(
+ handle,
+ "\n{} {} No issues found!{}",
+ ansi::SUCCESS,
+ icons::SUCCESS,
+ ansi::RESET
+ );
+ } else {
+ let _ = writeln!(handle);
+
+ // Priority breakdown
+ if let Some(by_priority) = summary.get("by_priority") {
+ let critical = by_priority["critical"].as_u64().unwrap_or(0);
+ let high = by_priority["high"].as_u64().unwrap_or(0);
+ let medium = by_priority["medium"].as_u64().unwrap_or(0);
+ let low = by_priority["low"].as_u64().unwrap_or(0);
+
+ let _ = write!(handle, " ");
+ if critical > 0 {
+ let _ = write!(
+ handle,
+ "{}{} {} critical{} ",
+ ansi::CRITICAL,
+ icons::CRITICAL,
+ critical,
+ ansi::RESET
+ );
+ }
+ if high > 0 {
+ let _ = write!(
+ handle,
+ "{}{} {} high{} ",
+ ansi::HIGH,
+ icons::HIGH,
+ high,
+ ansi::RESET
+ );
+ }
+ if medium > 0 {
+ let _ = write!(
+ handle,
+ "{}{} {} medium{} ",
+ ansi::MEDIUM,
+ icons::MEDIUM,
+ medium,
+ ansi::RESET
+ );
+ }
+ if low > 0 {
+ let _ = write!(
+ handle,
+ "{}{} {} low{}",
+ ansi::LOW,
+ icons::LOW,
+ low,
+ ansi::RESET
+ );
+ }
+ let _ = writeln!(handle);
+ }
+ }
+ }
+
+ // Quick fixes (most important)
+ if let Some(quick_fixes) = result.get("quick_fixes").and_then(|f| f.as_array()) {
+ if !quick_fixes.is_empty() {
+ let _ = writeln!(
+ handle,
+ "\n{}{} Quick Fixes:{}",
+ ansi::DOCKER_BLUE,
+ icons::FIX,
+ ansi::RESET
+ );
+ for fix in quick_fixes.iter().take(5) {
+ if let Some(fix_str) = fix.as_str() {
+ let _ = writeln!(
+ handle,
+ "{} {} {}{}",
+ ansi::INFO_BLUE,
+ icons::ARROW,
+ fix_str,
+ ansi::RESET
+ );
+ }
+ }
+ }
+ }
+
+ // Critical and High priority issues with details
+ Self::print_priority_section(&mut handle, result, "critical", "Critical Issues", ansi::CRITICAL);
+ Self::print_priority_section(&mut handle, result, "high", "High Priority", ansi::HIGH);
+
+ // Optionally show medium (collapsed)
+ if let Some(medium_issues) = result["action_plan"]["medium"].as_array() {
+ if !medium_issues.is_empty() {
+ let _ = writeln!(
+ handle,
+ "\n{} {} {} medium priority issue{} (run with --verbose to see all){}",
+ ansi::MEDIUM,
+ icons::MEDIUM,
+ medium_issues.len(),
+ if medium_issues.len() == 1 { "" } else { "s" },
+ ansi::RESET
+ );
+ }
+ }
+
+ // Footer separator
+ let _ = writeln!(
+ handle,
+ "{}{}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{}",
+ ansi::DOCKER_BLUE,
+ ansi::DIM,
+ ansi::RESET
+ );
+
+ let _ = handle.flush();
+ }
+
+ /// Print a section for a priority level
+ fn print_priority_section(
+ handle: &mut io::StdoutLock,
+ result: &serde_json::Value,
+ priority: &str,
+ title: &str,
+ color: &str,
+ ) {
+ if let Some(issues) = result["action_plan"][priority].as_array() {
+ if issues.is_empty() {
+ return;
+ }
+
+ let _ = writeln!(handle, "\n{} {}:{}", color, title, ansi::RESET);
+
+ for issue in issues.iter().take(10) {
+ let code = issue["code"].as_str().unwrap_or("???");
+ let line = issue["line"].as_u64().unwrap_or(0);
+ let message = issue["message"].as_str().unwrap_or("");
+ let category = issue["category"].as_str().unwrap_or("");
+
+ // Category badge
+ let category_badge = match category {
+ "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
+ "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
+ "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
+ "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
+ "maintainability" => format!("{}[MAINT]{}", ansi::GRAY, ansi::RESET),
+ _ => String::new(),
+ };
+
+ let _ = writeln!(
+ handle,
+ " {}{}:{}{} {}{}{} {} {}",
+ ansi::DIM,
+ line,
+ ansi::RESET,
+ ansi::DOCKER_BLUE,
+ code,
+ ansi::RESET,
+ category_badge,
+ ansi::GRAY,
+ message,
+ );
+
+ // Show fix recommendation
+ if let Some(fix) = issue["fix"].as_str() {
+ let _ = writeln!(
+ handle,
+ " {}→ {}{}",
+ ansi::INFO_BLUE,
+ fix,
+ ansi::RESET
+ );
+ }
+ }
+
+ if issues.len() > 10 {
+ let _ = writeln!(
+ handle,
+ " {}... and {} more{}",
+ ansi::DIM,
+ issues.len() - 10,
+ ansi::RESET
+ );
+ }
+ }
+ }
+
+ /// Format a compact single-line summary for tool call display
+ pub fn format_summary(json_result: &str) -> String {
+ if let Ok(parsed) = serde_json::from_str::(json_result) {
+ let success = parsed["success"].as_bool().unwrap_or(false);
+ let total = parsed["summary"]["total"].as_u64().unwrap_or(0);
+
+ if success && total == 0 {
+ format!(
+ "{}{} {} Dockerfile OK - no issues{}",
+ ansi::SUCCESS,
+ icons::SUCCESS,
+ icons::DOCKER,
+ ansi::RESET
+ )
+ } else {
+ let critical = parsed["summary"]["by_priority"]["critical"].as_u64().unwrap_or(0);
+ let high = parsed["summary"]["by_priority"]["high"].as_u64().unwrap_or(0);
+
+ if critical > 0 {
+ format!(
+ "{}{} {} {} critical, {} high priority issues{}",
+ ansi::CRITICAL,
+ icons::ERROR,
+ icons::DOCKER,
+ critical,
+ high,
+ ansi::RESET
+ )
+ } else if high > 0 {
+ format!(
+ "{}{} {} {} high priority issues{}",
+ ansi::HIGH,
+ icons::WARNING,
+ icons::DOCKER,
+ high,
+ ansi::RESET
+ )
+ } else {
+ format!(
+ "{}{} {} {} issues (medium/low){}",
+ ansi::MEDIUM,
+ icons::WARNING,
+ icons::DOCKER,
+ total,
+ ansi::RESET
+ )
+ }
+ }
+ } else {
+ format!("{} Hadolint analysis complete", icons::DOCKER)
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_format_summary_success() {
+ let json = r#"{"success": true, "summary": {"total": 0, "by_priority": {"critical": 0, "high": 0, "medium": 0, "low": 0}}}"#;
+ let summary = HadolintDisplay::format_summary(json);
+ assert!(summary.contains("OK"));
+ }
+
+ #[test]
+ fn test_format_summary_critical() {
+ let json = r#"{"success": false, "summary": {"total": 3, "by_priority": {"critical": 1, "high": 2, "medium": 0, "low": 0}}}"#;
+ let summary = HadolintDisplay::format_summary(json);
+ assert!(summary.contains("critical"));
+ }
+}
diff --git a/src/agent/ui/hooks.rs b/src/agent/ui/hooks.rs
index bb11857e..3e24b220 100644
--- a/src/agent/ui/hooks.rs
+++ b/src/agent/ui/hooks.rs
@@ -421,6 +421,7 @@ fn print_tool_result(name: &str, args: &str, result: &str) -> (bool, Vec
"list_directory" => format_list_result(&parsed),
"analyze_project" => format_analyze_result(&parsed),
"security_scan" | "check_vulnerabilities" => format_security_result(&parsed),
+ "hadolint" => format_hadolint_result(&parsed),
_ => (true, vec!["done".to_string()]),
};
@@ -711,6 +712,177 @@ fn format_security_result(parsed: &Result)
}
}
+/// Format hadolint result - uses new priority-based format with Docker styling
+fn format_hadolint_result(parsed: &Result) -> (bool, Vec) {
+ if let Ok(v) = parsed {
+ let success = v.get("success").and_then(|s| s.as_bool()).unwrap_or(true);
+ let summary = v.get("summary");
+ let action_plan = v.get("action_plan");
+
+ let mut lines = Vec::new();
+
+ // Get total count
+ let total = summary
+ .and_then(|s| s.get("total"))
+ .and_then(|t| t.as_u64())
+ .unwrap_or(0);
+
+ // Show docker-themed header
+ if total == 0 {
+ lines.push(format!(
+ "{}🐳 Dockerfile OK - no issues found{}",
+ ansi::SUCCESS, ansi::RESET
+ ));
+ return (true, lines);
+ }
+
+ // Get priority counts
+ let critical = summary
+ .and_then(|s| s.get("by_priority"))
+ .and_then(|p| p.get("critical"))
+ .and_then(|c| c.as_u64())
+ .unwrap_or(0);
+ let high = summary
+ .and_then(|s| s.get("by_priority"))
+ .and_then(|p| p.get("high"))
+ .and_then(|h| h.as_u64())
+ .unwrap_or(0);
+ let medium = summary
+ .and_then(|s| s.get("by_priority"))
+ .and_then(|p| p.get("medium"))
+ .and_then(|m| m.as_u64())
+ .unwrap_or(0);
+ let low = summary
+ .and_then(|s| s.get("by_priority"))
+ .and_then(|p| p.get("low"))
+ .and_then(|l| l.as_u64())
+ .unwrap_or(0);
+
+ // Summary with priority breakdown
+ let mut priority_parts = Vec::new();
+ if critical > 0 {
+ priority_parts.push(format!("{}🔴 {} critical{}", ansi::CRITICAL, critical, ansi::RESET));
+ }
+ if high > 0 {
+ priority_parts.push(format!("{}🟠 {} high{}", ansi::HIGH, high, ansi::RESET));
+ }
+ if medium > 0 {
+ priority_parts.push(format!("{}🟡 {} medium{}", ansi::MEDIUM, medium, ansi::RESET));
+ }
+ if low > 0 {
+ priority_parts.push(format!("{}🟢 {} low{}", ansi::LOW, low, ansi::RESET));
+ }
+
+ let header_color = if critical > 0 {
+ ansi::CRITICAL
+ } else if high > 0 {
+ ansi::HIGH
+ } else {
+ ansi::DOCKER_BLUE
+ };
+
+ lines.push(format!(
+ "{}🐳 {} issue{} found: {}{}",
+ header_color,
+ total,
+ if total == 1 { "" } else { "s" },
+ priority_parts.join(" "),
+ ansi::RESET
+ ));
+
+ // Show critical and high priority issues (these are most important)
+ let mut shown = 0;
+ const MAX_PREVIEW: usize = 6;
+
+ // Critical issues first
+ if let Some(critical_issues) = action_plan
+ .and_then(|a| a.get("critical"))
+ .and_then(|c| c.as_array())
+ {
+ for issue in critical_issues.iter().take(MAX_PREVIEW - shown) {
+ lines.push(format_hadolint_issue(issue, "🔴", ansi::CRITICAL));
+ shown += 1;
+ }
+ }
+
+ // Then high priority
+ if shown < MAX_PREVIEW {
+ if let Some(high_issues) = action_plan
+ .and_then(|a| a.get("high"))
+ .and_then(|h| h.as_array())
+ {
+ for issue in high_issues.iter().take(MAX_PREVIEW - shown) {
+ lines.push(format_hadolint_issue(issue, "🟠", ansi::HIGH));
+ shown += 1;
+ }
+ }
+ }
+
+ // Show quick fix hint for most important issue
+ if let Some(quick_fixes) = v.get("quick_fixes").and_then(|q| q.as_array()) {
+ if let Some(first_fix) = quick_fixes.first().and_then(|f| f.as_str()) {
+ let truncated = if first_fix.len() > 70 {
+ format!("{}...", &first_fix[..67])
+ } else {
+ first_fix.to_string()
+ };
+ lines.push(format!(
+ "{} → Fix: {}{}",
+ ansi::INFO_BLUE, truncated, ansi::RESET
+ ));
+ }
+ }
+
+ // Note about remaining issues
+ let remaining = total as usize - shown;
+ if remaining > 0 {
+ lines.push(format!(
+ "{} +{} more issue{}{}",
+ ansi::GRAY,
+ remaining,
+ if remaining == 1 { "" } else { "s" },
+ ansi::RESET
+ ));
+ }
+
+ (success, lines)
+ } else {
+ (false, vec!["parse error".to_string()])
+ }
+}
+
+/// Format a single hadolint issue for display
+fn format_hadolint_issue(issue: &serde_json::Value, icon: &str, color: &str) -> String {
+ let code = issue.get("code").and_then(|c| c.as_str()).unwrap_or("?");
+ let message = issue.get("message").and_then(|m| m.as_str()).unwrap_or("?");
+ let line_num = issue.get("line").and_then(|l| l.as_u64()).unwrap_or(0);
+ let category = issue.get("category").and_then(|c| c.as_str()).unwrap_or("");
+
+ // Category badge
+ let badge = match category {
+ "security" => format!("{}[SEC]{}", ansi::CRITICAL, ansi::RESET),
+ "best-practice" => format!("{}[BP]{}", ansi::INFO_BLUE, ansi::RESET),
+ "deprecated" => format!("{}[DEP]{}", ansi::MEDIUM, ansi::RESET),
+ "performance" => format!("{}[PERF]{}", ansi::CYAN, ansi::RESET),
+ _ => String::new(),
+ };
+
+ // Truncate message
+ let msg_display = if message.len() > 50 {
+ format!("{}...", &message[..47])
+ } else {
+ message.to_string()
+ };
+
+ format!(
+ "{}{} L{}:{} {}{}[{}]{} {} {}",
+ color, icon, line_num, ansi::RESET,
+ ansi::DOCKER_BLUE, ansi::BOLD, code, ansi::RESET,
+ badge,
+ msg_display
+ )
+}
+
// Legacy exports for compatibility
pub use crate::agent::ui::Spinner;
use tokio::sync::mpsc;
diff --git a/src/agent/ui/mod.rs b/src/agent/ui/mod.rs
index 3fec2470..1b7c335e 100644
--- a/src/agent/ui/mod.rs
+++ b/src/agent/ui/mod.rs
@@ -14,6 +14,7 @@ pub mod autocomplete;
pub mod colors;
pub mod confirmation;
pub mod diff;
+pub mod hadolint_display;
pub mod hooks;
pub mod input;
pub mod response;
@@ -26,6 +27,7 @@ pub use autocomplete::*;
pub use colors::*;
pub use confirmation::*;
pub use diff::*;
+pub use hadolint_display::*;
pub use hooks::*;
pub use input::*;
pub use response::*;
diff --git a/src/analyzer/Screenshot 2025-12-16 at 08.21.18.png b/src/analyzer/Screenshot 2025-12-16 at 08.21.18.png
deleted file mode 100644
index aceeb82d..00000000
Binary files a/src/analyzer/Screenshot 2025-12-16 at 08.21.18.png and /dev/null differ
diff --git a/src/analyzer/hadolint/config.rs b/src/analyzer/hadolint/config.rs
new file mode 100644
index 00000000..3d791503
--- /dev/null
+++ b/src/analyzer/hadolint/config.rs
@@ -0,0 +1,382 @@
+//! Configuration for the hadolint-rs linter.
+//!
+//! Supports configuration from:
+//! - Programmatic defaults
+//! - YAML config files (.hadolint.yaml)
+//!
+//! Configuration priority (highest to lowest):
+//! 1. Programmatic overrides
+//! 2. Config file settings
+//! 3. Defaults
+
+use crate::analyzer::hadolint::types::{RuleCode, Severity};
+use std::collections::{HashMap, HashSet};
+use std::path::Path;
+
+/// Label validation types for DL3049-DL3056 rules.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum LabelType {
+ /// Email address format
+ Email,
+ /// Git commit hash
+ GitHash,
+ /// Raw text (no validation)
+ RawText,
+ /// RFC3339 timestamp
+ Rfc3339,
+ /// Semantic versioning
+ SemVer,
+ /// SPDX license identifier
+ Spdx,
+ /// URL format
+ Url,
+}
+
+impl LabelType {
+ /// Parse a label type from a string.
+ pub fn from_str(s: &str) -> Option {
+ match s.to_lowercase().as_str() {
+ "email" => Some(Self::Email),
+ "hash" => Some(Self::GitHash),
+ "text" | "" => Some(Self::RawText),
+ "rfc3339" => Some(Self::Rfc3339),
+ "semver" => Some(Self::SemVer),
+ "spdx" => Some(Self::Spdx),
+ "url" => Some(Self::Url),
+ _ => None,
+ }
+ }
+
+ /// Get the string representation.
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::Email => "email",
+ Self::GitHash => "hash",
+ Self::RawText => "text",
+ Self::Rfc3339 => "rfc3339",
+ Self::SemVer => "semver",
+ Self::Spdx => "spdx",
+ Self::Url => "url",
+ }
+ }
+}
+
+/// Configuration for the hadolint linter.
+#[derive(Debug, Clone)]
+pub struct HadolintConfig {
+ /// Rules to ignore entirely.
+ pub ignore_rules: HashSet,
+ /// Rules to treat as errors (override default severity).
+ pub error_rules: HashSet,
+ /// Rules to treat as warnings (override default severity).
+ pub warning_rules: HashSet,
+ /// Rules to treat as info (override default severity).
+ pub info_rules: HashSet,
+ /// Rules to treat as style (override default severity).
+ pub style_rules: HashSet,
+ /// Allowed Docker registries (for DL3026).
+ pub allowed_registries: HashSet,
+ /// Label schema requirements (for DL3049-DL3056).
+ pub label_schema: HashMap,
+ /// Fail on labels not in schema.
+ pub strict_labels: bool,
+ /// Disable inline ignore pragmas.
+ pub disable_ignore_pragma: bool,
+ /// Minimum severity to report.
+ pub failure_threshold: Severity,
+ /// Don't fail even if rules are violated.
+ pub no_fail: bool,
+}
+
+impl Default for HadolintConfig {
+ fn default() -> Self {
+ Self {
+ ignore_rules: HashSet::new(),
+ error_rules: HashSet::new(),
+ warning_rules: HashSet::new(),
+ info_rules: HashSet::new(),
+ style_rules: HashSet::new(),
+ allowed_registries: HashSet::new(),
+ label_schema: HashMap::new(),
+ strict_labels: false,
+ disable_ignore_pragma: false,
+ failure_threshold: Severity::Info,
+ no_fail: false,
+ }
+ }
+}
+
+impl HadolintConfig {
+ /// Create a new config with defaults.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Load config from a YAML file.
+ pub fn from_yaml_file(path: &Path) -> Result {
+ let content = std::fs::read_to_string(path)
+ .map_err(|e| ConfigError::IoError(e.to_string()))?;
+ Self::from_yaml_str(&content)
+ }
+
+ /// Load config from a YAML string.
+ pub fn from_yaml_str(yaml: &str) -> Result {
+ let value: serde_yaml::Value = serde_yaml::from_str(yaml)
+ .map_err(|e| ConfigError::ParseError(e.to_string()))?;
+
+ let mut config = Self::default();
+
+ // Parse ignored rules
+ if let Some(ignored) = value.get("ignored").and_then(|v| v.as_sequence()) {
+ for item in ignored {
+ if let Some(code) = item.as_str() {
+ config.ignore_rules.insert(RuleCode::new(code));
+ }
+ }
+ }
+
+ // Parse override.error
+ if let Some(overrides) = value.get("override").and_then(|v| v.as_mapping()) {
+ if let Some(errors) = overrides.get("error").and_then(|v| v.as_sequence()) {
+ for item in errors {
+ if let Some(code) = item.as_str() {
+ config.error_rules.insert(RuleCode::new(code));
+ }
+ }
+ }
+ if let Some(warnings) = overrides.get("warning").and_then(|v| v.as_sequence()) {
+ for item in warnings {
+ if let Some(code) = item.as_str() {
+ config.warning_rules.insert(RuleCode::new(code));
+ }
+ }
+ }
+ if let Some(infos) = overrides.get("info").and_then(|v| v.as_sequence()) {
+ for item in infos {
+ if let Some(code) = item.as_str() {
+ config.info_rules.insert(RuleCode::new(code));
+ }
+ }
+ }
+ if let Some(styles) = overrides.get("style").and_then(|v| v.as_sequence()) {
+ for item in styles {
+ if let Some(code) = item.as_str() {
+ config.style_rules.insert(RuleCode::new(code));
+ }
+ }
+ }
+ }
+
+ // Parse trusted registries
+ if let Some(registries) = value.get("trustedRegistries").and_then(|v| v.as_sequence()) {
+ for item in registries {
+ if let Some(registry) = item.as_str() {
+ config.allowed_registries.insert(registry.to_string());
+ }
+ }
+ }
+
+ // Parse label schema
+ if let Some(schema) = value.get("label-schema").and_then(|v| v.as_mapping()) {
+ for (key, val) in schema {
+ if let (Some(label), Some(type_str)) = (key.as_str(), val.as_str()) {
+ if let Some(label_type) = LabelType::from_str(type_str) {
+ config.label_schema.insert(label.to_string(), label_type);
+ }
+ }
+ }
+ }
+
+ // Parse boolean flags
+ if let Some(strict) = value.get("strict-labels").and_then(|v| v.as_bool()) {
+ config.strict_labels = strict;
+ }
+ if let Some(disable) = value.get("disable-ignore-pragma").and_then(|v| v.as_bool()) {
+ config.disable_ignore_pragma = disable;
+ }
+ if let Some(no_fail) = value.get("no-fail").and_then(|v| v.as_bool()) {
+ config.no_fail = no_fail;
+ }
+
+ // Parse failure threshold
+ if let Some(threshold) = value.get("failure-threshold").and_then(|v| v.as_str()) {
+ if let Some(severity) = Severity::from_str(threshold) {
+ config.failure_threshold = severity;
+ }
+ }
+
+ Ok(config)
+ }
+
+ /// Find and load config from standard locations.
+ ///
+ /// Search order:
+ /// 1. .hadolint.yaml in current directory
+ /// 2. .hadolint.yml in current directory
+ /// 3. XDG config directory
+ /// 4. Home directory
+ pub fn find_and_load() -> Option {
+ let search_paths = [
+ ".hadolint.yaml",
+ ".hadolint.yml",
+ ];
+
+ for path in &search_paths {
+ let path = Path::new(path);
+ if path.exists() {
+ if let Ok(config) = Self::from_yaml_file(path) {
+ return Some(config);
+ }
+ }
+ }
+
+ // Try XDG config directory
+ if let Some(config_dir) = dirs::config_dir() {
+ let xdg_path = config_dir.join("hadolint.yaml");
+ if xdg_path.exists() {
+ if let Ok(config) = Self::from_yaml_file(&xdg_path) {
+ return Some(config);
+ }
+ }
+ }
+
+ // Try home directory
+ if let Some(home_dir) = dirs::home_dir() {
+ let home_path = home_dir.join(".hadolint.yaml");
+ if home_path.exists() {
+ if let Ok(config) = Self::from_yaml_file(&home_path) {
+ return Some(config);
+ }
+ }
+ }
+
+ None
+ }
+
+ /// Check if a rule should be ignored.
+ pub fn is_rule_ignored(&self, code: &RuleCode) -> bool {
+ self.ignore_rules.contains(code)
+ }
+
+ /// Get the effective severity for a rule.
+ pub fn effective_severity(&self, code: &RuleCode, default: Severity) -> Severity {
+ if self.error_rules.contains(code) {
+ return Severity::Error;
+ }
+ if self.warning_rules.contains(code) {
+ return Severity::Warning;
+ }
+ if self.info_rules.contains(code) {
+ return Severity::Info;
+ }
+ if self.style_rules.contains(code) {
+ return Severity::Style;
+ }
+ default
+ }
+
+ /// Builder method to add an ignored rule.
+ pub fn ignore(mut self, code: impl Into) -> Self {
+ self.ignore_rules.insert(code.into());
+ self
+ }
+
+ /// Builder method to add an allowed registry.
+ pub fn allow_registry(mut self, registry: impl Into) -> Self {
+ self.allowed_registries.insert(registry.into());
+ self
+ }
+
+ /// Builder method to set failure threshold.
+ pub fn with_threshold(mut self, threshold: Severity) -> Self {
+ self.failure_threshold = threshold;
+ self
+ }
+}
+
+/// Errors that can occur when loading configuration.
+#[derive(Debug, Clone)]
+pub enum ConfigError {
+ /// I/O error reading the file.
+ IoError(String),
+ /// YAML parsing error.
+ ParseError(String),
+}
+
+impl std::fmt::Display for ConfigError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::IoError(msg) => write!(f, "I/O error: {}", msg),
+ Self::ParseError(msg) => write!(f, "Parse error: {}", msg),
+ }
+ }
+}
+
+impl std::error::Error for ConfigError {}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_default_config() {
+ let config = HadolintConfig::default();
+ assert!(config.ignore_rules.is_empty());
+ assert!(!config.strict_labels);
+ assert!(!config.disable_ignore_pragma);
+ assert_eq!(config.failure_threshold, Severity::Info);
+ }
+
+ #[test]
+ fn test_yaml_parsing() {
+ let yaml = r#"
+ignored:
+ - DL3008
+ - DL3009
+
+override:
+ error:
+ - DL3001
+ warning:
+ - DL3002
+
+trustedRegistries:
+ - docker.io
+ - gcr.io
+
+failure-threshold: warning
+strict-labels: true
+"#;
+
+ let config = HadolintConfig::from_yaml_str(yaml).unwrap();
+ assert!(config.ignore_rules.contains(&RuleCode::new("DL3008")));
+ assert!(config.ignore_rules.contains(&RuleCode::new("DL3009")));
+ assert!(config.error_rules.contains(&RuleCode::new("DL3001")));
+ assert!(config.warning_rules.contains(&RuleCode::new("DL3002")));
+ assert!(config.allowed_registries.contains("docker.io"));
+ assert!(config.allowed_registries.contains("gcr.io"));
+ assert_eq!(config.failure_threshold, Severity::Warning);
+ assert!(config.strict_labels);
+ }
+
+ #[test]
+ fn test_effective_severity() {
+ let config = HadolintConfig::default()
+ .ignore("DL3008".to_string());
+
+ assert!(config.is_rule_ignored(&RuleCode::new("DL3008")));
+ assert!(!config.is_rule_ignored(&RuleCode::new("DL3009")));
+ }
+
+ #[test]
+ fn test_builder_pattern() {
+ let config = HadolintConfig::new()
+ .ignore("DL3008")
+ .allow_registry("docker.io")
+ .with_threshold(Severity::Warning);
+
+ assert!(config.ignore_rules.contains(&RuleCode::new("DL3008")));
+ assert!(config.allowed_registries.contains("docker.io"));
+ assert_eq!(config.failure_threshold, Severity::Warning);
+ }
+}
diff --git a/src/analyzer/hadolint/formatter/checkstyle.rs b/src/analyzer/hadolint/formatter/checkstyle.rs
new file mode 100644
index 00000000..c13838ed
--- /dev/null
+++ b/src/analyzer/hadolint/formatter/checkstyle.rs
@@ -0,0 +1,103 @@
+//! Checkstyle XML formatter for hadolint-rs.
+//!
+//! Outputs lint results in Checkstyle XML format for Jenkins and other CI tools.
+
+use crate::analyzer::hadolint::formatter::Formatter;
+use crate::analyzer::hadolint::lint::LintResult;
+use crate::analyzer::hadolint::types::Severity;
+use std::io::Write;
+
+/// Checkstyle XML output formatter for Jenkins.
+#[derive(Debug, Clone, Default)]
+pub struct CheckstyleFormatter;
+
+impl CheckstyleFormatter {
+ /// Create a new Checkstyle formatter.
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+fn escape_xml(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+ .replace('"', """)
+ .replace('\'', "'")
+}
+
+fn severity_to_checkstyle(severity: Severity) -> &'static str {
+ match severity {
+ Severity::Error => "error",
+ Severity::Warning => "warning",
+ Severity::Info => "info",
+ Severity::Style => "info",
+ Severity::Ignore => "info",
+ }
+}
+
+impl Formatter for CheckstyleFormatter {
+ fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> {
+ writeln!(writer, r#""#)?;
+ writeln!(writer, r#""#)?;
+
+ if !result.failures.is_empty() {
+ writeln!(writer, r#" "#, escape_xml(filename))?;
+
+ for failure in &result.failures {
+ let col_attr = failure
+ .column
+ .map(|c| format!(r#" column="{}""#, c))
+ .unwrap_or_default();
+
+ writeln!(
+ writer,
+ r#" "#,
+ failure.line,
+ col_attr,
+ severity_to_checkstyle(failure.severity),
+ escape_xml(&failure.message),
+ escape_xml(&failure.code.to_string())
+ )?;
+ }
+
+ writeln!(writer, " ")?;
+ }
+
+ writeln!(writer, "")
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::types::CheckFailure;
+
+ #[test]
+ fn test_checkstyle_output() {
+ let mut result = LintResult::new();
+ result.failures.push(CheckFailure::new(
+ "DL3008",
+ Severity::Warning,
+ "Pin versions in apt get install",
+ 5,
+ ));
+
+ let formatter = CheckstyleFormatter::new();
+ let output = formatter.format_to_string(&result, "Dockerfile");
+
+ assert!(output.contains(""#));
+ assert!(output.contains(r#"line="5""#));
+ assert!(output.contains(r#"severity="warning""#));
+ assert!(output.contains("DL3008"));
+ }
+
+ #[test]
+ fn test_xml_escaping() {
+ assert_eq!(escape_xml("a < b"), "a < b");
+ assert_eq!(escape_xml("a & b"), "a & b");
+ assert_eq!(escape_xml(r#"a "b""#), "a "b"");
+ }
+}
diff --git a/src/analyzer/hadolint/formatter/codeclimate.rs b/src/analyzer/hadolint/formatter/codeclimate.rs
new file mode 100644
index 00000000..2935c2bf
--- /dev/null
+++ b/src/analyzer/hadolint/formatter/codeclimate.rs
@@ -0,0 +1,196 @@
+//! CodeClimate formatter for hadolint-rs.
+//!
+//! Outputs lint results in CodeClimate JSON format for GitLab CI integration.
+//!
+//! CodeClimate Specification: https://github.com/codeclimate/platform/blob/master/spec/analyzers/SPEC.md
+
+use crate::analyzer::hadolint::formatter::Formatter;
+use crate::analyzer::hadolint::lint::LintResult;
+use crate::analyzer::hadolint::types::Severity;
+use serde::Serialize;
+use std::io::Write;
+
+/// CodeClimate JSON output formatter for GitLab CI.
+#[derive(Debug, Clone, Default)]
+pub struct CodeClimateFormatter;
+
+impl CodeClimateFormatter {
+ /// Create a new CodeClimate formatter.
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+/// CodeClimate issue structure.
+#[derive(Debug, Serialize)]
+struct CodeClimateIssue {
+ #[serde(rename = "type")]
+ issue_type: &'static str,
+ check_name: String,
+ description: String,
+ content: CodeClimateContent,
+ categories: Vec<&'static str>,
+ location: CodeClimateLocation,
+ severity: &'static str,
+ fingerprint: String,
+}
+
+#[derive(Debug, Serialize)]
+struct CodeClimateContent {
+ body: String,
+}
+
+#[derive(Debug, Serialize)]
+struct CodeClimateLocation {
+ path: String,
+ lines: CodeClimateLines,
+}
+
+#[derive(Debug, Serialize)]
+struct CodeClimateLines {
+ begin: u32,
+ end: u32,
+}
+
+fn severity_to_codeclimate(severity: Severity) -> &'static str {
+ match severity {
+ Severity::Error => "critical",
+ Severity::Warning => "major",
+ Severity::Info => "minor",
+ Severity::Style => "info",
+ Severity::Ignore => "info",
+ }
+}
+
+fn get_categories(code: &str) -> Vec<&'static str> {
+ // Categorize based on rule code prefix
+ if code.starts_with("DL") {
+ // Dockerfile linting rules
+ let rule_num: u32 = code[2..].parse().unwrap_or(0);
+ match rule_num {
+ // Security-related rules
+ 3000..=3010 => vec!["Security", "Bug Risk"],
+ // Best practices
+ 3011..=3030 => vec!["Style", "Clarity"],
+ // Performance
+ 3031..=3050 => vec!["Performance"],
+ // Deprecated instructions
+ 4000..=4999 => vec!["Compatibility", "Bug Risk"],
+ _ => vec!["Style"],
+ }
+ } else if code.starts_with("SC") {
+ // ShellCheck rules
+ vec!["Bug Risk", "Security"]
+ } else {
+ vec!["Style"]
+ }
+}
+
+fn generate_fingerprint(filename: &str, code: &str, line: u32) -> String {
+ use std::collections::hash_map::DefaultHasher;
+ use std::hash::{Hash, Hasher};
+
+ let mut hasher = DefaultHasher::new();
+ filename.hash(&mut hasher);
+ code.hash(&mut hasher);
+ line.hash(&mut hasher);
+ format!("{:016x}", hasher.finish())
+}
+
+fn get_help_body(code: &str) -> String {
+ if code.starts_with("DL") {
+ format!(
+ "See the hadolint wiki for more information: https://github.com/hadolint/hadolint/wiki/{}",
+ code
+ )
+ } else if code.starts_with("SC") {
+ format!(
+ "See the ShellCheck wiki for more information: https://www.shellcheck.net/wiki/{}",
+ code
+ )
+ } else {
+ "See hadolint documentation for more information.".to_string()
+ }
+}
+
+impl Formatter for CodeClimateFormatter {
+ fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> {
+ let issues: Vec = result
+ .failures
+ .iter()
+ .map(|f| {
+ let code = f.code.to_string();
+ CodeClimateIssue {
+ issue_type: "issue",
+ check_name: code.clone(),
+ description: f.message.clone(),
+ content: CodeClimateContent {
+ body: get_help_body(&code),
+ },
+ categories: get_categories(&code),
+ location: CodeClimateLocation {
+ path: filename.to_string(),
+ lines: CodeClimateLines {
+ begin: f.line,
+ end: f.line,
+ },
+ },
+ severity: severity_to_codeclimate(f.severity),
+ fingerprint: generate_fingerprint(filename, &code, f.line),
+ }
+ })
+ .collect();
+
+ // CodeClimate expects newline-delimited JSON (NDJSON)
+ for issue in &issues {
+ let json = serde_json::to_string(issue)
+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
+ writeln!(writer, "{}", json)?;
+ }
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::types::CheckFailure;
+
+ #[test]
+ fn test_codeclimate_output() {
+ let mut result = LintResult::new();
+ result.failures.push(CheckFailure::new(
+ "DL3008",
+ Severity::Warning,
+ "Pin versions in apt get install",
+ 5,
+ ));
+
+ let formatter = CodeClimateFormatter::new();
+ let output = formatter.format_to_string(&result, "Dockerfile");
+
+ assert!(output.contains("\"type\":\"issue\""));
+ assert!(output.contains("\"check_name\":\"DL3008\""));
+ assert!(output.contains("\"severity\":\"major\""));
+ assert!(output.contains("\"path\":\"Dockerfile\""));
+ assert!(output.contains("\"fingerprint\""));
+ }
+
+ #[test]
+ fn test_fingerprint_consistency() {
+ let fp1 = generate_fingerprint("Dockerfile", "DL3008", 5);
+ let fp2 = generate_fingerprint("Dockerfile", "DL3008", 5);
+ let fp3 = generate_fingerprint("Dockerfile", "DL3008", 6);
+
+ assert_eq!(fp1, fp2);
+ assert_ne!(fp1, fp3);
+ }
+
+ #[test]
+ fn test_categories() {
+ assert!(get_categories("DL3000").contains(&"Security"));
+ assert!(get_categories("SC2086").contains(&"Bug Risk"));
+ assert!(get_categories("DL4000").contains(&"Compatibility"));
+ }
+}
diff --git a/src/analyzer/hadolint/formatter/gnu.rs b/src/analyzer/hadolint/formatter/gnu.rs
new file mode 100644
index 00000000..0f9d0c9e
--- /dev/null
+++ b/src/analyzer/hadolint/formatter/gnu.rs
@@ -0,0 +1,101 @@
+//! GNU formatter for hadolint-rs.
+//!
+//! Outputs lint results in GNU compiler-style format for editor integration.
+//! Format: filename:line:column: severity: message [code]
+
+use crate::analyzer::hadolint::formatter::Formatter;
+use crate::analyzer::hadolint::lint::LintResult;
+use crate::analyzer::hadolint::types::Severity;
+use std::io::Write;
+
+/// GNU compiler-style output formatter.
+#[derive(Debug, Clone, Default)]
+pub struct GnuFormatter;
+
+impl GnuFormatter {
+ /// Create a new GNU formatter.
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl Formatter for GnuFormatter {
+ fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> {
+ for failure in &result.failures {
+ let severity_str = match failure.severity {
+ Severity::Error => "error",
+ Severity::Warning => "warning",
+ Severity::Info => "info",
+ Severity::Style => "style",
+ Severity::Ignore => "note",
+ };
+
+ // GNU format: file:line:column: severity: message [code]
+ if let Some(col) = failure.column {
+ writeln!(
+ writer,
+ "{}:{}:{}: {}: {} [{}]",
+ filename,
+ failure.line,
+ col,
+ severity_str,
+ failure.message,
+ failure.code
+ )?;
+ } else {
+ writeln!(
+ writer,
+ "{}:{}: {}: {} [{}]",
+ filename,
+ failure.line,
+ severity_str,
+ failure.message,
+ failure.code
+ )?;
+ }
+ }
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::types::CheckFailure;
+
+ #[test]
+ fn test_gnu_output() {
+ let mut result = LintResult::new();
+ result.failures.push(CheckFailure::new(
+ "DL3008",
+ Severity::Warning,
+ "Pin versions in apt get install",
+ 5,
+ ));
+
+ let formatter = GnuFormatter::new();
+ let output = formatter.format_to_string(&result, "Dockerfile");
+
+ assert_eq!(
+ output.trim(),
+ "Dockerfile:5: warning: Pin versions in apt get install [DL3008]"
+ );
+ }
+
+ #[test]
+ fn test_gnu_output_with_column() {
+ let mut result = LintResult::new();
+ result.failures.push(CheckFailure::with_column(
+ "DL3008",
+ Severity::Warning,
+ "Pin versions",
+ 5,
+ 10,
+ ));
+
+ let formatter = GnuFormatter::new();
+ let output = formatter.format_to_string(&result, "Dockerfile");
+
+ assert!(output.contains("Dockerfile:5:10:"));
+ }
+}
diff --git a/src/analyzer/hadolint/formatter/json.rs b/src/analyzer/hadolint/formatter/json.rs
new file mode 100644
index 00000000..2f8b11ca
--- /dev/null
+++ b/src/analyzer/hadolint/formatter/json.rs
@@ -0,0 +1,98 @@
+//! JSON formatter for hadolint-rs.
+//!
+//! Outputs lint results in JSON format for CI/CD pipeline integration.
+//! Compatible with the original hadolint JSON output.
+
+use crate::analyzer::hadolint::formatter::Formatter;
+use crate::analyzer::hadolint::lint::LintResult;
+use crate::analyzer::hadolint::types::Severity;
+use serde::Serialize;
+use std::io::Write;
+
+/// JSON output formatter.
+#[derive(Debug, Clone, Default)]
+pub struct JsonFormatter {
+ /// Pretty-print the JSON output.
+ pub pretty: bool,
+}
+
+impl JsonFormatter {
+ /// Create a new JSON formatter.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Create a JSON formatter with pretty-printing enabled.
+ pub fn pretty() -> Self {
+ Self { pretty: true }
+ }
+}
+
+/// JSON representation of a lint failure.
+#[derive(Debug, Serialize)]
+struct JsonFailure {
+ line: u32,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ column: Option,
+ code: String,
+ message: String,
+ level: String,
+ file: String,
+}
+
+impl Formatter for JsonFormatter {
+ fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> {
+ let failures: Vec = result
+ .failures
+ .iter()
+ .map(|f| JsonFailure {
+ line: f.line,
+ column: f.column,
+ code: f.code.to_string(),
+ message: f.message.clone(),
+ level: match f.severity {
+ Severity::Error => "error",
+ Severity::Warning => "warning",
+ Severity::Info => "info",
+ Severity::Style => "style",
+ Severity::Ignore => "ignore",
+ }
+ .to_string(),
+ file: filename.to_string(),
+ })
+ .collect();
+
+ let json = if self.pretty {
+ serde_json::to_string_pretty(&failures)
+ } else {
+ serde_json::to_string(&failures)
+ }
+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
+
+ writeln!(writer, "{}", json)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::types::CheckFailure;
+
+ #[test]
+ fn test_json_output() {
+ let mut result = LintResult::new();
+ result.failures.push(CheckFailure::new(
+ "DL3008",
+ Severity::Warning,
+ "Pin versions in apt get install",
+ 5,
+ ));
+
+ let formatter = JsonFormatter::new();
+ let output = formatter.format_to_string(&result, "Dockerfile");
+
+ assert!(output.contains("DL3008"));
+ assert!(output.contains("warning"));
+ assert!(output.contains("Pin versions"));
+ }
+}
diff --git a/src/analyzer/hadolint/formatter/mod.rs b/src/analyzer/hadolint/formatter/mod.rs
new file mode 100644
index 00000000..6adbc283
--- /dev/null
+++ b/src/analyzer/hadolint/formatter/mod.rs
@@ -0,0 +1,101 @@
+//! Output formatters for hadolint-rs lint results.
+//!
+//! Provides multiple output formats for compatibility with various CI/CD systems:
+//! - **TTY**: Colored terminal output for human readability
+//! - **JSON**: Machine-readable format for CI/CD pipelines
+//! - **SARIF**: Static Analysis Results Interchange Format for GitHub Actions
+//! - **Checkstyle**: XML format for Jenkins and other tools
+//! - **CodeClimate**: JSON format for GitLab CI
+//! - **GNU**: Standard compiler-style output for editors
+
+mod checkstyle;
+mod codeclimate;
+mod gnu;
+mod json;
+mod sarif;
+mod tty;
+
+pub use checkstyle::CheckstyleFormatter;
+pub use codeclimate::CodeClimateFormatter;
+pub use gnu::GnuFormatter;
+pub use json::JsonFormatter;
+pub use sarif::SarifFormatter;
+pub use tty::TtyFormatter;
+
+use crate::analyzer::hadolint::lint::LintResult;
+use std::io::Write;
+
+/// Output format for lint results.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum OutputFormat {
+ /// Colored terminal output (default)
+ #[default]
+ Tty,
+ /// JSON format for CI/CD
+ Json,
+ /// SARIF format for GitHub Actions
+ Sarif,
+ /// Checkstyle XML for Jenkins
+ Checkstyle,
+ /// CodeClimate JSON for GitLab
+ CodeClimate,
+ /// GNU compiler-style output
+ Gnu,
+}
+
+impl OutputFormat {
+ /// Parse format from string (case-insensitive).
+ pub fn from_str(s: &str) -> Option {
+ match s.to_lowercase().as_str() {
+ "tty" | "terminal" | "color" => Some(Self::Tty),
+ "json" => Some(Self::Json),
+ "sarif" => Some(Self::Sarif),
+ "checkstyle" => Some(Self::Checkstyle),
+ "codeclimate" | "gitlab" => Some(Self::CodeClimate),
+ "gnu" => Some(Self::Gnu),
+ _ => None,
+ }
+ }
+
+ /// Get all available format names.
+ pub fn all_names() -> &'static [&'static str] {
+ &["tty", "json", "sarif", "checkstyle", "codeclimate", "gnu"]
+ }
+}
+
+/// Trait for formatting lint results.
+pub trait Formatter {
+ /// Format the lint result and write to the given writer.
+ fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()>;
+
+ /// Format the lint result to a string.
+ fn format_to_string(&self, result: &LintResult, filename: &str) -> String {
+ let mut buf = Vec::new();
+ self.format(result, filename, &mut buf).unwrap_or_default();
+ String::from_utf8(buf).unwrap_or_default()
+ }
+}
+
+/// Format a lint result using the specified output format.
+pub fn format_result(
+ result: &LintResult,
+ filename: &str,
+ format: OutputFormat,
+ writer: &mut W,
+) -> std::io::Result<()> {
+ match format {
+ OutputFormat::Tty => TtyFormatter::new().format(result, filename, writer),
+ OutputFormat::Json => JsonFormatter::new().format(result, filename, writer),
+ OutputFormat::Sarif => SarifFormatter::new().format(result, filename, writer),
+ OutputFormat::Checkstyle => CheckstyleFormatter::new().format(result, filename, writer),
+ OutputFormat::CodeClimate => CodeClimateFormatter::new().format(result, filename, writer),
+ OutputFormat::Gnu => GnuFormatter::new().format(result, filename, writer),
+ }
+}
+
+/// Format a lint result to a string using the specified output format.
+pub fn format_result_to_string(result: &LintResult, filename: &str, format: OutputFormat) -> String {
+ let mut buf = Vec::new();
+ format_result(result, filename, format, &mut buf).unwrap_or_default();
+ String::from_utf8(buf).unwrap_or_default()
+}
diff --git a/src/analyzer/hadolint/formatter/sarif.rs b/src/analyzer/hadolint/formatter/sarif.rs
new file mode 100644
index 00000000..7acda7f4
--- /dev/null
+++ b/src/analyzer/hadolint/formatter/sarif.rs
@@ -0,0 +1,234 @@
+//! SARIF formatter for hadolint-rs.
+//!
+//! Outputs lint results in SARIF (Static Analysis Results Interchange Format)
+//! for GitHub Actions Code Scanning integration.
+//!
+//! SARIF Specification: https://sarifweb.azurewebsites.net/
+
+use crate::analyzer::hadolint::formatter::Formatter;
+use crate::analyzer::hadolint::lint::LintResult;
+use crate::analyzer::hadolint::types::Severity;
+use serde::Serialize;
+use std::io::Write;
+
+/// SARIF output formatter for GitHub Actions.
+#[derive(Debug, Clone, Default)]
+pub struct SarifFormatter;
+
+impl SarifFormatter {
+ /// Create a new SARIF formatter.
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+/// SARIF 2.1.0 schema structures.
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifReport {
+ #[serde(rename = "$schema")]
+ schema: &'static str,
+ version: &'static str,
+ runs: Vec,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifRun {
+ tool: SarifTool,
+ results: Vec,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifTool {
+ driver: SarifDriver,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifDriver {
+ name: &'static str,
+ information_uri: &'static str,
+ version: &'static str,
+ rules: Vec,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifRule {
+ id: String,
+ name: String,
+ short_description: SarifMessage,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ help_uri: Option,
+ default_configuration: SarifRuleConfiguration,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifRuleConfiguration {
+ level: &'static str,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifMessage {
+ text: String,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifResult {
+ rule_id: String,
+ level: &'static str,
+ message: SarifMessage,
+ locations: Vec,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifLocation {
+ physical_location: SarifPhysicalLocation,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifPhysicalLocation {
+ artifact_location: SarifArtifactLocation,
+ region: SarifRegion,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifArtifactLocation {
+ uri: String,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SarifRegion {
+ start_line: u32,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ start_column: Option,
+}
+
+fn severity_to_sarif_level(severity: Severity) -> &'static str {
+ match severity {
+ Severity::Error => "error",
+ Severity::Warning => "warning",
+ Severity::Info => "note",
+ Severity::Style => "note",
+ Severity::Ignore => "none",
+ }
+}
+
+fn get_rule_help_uri(code: &str) -> Option {
+ if code.starts_with("DL") {
+ Some(format!(
+ "https://github.com/hadolint/hadolint/wiki/{}",
+ code
+ ))
+ } else if code.starts_with("SC") {
+ Some(format!("https://www.shellcheck.net/wiki/{}", code))
+ } else {
+ None
+ }
+}
+
+impl Formatter for SarifFormatter {
+ fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> {
+ // Collect unique rules for the rules array
+ let mut rules: Vec = Vec::new();
+ let mut seen_rules = std::collections::HashSet::new();
+
+ for failure in &result.failures {
+ let code = failure.code.to_string();
+ if !seen_rules.contains(&code) {
+ seen_rules.insert(code.clone());
+ rules.push(SarifRule {
+ id: code.clone(),
+ name: code.clone(),
+ short_description: SarifMessage {
+ text: failure.message.clone(),
+ },
+ help_uri: get_rule_help_uri(&code),
+ default_configuration: SarifRuleConfiguration {
+ level: severity_to_sarif_level(failure.severity),
+ },
+ });
+ }
+ }
+
+ // Build results
+ let results: Vec = result
+ .failures
+ .iter()
+ .map(|f| SarifResult {
+ rule_id: f.code.to_string(),
+ level: severity_to_sarif_level(f.severity),
+ message: SarifMessage {
+ text: f.message.clone(),
+ },
+ locations: vec![SarifLocation {
+ physical_location: SarifPhysicalLocation {
+ artifact_location: SarifArtifactLocation {
+ uri: filename.to_string(),
+ },
+ region: SarifRegion {
+ start_line: f.line,
+ start_column: f.column,
+ },
+ },
+ }],
+ })
+ .collect();
+
+ let report = SarifReport {
+ schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
+ version: "2.1.0",
+ runs: vec![SarifRun {
+ tool: SarifTool {
+ driver: SarifDriver {
+ name: "hadolint-rs",
+ information_uri: "https://github.com/syncable-dev/syncable-cli",
+ version: env!("CARGO_PKG_VERSION"),
+ rules,
+ },
+ },
+ results,
+ }],
+ };
+
+ let json = serde_json::to_string_pretty(&report)
+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
+
+ writeln!(writer, "{}", json)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::types::CheckFailure;
+
+ #[test]
+ fn test_sarif_output() {
+ let mut result = LintResult::new();
+ result.failures.push(CheckFailure::new(
+ "DL3008",
+ Severity::Warning,
+ "Pin versions in apt get install",
+ 5,
+ ));
+
+ let formatter = SarifFormatter::new();
+ let output = formatter.format_to_string(&result, "Dockerfile");
+
+ assert!(output.contains("\"$schema\""));
+ assert!(output.contains("\"version\": \"2.1.0\""));
+ assert!(output.contains("hadolint-rs"));
+ assert!(output.contains("DL3008"));
+ assert!(output.contains("warning"));
+ }
+}
diff --git a/src/analyzer/hadolint/formatter/tty.rs b/src/analyzer/hadolint/formatter/tty.rs
new file mode 100644
index 00000000..95b35dde
--- /dev/null
+++ b/src/analyzer/hadolint/formatter/tty.rs
@@ -0,0 +1,212 @@
+//! TTY formatter for hadolint-rs.
+//!
+//! Outputs lint results with colored terminal output for human readability.
+//! Uses ANSI escape codes for colors.
+
+use crate::analyzer::hadolint::formatter::Formatter;
+use crate::analyzer::hadolint::lint::LintResult;
+use crate::analyzer::hadolint::types::Severity;
+use std::io::Write;
+
+/// TTY (terminal) output formatter with colors.
+#[derive(Debug, Clone)]
+pub struct TtyFormatter {
+ /// Use colors in output.
+ pub colors: bool,
+ /// Show the filename in each line.
+ pub show_filename: bool,
+}
+
+impl Default for TtyFormatter {
+ fn default() -> Self {
+ Self {
+ colors: true,
+ show_filename: true,
+ }
+ }
+}
+
+impl TtyFormatter {
+ /// Create a new TTY formatter with colors enabled.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Create a TTY formatter without colors.
+ pub fn no_color() -> Self {
+ Self {
+ colors: false,
+ show_filename: true,
+ }
+ }
+
+ fn severity_color(&self, severity: Severity) -> &'static str {
+ if !self.colors {
+ return "";
+ }
+ match severity {
+ Severity::Error => "\x1b[1;31m", // Bold red
+ Severity::Warning => "\x1b[1;33m", // Bold yellow
+ Severity::Info => "\x1b[1;36m", // Bold cyan
+ Severity::Style => "\x1b[1;35m", // Bold magenta
+ Severity::Ignore => "\x1b[2m", // Dim
+ }
+ }
+
+ fn reset(&self) -> &'static str {
+ if self.colors {
+ "\x1b[0m"
+ } else {
+ ""
+ }
+ }
+
+ fn dim(&self) -> &'static str {
+ if self.colors {
+ "\x1b[2m"
+ } else {
+ ""
+ }
+ }
+
+ fn bold(&self) -> &'static str {
+ if self.colors {
+ "\x1b[1m"
+ } else {
+ ""
+ }
+ }
+}
+
+impl Formatter for TtyFormatter {
+ fn format(&self, result: &LintResult, filename: &str, writer: &mut W) -> std::io::Result<()> {
+ if result.failures.is_empty() {
+ return Ok(());
+ }
+
+ for failure in &result.failures {
+ let color = self.severity_color(failure.severity);
+ let reset = self.reset();
+ let dim = self.dim();
+ let bold = self.bold();
+
+ // Format: filename:line severity: [code] message
+ if self.show_filename {
+ write!(
+ writer,
+ "{}{}{}{}:{}",
+ bold, filename, reset, dim, reset
+ )?;
+ }
+
+ write!(
+ writer,
+ "{}{}{} ",
+ dim,
+ failure.line,
+ reset
+ )?;
+
+ // Severity badge
+ let severity_str = match failure.severity {
+ Severity::Error => "error",
+ Severity::Warning => "warning",
+ Severity::Info => "info",
+ Severity::Style => "style",
+ Severity::Ignore => "ignore",
+ };
+
+ write!(
+ writer,
+ "{}{}{}",
+ color, severity_str, reset
+ )?;
+
+ // Rule code
+ write!(
+ writer,
+ " {}{}{}: ",
+ dim, failure.code, reset
+ )?;
+
+ // Message
+ writeln!(writer, "{}", failure.message)?;
+ }
+
+ // Summary line
+ let error_count = result.failures.iter().filter(|f| f.severity == Severity::Error).count();
+ let warning_count = result.failures.iter().filter(|f| f.severity == Severity::Warning).count();
+ let info_count = result.failures.iter().filter(|f| f.severity == Severity::Info).count();
+ let style_count = result.failures.iter().filter(|f| f.severity == Severity::Style).count();
+
+ writeln!(writer)?;
+
+ let mut parts = Vec::new();
+ if error_count > 0 {
+ parts.push(format!(
+ "{}{} error{}{}",
+ self.severity_color(Severity::Error),
+ error_count,
+ if error_count == 1 { "" } else { "s" },
+ self.reset()
+ ));
+ }
+ if warning_count > 0 {
+ parts.push(format!(
+ "{}{} warning{}{}",
+ self.severity_color(Severity::Warning),
+ warning_count,
+ if warning_count == 1 { "" } else { "s" },
+ self.reset()
+ ));
+ }
+ if info_count > 0 {
+ parts.push(format!(
+ "{}{} info{}",
+ self.severity_color(Severity::Info),
+ info_count,
+ self.reset()
+ ));
+ }
+ if style_count > 0 {
+ parts.push(format!(
+ "{}{} style{}",
+ self.severity_color(Severity::Style),
+ style_count,
+ self.reset()
+ ));
+ }
+
+ if !parts.is_empty() {
+ writeln!(writer, "{}", parts.join(", "))?;
+ }
+
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::types::CheckFailure;
+
+ #[test]
+ fn test_tty_output_no_color() {
+ let mut result = LintResult::new();
+ result.failures.push(CheckFailure::new(
+ "DL3008",
+ Severity::Warning,
+ "Pin versions in apt get install",
+ 5,
+ ));
+
+ let formatter = TtyFormatter::no_color();
+ let output = formatter.format_to_string(&result, "Dockerfile");
+
+ assert!(output.contains("Dockerfile"));
+ assert!(output.contains("5"));
+ assert!(output.contains("warning"));
+ assert!(output.contains("DL3008"));
+ assert!(output.contains("Pin versions"));
+ }
+}
diff --git a/src/analyzer/hadolint/lint.rs b/src/analyzer/hadolint/lint.rs
new file mode 100644
index 00000000..7f786059
--- /dev/null
+++ b/src/analyzer/hadolint/lint.rs
@@ -0,0 +1,448 @@
+//! Main linting orchestration for hadolint-rs.
+//!
+//! This module ties together parsing, rules, and pragmas to provide
+//! the main linting API.
+
+use crate::analyzer::hadolint::config::HadolintConfig;
+use crate::analyzer::hadolint::parser::{parse_dockerfile, InstructionPos};
+use crate::analyzer::hadolint::pragma::{extract_pragmas, PragmaState};
+use crate::analyzer::hadolint::rules::{all_rules, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::{CheckFailure, Severity};
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+
+use std::path::Path;
+
+/// Result of linting a Dockerfile.
+#[derive(Debug, Clone)]
+pub struct LintResult {
+ /// Rule violations found.
+ pub failures: Vec,
+ /// Parse errors (if any).
+ pub parse_errors: Vec,
+}
+
+impl LintResult {
+ /// Create a new empty result.
+ pub fn new() -> Self {
+ Self {
+ failures: Vec::new(),
+ parse_errors: Vec::new(),
+ }
+ }
+
+ /// Check if there are any failures.
+ pub fn has_failures(&self) -> bool {
+ !self.failures.is_empty()
+ }
+
+ /// Check if there are any errors (failure with Error severity).
+ pub fn has_errors(&self) -> bool {
+ self.failures.iter().any(|f| f.severity == Severity::Error)
+ }
+
+ /// Check if there are any warnings (failure with Warning severity).
+ pub fn has_warnings(&self) -> bool {
+ self.failures.iter().any(|f| f.severity == Severity::Warning)
+ }
+
+ /// Get the maximum severity in the results.
+ pub fn max_severity(&self) -> Option {
+ self.failures.iter().map(|f| f.severity).max()
+ }
+
+ /// Check if the results should cause a non-zero exit.
+ pub fn should_fail(&self, config: &HadolintConfig) -> bool {
+ if config.no_fail {
+ return false;
+ }
+
+ if let Some(max) = self.max_severity() {
+ max >= config.failure_threshold
+ } else {
+ false
+ }
+ }
+
+ /// Filter failures by severity threshold.
+ pub fn filter_by_threshold(&mut self, threshold: Severity) {
+ self.failures.retain(|f| f.severity >= threshold);
+ }
+
+ /// Sort failures by line number.
+ pub fn sort(&mut self) {
+ self.failures.sort();
+ }
+}
+
+impl Default for LintResult {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+/// Lint a Dockerfile string.
+pub fn lint(content: &str, config: &HadolintConfig) -> LintResult {
+ let mut result = LintResult::new();
+
+ // Parse Dockerfile
+ let instructions = match parse_dockerfile(content) {
+ Ok(instrs) => instrs,
+ Err(err) => {
+ result.parse_errors.push(err.to_string());
+ return result;
+ }
+ };
+
+ // Extract pragmas
+ let pragmas = if config.disable_ignore_pragma {
+ PragmaState::new()
+ } else {
+ extract_pragmas(&instructions)
+ };
+
+ // Run rules
+ let failures = run_rules(&instructions, config, &pragmas);
+
+ // Filter by config
+ result.failures = failures
+ .into_iter()
+ .filter(|f| {
+ // Apply config severity overrides
+ let effective_severity = config.effective_severity(&f.code, f.severity);
+
+ // Filter by threshold
+ effective_severity >= config.failure_threshold
+ })
+ .filter(|f| !config.is_rule_ignored(&f.code))
+ .filter(|f| !pragmas.is_ignored(&f.code, f.line))
+ .map(|mut f| {
+ // Apply severity overrides
+ f.severity = config.effective_severity(&f.code, f.severity);
+ f
+ })
+ .collect();
+
+ // Sort by line number
+ result.sort();
+
+ result
+}
+
+/// Lint a Dockerfile from a file path.
+pub fn lint_file(path: &Path, config: &HadolintConfig) -> LintResult {
+ match std::fs::read_to_string(path) {
+ Ok(content) => lint(&content, config),
+ Err(err) => {
+ let mut result = LintResult::new();
+ result.parse_errors.push(format!("Failed to read file: {}", err));
+ result
+ }
+ }
+}
+
+/// Run all enabled rules on the instructions.
+fn run_rules(
+ instructions: &[InstructionPos],
+ config: &HadolintConfig,
+ pragmas: &PragmaState,
+) -> Vec {
+ let rules = all_rules();
+ let mut all_failures = Vec::new();
+
+ for rule in rules {
+ // Skip ignored rules
+ if config.is_rule_ignored(rule.code()) {
+ continue;
+ }
+
+ let mut state = RuleState::new();
+
+ // Process each instruction
+ for instr in instructions {
+ // Parse shell if this is a RUN instruction
+ let shell = match &instr.instruction {
+ Instruction::Run(args) => Some(ParsedShell::from_run_args(args)),
+ _ => None,
+ };
+
+ // Check the instruction
+ rule.check(&mut state, instr.line_number, &instr.instruction, shell.as_ref());
+
+ // Also check ONBUILD contents
+ if let Instruction::OnBuild(inner) = &instr.instruction {
+ let inner_shell = match inner.as_ref() {
+ Instruction::Run(args) => Some(ParsedShell::from_run_args(args)),
+ _ => None,
+ };
+ rule.check(&mut state, instr.line_number, inner.as_ref(), inner_shell.as_ref());
+ }
+ }
+
+ // Finalize the rule
+ let failures = rule.finalize(state);
+ all_failures.extend(failures);
+ }
+
+ all_failures
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_lint_empty() {
+ let result = lint("", &HadolintConfig::default());
+ assert!(result.failures.is_empty());
+ }
+
+ #[test]
+ fn test_lint_valid_dockerfile() {
+ let dockerfile = r#"
+FROM ubuntu:20.04
+WORKDIR /app
+COPY . .
+CMD ["./app"]
+"#;
+ let result = lint(dockerfile, &HadolintConfig::default());
+ // Should have no DL3000 (WORKDIR is absolute)
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3000"));
+ }
+
+ #[test]
+ fn test_lint_relative_workdir() {
+ let dockerfile = r#"
+FROM ubuntu:20.04
+WORKDIR app
+"#;
+ let result = lint(dockerfile, &HadolintConfig::default());
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3000"));
+ }
+
+ #[test]
+ fn test_lint_maintainer() {
+ let dockerfile = r#"
+FROM ubuntu:20.04
+MAINTAINER John Doe
+"#;
+ let result = lint(dockerfile, &HadolintConfig::default());
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL4000"));
+ }
+
+ #[test]
+ fn test_lint_untagged_image() {
+ let dockerfile = "FROM ubuntu\n";
+ let result = lint(dockerfile, &HadolintConfig::default());
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3006"));
+ }
+
+ #[test]
+ fn test_lint_latest_tag() {
+ let dockerfile = "FROM ubuntu:latest\n";
+ let result = lint(dockerfile, &HadolintConfig::default());
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3007"));
+ }
+
+ #[test]
+ fn test_lint_ignore_pragma() {
+ let dockerfile = r#"
+# hadolint ignore=DL3006
+FROM ubuntu
+"#;
+ let result = lint(dockerfile, &HadolintConfig::default());
+ // DL3006 should be ignored
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3006"));
+ }
+
+ #[test]
+ fn test_lint_config_ignore() {
+ let dockerfile = "FROM ubuntu\n";
+ let config = HadolintConfig::default().ignore("DL3006");
+ let result = lint(dockerfile, &config);
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3006"));
+ }
+
+ #[test]
+ fn test_lint_threshold() {
+ let dockerfile = r#"
+FROM ubuntu
+MAINTAINER John
+"#;
+ let mut config = HadolintConfig::default();
+ config.failure_threshold = Severity::Error;
+ let result = lint(dockerfile, &config);
+ // DL3006 (warning) should be filtered out
+ // DL4000 (error) should remain
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3006"));
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL4000"));
+ }
+
+ #[test]
+ fn test_should_fail() {
+ let dockerfile = "FROM ubuntu:latest\n";
+ let config = HadolintConfig::default().with_threshold(Severity::Warning);
+ let result = lint(dockerfile, &config);
+
+ // DL3007 is a warning, should trigger failure with Warning threshold
+ assert!(result.should_fail(&config));
+
+ // With no_fail, should not fail
+ let mut no_fail_config = config.clone();
+ no_fail_config.no_fail = true;
+ assert!(!result.should_fail(&no_fail_config));
+ }
+
+ #[test]
+ fn test_lint_sudo() {
+ let dockerfile = r#"
+FROM ubuntu:20.04
+RUN sudo apt-get update
+"#;
+ let result = lint(dockerfile, &HadolintConfig::default());
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3004"));
+ }
+
+ #[test]
+ fn test_lint_cd() {
+ let dockerfile = r#"
+FROM ubuntu:20.04
+RUN cd /app && npm install
+"#;
+ let result = lint(dockerfile, &HadolintConfig::default());
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3003"));
+ }
+
+ #[test]
+ fn test_lint_shell_form_cmd() {
+ let dockerfile = r#"
+FROM ubuntu:20.04
+CMD node app.js
+"#;
+ let result = lint(dockerfile, &HadolintConfig::default());
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3025"));
+ }
+
+ #[test]
+ fn test_lint_exec_form_cmd() {
+ let dockerfile = r#"
+FROM ubuntu:20.04
+CMD ["node", "app.js"]
+"#;
+ let result = lint(dockerfile, &HadolintConfig::default());
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3025"));
+ }
+
+ #[test]
+ fn test_lint_error_dockerfile() {
+ // Comprehensive test Dockerfile with many intentional errors
+ let dockerfile = r#"
+# Test Dockerfile with maximum hadolint errors
+MAINTAINER bad@example.com
+
+FROM ubuntu:latest
+
+LABEL maintainer="test@test.com" \
+ description="" \
+ org.opencontainers.image.created="not-a-date" \
+ org.opencontainers.image.licenses="INVALID" \
+ org.opencontainers.image.title="" \
+ org.opencontainers.image.description="" \
+ org.opencontainers.image.documentation="not-url" \
+ org.opencontainers.image.source="not-url" \
+ org.opencontainers.image.url="not-url"
+
+ENV FOO=bar BAR=$FOO
+
+COPY package.json app/
+
+WORKDIR relative/path
+
+RUN apt update
+RUN apt-get upgrade
+RUN apt-get install curl wget nginx
+
+RUN sudo useradd -m testuser
+
+RUN cd /app && echo "hello"
+
+RUN pip install flask requests
+
+RUN npm install -g express
+
+RUN gem install rails
+
+FROM alpine:latest AS alpine-stage
+RUN apk upgrade
+RUN apk add nginx
+
+FROM centos:latest AS centos-stage
+RUN yum update -y
+RUN yum install -y httpd
+
+FROM fedora:latest AS fedora-stage
+RUN dnf update
+RUN dnf install nginx
+
+FROM ubuntu:latest AS builder
+FROM debian:latest AS builder
+
+ADD https://example.com/file.txt /app/
+ADD localfile.txt /app/
+
+COPY --from=nonexistent /app /app
+
+EXPOSE 99999
+
+RUN ln -s /bin/bash /bin/sh
+
+RUN curl http://example.com | grep pattern
+
+RUN wget http://example.com/file1
+RUN curl http://example.com/file2
+
+ENTRYPOINT /bin/bash start.sh
+
+CMD echo "first"
+CMD echo "second"
+
+ENTRYPOINT ["python"]
+ENTRYPOINT ["node"]
+
+HEALTHCHECK CMD curl localhost
+HEALTHCHECK CMD wget localhost
+
+USER root
+"#;
+ let result = lint(dockerfile, &HadolintConfig::default());
+
+ // Collect unique rule codes triggered
+ let mut triggered_rules: Vec<&str> = result.failures.iter()
+ .map(|f| f.code.as_str())
+ .collect();
+ triggered_rules.sort();
+ triggered_rules.dedup();
+
+ // Print summary for debugging
+ println!("\n=== HADOLINT ERROR DOCKERFILE TEST ===");
+ println!("Total violations: {}", result.failures.len());
+ println!("Unique rules triggered: {}", triggered_rules.len());
+ println!("\nRules triggered:");
+ for rule in &triggered_rules {
+ let count = result.failures.iter().filter(|f| f.code.as_str() == *rule).count();
+ println!(" {} ({}x)", rule, count);
+ }
+
+ // Verify we catch many rules
+ assert!(triggered_rules.len() >= 30, "Expected at least 30 different rules, got {}", triggered_rules.len());
+
+ // Verify some key rules are triggered
+ assert!(triggered_rules.contains(&"DL3000"), "DL3000 not triggered");
+ assert!(triggered_rules.contains(&"DL3004"), "DL3004 not triggered");
+ assert!(triggered_rules.contains(&"DL3007"), "DL3007 not triggered");
+ assert!(triggered_rules.contains(&"DL3027"), "DL3027 not triggered");
+ assert!(triggered_rules.contains(&"DL4000"), "DL4000 not triggered");
+ assert!(triggered_rules.contains(&"DL4003"), "DL4003 not triggered");
+ assert!(triggered_rules.contains(&"DL4004"), "DL4004 not triggered");
+ }
+}
diff --git a/src/analyzer/hadolint/mod.rs b/src/analyzer/hadolint/mod.rs
new file mode 100644
index 00000000..3b0f3a9b
--- /dev/null
+++ b/src/analyzer/hadolint/mod.rs
@@ -0,0 +1,55 @@
+//! Hadolint-RS: Native Rust Dockerfile Linter
+//!
+//! A Rust translation of the Hadolint Dockerfile linter.
+//!
+//! # Attribution
+//!
+//! This module is a derivative work based on [Hadolint](https://github.com/hadolint/hadolint),
+//! originally written in Haskell by Lukas Martinelli and contributors.
+//!
+//! **Original Project:**
+//! **Original License:** GPL-3.0
+//! **Original Copyright:** Copyright (c) 2016-2024 Lukas Martinelli and contributors
+//!
+//! This Rust translation is licensed under GPL-3.0 as required by the original license.
+//! See THIRD_PARTY_NOTICES.md and LICENSE files for full details.
+//!
+//! # Features
+//!
+//! - Dockerfile parsing into an AST
+//! - Configurable linting rules (DL3xxx, DL4xxx)
+//! - ShellCheck-inspired RUN instruction analysis
+//! - Inline pragma support for ignoring rules
+//!
+//! # Example
+//!
+//! ```rust,ignore
+//! use syncable_cli::analyzer::hadolint::{lint, HadolintConfig, LintResult};
+//!
+//! let dockerfile = r#"
+//! FROM ubuntu:latest
+//! RUN apt-get update && apt-get install -y nginx
+//! "#;
+//!
+//! let config = HadolintConfig::default();
+//! let result = lint(dockerfile, &config);
+//!
+//! for failure in result.failures {
+//! println!("{}: {} - {}", failure.line, failure.code, failure.message);
+//! }
+//! ```
+
+pub mod config;
+pub mod formatter;
+pub mod lint;
+pub mod parser;
+pub mod pragma;
+pub mod rules;
+pub mod shell;
+pub mod types;
+
+// Re-export main types and functions
+pub use config::HadolintConfig;
+pub use formatter::{format_result, format_result_to_string, Formatter, OutputFormat};
+pub use lint::{lint, lint_file, LintResult};
+pub use types::{CheckFailure, RuleCode, Severity};
diff --git a/src/analyzer/hadolint/parser/dockerfile.rs b/src/analyzer/hadolint/parser/dockerfile.rs
new file mode 100644
index 00000000..f085647a
--- /dev/null
+++ b/src/analyzer/hadolint/parser/dockerfile.rs
@@ -0,0 +1,1070 @@
+//! Dockerfile parser using nom.
+//!
+//! Parses Dockerfile content into an AST of `InstructionPos` elements.
+
+use nom::{
+ branch::alt,
+ bytes::complete::{tag, tag_no_case, take_till, take_while},
+ character::complete::{char, space0, space1},
+ combinator::opt,
+ multi::separated_list0,
+ sequence::{pair, preceded, tuple},
+ IResult,
+};
+
+use super::instruction::*;
+
+/// Parse error information.
+#[derive(Debug, Clone)]
+pub struct ParseError {
+ /// Error message.
+ pub message: String,
+ /// Line number where the error occurred (1-indexed).
+ pub line: u32,
+ /// Column number (1-indexed, if available).
+ pub column: Option,
+}
+
+impl std::fmt::Display for ParseError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self.column {
+ Some(col) => write!(f, "line {}:{}: {}", self.line, col, self.message),
+ None => write!(f, "line {}: {}", self.line, self.message),
+ }
+ }
+}
+
+impl std::error::Error for ParseError {}
+
+/// Parse a Dockerfile string into a list of positioned instructions.
+pub fn parse_dockerfile(input: &str) -> Result, ParseError> {
+ let mut instructions = Vec::new();
+ let mut line_number = 1u32;
+
+ // Process line by line, handling line continuations
+ let lines: Vec<&str> = input.lines().collect();
+ let mut i = 0;
+
+ while i < lines.len() {
+ let start_line = line_number;
+ let mut combined_line = String::new();
+ let mut source_text = String::new();
+
+ // Collect lines with continuations
+ loop {
+ let line = lines.get(i).unwrap_or(&"");
+ source_text.push_str(line);
+ source_text.push('\n');
+
+ let trimmed = line.trim_end();
+ if trimmed.ends_with('\\') {
+ // Line continuation - remove backslash and continue
+ combined_line.push_str(&trimmed[..trimmed.len() - 1]);
+ combined_line.push(' ');
+ i += 1;
+ line_number += 1;
+ if i >= lines.len() {
+ break;
+ }
+ } else {
+ combined_line.push_str(trimmed);
+ i += 1;
+ line_number += 1;
+ break;
+ }
+ }
+
+ let trimmed = combined_line.trim();
+
+ // Skip empty lines
+ if trimmed.is_empty() {
+ continue;
+ }
+
+ // Parse the instruction
+ match parse_instruction(trimmed) {
+ Ok((_, instruction)) => {
+ instructions.push(InstructionPos::new(
+ instruction,
+ start_line,
+ source_text.trim_end().to_string(),
+ ));
+ }
+ Err(_) => {
+ // Try to parse as comment
+ if trimmed.starts_with('#') {
+ let comment = trimmed[1..].trim().to_string();
+ instructions.push(InstructionPos::new(
+ Instruction::Comment(comment),
+ start_line,
+ source_text.trim_end().to_string(),
+ ));
+ }
+ // Skip unparseable lines (parser directives, empty lines after continuation, etc.)
+ }
+ }
+ }
+
+ Ok(instructions)
+}
+
+/// Parse a single instruction.
+fn parse_instruction(input: &str) -> IResult<&str, Instruction> {
+ alt((
+ parse_from,
+ parse_run,
+ parse_copy,
+ parse_add,
+ parse_env,
+ parse_label,
+ parse_expose,
+ parse_arg,
+ parse_entrypoint,
+ parse_cmd,
+ parse_shell,
+ parse_user,
+ parse_workdir,
+ parse_volume,
+ parse_maintainer,
+ parse_healthcheck,
+ parse_onbuild,
+ parse_stopsignal,
+ parse_comment,
+ ))(input)
+}
+
+/// Parse FROM instruction.
+fn parse_from(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("FROM")(input)?;
+ let (input, _) = space1(input)?;
+
+ // Parse optional --platform flag
+ let (input, platform) = opt(preceded(
+ pair(tag("--platform="), space0),
+ take_till(|c: char| c.is_whitespace()),
+ ))(input)?;
+ let (input, _) = space0(input)?;
+
+ // Parse platform with space separator
+ let (input, platform) = if platform.is_none() {
+ opt(preceded(
+ pair(tag("--platform"), space0),
+ preceded(char('='), take_till(|c: char| c.is_whitespace())),
+ ))(input)?
+ } else {
+ (input, platform)
+ };
+ let (input, _) = space0(input)?;
+
+ // Parse image reference
+ let (input, image_ref) = take_till(|c: char| c.is_whitespace())(input)?;
+ let (input, _) = space0(input)?;
+
+ // Parse optional AS alias
+ let (input, alias) = opt(preceded(
+ pair(tag_no_case("AS"), space1),
+ take_while(|c: char| c.is_alphanumeric() || c == '_' || c == '-'),
+ ))(input)?;
+
+ // Parse image reference into components
+ let base_image = parse_image_reference(image_ref, platform.map(|s| s.to_string()), alias.map(|s| ImageAlias::new(s)));
+
+ Ok((input, Instruction::From(base_image)))
+}
+
+/// Parse image reference into BaseImage.
+fn parse_image_reference(
+ image_ref: &str,
+ platform: Option,
+ alias: Option,
+) -> BaseImage {
+ // Handle digest
+ if let Some(at_pos) = image_ref.find('@') {
+ let (image_part, digest) = image_ref.split_at(at_pos);
+ let digest = &digest[1..]; // Remove @
+
+ let (image, tag) = parse_image_tag(image_part);
+ return BaseImage {
+ image,
+ tag,
+ digest: Some(digest.to_string()),
+ alias,
+ platform,
+ };
+ }
+
+ // Handle tag
+ let (image, tag) = parse_image_tag(image_ref);
+
+ BaseImage {
+ image,
+ tag,
+ digest: None,
+ alias,
+ platform,
+ }
+}
+
+/// Parse image:tag into Image and optional tag.
+fn parse_image_tag(image_ref: &str) -> (Image, Option) {
+ // Find the last colon that's not part of a port or registry
+ // Registry format: host:port/name or host/name
+ // Tag format: name:tag
+
+ let parts: Vec<&str> = image_ref.split('/').collect();
+
+ if parts.len() == 1 {
+ // Simple name or name:tag
+ if let Some(colon_pos) = image_ref.rfind(':') {
+ let name = &image_ref[..colon_pos];
+ let tag = &image_ref[colon_pos + 1..];
+ (Image::new(name), Some(tag.to_string()))
+ } else {
+ (Image::new(image_ref), None)
+ }
+ } else {
+ // Has path separators - might have registry
+ let last_part = parts.last().unwrap();
+
+ // Check if last part has a tag
+ if let Some(colon_pos) = last_part.rfind(':') {
+ // Check if it looks like a tag (not a port)
+ let potential_tag = &last_part[colon_pos + 1..];
+ if !potential_tag.chars().all(|c| c.is_ascii_digit()) || potential_tag.len() > 5 {
+ // It's a tag, not a port
+ let full_name = image_ref[..image_ref.len() - potential_tag.len() - 1].to_string();
+ let (registry, name) = split_registry(&full_name);
+ return (
+ match registry {
+ Some(r) => Image::with_registry(r, name),
+ None => Image::new(name),
+ },
+ Some(potential_tag.to_string()),
+ );
+ }
+ }
+
+ // No tag
+ let (registry, name) = split_registry(image_ref);
+ (
+ match registry {
+ Some(r) => Image::with_registry(r, name),
+ None => Image::new(name),
+ },
+ None,
+ )
+ }
+}
+
+/// Split registry from image name.
+fn split_registry(name: &str) -> (Option, String) {
+ // Registry indicators: contains '.', ':', or is 'localhost'
+ if let Some(slash_pos) = name.find('/') {
+ let potential_registry = &name[..slash_pos];
+ if potential_registry.contains('.')
+ || potential_registry.contains(':')
+ || potential_registry == "localhost"
+ {
+ return (
+ Some(potential_registry.to_string()),
+ name[slash_pos + 1..].to_string(),
+ );
+ }
+ }
+ (None, name.to_string())
+}
+
+/// Parse RUN instruction.
+fn parse_run(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("RUN")(input)?;
+ let (input, _) = space0(input)?;
+
+ // Parse flags (--mount, --network, --security)
+ let (input, flags) = parse_run_flags(input)?;
+ let (input, _) = space0(input)?;
+
+ // Parse arguments (exec form or shell form)
+ let (input, arguments) = parse_arguments(input)?;
+
+ Ok((input, Instruction::Run(RunArgs { arguments, flags })))
+}
+
+/// Parse RUN flags.
+fn parse_run_flags(input: &str) -> IResult<&str, RunFlags> {
+ let mut flags = RunFlags::default();
+ let mut remaining = input;
+
+ loop {
+ let (input, _) = space0(remaining)?;
+
+ // Check for --mount
+ if let Ok((input, mount)) = parse_mount_flag(input) {
+ flags.mount.insert(mount);
+ remaining = input;
+ continue;
+ }
+
+ // Check for --network
+ if let Ok((input, network)) = parse_flag_value(input, "--network") {
+ flags.network = Some(network.to_string());
+ remaining = input;
+ continue;
+ }
+
+ // Check for --security
+ if let Ok((input, security)) = parse_flag_value(input, "--security") {
+ flags.security = Some(security.to_string());
+ remaining = input;
+ continue;
+ }
+
+ break;
+ }
+
+ Ok((remaining, flags))
+}
+
+/// Parse --flag=value.
+fn parse_flag_value<'a>(input: &'a str, flag: &str) -> IResult<&'a str, &'a str> {
+ let (input, _) = tag(flag)(input)?;
+ let (input, _) = char('=')(input)?;
+ take_till(|c: char| c.is_whitespace())(input)
+}
+
+/// Parse --mount flag.
+fn parse_mount_flag(input: &str) -> IResult<&str, RunMount> {
+ let (input, _) = tag("--mount=")(input)?;
+ let (input, mount_str) = take_till(|c: char| c.is_whitespace())(input)?;
+
+ // Parse mount options
+ let mount = parse_mount_options(mount_str);
+ Ok((input, mount))
+}
+
+/// Parse mount options string.
+fn parse_mount_options(s: &str) -> RunMount {
+ let opts: std::collections::HashMap<&str, &str> = s
+ .split(',')
+ .filter_map(|part| {
+ let mut parts = part.splitn(2, '=');
+ let key = parts.next()?;
+ let value = parts.next().unwrap_or("");
+ Some((key, value))
+ })
+ .collect();
+
+ let mount_type = opts.get("type").copied().unwrap_or("bind");
+
+ match mount_type {
+ "cache" => RunMount::Cache(CacheOpts {
+ target: opts.get("target").map(|s| s.to_string()),
+ id: opts.get("id").map(|s| s.to_string()),
+ sharing: opts.get("sharing").map(|s| s.to_string()),
+ from: opts.get("from").map(|s| s.to_string()),
+ source: opts.get("source").map(|s| s.to_string()),
+ mode: opts.get("mode").map(|s| s.to_string()),
+ uid: opts.get("uid").and_then(|s| s.parse().ok()),
+ gid: opts.get("gid").and_then(|s| s.parse().ok()),
+ read_only: opts.get("ro").is_some() || opts.get("readonly").is_some(),
+ }),
+ "tmpfs" => RunMount::Tmpfs(TmpOpts {
+ target: opts.get("target").map(|s| s.to_string()),
+ size: opts.get("size").map(|s| s.to_string()),
+ }),
+ "secret" => RunMount::Secret(SecretOpts {
+ id: opts.get("id").map(|s| s.to_string()),
+ target: opts.get("target").map(|s| s.to_string()),
+ required: opts.get("required").map(|s| *s == "true").unwrap_or(false),
+ mode: opts.get("mode").map(|s| s.to_string()),
+ uid: opts.get("uid").and_then(|s| s.parse().ok()),
+ gid: opts.get("gid").and_then(|s| s.parse().ok()),
+ }),
+ "ssh" => RunMount::Ssh(SshOpts {
+ id: opts.get("id").map(|s| s.to_string()),
+ target: opts.get("target").map(|s| s.to_string()),
+ required: opts.get("required").map(|s| *s == "true").unwrap_or(false),
+ mode: opts.get("mode").map(|s| s.to_string()),
+ uid: opts.get("uid").and_then(|s| s.parse().ok()),
+ gid: opts.get("gid").and_then(|s| s.parse().ok()),
+ }),
+ _ => RunMount::Bind(BindOpts {
+ target: opts.get("target").map(|s| s.to_string()),
+ source: opts.get("source").map(|s| s.to_string()),
+ from: opts.get("from").map(|s| s.to_string()),
+ read_only: opts.get("ro").is_some() || opts.get("readonly").is_some(),
+ }),
+ }
+}
+
+/// Parse arguments (exec form or shell form).
+fn parse_arguments(input: &str) -> IResult<&str, Arguments> {
+ // Try exec form first
+ if let Ok((remaining, list)) = parse_json_array(input) {
+ return Ok((remaining, Arguments::List(list)));
+ }
+
+ // Fall back to shell form
+ Ok(("", Arguments::Text(input.trim().to_string())))
+}
+
+/// Parse JSON array for exec form.
+fn parse_json_array(input: &str) -> IResult<&str, Vec> {
+ let (input, _) = char('[')(input)?;
+ let (input, _) = space0(input)?;
+ let (input, items) = separated_list0(
+ tuple((space0, char(','), space0)),
+ parse_json_string,
+ )(input)?;
+ let (input, _) = space0(input)?;
+ let (input, _) = char(']')(input)?;
+ Ok((input, items))
+}
+
+/// Parse a JSON string.
+fn parse_json_string(input: &str) -> IResult<&str, String> {
+ let (input, _) = char('"')(input)?;
+ let mut result = String::new();
+ let mut chars = input.chars().peekable();
+ let mut consumed = 0;
+
+ while let Some(c) = chars.next() {
+ consumed += c.len_utf8();
+ if c == '"' {
+ return Ok((&input[consumed..], result));
+ } else if c == '\\' {
+ if let Some(next) = chars.next() {
+ consumed += next.len_utf8();
+ match next {
+ 'n' => result.push('\n'),
+ 't' => result.push('\t'),
+ 'r' => result.push('\r'),
+ '\\' => result.push('\\'),
+ '"' => result.push('"'),
+ _ => {
+ result.push('\\');
+ result.push(next);
+ }
+ }
+ }
+ } else {
+ result.push(c);
+ }
+ }
+
+ Err(nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Char)))
+}
+
+/// Parse COPY instruction.
+fn parse_copy(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("COPY")(input)?;
+ let (input, _) = space0(input)?;
+
+ // Parse flags
+ let (input, flags) = parse_copy_flags(input)?;
+ let (input, _) = space0(input)?;
+
+ // Parse sources and destination
+ let (input, args) = parse_copy_args(input)?;
+
+ Ok((input, Instruction::Copy(args, flags)))
+}
+
+/// Parse COPY flags.
+fn parse_copy_flags(input: &str) -> IResult<&str, CopyFlags> {
+ let mut flags = CopyFlags::default();
+ let mut remaining = input;
+
+ loop {
+ let (input, _) = space0(remaining)?;
+
+ if let Ok((input, from)) = parse_flag_value(input, "--from") {
+ flags.from = Some(from.to_string());
+ remaining = input;
+ continue;
+ }
+ if let Ok((input, chown)) = parse_flag_value(input, "--chown") {
+ flags.chown = Some(chown.to_string());
+ remaining = input;
+ continue;
+ }
+ if let Ok((input, chmod)) = parse_flag_value(input, "--chmod") {
+ flags.chmod = Some(chmod.to_string());
+ remaining = input;
+ continue;
+ }
+ if let Ok((input, _)) = tag::<&str, &str, nom::error::Error<&str>>("--link")(input) {
+ flags.link = true;
+ remaining = input;
+ continue;
+ }
+
+ break;
+ }
+
+ Ok((remaining, flags))
+}
+
+/// Parse COPY arguments.
+fn parse_copy_args(input: &str) -> IResult<&str, CopyArgs> {
+ // Try exec form first
+ if let Ok((remaining, items)) = parse_json_array(input) {
+ if items.len() >= 2 {
+ let dest = items.last().unwrap().clone();
+ let sources = items[..items.len() - 1].to_vec();
+ return Ok((remaining, CopyArgs::new(sources, dest)));
+ }
+ }
+
+ // Shell form: space-separated paths
+ let parts: Vec<&str> = input.split_whitespace().collect();
+ if parts.len() >= 2 {
+ let dest = parts.last().unwrap().to_string();
+ let sources: Vec = parts[..parts.len() - 1].iter().map(|s| s.to_string()).collect();
+ Ok(("", CopyArgs::new(sources, dest)))
+ } else if parts.len() == 1 {
+ // Single argument - treat as both source and dest
+ Ok(("", CopyArgs::new(vec![parts[0].to_string()], parts[0])))
+ } else {
+ Err(nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Space)))
+ }
+}
+
+/// Parse ADD instruction.
+fn parse_add(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("ADD")(input)?;
+ let (input, _) = space0(input)?;
+
+ // Parse flags
+ let (input, flags) = parse_add_flags(input)?;
+ let (input, _) = space0(input)?;
+
+ // Parse sources and destination (same as COPY)
+ let (input, copy_args) = parse_copy_args(input)?;
+ let args = AddArgs::new(copy_args.sources, copy_args.dest);
+
+ Ok((input, Instruction::Add(args, flags)))
+}
+
+/// Parse ADD flags.
+fn parse_add_flags(input: &str) -> IResult<&str, AddFlags> {
+ let mut flags = AddFlags::default();
+ let mut remaining = input;
+
+ loop {
+ let (input, _) = space0(remaining)?;
+
+ if let Ok((input, chown)) = parse_flag_value(input, "--chown") {
+ flags.chown = Some(chown.to_string());
+ remaining = input;
+ continue;
+ }
+ if let Ok((input, chmod)) = parse_flag_value(input, "--chmod") {
+ flags.chmod = Some(chmod.to_string());
+ remaining = input;
+ continue;
+ }
+ if let Ok((input, checksum)) = parse_flag_value(input, "--checksum") {
+ flags.checksum = Some(checksum.to_string());
+ remaining = input;
+ continue;
+ }
+ if let Ok((input, _)) = tag::<&str, &str, nom::error::Error<&str>>("--link")(input) {
+ flags.link = true;
+ remaining = input;
+ continue;
+ }
+
+ break;
+ }
+
+ Ok((remaining, flags))
+}
+
+/// Parse ENV instruction.
+fn parse_env(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("ENV")(input)?;
+ let (input, _) = space1(input)?;
+
+ // ENV can be KEY=VALUE or KEY VALUE
+ let pairs = parse_key_value_pairs(input);
+ Ok(("", Instruction::Env(pairs)))
+}
+
+/// Parse LABEL instruction.
+fn parse_label(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("LABEL")(input)?;
+ let (input, _) = space1(input)?;
+
+ let pairs = parse_key_value_pairs(input);
+ Ok(("", Instruction::Label(pairs)))
+}
+
+/// Parse key=value pairs.
+fn parse_key_value_pairs(input: &str) -> Vec<(String, String)> {
+ let mut pairs = Vec::new();
+ let mut remaining = input.trim();
+
+ while !remaining.is_empty() {
+ // Find key
+ let key_end = remaining.find(|c: char| c == '=' || c.is_whitespace()).unwrap_or(remaining.len());
+ if key_end == 0 {
+ remaining = remaining.trim_start();
+ continue;
+ }
+
+ let key = &remaining[..key_end];
+ remaining = &remaining[key_end..];
+
+ // Check for = sign
+ if remaining.starts_with('=') {
+ remaining = &remaining[1..];
+ // Parse value
+ let value = if remaining.starts_with('"') {
+ // Quoted value
+ let end = find_closing_quote(remaining);
+ let val = &remaining[1..end];
+ remaining = &remaining[end + 1..];
+ val.to_string()
+ } else {
+ // Unquoted value
+ let end = remaining.find(|c: char| c.is_whitespace()).unwrap_or(remaining.len());
+ let val = &remaining[..end];
+ remaining = &remaining[end..];
+ val.to_string()
+ };
+ pairs.push((key.to_string(), value));
+ } else {
+ // Legacy format: KEY VALUE (no =)
+ remaining = remaining.trim_start();
+ if !remaining.is_empty() {
+ let value = if remaining.starts_with('"') {
+ let end = find_closing_quote(remaining);
+ let val = &remaining[1..end];
+ remaining = &remaining[end + 1..];
+ val.to_string()
+ } else {
+ remaining.to_string()
+ };
+ pairs.push((key.to_string(), value.trim().to_string()));
+ break;
+ }
+ }
+
+ remaining = remaining.trim_start();
+ }
+
+ pairs
+}
+
+/// Find closing quote position.
+fn find_closing_quote(s: &str) -> usize {
+ let mut escaped = false;
+ for (i, c) in s.char_indices().skip(1) {
+ if escaped {
+ escaped = false;
+ } else if c == '\\' {
+ escaped = true;
+ } else if c == '"' {
+ return i;
+ }
+ }
+ s.len() - 1
+}
+
+/// Parse EXPOSE instruction.
+fn parse_expose(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("EXPOSE")(input)?;
+ let (input, _) = space1(input)?;
+
+ let mut ports = Vec::new();
+ for part in input.split_whitespace() {
+ if let Some(port) = parse_port_spec(part) {
+ ports.push(port);
+ }
+ }
+
+ Ok(("", Instruction::Expose(ports)))
+}
+
+/// Parse a port specification like "80", "80/tcp", "53/udp".
+fn parse_port_spec(s: &str) -> Option {
+ let parts: Vec<&str> = s.split('/').collect();
+ let port_num: u16 = parts[0].parse().ok()?;
+ let protocol = parts.get(1).map(|p| {
+ if p.eq_ignore_ascii_case("udp") {
+ PortProtocol::Udp
+ } else {
+ PortProtocol::Tcp
+ }
+ }).unwrap_or(PortProtocol::Tcp);
+
+ Some(Port { number: port_num, protocol })
+}
+
+/// Parse ARG instruction.
+fn parse_arg(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("ARG")(input)?;
+ let (input, _) = space1(input)?;
+
+ let content = input.trim();
+ if let Some(eq_pos) = content.find('=') {
+ let name = content[..eq_pos].to_string();
+ let default = content[eq_pos + 1..].to_string();
+ Ok(("", Instruction::Arg(name, Some(default))))
+ } else {
+ Ok(("", Instruction::Arg(content.to_string(), None)))
+ }
+}
+
+/// Parse ENTRYPOINT instruction.
+fn parse_entrypoint(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("ENTRYPOINT")(input)?;
+ let (input, _) = space0(input)?;
+
+ let (input, arguments) = parse_arguments(input)?;
+ Ok((input, Instruction::Entrypoint(arguments)))
+}
+
+/// Parse CMD instruction.
+fn parse_cmd(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("CMD")(input)?;
+ let (input, _) = space0(input)?;
+
+ let (input, arguments) = parse_arguments(input)?;
+ Ok((input, Instruction::Cmd(arguments)))
+}
+
+/// Parse SHELL instruction.
+fn parse_shell(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("SHELL")(input)?;
+ let (input, _) = space0(input)?;
+
+ let (input, arguments) = parse_arguments(input)?;
+ Ok((input, Instruction::Shell(arguments)))
+}
+
+/// Parse USER instruction.
+fn parse_user(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("USER")(input)?;
+ let (input, _) = space1(input)?;
+
+ Ok(("", Instruction::User(input.trim().to_string())))
+}
+
+/// Parse WORKDIR instruction.
+fn parse_workdir(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("WORKDIR")(input)?;
+ let (input, _) = space1(input)?;
+
+ Ok(("", Instruction::Workdir(input.trim().to_string())))
+}
+
+/// Parse VOLUME instruction.
+fn parse_volume(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("VOLUME")(input)?;
+ let (input, _) = space1(input)?;
+
+ // VOLUME can be JSON array or space-separated
+ // For simplicity, store as single string
+ Ok(("", Instruction::Volume(input.trim().to_string())))
+}
+
+/// Parse MAINTAINER instruction (deprecated).
+fn parse_maintainer(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("MAINTAINER")(input)?;
+ let (input, _) = space1(input)?;
+
+ Ok(("", Instruction::Maintainer(input.trim().to_string())))
+}
+
+/// Parse HEALTHCHECK instruction.
+fn parse_healthcheck(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("HEALTHCHECK")(input)?;
+ let (input, _) = space1(input)?;
+
+ let content = input.trim();
+
+ // Check for NONE
+ if content.eq_ignore_ascii_case("NONE") {
+ return Ok(("", Instruction::Healthcheck(HealthCheck::None)));
+ }
+
+ // Parse options
+ let mut interval = None;
+ let mut timeout = None;
+ let mut start_period = None;
+ let mut retries = None;
+ let mut remaining = content;
+
+ loop {
+ remaining = remaining.trim_start();
+ if remaining.starts_with("--interval=") {
+ let value_start = 11;
+ let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len());
+ interval = Some(remaining[value_start..value_end].to_string());
+ remaining = &remaining[value_end..];
+ } else if remaining.starts_with("--timeout=") {
+ let value_start = 10;
+ let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len());
+ timeout = Some(remaining[value_start..value_end].to_string());
+ remaining = &remaining[value_end..];
+ } else if remaining.starts_with("--start-period=") {
+ let value_start = 15;
+ let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len());
+ start_period = Some(remaining[value_start..value_end].to_string());
+ remaining = &remaining[value_end..];
+ } else if remaining.starts_with("--retries=") {
+ let value_start = 10;
+ let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len());
+ retries = remaining[value_start..value_end].parse().ok();
+ remaining = &remaining[value_end..];
+ } else {
+ break;
+ }
+ }
+
+ // Parse CMD
+ remaining = remaining.trim_start();
+ if remaining.to_uppercase().starts_with("CMD") {
+ remaining = &remaining[3..].trim_start();
+ }
+
+ let (_, arguments) = parse_arguments(remaining)?;
+
+ Ok(("", Instruction::Healthcheck(HealthCheck::Cmd {
+ cmd: arguments,
+ interval,
+ timeout,
+ start_period,
+ retries,
+ })))
+}
+
+/// Parse ONBUILD instruction.
+fn parse_onbuild(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("ONBUILD")(input)?;
+ let (input, _) = space1(input)?;
+
+ let (remaining, inner) = parse_instruction(input)?;
+ Ok((remaining, Instruction::OnBuild(Box::new(inner))))
+}
+
+/// Parse STOPSIGNAL instruction.
+fn parse_stopsignal(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = tag_no_case("STOPSIGNAL")(input)?;
+ let (input, _) = space1(input)?;
+
+ Ok(("", Instruction::Stopsignal(input.trim().to_string())))
+}
+
+/// Parse comment.
+fn parse_comment(input: &str) -> IResult<&str, Instruction> {
+ let (input, _) = char('#')(input)?;
+ Ok(("", Instruction::Comment(input.trim().to_string())))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_from_simple() {
+ let result = parse_dockerfile("FROM ubuntu").unwrap();
+ assert_eq!(result.len(), 1);
+ match &result[0].instruction {
+ Instruction::From(base) => {
+ assert_eq!(base.image.name, "ubuntu");
+ assert!(base.tag.is_none());
+ }
+ _ => panic!("Expected FROM instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_from_with_tag() {
+ let result = parse_dockerfile("FROM ubuntu:20.04").unwrap();
+ match &result[0].instruction {
+ Instruction::From(base) => {
+ assert_eq!(base.image.name, "ubuntu");
+ assert_eq!(base.tag, Some("20.04".to_string()));
+ }
+ _ => panic!("Expected FROM instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_from_with_alias() {
+ let result = parse_dockerfile("FROM ubuntu:20.04 AS builder").unwrap();
+ match &result[0].instruction {
+ Instruction::From(base) => {
+ assert_eq!(base.image.name, "ubuntu");
+ assert_eq!(base.alias.as_ref().map(|a| a.as_str()), Some("builder"));
+ }
+ _ => panic!("Expected FROM instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_run_shell() {
+ let result = parse_dockerfile("RUN apt-get update && apt-get install -y nginx").unwrap();
+ match &result[0].instruction {
+ Instruction::Run(args) => {
+ assert!(args.arguments.is_shell_form());
+ assert!(args.arguments.as_text().unwrap().contains("apt-get"));
+ }
+ _ => panic!("Expected RUN instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_run_exec() {
+ let result = parse_dockerfile(r#"RUN ["apt-get", "update"]"#).unwrap();
+ match &result[0].instruction {
+ Instruction::Run(args) => {
+ assert!(args.arguments.is_exec_form());
+ let list = args.arguments.as_list().unwrap();
+ assert_eq!(list[0], "apt-get");
+ assert_eq!(list[1], "update");
+ }
+ _ => panic!("Expected RUN instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_copy() {
+ let result = parse_dockerfile("COPY src/ /app/").unwrap();
+ match &result[0].instruction {
+ Instruction::Copy(args, _) => {
+ assert_eq!(args.sources, vec!["src/"]);
+ assert_eq!(args.dest, "/app/");
+ }
+ _ => panic!("Expected COPY instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_copy_with_from() {
+ let result = parse_dockerfile("COPY --from=builder /app/dist /app/").unwrap();
+ match &result[0].instruction {
+ Instruction::Copy(args, flags) => {
+ assert_eq!(flags.from, Some("builder".to_string()));
+ assert_eq!(args.sources, vec!["/app/dist"]);
+ assert_eq!(args.dest, "/app/");
+ }
+ _ => panic!("Expected COPY instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_env() {
+ let result = parse_dockerfile("ENV NODE_ENV=production").unwrap();
+ match &result[0].instruction {
+ Instruction::Env(pairs) => {
+ assert_eq!(pairs.len(), 1);
+ assert_eq!(pairs[0].0, "NODE_ENV");
+ assert_eq!(pairs[0].1, "production");
+ }
+ _ => panic!("Expected ENV instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_expose() {
+ let result = parse_dockerfile("EXPOSE 80 443/tcp 53/udp").unwrap();
+ match &result[0].instruction {
+ Instruction::Expose(ports) => {
+ assert_eq!(ports.len(), 3);
+ assert_eq!(ports[0].number, 80);
+ assert_eq!(ports[1].number, 443);
+ assert_eq!(ports[2].number, 53);
+ assert_eq!(ports[2].protocol, PortProtocol::Udp);
+ }
+ _ => panic!("Expected EXPOSE instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_workdir() {
+ let result = parse_dockerfile("WORKDIR /app").unwrap();
+ match &result[0].instruction {
+ Instruction::Workdir(path) => {
+ assert_eq!(path, "/app");
+ }
+ _ => panic!("Expected WORKDIR instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_user() {
+ let result = parse_dockerfile("USER node").unwrap();
+ match &result[0].instruction {
+ Instruction::User(user) => {
+ assert_eq!(user, "node");
+ }
+ _ => panic!("Expected USER instruction"),
+ }
+ }
+
+ #[test]
+ fn test_parse_comment() {
+ let result = parse_dockerfile("# This is a comment").unwrap();
+ match &result[0].instruction {
+ Instruction::Comment(text) => {
+ assert_eq!(text, "This is a comment");
+ }
+ _ => panic!("Expected Comment"),
+ }
+ }
+
+ #[test]
+ fn test_parse_full_dockerfile() {
+ let dockerfile = r#"
+FROM node:18-alpine AS builder
+WORKDIR /app
+COPY package*.json ./
+RUN npm ci
+COPY . .
+RUN npm run build
+
+FROM node:18-alpine
+WORKDIR /app
+COPY --from=builder /app/dist ./dist
+EXPOSE 3000
+CMD ["node", "dist/index.js"]
+"#;
+
+ let result = parse_dockerfile(dockerfile).unwrap();
+ // Should have multiple instructions
+ assert!(result.len() >= 10);
+ }
+
+ #[test]
+ fn test_line_continuation() {
+ let dockerfile = r#"RUN apt-get update && \
+ apt-get install -y nginx"#;
+
+ let result = parse_dockerfile(dockerfile).unwrap();
+ assert_eq!(result.len(), 1);
+ match &result[0].instruction {
+ Instruction::Run(args) => {
+ let text = args.arguments.as_text().unwrap();
+ assert!(text.contains("apt-get update"));
+ assert!(text.contains("apt-get install"));
+ }
+ _ => panic!("Expected RUN instruction"),
+ }
+ }
+
+ #[test]
+ fn test_image_with_registry() {
+ let result = parse_dockerfile("FROM gcr.io/my-project/my-image:latest").unwrap();
+ match &result[0].instruction {
+ Instruction::From(base) => {
+ assert_eq!(base.image.registry, Some("gcr.io".to_string()));
+ assert_eq!(base.image.name, "my-project/my-image");
+ assert_eq!(base.tag, Some("latest".to_string()));
+ }
+ _ => panic!("Expected FROM instruction"),
+ }
+ }
+}
diff --git a/src/analyzer/hadolint/parser/instruction.rs b/src/analyzer/hadolint/parser/instruction.rs
new file mode 100644
index 00000000..5716564e
--- /dev/null
+++ b/src/analyzer/hadolint/parser/instruction.rs
@@ -0,0 +1,549 @@
+//! Dockerfile instruction AST types.
+//!
+//! These types represent the parsed structure of a Dockerfile,
+//! matching the Haskell `language-docker` library for compatibility.
+
+use std::collections::HashSet;
+
+/// A positioned instruction with source location information.
+#[derive(Debug, Clone, PartialEq)]
+pub struct InstructionPos {
+ /// The parsed instruction.
+ pub instruction: Instruction,
+ /// Line number (1-indexed).
+ pub line_number: u32,
+ /// Original source text of the instruction.
+ pub source_text: String,
+}
+
+impl InstructionPos {
+ /// Create a new positioned instruction.
+ pub fn new(instruction: Instruction, line_number: u32, source_text: String) -> Self {
+ Self {
+ instruction,
+ line_number,
+ source_text,
+ }
+ }
+}
+
+/// Dockerfile instructions.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Instruction {
+ /// FROM instruction
+ From(BaseImage),
+ /// RUN instruction
+ Run(RunArgs),
+ /// COPY instruction
+ Copy(CopyArgs, CopyFlags),
+ /// ADD instruction
+ Add(AddArgs, AddFlags),
+ /// ENV instruction
+ Env(Vec<(String, String)>),
+ /// LABEL instruction
+ Label(Vec<(String, String)>),
+ /// EXPOSE instruction
+ Expose(Vec),
+ /// ARG instruction
+ Arg(String, Option),
+ /// ENTRYPOINT instruction
+ Entrypoint(Arguments),
+ /// CMD instruction
+ Cmd(Arguments),
+ /// SHELL instruction
+ Shell(Arguments),
+ /// USER instruction
+ User(String),
+ /// WORKDIR instruction
+ Workdir(String),
+ /// VOLUME instruction
+ Volume(String),
+ /// MAINTAINER instruction (deprecated)
+ Maintainer(String),
+ /// HEALTHCHECK instruction
+ Healthcheck(HealthCheck),
+ /// ONBUILD instruction (wraps another instruction)
+ OnBuild(Box),
+ /// STOPSIGNAL instruction
+ Stopsignal(String),
+ /// Comment line
+ Comment(String),
+}
+
+impl Instruction {
+ /// Check if this is a FROM instruction.
+ pub fn is_from(&self) -> bool {
+ matches!(self, Self::From(_))
+ }
+
+ /// Check if this is a RUN instruction.
+ pub fn is_run(&self) -> bool {
+ matches!(self, Self::Run(_))
+ }
+
+ /// Check if this is a COPY instruction.
+ pub fn is_copy(&self) -> bool {
+ matches!(self, Self::Copy(_, _))
+ }
+
+ /// Check if this is an ONBUILD instruction.
+ pub fn is_onbuild(&self) -> bool {
+ matches!(self, Self::OnBuild(_))
+ }
+
+ /// Get the wrapped instruction if this is ONBUILD.
+ pub fn unwrap_onbuild(&self) -> Option<&Instruction> {
+ match self {
+ Self::OnBuild(inner) => Some(inner.as_ref()),
+ _ => None,
+ }
+ }
+}
+
+/// Base image in FROM instruction.
+#[derive(Debug, Clone, PartialEq)]
+pub struct BaseImage {
+ /// The image reference.
+ pub image: Image,
+ /// Image tag (e.g., "latest", "3.9").
+ pub tag: Option,
+ /// Image digest (e.g., "sha256:...").
+ pub digest: Option,
+ /// Stage alias (AS name).
+ pub alias: Option,
+ /// Target platform (--platform=...).
+ pub platform: Option,
+}
+
+impl BaseImage {
+ /// Create a new base image with just a name.
+ pub fn new(name: impl Into) -> Self {
+ Self {
+ image: Image::new(name),
+ tag: None,
+ digest: None,
+ alias: None,
+ platform: None,
+ }
+ }
+
+ /// Check if the image uses a variable reference.
+ pub fn is_variable(&self) -> bool {
+ self.image.name.starts_with('$')
+ }
+
+ /// Check if this is the scratch image.
+ pub fn is_scratch(&self) -> bool {
+ self.image.name.eq_ignore_ascii_case("scratch")
+ }
+
+ /// Check if the image has an explicit tag or digest.
+ pub fn has_version(&self) -> bool {
+ self.tag.is_some() || self.digest.is_some()
+ }
+}
+
+/// Docker image reference.
+#[derive(Debug, Clone, PartialEq)]
+pub struct Image {
+ /// Optional registry (e.g., "docker.io", "gcr.io").
+ pub registry: Option,
+ /// Image name (e.g., "ubuntu", "library/ubuntu").
+ pub name: String,
+}
+
+impl Image {
+ /// Create a new image with just a name.
+ pub fn new(name: impl Into) -> Self {
+ Self {
+ registry: None,
+ name: name.into(),
+ }
+ }
+
+ /// Create a new image with registry.
+ pub fn with_registry(registry: impl Into, name: impl Into) -> Self {
+ Self {
+ registry: Some(registry.into()),
+ name: name.into(),
+ }
+ }
+
+ /// Get the full image reference.
+ pub fn full_name(&self) -> String {
+ match &self.registry {
+ Some(reg) => format!("{}/{}", reg, self.name),
+ None => self.name.clone(),
+ }
+ }
+}
+
+/// Image alias (AS name in FROM).
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct ImageAlias(pub String);
+
+impl ImageAlias {
+ /// Create a new image alias.
+ pub fn new(name: impl Into) -> Self {
+ Self(name.into())
+ }
+
+ /// Get the alias name.
+ pub fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+/// RUN instruction arguments.
+#[derive(Debug, Clone, PartialEq)]
+pub struct RunArgs {
+ /// The command arguments.
+ pub arguments: Arguments,
+ /// RUN flags (--mount, --network, etc.).
+ pub flags: RunFlags,
+}
+
+impl RunArgs {
+ /// Create a new RUN with shell form.
+ pub fn shell(cmd: impl Into) -> Self {
+ Self {
+ arguments: Arguments::Text(cmd.into()),
+ flags: RunFlags::default(),
+ }
+ }
+
+ /// Create a new RUN with exec form.
+ pub fn exec(args: Vec) -> Self {
+ Self {
+ arguments: Arguments::List(args),
+ flags: RunFlags::default(),
+ }
+ }
+}
+
+/// RUN instruction flags.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct RunFlags {
+ /// Mount options (--mount=...).
+ pub mount: HashSet,
+ /// Network mode (--network=...).
+ pub network: Option,
+ /// Security mode (--security=...).
+ pub security: Option,
+}
+
+/// RUN mount types.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum RunMount {
+ /// Bind mount
+ Bind(BindOpts),
+ /// Cache mount
+ Cache(CacheOpts),
+ /// Tmpfs mount
+ Tmpfs(TmpOpts),
+ /// Secret mount
+ Secret(SecretOpts),
+ /// SSH mount
+ Ssh(SshOpts),
+}
+
+/// Bind mount options.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
+pub struct BindOpts {
+ pub target: Option,
+ pub source: Option,
+ pub from: Option,
+ pub read_only: bool,
+}
+
+/// Cache mount options.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
+pub struct CacheOpts {
+ pub target: Option,
+ pub id: Option,
+ pub sharing: Option,
+ pub from: Option,
+ pub source: Option,
+ pub mode: Option,
+ pub uid: Option,
+ pub gid: Option,
+ pub read_only: bool,
+}
+
+/// Tmpfs mount options.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
+pub struct TmpOpts {
+ pub target: Option,
+ pub size: Option,
+}
+
+/// Secret mount options.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
+pub struct SecretOpts {
+ pub id: Option,
+ pub target: Option,
+ pub required: bool,
+ pub mode: Option,
+ pub uid: Option,
+ pub gid: Option,
+}
+
+/// SSH mount options.
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
+pub struct SshOpts {
+ pub id: Option,
+ pub target: Option,
+ pub required: bool,
+ pub mode: Option,
+ pub uid: Option,
+ pub gid: Option,
+}
+
+/// COPY instruction arguments.
+#[derive(Debug, Clone, PartialEq)]
+pub struct CopyArgs {
+ /// Source paths.
+ pub sources: Vec,
+ /// Destination path.
+ pub dest: String,
+}
+
+impl CopyArgs {
+ /// Create new copy args.
+ pub fn new(sources: Vec, dest: impl Into) -> Self {
+ Self {
+ sources,
+ dest: dest.into(),
+ }
+ }
+}
+
+/// COPY instruction flags.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct CopyFlags {
+ /// --from=
+ pub from: Option,
+ /// --chown=
+ pub chown: Option,
+ /// --chmod=
+ pub chmod: Option,
+ /// --link
+ pub link: bool,
+}
+
+/// ADD instruction arguments.
+#[derive(Debug, Clone, PartialEq)]
+pub struct AddArgs {
+ /// Source paths/URLs.
+ pub sources: Vec,
+ /// Destination path.
+ pub dest: String,
+}
+
+impl AddArgs {
+ /// Create new add args.
+ pub fn new(sources: Vec, dest: impl Into) -> Self {
+ Self {
+ sources,
+ dest: dest.into(),
+ }
+ }
+
+ /// Check if any source is a URL.
+ pub fn has_url(&self) -> bool {
+ self.sources.iter().any(|s| s.starts_with("http://") || s.starts_with("https://"))
+ }
+
+ /// Check if any source appears to be an archive.
+ pub fn has_archive(&self) -> bool {
+ const ARCHIVE_EXTENSIONS: &[&str] = &[
+ ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz",
+ ".zip", ".gz", ".bz2", ".xz", ".Z", ".lz", ".lzma",
+ ];
+ self.sources.iter().any(|s| {
+ ARCHIVE_EXTENSIONS.iter().any(|ext| s.ends_with(ext))
+ })
+ }
+}
+
+/// ADD instruction flags.
+#[derive(Debug, Clone, PartialEq, Default)]
+pub struct AddFlags {
+ /// --chown=
+ pub chown: Option,
+ /// --chmod=
+ pub chmod: Option,
+ /// --link
+ pub link: bool,
+ /// --checksum=
+ pub checksum: Option,
+}
+
+/// Port specification.
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct Port {
+ /// Port number.
+ pub number: u16,
+ /// Protocol (tcp/udp).
+ pub protocol: PortProtocol,
+}
+
+impl Port {
+ /// Create a TCP port.
+ pub fn tcp(number: u16) -> Self {
+ Self {
+ number,
+ protocol: PortProtocol::Tcp,
+ }
+ }
+
+ /// Create a UDP port.
+ pub fn udp(number: u16) -> Self {
+ Self {
+ number,
+ protocol: PortProtocol::Udp,
+ }
+ }
+}
+
+/// Port protocol.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
+pub enum PortProtocol {
+ #[default]
+ Tcp,
+ Udp,
+}
+
+/// Arguments in shell form or exec form.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Arguments {
+ /// Shell form: RUN apt-get update
+ Text(String),
+ /// Exec form: RUN ["apt-get", "update"]
+ List(Vec),
+}
+
+impl Arguments {
+ /// Check if this is shell form.
+ pub fn is_shell_form(&self) -> bool {
+ matches!(self, Self::Text(_))
+ }
+
+ /// Check if this is exec form.
+ pub fn is_exec_form(&self) -> bool {
+ matches!(self, Self::List(_))
+ }
+
+ /// Get the text if shell form.
+ pub fn as_text(&self) -> Option<&str> {
+ match self {
+ Self::Text(s) => Some(s),
+ _ => None,
+ }
+ }
+
+ /// Get the list if exec form.
+ pub fn as_list(&self) -> Option<&[String]> {
+ match self {
+ Self::List(v) => Some(v),
+ _ => None,
+ }
+ }
+
+ /// Convert to a single string (for shell form, returns as-is; for exec form, joins with spaces).
+ pub fn to_string_lossy(&self) -> String {
+ match self {
+ Self::Text(s) => s.clone(),
+ Self::List(v) => v.join(" "),
+ }
+ }
+}
+
+/// HEALTHCHECK instruction.
+#[derive(Debug, Clone, PartialEq)]
+pub enum HealthCheck {
+ /// HEALTHCHECK NONE
+ None,
+ /// HEALTHCHECK CMD ...
+ Cmd {
+ /// The command to run.
+ cmd: Arguments,
+ /// Interval between checks.
+ interval: Option,
+ /// Timeout for each check.
+ timeout: Option,
+ /// Start period before checks begin.
+ start_period: Option,
+ /// Number of retries before unhealthy.
+ retries: Option,
+ },
+}
+
+impl HealthCheck {
+ /// Create a HEALTHCHECK CMD with defaults.
+ pub fn cmd(cmd: Arguments) -> Self {
+ Self::Cmd {
+ cmd,
+ interval: None,
+ timeout: None,
+ start_period: None,
+ retries: None,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_base_image() {
+ let img = BaseImage::new("ubuntu");
+ assert!(!img.is_scratch());
+ assert!(!img.is_variable());
+ assert!(!img.has_version());
+
+ let scratch = BaseImage::new("scratch");
+ assert!(scratch.is_scratch());
+
+ let var = BaseImage::new("${BASE_IMAGE}");
+ assert!(var.is_variable());
+
+ let tagged = BaseImage {
+ tag: Some("20.04".to_string()),
+ ..BaseImage::new("ubuntu")
+ };
+ assert!(tagged.has_version());
+ }
+
+ #[test]
+ fn test_image() {
+ let img = Image::new("ubuntu");
+ assert_eq!(img.full_name(), "ubuntu");
+
+ let img_with_reg = Image::with_registry("gcr.io", "my-project/my-image");
+ assert_eq!(img_with_reg.full_name(), "gcr.io/my-project/my-image");
+ }
+
+ #[test]
+ fn test_arguments() {
+ let shell = Arguments::Text("apt-get update".to_string());
+ assert!(shell.is_shell_form());
+ assert_eq!(shell.as_text(), Some("apt-get update"));
+
+ let exec = Arguments::List(vec!["apt-get".to_string(), "update".to_string()]);
+ assert!(exec.is_exec_form());
+ assert_eq!(exec.as_list(), Some(&["apt-get".to_string(), "update".to_string()][..]));
+ }
+
+ #[test]
+ fn test_add_args() {
+ let add = AddArgs::new(vec!["app.tar.gz".to_string()], "/app");
+ assert!(add.has_archive());
+ assert!(!add.has_url());
+
+ let add_url = AddArgs::new(vec!["https://example.com/file.txt".to_string()], "/app");
+ assert!(add_url.has_url());
+ assert!(!add_url.has_archive());
+ }
+}
diff --git a/src/analyzer/hadolint/parser/mod.rs b/src/analyzer/hadolint/parser/mod.rs
new file mode 100644
index 00000000..13bb1789
--- /dev/null
+++ b/src/analyzer/hadolint/parser/mod.rs
@@ -0,0 +1,11 @@
+//! Dockerfile parser module.
+//!
+//! Provides:
+//! - `instruction` - Dockerfile AST types
+//! - `dockerfile` - nom-based parser implementation
+
+pub mod dockerfile;
+pub mod instruction;
+
+pub use dockerfile::{parse_dockerfile, ParseError};
+pub use instruction::*;
diff --git a/src/analyzer/hadolint/pragma.rs b/src/analyzer/hadolint/pragma.rs
new file mode 100644
index 00000000..7dfae7e2
--- /dev/null
+++ b/src/analyzer/hadolint/pragma.rs
@@ -0,0 +1,224 @@
+//! Pragma parsing for inline rule ignores.
+//!
+//! Hadolint supports inline pragmas to ignore rules:
+//! - `# hadolint ignore=DL3008,DL3009` - Ignore for next instruction
+//! - `# hadolint global ignore=DL3008` - Ignore for entire file
+//! - `# hadolint shell=/bin/bash` - Set shell for ShellCheck
+
+use crate::analyzer::hadolint::types::RuleCode;
+use std::collections::{HashMap, HashSet};
+
+/// Parsed pragma state for a Dockerfile.
+#[derive(Debug, Clone, Default)]
+pub struct PragmaState {
+ /// Per-line ignored rules: line -> set of ignored codes.
+ pub ignored: HashMap>,
+ /// Globally ignored rules.
+ pub global_ignored: HashSet,
+ /// Shell override (if specified).
+ pub shell: Option,
+}
+
+impl PragmaState {
+ /// Create a new empty pragma state.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Check if a rule should be ignored on a specific line.
+ pub fn is_ignored(&self, code: &RuleCode, line: u32) -> bool {
+ // Check global ignores
+ if self.global_ignored.contains(code) {
+ return true;
+ }
+
+ // Check line-specific ignores (check previous line, as pragma applies to next line)
+ if let Some(ignored) = self.ignored.get(&line) {
+ if ignored.contains(code) {
+ return true;
+ }
+ }
+
+ // Also check if the pragma was on the line before
+ if line > 0 {
+ if let Some(ignored) = self.ignored.get(&(line - 1)) {
+ if ignored.contains(code) {
+ return true;
+ }
+ }
+ }
+
+ false
+ }
+}
+
+/// Parse pragma from a comment string.
+/// Returns the pragma type and any associated data.
+pub fn parse_pragma(comment: &str) -> Option {
+ let comment = comment.trim();
+
+ // Look for hadolint pragma
+ let pragma_start = comment.find("hadolint")?;
+ let pragma_content = &comment[pragma_start + "hadolint".len()..].trim();
+
+ // Parse global ignore
+ if pragma_content.starts_with("global") {
+ let rest = &pragma_content["global".len()..].trim();
+ if let Some(codes) = parse_ignore_list(rest) {
+ return Some(Pragma::GlobalIgnore(codes));
+ }
+ }
+
+ // Parse ignore
+ if let Some(codes) = parse_ignore_list(pragma_content) {
+ return Some(Pragma::Ignore(codes));
+ }
+
+ // Parse shell
+ if pragma_content.starts_with("shell=") {
+ let shell = &pragma_content["shell=".len()..].trim();
+ return Some(Pragma::Shell(shell.to_string()));
+ }
+
+ None
+}
+
+/// Parse an ignore list from a pragma string.
+fn parse_ignore_list(s: &str) -> Option> {
+ let s = s.trim();
+
+ // Look for ignore= pattern
+ if !s.starts_with("ignore=") && !s.starts_with("ignore =") {
+ return None;
+ }
+
+ // Find the = sign and get the codes
+ let eq_pos = s.find('=')?;
+ let codes_str = &s[eq_pos + 1..].trim();
+
+ // Split by comma and parse codes
+ let codes: Vec = codes_str
+ .split(',')
+ .map(|s| s.trim())
+ .filter(|s| !s.is_empty())
+ .map(|s| RuleCode::new(s))
+ .collect();
+
+ if codes.is_empty() {
+ None
+ } else {
+ Some(codes)
+ }
+}
+
+/// Parsed pragma types.
+#[derive(Debug, Clone)]
+pub enum Pragma {
+ /// Ignore rules for the next instruction.
+ Ignore(Vec),
+ /// Ignore rules globally for the entire file.
+ GlobalIgnore(Vec),
+ /// Set shell for ShellCheck analysis.
+ Shell(String),
+}
+
+/// Extract pragma state from Dockerfile instructions.
+pub fn extract_pragmas(instructions: &[crate::analyzer::hadolint::parser::InstructionPos]) -> PragmaState {
+ let mut state = PragmaState::new();
+
+ for instr in instructions {
+ if let crate::analyzer::hadolint::parser::instruction::Instruction::Comment(comment) = &instr.instruction {
+ if let Some(pragma) = parse_pragma(comment) {
+ match pragma {
+ Pragma::Ignore(codes) => {
+ // Ignore applies to the next line
+ let entry = state.ignored.entry(instr.line_number).or_default();
+ for code in codes {
+ entry.insert(code);
+ }
+ }
+ Pragma::GlobalIgnore(codes) => {
+ for code in codes {
+ state.global_ignored.insert(code);
+ }
+ }
+ Pragma::Shell(shell) => {
+ state.shell = Some(shell);
+ }
+ }
+ }
+ }
+ }
+
+ state
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_parse_ignore() {
+ let pragma = parse_pragma("# hadolint ignore=DL3008,DL3009").unwrap();
+ match pragma {
+ Pragma::Ignore(codes) => {
+ assert_eq!(codes.len(), 2);
+ assert_eq!(codes[0].as_str(), "DL3008");
+ assert_eq!(codes[1].as_str(), "DL3009");
+ }
+ _ => panic!("Expected Ignore pragma"),
+ }
+ }
+
+ #[test]
+ fn test_parse_global_ignore() {
+ let pragma = parse_pragma("# hadolint global ignore=DL3008").unwrap();
+ match pragma {
+ Pragma::GlobalIgnore(codes) => {
+ assert_eq!(codes.len(), 1);
+ assert_eq!(codes[0].as_str(), "DL3008");
+ }
+ _ => panic!("Expected GlobalIgnore pragma"),
+ }
+ }
+
+ #[test]
+ fn test_parse_shell() {
+ let pragma = parse_pragma("# hadolint shell=/bin/bash").unwrap();
+ match pragma {
+ Pragma::Shell(shell) => {
+ assert_eq!(shell, "/bin/bash");
+ }
+ _ => panic!("Expected Shell pragma"),
+ }
+ }
+
+ #[test]
+ fn test_no_pragma() {
+ assert!(parse_pragma("# This is a regular comment").is_none());
+ }
+
+ #[test]
+ fn test_pragma_state_is_ignored() {
+ let mut state = PragmaState::new();
+
+ // Add line-specific ignore
+ let mut codes = HashSet::new();
+ codes.insert(RuleCode::new("DL3008"));
+ state.ignored.insert(5, codes);
+
+ // Add global ignore
+ state.global_ignored.insert(RuleCode::new("DL3009"));
+
+ // Test line-specific (pragma on line 5 affects line 6)
+ assert!(state.is_ignored(&RuleCode::new("DL3008"), 6));
+ assert!(!state.is_ignored(&RuleCode::new("DL3008"), 10));
+
+ // Test global
+ assert!(state.is_ignored(&RuleCode::new("DL3009"), 1));
+ assert!(state.is_ignored(&RuleCode::new("DL3009"), 100));
+
+ // Test non-ignored
+ assert!(!state.is_ignored(&RuleCode::new("DL3010"), 1));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl1001.rs b/src/analyzer/hadolint/rules/dl1001.rs
new file mode 100644
index 00000000..ed4652cf
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl1001.rs
@@ -0,0 +1,51 @@
+//! DL1001: Please refrain from using inline ignore pragmas
+//!
+//! This is a meta-rule that warns when inline ignore pragmas are used.
+//! It's disabled by default but can be enabled for strict linting.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL1001",
+ Severity::Info,
+ "Please refrain from using inline ignore pragmas `# hadolint ignore=...`.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Comment(comment) => {
+ // Check if it's a hadolint ignore pragma
+ let lower = comment.to_lowercase();
+ !lower.contains("hadolint") || !lower.contains("ignore")
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_ignore_pragma() {
+ let rule = rule();
+ let mut state = RuleState::new();
+ let instr = Instruction::Comment("hadolint ignore=DL3008".to_string());
+ rule.check(&mut state, 1, &instr, None);
+ assert_eq!(state.failures.len(), 1);
+ }
+
+ #[test]
+ fn test_regular_comment() {
+ let rule = rule();
+ let mut state = RuleState::new();
+ let instr = Instruction::Comment("This is a regular comment".to_string());
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3000.rs b/src/analyzer/hadolint/rules/dl3000.rs
new file mode 100644
index 00000000..895c48a7
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3000.rs
@@ -0,0 +1,72 @@
+//! DL3000: Use absolute WORKDIR
+//!
+//! WORKDIR should use an absolute path to avoid confusion about the
+//! starting directory.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3000",
+ Severity::Error,
+ "Use absolute WORKDIR",
+ |instr, _shell| {
+ match instr {
+ Instruction::Workdir(path) => {
+ // Allow absolute paths and variables
+ path.starts_with('/') || path.starts_with('$') || is_windows_absolute(path)
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+/// Check if path is a Windows absolute path.
+fn is_windows_absolute(path: &str) -> bool {
+ let chars: Vec = path.chars().collect();
+ chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':'
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_absolute_path() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ // Good: absolute path
+ let instr = Instruction::Workdir("/app".to_string());
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_relative_path() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ // Bad: relative path
+ let instr = Instruction::Workdir("app".to_string());
+ rule.check(&mut state, 1, &instr, None);
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3000");
+ }
+
+ #[test]
+ fn test_variable_path() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ // Good: variable
+ let instr = Instruction::Workdir("$APP_DIR".to_string());
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3001.rs b/src/analyzer/hadolint/rules/dl3001.rs
new file mode 100644
index 00000000..cb6145a2
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3001.rs
@@ -0,0 +1,85 @@
+//! DL3001: Don't use invalid commands in RUN
+//!
+//! Commands like ssh, vim, shutdown, service, ps, free, top, kill, and mount
+//! are not appropriate for Dockerfile RUN instructions.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+/// Invalid commands that shouldn't be used in Dockerfiles.
+const INVALID_COMMANDS: &[&str] = &[
+ "ssh",
+ "vim",
+ "shutdown",
+ "service",
+ "ps",
+ "free",
+ "top",
+ "kill",
+ "mount",
+ "ifconfig",
+ "nano",
+];
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3001",
+ Severity::Info,
+ "For some bash commands it makes no sense running them in a Docker container like ssh, vim, shutdown, service, ps, free, top, kill, mount, ifconfig",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| INVALID_COMMANDS.contains(&cmd.name.as_str()))
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::RunArgs;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_valid_command() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("apt-get update"));
+ let shell = ParsedShell::parse("apt-get update");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_invalid_ssh() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("ssh user@host"));
+ let shell = ParsedShell::parse("ssh user@host");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3001");
+ }
+
+ #[test]
+ fn test_invalid_vim() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("vim /etc/config"));
+ let shell = ParsedShell::parse("vim /etc/config");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert_eq!(state.failures.len(), 1);
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3002.rs b/src/analyzer/hadolint/rules/dl3002.rs
new file mode 100644
index 00000000..0d471e9c
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3002.rs
@@ -0,0 +1,108 @@
+//! DL3002: Last USER should not be root
+//!
+//! Running as root in containers is a security risk. The last USER
+//! instruction should switch to a non-root user.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL3002",
+ Severity::Warning,
+ "Last USER should not be root",
+ |state, line, instr, _shell| {
+ match instr {
+ Instruction::From(_) => {
+ // Reset state for each stage
+ state.data.set_bool("is_root", true);
+ state.data.set_int("last_user_line", 0);
+ }
+ Instruction::User(user) => {
+ let is_root = user == "root" || user == "0" || user.starts_with("root:");
+ state.data.set_bool("is_root", is_root);
+ state.data.set_int("last_user_line", line as i64);
+ }
+ _ => {}
+ }
+ },
+ )
+}
+
+/// Custom finalize implementation for DL3002.
+/// This is called manually in the lint process.
+pub fn finalize(state: RuleState) -> Vec {
+ let mut failures = state.failures;
+
+ // Check if the last USER was root
+ if state.data.get_bool("is_root") {
+ let last_line = state.data.get_int("last_user_line");
+ if last_line > 0 {
+ failures.push(crate::analyzer::hadolint::types::CheckFailure::new(
+ "DL3002",
+ Severity::Warning,
+ "Last USER should not be root",
+ last_line as u32,
+ ));
+ }
+ }
+
+ failures
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::BaseImage;
+ use crate::analyzer::hadolint::rules::Rule;
+
+ #[test]
+ fn test_non_root_user() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let user = Instruction::User("appuser".to_string());
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, &user, None);
+
+ let failures = finalize(state);
+ assert!(failures.is_empty());
+ }
+
+ #[test]
+ fn test_root_user() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let user = Instruction::User("root".to_string());
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, &user, None);
+
+ let failures = finalize(state);
+ assert_eq!(failures.len(), 1);
+ assert_eq!(failures[0].code.as_str(), "DL3002");
+ }
+
+ #[test]
+ fn test_switch_from_root() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let user1 = Instruction::User("root".to_string());
+ let user2 = Instruction::User("appuser".to_string());
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, &user1, None);
+ rule.check(&mut state, 3, &user2, None);
+
+ let failures = finalize(state);
+ assert!(failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3003.rs b/src/analyzer/hadolint/rules/dl3003.rs
new file mode 100644
index 00000000..787a90b6
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3003.rs
@@ -0,0 +1,60 @@
+//! DL3003: Use WORKDIR to switch to a directory
+//!
+//! Don't use `cd` in RUN instructions. Use WORKDIR instead to change
+//! the working directory.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3003",
+ Severity::Warning,
+ "Use WORKDIR to switch to a directory",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ // Check if cd is used as a command
+ !shell.any_command(|cmd| cmd.name == "cd")
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::RunArgs;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_no_cd() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("apt-get update"));
+ let shell = ParsedShell::parse("apt-get update");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_with_cd() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("cd /app && npm install"));
+ let shell = ParsedShell::parse("cd /app && npm install");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3003");
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3004.rs b/src/analyzer/hadolint/rules/dl3004.rs
new file mode 100644
index 00000000..b7c6b0b0
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3004.rs
@@ -0,0 +1,59 @@
+//! DL3004: Do not use sudo
+//!
+//! Using sudo in Dockerfiles is unnecessary since containers run as root
+//! by default, and using it indicates a misunderstanding of Docker.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3004",
+ Severity::Error,
+ "Do not use sudo as it leads to unpredictable behavior. Use a tool like gosu to enforce root",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| cmd.name == "sudo")
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::RunArgs;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_no_sudo() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("apt-get update"));
+ let shell = ParsedShell::parse("apt-get update");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_with_sudo() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("sudo apt-get update"));
+ let shell = ParsedShell::parse("sudo apt-get update");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3004");
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3005.rs b/src/analyzer/hadolint/rules/dl3005.rs
new file mode 100644
index 00000000..1f1a0287
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3005.rs
@@ -0,0 +1,66 @@
+//! DL3005: Do not use apt-get upgrade or dist-upgrade
+//!
+//! Using apt-get upgrade or dist-upgrade in a Dockerfile is not recommended
+//! as it can lead to unpredictable builds.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3005",
+ Severity::Warning,
+ "Do not use `apt-get upgrade` or `dist-upgrade`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ cmd.name == "apt-get" && cmd.has_any_arg(&["upgrade", "dist-upgrade"])
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_apt_get_upgrade() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get update && apt-get upgrade");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3005"));
+ }
+
+ #[test]
+ fn test_apt_get_dist_upgrade() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get dist-upgrade");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3005"));
+ }
+
+ #[test]
+ fn test_apt_get_update() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get update");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3005"));
+ }
+
+ #[test]
+ fn test_apt_get_install() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install -y nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3005"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3006.rs b/src/analyzer/hadolint/rules/dl3006.rs
new file mode 100644
index 00000000..e9fe7d5b
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3006.rs
@@ -0,0 +1,133 @@
+//! DL3006: Always tag the version of an image explicitly
+//!
+//! Images should be tagged to ensure reproducible builds.
+//! Using untagged images may result in different versions being pulled.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL3006",
+ Severity::Warning,
+ "Always tag the version of an image explicitly",
+ |state, line, instr, _shell| {
+ match instr {
+ Instruction::From(base) => {
+ // Remember stage aliases
+ if let Some(alias) = &base.alias {
+ state.data.insert_to_set("aliases", alias.as_str());
+ }
+
+ // Check if image needs a tag
+ let image_name = &base.image.name;
+
+ // Skip check for:
+ // 1. scratch image
+ // 2. images with tags
+ // 3. images with digests
+ // 4. variable references
+ // 5. references to previous build stages
+
+ if base.is_scratch() {
+ return;
+ }
+
+ if base.has_version() {
+ return;
+ }
+
+ if base.is_variable() {
+ return;
+ }
+
+ // Check if it's a reference to a previous stage
+ if state.data.set_contains("aliases", image_name) {
+ return;
+ }
+
+ // Image doesn't have a tag
+ state.add_failure(
+ "DL3006",
+ Severity::Warning,
+ "Always tag the version of an image explicitly",
+ line,
+ );
+ }
+ _ => {}
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::{BaseImage, ImageAlias};
+ use crate::analyzer::hadolint::rules::Rule;
+
+ #[test]
+ fn test_tagged_image() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let mut base = BaseImage::new("ubuntu");
+ base.tag = Some("20.04".to_string());
+ let instr = Instruction::From(base);
+
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_untagged_image() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::From(BaseImage::new("ubuntu"));
+ rule.check(&mut state, 1, &instr, None);
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3006");
+ }
+
+ #[test]
+ fn test_scratch_image() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::From(BaseImage::new("scratch"));
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_stage_reference() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ // First stage with alias
+ let mut base1 = BaseImage::new("node");
+ base1.tag = Some("18".to_string());
+ base1.alias = Some(ImageAlias::new("builder"));
+ let instr1 = Instruction::From(base1);
+ rule.check(&mut state, 1, &instr1, None);
+
+ // Second stage referencing first
+ let instr2 = Instruction::From(BaseImage::new("builder"));
+ rule.check(&mut state, 10, &instr2, None);
+
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_variable_image() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::From(BaseImage::new("${BASE_IMAGE}"));
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3007.rs b/src/analyzer/hadolint/rules/dl3007.rs
new file mode 100644
index 00000000..becd8057
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3007.rs
@@ -0,0 +1,74 @@
+//! DL3007: Using latest is prone to errors
+//!
+//! Using the :latest tag can lead to inconsistent builds and should be avoided.
+//! Use specific version tags instead.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3007",
+ Severity::Warning,
+ "Using latest is prone to errors if the image will ever update. Pin the version explicitly to a release tag",
+ |instr, _shell| {
+ match instr {
+ Instruction::From(base) => {
+ // Check if tag is "latest"
+ match &base.tag {
+ Some(tag) => tag != "latest",
+ None => true, // No tag is handled by DL3006
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::BaseImage;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_specific_tag() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let mut base = BaseImage::new("ubuntu");
+ base.tag = Some("20.04".to_string());
+ let instr = Instruction::From(base);
+
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_latest_tag() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let mut base = BaseImage::new("ubuntu");
+ base.tag = Some("latest".to_string());
+ let instr = Instruction::From(base);
+
+ rule.check(&mut state, 1, &instr, None);
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3007");
+ }
+
+ #[test]
+ fn test_no_tag() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::From(BaseImage::new("ubuntu"));
+ rule.check(&mut state, 1, &instr, None);
+ // No tag is OK here (handled by DL3006)
+ assert!(state.failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3008.rs b/src/analyzer/hadolint/rules/dl3008.rs
new file mode 100644
index 00000000..0416610d
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3008.rs
@@ -0,0 +1,115 @@
+//! DL3008: Pin versions in apt-get install
+//!
+//! Package versions should be pinned in apt-get install to ensure
+//! reproducible builds.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3008",
+ Severity::Warning,
+ "Pin versions in apt get install. Instead of `apt-get install ` use `apt-get install =`",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ // Get apt-get install packages
+ let packages = apt_get_packages(shell);
+ // All packages should have versions pinned
+ packages.iter().all(|pkg| is_version_pinned(pkg))
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+/// Extract packages from apt-get install commands.
+fn apt_get_packages(shell: &ParsedShell) -> Vec {
+ let mut packages = Vec::new();
+
+ for cmd in &shell.commands {
+ if cmd.name == "apt-get" && cmd.arguments.iter().any(|a| a == "install") {
+ // Get arguments that aren't flags and aren't "install"
+ let args: Vec<&str> = cmd
+ .args_no_flags()
+ .into_iter()
+ .filter(|a| *a != "install")
+ // Filter out -t/--target-release arguments
+ .collect();
+
+ packages.extend(args.into_iter().map(|s| s.to_string()));
+ }
+ }
+
+ packages
+}
+
+/// Check if a package has a version pinned.
+fn is_version_pinned(package: &str) -> bool {
+ // Version pinned: package=version
+ package.contains('=')
+ // APT pinning: package/release
+ || package.contains('/')
+ // Local .deb file
+ || package.ends_with(".deb")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::RunArgs;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_pinned_version() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("apt-get install -y nginx=1.18.0-0ubuntu1"));
+ let shell = ParsedShell::parse("apt-get install -y nginx=1.18.0-0ubuntu1");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_unpinned_version() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("apt-get install -y nginx"));
+ let shell = ParsedShell::parse("apt-get install -y nginx");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3008");
+ }
+
+ #[test]
+ fn test_apt_pinning() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("apt-get install -y nginx/focal"));
+ let shell = ParsedShell::parse("apt-get install -y nginx/focal");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_update_only() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("apt-get update"));
+ let shell = ParsedShell::parse("apt-get update");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3009.rs b/src/analyzer/hadolint/rules/dl3009.rs
new file mode 100644
index 00000000..b02d4f33
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3009.rs
@@ -0,0 +1,87 @@
+//! DL3009: Delete the apt-get lists after installing something
+//!
+//! After installing packages with apt-get, the package lists should be
+//! removed to reduce image size.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3009",
+ Severity::Info,
+ "Delete the apt-get lists after installing something.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ // Check if apt-get install is used
+ let has_apt_install = shell.any_command(|cmd| {
+ cmd.name == "apt-get" && cmd.has_any_arg(&["install"])
+ });
+
+ if !has_apt_install {
+ return true;
+ }
+
+ // Check if lists are cleaned
+ let has_cleanup = shell.any_command(|cmd| {
+ // rm -rf /var/lib/apt/lists/*
+ (cmd.name == "rm" && cmd.arguments.iter().any(|arg| {
+ arg.contains("/var/lib/apt/lists")
+ }))
+ // Or apt-get clean
+ || (cmd.name == "apt-get" && cmd.has_any_arg(&["clean", "autoclean"]))
+ });
+
+ has_cleanup
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_apt_get_without_cleanup() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3009"));
+ }
+
+ #[test]
+ fn test_apt_get_with_rm_cleanup() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y nginx && rm -rf /var/lib/apt/lists/*"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3009"));
+ }
+
+ #[test]
+ fn test_apt_get_with_clean() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y nginx && apt-get clean"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3009"));
+ }
+
+ #[test]
+ fn test_no_apt_get() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3009"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3010.rs b/src/analyzer/hadolint/rules/dl3010.rs
new file mode 100644
index 00000000..c839e6aa
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3010.rs
@@ -0,0 +1,83 @@
+//! DL3010: Use ADD for extracting archives into an image
+//!
+//! ADD can automatically extract tar archives. Use ADD instead of
+//! COPY + RUN tar for better efficiency.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3010",
+ Severity::Info,
+ "Use ADD for extracting archives into an image.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Copy(args, _) => {
+ // Check if any source looks like a local tar archive
+ !args.sources.iter().any(|src| is_local_archive(src))
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+/// Check if source is a local archive file (not URL)
+fn is_local_archive(src: &str) -> bool {
+ // Skip URLs
+ if src.starts_with("http://") || src.starts_with("https://") || src.starts_with("ftp://") {
+ return false;
+ }
+
+ // Skip variables
+ if src.starts_with('$') {
+ return false;
+ }
+
+ // Check for archive extensions
+ let archive_extensions = [
+ ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz",
+ ".tar.zst", ".tar.lz", ".tar.lzma"
+ ];
+
+ let lower = src.to_lowercase();
+ archive_extensions.iter().any(|ext| lower.ends_with(ext))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_copy_tar_file() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY app.tar.gz /app/");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3010"));
+ }
+
+ #[test]
+ fn test_copy_tgz_file() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY archive.tgz /tmp/");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3010"));
+ }
+
+ #[test]
+ fn test_copy_regular_file() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY app.js /app/");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3010"));
+ }
+
+ #[test]
+ fn test_copy_directory() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY src/ /app/");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3010"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3011.rs b/src/analyzer/hadolint/rules/dl3011.rs
new file mode 100644
index 00000000..1f2f273d
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3011.rs
@@ -0,0 +1,62 @@
+//! DL3011: Valid UNIX ports range from 0 to 65535
+//!
+//! EXPOSE instruction must use valid port numbers.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3011",
+ Severity::Error,
+ "Valid UNIX ports range from 0 to 65535.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Expose(ports) => {
+ // All ports must be valid (0-65535)
+ // The parser already validates this as u16, so this should always pass
+ // But we check anyway for safety
+ ports.iter().all(|p| p.number <= 65535)
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_valid_port() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nEXPOSE 8080");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3011"));
+ }
+
+ #[test]
+ fn test_valid_multiple_ports() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nEXPOSE 80 443 8080");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3011"));
+ }
+
+ #[test]
+ fn test_max_valid_port() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nEXPOSE 65535");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3011"));
+ }
+
+ #[test]
+ fn test_min_valid_port() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nEXPOSE 0");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3011"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3012.rs b/src/analyzer/hadolint/rules/dl3012.rs
new file mode 100644
index 00000000..656b32b2
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3012.rs
@@ -0,0 +1,78 @@
+//! DL3012: Multiple HEALTHCHECK instructions
+//!
+//! Only one HEALTHCHECK instruction is allowed per stage.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL3012",
+ Severity::Error,
+ "Multiple `HEALTHCHECK` instructions.",
+ |state, line, instr, _shell| {
+ match instr {
+ Instruction::From(_) => {
+ // Reset healthcheck count for new stage
+ state.data.set_int("healthcheck_count", 0);
+ }
+ Instruction::Healthcheck(_) => {
+ let count = state.data.get_int("healthcheck_count");
+ if count > 0 {
+ state.add_failure(
+ "DL3012",
+ Severity::Error,
+ "Multiple `HEALTHCHECK` instructions.",
+ line,
+ );
+ }
+ state.data.set_int("healthcheck_count", count + 1);
+ }
+ _ => {}
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_single_healthcheck() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04\nHEALTHCHECK CMD curl -f http://localhost/ || exit 1"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3012"));
+ }
+
+ #[test]
+ fn test_multiple_healthchecks() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04\nHEALTHCHECK CMD curl http://localhost/\nHEALTHCHECK CMD wget http://localhost/"
+ );
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3012"));
+ }
+
+ #[test]
+ fn test_healthcheck_different_stages() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04 AS builder\nHEALTHCHECK CMD curl http://localhost/\nFROM ubuntu:20.04\nHEALTHCHECK CMD wget http://localhost/"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3012"));
+ }
+
+ #[test]
+ fn test_no_healthcheck() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3012"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3013.rs b/src/analyzer/hadolint/rules/dl3013.rs
new file mode 100644
index 00000000..dcf55006
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3013.rs
@@ -0,0 +1,134 @@
+//! DL3013: Pin versions in pip install
+//!
+//! Package versions should be pinned in pip install to ensure
+//! reproducible builds.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3013",
+ Severity::Warning,
+ "Pin versions in pip. Instead of `pip install ` use `pip install ==` or `pip install --requirement `",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ // Get pip install packages
+ let packages = pip_packages(shell);
+ // Check if using requirements file
+ let uses_requirements = uses_requirements_file(shell);
+ // All packages should have versions pinned or use requirements
+ uses_requirements || packages.iter().all(|pkg| is_pip_version_pinned(pkg))
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+/// Extract packages from pip install commands.
+fn pip_packages(shell: &ParsedShell) -> Vec {
+ let mut packages = Vec::new();
+
+ for cmd in &shell.commands {
+ if cmd.is_pip_install() {
+ // Get arguments that aren't flags and aren't pip-related commands
+ let skip_args = ["install", "pip", "-m"];
+ let args: Vec<&str> = cmd
+ .args_no_flags()
+ .into_iter()
+ .filter(|a| !skip_args.contains(a))
+ .collect();
+
+ packages.extend(args.into_iter().map(|s| s.to_string()));
+ }
+ }
+
+ packages
+}
+
+/// Check if pip uses a requirements file.
+fn uses_requirements_file(shell: &ParsedShell) -> bool {
+ shell.any_command(|cmd| {
+ cmd.is_pip_install() && (cmd.has_any_flag(&["r", "requirement"]) || cmd.has_flag("constraint"))
+ })
+}
+
+/// Check if a pip package has a version pinned.
+fn is_pip_version_pinned(package: &str) -> bool {
+ // Skip if it starts with - (it's a flag)
+ if package.starts_with('-') {
+ return true;
+ }
+
+ // Skip if it looks like a URL or path
+ if package.contains("://") || package.starts_with('/') || package.starts_with('.') {
+ return true;
+ }
+
+ // Version pinned: package==version or package>=version, etc.
+ package.contains("==")
+ || package.contains(">=")
+ || package.contains("<=")
+ || package.contains("!=")
+ || package.contains("~=")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::RunArgs;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_pinned_version() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("pip install requests==2.28.0"));
+ let shell = ParsedShell::parse("pip install requests==2.28.0");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_unpinned_version() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("pip install requests"));
+ let shell = ParsedShell::parse("pip install requests");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3013");
+ }
+
+ #[test]
+ fn test_requirements_file() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("pip install -r requirements.txt"));
+ let shell = ParsedShell::parse("pip install -r requirements.txt");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_min_version() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("pip install requests>=2.28.0"));
+ let shell = ParsedShell::parse("pip install requests>=2.28.0");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3014.rs b/src/analyzer/hadolint/rules/dl3014.rs
new file mode 100644
index 00000000..4c294840
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3014.rs
@@ -0,0 +1,78 @@
+//! DL3014: Use the -y switch to avoid manual input
+//!
+//! apt-get install should use -y to avoid prompts during build.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3014",
+ Severity::Warning,
+ "Use the `-y` switch to avoid manual input `apt-get -y install `.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ // Check all apt-get install commands
+ !shell.any_command(|cmd| {
+ if cmd.name == "apt-get" && cmd.has_any_arg(&["install"]) {
+ // Must have -y, --yes, or --assume-yes
+ !cmd.has_any_flag(&["y", "yes", "assume-yes"])
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_apt_get_without_y() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3014"));
+ }
+
+ #[test]
+ fn test_apt_get_with_y() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install -y nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3014"));
+ }
+
+ #[test]
+ fn test_apt_get_with_yes() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install --yes nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3014"));
+ }
+
+ #[test]
+ fn test_apt_get_with_assume_yes() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install --assume-yes nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3014"));
+ }
+
+ #[test]
+ fn test_apt_get_update_no_y() {
+ // apt-get update doesn't need -y
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get update");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3014"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3015.rs b/src/analyzer/hadolint/rules/dl3015.rs
new file mode 100644
index 00000000..487ba134
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3015.rs
@@ -0,0 +1,66 @@
+//! DL3015: Avoid additional packages by specifying --no-install-recommends
+//!
+//! apt-get install should use --no-install-recommends to avoid
+//! installing unnecessary packages.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3015",
+ Severity::Info,
+ "Avoid additional packages by specifying `--no-install-recommends`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ // Check all apt-get install commands
+ !shell.any_command(|cmd| {
+ if cmd.name == "apt-get" && cmd.has_any_arg(&["install"]) {
+ // Must have --no-install-recommends
+ !cmd.has_any_flag(&["no-install-recommends"])
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_apt_get_without_no_install_recommends() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install -y nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3015"));
+ }
+
+ #[test]
+ fn test_apt_get_with_no_install_recommends() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get install -y --no-install-recommends nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3015"));
+ }
+
+ #[test]
+ fn test_apt_get_update_no_flag_needed() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN apt-get update");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3015"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3016.rs b/src/analyzer/hadolint/rules/dl3016.rs
new file mode 100644
index 00000000..88a27973
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3016.rs
@@ -0,0 +1,140 @@
+//! DL3016: Pin versions in npm install
+//!
+//! npm packages should be pinned to specific versions.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3016",
+ Severity::Warning,
+ "Pin versions in npm. Instead of `npm install ` use `npm install @`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "npm" && cmd.has_any_arg(&["install", "i"]) {
+ // Get packages (args after install, excluding flags)
+ let packages = get_npm_packages(cmd);
+ // Check if any package is unpinned
+ packages.iter().any(|pkg| !is_pinned_npm_package(pkg))
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+/// Extract package names from npm install command
+fn get_npm_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> {
+ let mut packages = Vec::new();
+ let mut found_install = false;
+
+ for arg in &cmd.arguments {
+ if arg == "install" || arg == "i" {
+ found_install = true;
+ continue;
+ }
+ if found_install && !arg.starts_with('-') {
+ packages.push(arg.as_str());
+ }
+ }
+
+ packages
+}
+
+/// Check if npm package is pinned
+fn is_pinned_npm_package(pkg: &str) -> bool {
+ // Skip scoped packages check - just check if version is present
+ // Pinned formats: package@version, package@^version, package@~version
+ // Also valid: local paths, git URLs, etc.
+
+ // Skip flags
+ if pkg.starts_with('-') {
+ return true;
+ }
+
+ // Local paths are fine
+ if pkg.starts_with('.') || pkg.starts_with('/') || pkg.starts_with("file:") {
+ return true;
+ }
+
+ // Git URLs are fine
+ if pkg.starts_with("git") || pkg.contains("github.com") || pkg.contains("gitlab.com") {
+ return true;
+ }
+
+ // Check for @ version specifier (but not scoped package @org/name)
+ if pkg.contains('@') {
+ let parts: Vec<&str> = pkg.split('@').collect();
+ // Scoped package: @org/name or @org/name@version
+ if pkg.starts_with('@') {
+ // @org/name@version - has 3 parts
+ parts.len() >= 3
+ } else {
+ // name@version - has 2 parts
+ parts.len() >= 2 && !parts[1].is_empty()
+ }
+ } else {
+ false
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_npm_install_unpinned() {
+ let result = lint_dockerfile("FROM node:18\nRUN npm install express");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3016"));
+ }
+
+ #[test]
+ fn test_npm_install_pinned() {
+ let result = lint_dockerfile("FROM node:18\nRUN npm install express@4.18.2");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3016"));
+ }
+
+ #[test]
+ fn test_npm_install_pinned_caret() {
+ let result = lint_dockerfile("FROM node:18\nRUN npm install express@^4.18.0");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3016"));
+ }
+
+ #[test]
+ fn test_npm_ci() {
+ // npm ci uses package-lock.json, so no packages listed
+ let result = lint_dockerfile("FROM node:18\nRUN npm ci");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3016"));
+ }
+
+ #[test]
+ fn test_npm_install_global_unpinned() {
+ let result = lint_dockerfile("FROM node:18\nRUN npm install -g typescript");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3016"));
+ }
+
+ #[test]
+ fn test_npm_install_global_pinned() {
+ let result = lint_dockerfile("FROM node:18\nRUN npm install -g typescript@5.0.0");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3016"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3017.rs b/src/analyzer/hadolint/rules/dl3017.rs
new file mode 100644
index 00000000..d9d4900b
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3017.rs
@@ -0,0 +1,60 @@
+//! DL3017: Do not use apk upgrade
+//!
+//! Using apk upgrade in a Dockerfile is not recommended
+//! as it can lead to unpredictable builds.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3017",
+ Severity::Warning,
+ "Do not use `apk upgrade`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ cmd.name == "apk" && cmd.has_any_arg(&["upgrade"])
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_apk_upgrade() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk upgrade");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3017"));
+ }
+
+ #[test]
+ fn test_apk_update() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk update");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3017"));
+ }
+
+ #[test]
+ fn test_apk_add() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add --no-cache curl=8.0.0");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3017"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3018.rs b/src/analyzer/hadolint/rules/dl3018.rs
new file mode 100644
index 00000000..c2aaab16
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3018.rs
@@ -0,0 +1,112 @@
+//! DL3018: Pin versions in apk add
+//!
+//! Alpine packages should be pinned to specific versions.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3018",
+ Severity::Warning,
+ "Pin versions in apk add. Instead of `apk add ` use `apk add =`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "apk" && cmd.has_any_arg(&["add"]) {
+ // Get packages (args after add, excluding flags)
+ let packages = get_apk_packages(cmd);
+ // Check if any package is unpinned
+ packages.iter().any(|pkg| !is_pinned_apk_package(pkg))
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+/// Extract package names from apk add command
+fn get_apk_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> {
+ let mut packages = Vec::new();
+ let mut found_add = false;
+
+ for arg in &cmd.arguments {
+ if arg == "add" {
+ found_add = true;
+ continue;
+ }
+ if found_add && !arg.starts_with('-') {
+ packages.push(arg.as_str());
+ }
+ }
+
+ packages
+}
+
+/// Check if apk package is pinned
+fn is_pinned_apk_package(pkg: &str) -> bool {
+ // Skip flags
+ if pkg.starts_with('-') {
+ return true;
+ }
+
+ // Skip virtual packages (start with .)
+ if pkg.starts_with('.') {
+ return true;
+ }
+
+ // Pinned formats: package=version or package~version
+ pkg.contains('=') || pkg.contains('~')
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_apk_add_unpinned() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3018"));
+ }
+
+ #[test]
+ fn test_apk_add_pinned() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add nginx=1.24.0-r0");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3018"));
+ }
+
+ #[test]
+ fn test_apk_add_pinned_tilde() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add nginx~1.24");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3018"));
+ }
+
+ #[test]
+ fn test_apk_add_no_cache_unpinned() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add --no-cache curl");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3018"));
+ }
+
+ #[test]
+ fn test_apk_update() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk update");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3018"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3019.rs b/src/analyzer/hadolint/rules/dl3019.rs
new file mode 100644
index 00000000..16f54b78
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3019.rs
@@ -0,0 +1,64 @@
+//! DL3019: Use --no-cache for apk add
+//!
+//! Use `apk add --no-cache` to avoid caching the index locally.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3019",
+ Severity::Info,
+ "Use the `--no-cache` switch to avoid the need to use `--update` and remove `/var/cache/apk/*`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "apk" && cmd.has_any_arg(&["add"]) {
+ // Must have --no-cache
+ !cmd.has_any_flag(&["no-cache"])
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_apk_add_without_no_cache() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add nginx=1.24.0");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3019"));
+ }
+
+ #[test]
+ fn test_apk_add_with_no_cache() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk add --no-cache nginx=1.24.0");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3019"));
+ }
+
+ #[test]
+ fn test_apk_update() {
+ let result = lint_dockerfile("FROM alpine:3.18\nRUN apk update");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3019"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3020.rs b/src/analyzer/hadolint/rules/dl3020.rs
new file mode 100644
index 00000000..eb1ada2a
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3020.rs
@@ -0,0 +1,78 @@
+//! DL3020: Use COPY instead of ADD for files/dirs
+//!
+//! ADD has special behaviors (URL download, tar extraction) that make it
+//! less predictable. Use COPY for simply copying files.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3020",
+ Severity::Error,
+ "Use COPY instead of ADD for files and folders",
+ |instr, _shell| {
+ match instr {
+ Instruction::Add(args, _) => {
+ // ADD is OK for URLs and archives
+ args.has_url() || args.has_archive()
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::{AddArgs, AddFlags};
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_add_file() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let args = AddArgs::new(vec!["app.js".to_string()], "/app/");
+ let instr = Instruction::Add(args, AddFlags::default());
+ rule.check(&mut state, 1, &instr, None);
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3020");
+ }
+
+ #[test]
+ fn test_add_url() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let args = AddArgs::new(vec!["https://example.com/file.tar.gz".to_string()], "/app/");
+ let instr = Instruction::Add(args, AddFlags::default());
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_add_archive() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let args = AddArgs::new(vec!["app.tar.gz".to_string()], "/app/");
+ let instr = Instruction::Add(args, AddFlags::default());
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_copy_ok() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ // COPY is always OK
+ let instr = Instruction::Workdir("/app".to_string()); // Different instruction
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3021.rs b/src/analyzer/hadolint/rules/dl3021.rs
new file mode 100644
index 00000000..d77e2421
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3021.rs
@@ -0,0 +1,86 @@
+//! DL3021: Use COPY instead of ADD for non-URL archives
+//!
+//! COPY is preferred over ADD unless you need ADD's special features
+//! (URL download or auto-extraction from remote archives).
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3021",
+ Severity::Error,
+ "Use `COPY` instead of `ADD` for copying non-archive files.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Add(args, _) => {
+ // ADD is acceptable if:
+ // 1. Source is a URL (ADD auto-downloads)
+ // 2. Source is a local tar archive (ADD auto-extracts)
+ args.sources.iter().all(|src| {
+ is_url(src) || is_archive(src)
+ })
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+/// Check if source is a URL
+fn is_url(src: &str) -> bool {
+ src.starts_with("http://") || src.starts_with("https://") || src.starts_with("ftp://")
+}
+
+/// Check if source is an archive that ADD will extract
+fn is_archive(src: &str) -> bool {
+ // Skip variables
+ if src.starts_with('$') {
+ return true;
+ }
+
+ let archive_extensions = [
+ ".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz",
+ ".tar.zst", ".tar.lz", ".tar.lzma", ".gz", ".bz2", ".xz"
+ ];
+
+ let lower = src.to_lowercase();
+ archive_extensions.iter().any(|ext| lower.ends_with(ext))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_add_regular_file() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nADD config.json /etc/app/");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3021"));
+ }
+
+ #[test]
+ fn test_add_url() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nADD https://example.com/file.tar.gz /tmp/");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3021"));
+ }
+
+ #[test]
+ fn test_add_tar_archive() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nADD app.tar.gz /app/");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3021"));
+ }
+
+ #[test]
+ fn test_add_directory() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nADD src/ /app/");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3021"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3022.rs b/src/analyzer/hadolint/rules/dl3022.rs
new file mode 100644
index 00000000..6361643d
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3022.rs
@@ -0,0 +1,106 @@
+//! DL3022: COPY --from should reference a previously defined FROM alias
+//!
+//! When using multi-stage builds, COPY --from should reference a stage
+//! that was previously defined.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL3022",
+ Severity::Warning,
+ "`COPY --from` should reference a previously defined `FROM` alias.",
+ |state, line, instr, _shell| {
+ match instr {
+ Instruction::From(base) => {
+ // Track stage aliases
+ if let Some(alias) = &base.alias {
+ state.data.insert_to_set("stage_aliases", alias.as_str());
+ }
+ // Track stage index
+ let stage_count = state.data.get_int("stage_count");
+ state.data.set_int("stage_count", stage_count + 1);
+ }
+ Instruction::Copy(_, flags) => {
+ if let Some(from) = &flags.from {
+ // Check if it's a stage reference
+ // It's valid if:
+ // 1. It's a known alias
+ // 2. It's a numeric index less than current stage count
+ // 3. It's an external image reference
+
+ let is_known_alias = state.data.set_contains("stage_aliases", from);
+ let is_numeric_index = from.parse::().ok()
+ .map(|n| n < state.data.get_int("stage_count"))
+ .unwrap_or(false);
+
+ // If it looks like an image name (contains / or :), allow it
+ let is_external_image = from.contains('/') || from.contains(':');
+
+ if !is_known_alias && !is_numeric_index && !is_external_image {
+ state.add_failure(
+ "DL3022",
+ Severity::Warning,
+ format!("`COPY --from={}` references an undefined stage.", from),
+ line,
+ );
+ }
+ }
+ }
+ _ => {}
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_copy_from_valid_alias() {
+ let result = lint_dockerfile(
+ "FROM node:18 AS builder\nRUN npm ci\nFROM node:18-alpine\nCOPY --from=builder /app /app"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022"));
+ }
+
+ #[test]
+ fn test_copy_from_invalid_alias() {
+ let result = lint_dockerfile(
+ "FROM node:18\nFROM node:18-alpine\nCOPY --from=nonexistent /app /app"
+ );
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3022"));
+ }
+
+ #[test]
+ fn test_copy_from_numeric_index() {
+ let result = lint_dockerfile(
+ "FROM node:18\nRUN npm ci\nFROM node:18-alpine\nCOPY --from=0 /app /app"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022"));
+ }
+
+ #[test]
+ fn test_copy_from_external_image() {
+ let result = lint_dockerfile(
+ "FROM node:18\nCOPY --from=nginx:latest /etc/nginx/nginx.conf /etc/nginx/"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022"));
+ }
+
+ #[test]
+ fn test_copy_without_from() {
+ let result = lint_dockerfile("FROM node:18\nCOPY package.json /app/");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3022"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3023.rs b/src/analyzer/hadolint/rules/dl3023.rs
new file mode 100644
index 00000000..57fd16d0
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3023.rs
@@ -0,0 +1,95 @@
+//! DL3023: COPY --from cannot reference its own FROM alias
+//!
+//! A COPY instruction cannot reference the current stage as the source.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL3023",
+ Severity::Error,
+ "`COPY --from` cannot reference its own `FROM` alias.",
+ |state, line, instr, _shell| {
+ match instr {
+ Instruction::From(base) => {
+ // Track current stage alias
+ if let Some(alias) = &base.alias {
+ state.data.set_string("current_stage", alias.as_str());
+ } else {
+ state.data.strings.remove("current_stage");
+ }
+ // Track current stage index
+ let stage_count = state.data.get_int("stage_count");
+ state.data.set_int("current_stage_index", stage_count);
+ state.data.set_int("stage_count", stage_count + 1);
+ }
+ Instruction::Copy(_, flags) => {
+ if let Some(from) = &flags.from {
+ // Check if referencing current stage
+ let is_current_alias = state.data.get_string("current_stage")
+ .map(|s| s == from)
+ .unwrap_or(false);
+
+ let is_current_index = from.parse::().ok()
+ .map(|n| n == state.data.get_int("current_stage_index"))
+ .unwrap_or(false);
+
+ if is_current_alias || is_current_index {
+ state.add_failure(
+ "DL3023",
+ Severity::Error,
+ "`COPY --from` cannot reference its own `FROM` alias.",
+ line,
+ );
+ }
+ }
+ }
+ _ => {}
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_copy_from_same_stage() {
+ let result = lint_dockerfile(
+ "FROM node:18 AS builder\nCOPY --from=builder /app /app"
+ );
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3023"));
+ }
+
+ #[test]
+ fn test_copy_from_same_index() {
+ let result = lint_dockerfile(
+ "FROM node:18\nCOPY --from=0 /app /app"
+ );
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3023"));
+ }
+
+ #[test]
+ fn test_copy_from_different_stage() {
+ let result = lint_dockerfile(
+ "FROM node:18 AS builder\nRUN npm ci\nFROM node:18-alpine\nCOPY --from=builder /app /app"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3023"));
+ }
+
+ #[test]
+ fn test_copy_without_from() {
+ let result = lint_dockerfile("FROM node:18 AS builder\nCOPY package.json /app/");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3023"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3024.rs b/src/analyzer/hadolint/rules/dl3024.rs
new file mode 100644
index 00000000..785e7e90
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3024.rs
@@ -0,0 +1,74 @@
+//! DL3024: FROM aliases must be unique
+//!
+//! Each FROM instruction should have a unique alias.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL3024",
+ Severity::Error,
+ "`FROM` aliases (stage names) must be unique.",
+ |state, line, instr, _shell| {
+ if let Instruction::From(base) = instr {
+ if let Some(alias) = &base.alias {
+ let alias_str = alias.as_str();
+ if state.data.set_contains("seen_aliases", alias_str) {
+ state.add_failure(
+ "DL3024",
+ Severity::Error,
+ format!("Duplicate `FROM` alias `{}`.", alias_str),
+ line,
+ );
+ } else {
+ state.data.insert_to_set("seen_aliases", alias_str);
+ }
+ }
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_duplicate_alias() {
+ let result = lint_dockerfile(
+ "FROM node:18 AS builder\nRUN npm ci\nFROM node:18-alpine AS builder\nRUN echo done"
+ );
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3024"));
+ }
+
+ #[test]
+ fn test_unique_aliases() {
+ let result = lint_dockerfile(
+ "FROM node:18 AS builder\nRUN npm ci\nFROM node:18-alpine AS runner\nRUN echo done"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3024"));
+ }
+
+ #[test]
+ fn test_no_aliases() {
+ let result = lint_dockerfile(
+ "FROM node:18\nRUN npm ci\nFROM node:18-alpine\nRUN echo done"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3024"));
+ }
+
+ #[test]
+ fn test_single_stage() {
+ let result = lint_dockerfile("FROM node:18 AS builder\nRUN npm ci");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3024"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3025.rs b/src/analyzer/hadolint/rules/dl3025.rs
new file mode 100644
index 00000000..05ed59f1
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3025.rs
@@ -0,0 +1,77 @@
+//! DL3025: Use arguments JSON notation for CMD and ENTRYPOINT arguments
+//!
+//! Using exec form (JSON notation) for CMD and ENTRYPOINT ensures proper
+//! signal handling and avoids shell processing issues.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3025",
+ Severity::Warning,
+ "Use arguments JSON notation for CMD and ENTRYPOINT arguments",
+ |instr, _shell| {
+ match instr {
+ Instruction::Cmd(args) | Instruction::Entrypoint(args) => {
+ args.is_exec_form()
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::Arguments;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_exec_form() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let args = Arguments::List(vec!["node".to_string(), "app.js".to_string()]);
+ let instr = Instruction::Cmd(args);
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_shell_form() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let args = Arguments::Text("node app.js".to_string());
+ let instr = Instruction::Cmd(args);
+ rule.check(&mut state, 1, &instr, None);
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3025");
+ }
+
+ #[test]
+ fn test_entrypoint_exec() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let args = Arguments::List(vec!["./entrypoint.sh".to_string()]);
+ let instr = Instruction::Entrypoint(args);
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_entrypoint_shell() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let args = Arguments::Text("./entrypoint.sh".to_string());
+ let instr = Instruction::Entrypoint(args);
+ rule.check(&mut state, 1, &instr, None);
+ assert_eq!(state.failures.len(), 1);
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3026.rs b/src/analyzer/hadolint/rules/dl3026.rs
new file mode 100644
index 00000000..063f6878
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3026.rs
@@ -0,0 +1,53 @@
+//! DL3026: Use only an allowed registry in the FROM image
+//!
+//! Restricts base images to trusted registries configured in the config file.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3026",
+ Severity::Error,
+ "Use only an allowed registry in the FROM image.",
+ |instr, _shell| {
+ // This rule requires configuration to be useful
+ // By default, we allow all registries
+ // The actual check is done in lint.rs with config.allowed_registries
+ match instr {
+ Instruction::From(_) => {
+ // Always pass by default - config-dependent rule
+ true
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_docker_hub_default() {
+ // By default, all registries are allowed
+ let result = lint_dockerfile("FROM ubuntu:20.04");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3026"));
+ }
+
+ #[test]
+ fn test_custom_registry_default() {
+ // By default, all registries are allowed
+ let result = lint_dockerfile("FROM gcr.io/my-project/my-image:latest");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3026"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3027.rs b/src/analyzer/hadolint/rules/dl3027.rs
new file mode 100644
index 00000000..f8426200
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3027.rs
@@ -0,0 +1,59 @@
+//! DL3027: Do not use apt as it is meant for interactive use
+//!
+//! apt is designed for interactive use. apt-get is more stable for scripts
+//! and Dockerfiles.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3027",
+ Severity::Warning,
+ "Do not use apt as it is meant to be an end-user tool, use apt-get or apt-cache instead",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| cmd.name == "apt")
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::RunArgs;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_apt_get() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("apt-get update"));
+ let shell = ParsedShell::parse("apt-get update");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_apt() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("apt update"));
+ let shell = ParsedShell::parse("apt update");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3027");
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3028.rs b/src/analyzer/hadolint/rules/dl3028.rs
new file mode 100644
index 00000000..c4980903
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3028.rs
@@ -0,0 +1,104 @@
+//! DL3028: Pin versions in gem install
+//!
+//! Ruby gems should be pinned to specific versions.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3028",
+ Severity::Warning,
+ "Pin versions in gem install. Instead of `gem install ` use `gem install :`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "gem" && cmd.has_any_arg(&["install"]) {
+ // Get gems (args after install, excluding flags)
+ let gems = get_gem_packages(cmd);
+ // Check if any gem is unpinned
+ gems.iter().any(|gem| !is_pinned_gem(gem))
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+/// Extract gem names from gem install command
+fn get_gem_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> {
+ let mut gems = Vec::new();
+ let mut found_install = false;
+
+ for arg in &cmd.arguments {
+ if arg == "install" {
+ found_install = true;
+ continue;
+ }
+ if found_install && !arg.starts_with('-') {
+ gems.push(arg.as_str());
+ }
+ }
+
+ gems
+}
+
+/// Check if gem is pinned
+fn is_pinned_gem(gem: &str) -> bool {
+ // Skip flags
+ if gem.starts_with('-') {
+ return true;
+ }
+
+ // Check for version specifier
+ // gem install rails:7.0.0
+ // gem install rails -v 7.0.0 (handled separately via flag check)
+ gem.contains(':')
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_gem_install_unpinned() {
+ let result = lint_dockerfile("FROM ruby:3.2\nRUN gem install rails");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3028"));
+ }
+
+ #[test]
+ fn test_gem_install_pinned() {
+ let result = lint_dockerfile("FROM ruby:3.2\nRUN gem install rails:7.0.0");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3028"));
+ }
+
+ #[test]
+ fn test_gem_install_multiple_unpinned() {
+ let result = lint_dockerfile("FROM ruby:3.2\nRUN gem install bundler rake");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3028"));
+ }
+
+ #[test]
+ fn test_bundle_install() {
+ // bundle install uses Gemfile.lock, not relevant
+ let result = lint_dockerfile("FROM ruby:3.2\nRUN bundle install");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3028"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3029.rs b/src/analyzer/hadolint/rules/dl3029.rs
new file mode 100644
index 00000000..bc5e9bbd
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3029.rs
@@ -0,0 +1,55 @@
+//! DL3029: Use --platform flag with FROM for cross-architecture builds
+//!
+//! When building for multiple architectures, use --platform to be explicit.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3029",
+ Severity::Warning,
+ "Do not use --platform flag with FROM unless you're building cross-platform images.",
+ |instr, _shell| {
+ // This rule is informational - it's the inverse of what you might expect
+ // It warns when --platform IS used, suggesting it may not be necessary
+ // unless specifically building cross-platform images
+
+ // For now, we'll make this a no-op and always pass
+ // The original hadolint rule is more nuanced about when to warn
+ match instr {
+ Instruction::From(_base) => {
+ // Always pass - this is an informational rule about explicit platform use
+ true
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_from_without_platform() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3029"));
+ }
+
+ #[test]
+ fn test_from_with_platform() {
+ let result = lint_dockerfile("FROM --platform=linux/amd64 ubuntu:20.04\nRUN echo hello");
+ // This is informational, not an error
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3029"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3030.rs b/src/analyzer/hadolint/rules/dl3030.rs
new file mode 100644
index 00000000..7f254d13
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3030.rs
@@ -0,0 +1,63 @@
+//! DL3030: Use the --yes switch to avoid prompts for zypper install
+//!
+//! zypper install should use --non-interactive or -n to avoid prompts.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3030",
+ Severity::Warning,
+ "Use the `--non-interactive` switch to avoid prompts during `zypper` install.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "zypper" && cmd.has_any_arg(&["install", "in"]) {
+ !cmd.has_any_flag(&["n", "non-interactive", "no-confirm", "y"])
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_zypper_without_flag() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper install nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3030"));
+ }
+
+ #[test]
+ fn test_zypper_with_n() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3030"));
+ }
+
+ #[test]
+ fn test_zypper_with_non_interactive() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper --non-interactive install nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3030"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3031.rs b/src/analyzer/hadolint/rules/dl3031.rs
new file mode 100644
index 00000000..9b5696b4
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3031.rs
@@ -0,0 +1,53 @@
+//! DL3031: Do not use yum update
+//!
+//! Using yum update in a Dockerfile is not recommended.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3031",
+ Severity::Warning,
+ "Do not use `yum update`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ cmd.name == "yum" && cmd.has_any_arg(&["update", "upgrade"])
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_yum_update() {
+ let result = lint_dockerfile("FROM centos:7\nRUN yum update -y");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3031"));
+ }
+
+ #[test]
+ fn test_yum_install() {
+ let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx-1.20.0");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3031"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3032.rs b/src/analyzer/hadolint/rules/dl3032.rs
new file mode 100644
index 00000000..ff914060
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3032.rs
@@ -0,0 +1,80 @@
+//! DL3032: yum clean all after yum install
+//!
+//! Clean up yum cache after installing packages to reduce image size.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3032",
+ Severity::Warning,
+ "`yum clean all` missing after yum command.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ // Check if yum install is used
+ let has_yum_install = shell.any_command(|cmd| {
+ cmd.name == "yum" && cmd.has_any_arg(&["install", "groupinstall", "localinstall"])
+ });
+
+ if !has_yum_install {
+ return true;
+ }
+
+ // Check if cleanup is done
+ let has_cleanup = shell.any_command(|cmd| {
+ (cmd.name == "yum" && cmd.has_any_arg(&["clean"]))
+ || (cmd.name == "rm" && cmd.arguments.iter().any(|arg| {
+ arg.contains("/var/cache/yum")
+ }))
+ });
+
+ has_cleanup
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_yum_install_without_clean() {
+ let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3032"));
+ }
+
+ #[test]
+ fn test_yum_install_with_clean() {
+ let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx && yum clean all");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3032"));
+ }
+
+ #[test]
+ fn test_yum_install_with_rm_cache() {
+ let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx && rm -rf /var/cache/yum");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3032"));
+ }
+
+ #[test]
+ fn test_no_yum_install() {
+ let result = lint_dockerfile("FROM centos:7\nRUN yum update");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3032"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3033.rs b/src/analyzer/hadolint/rules/dl3033.rs
new file mode 100644
index 00000000..f19f8a62
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3033.rs
@@ -0,0 +1,114 @@
+//! DL3033: Pin versions in yum install
+//!
+//! Yum packages should be pinned to specific versions.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3033",
+ Severity::Warning,
+ "Specify version with `yum install -y -`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "yum" && cmd.has_any_arg(&["install"]) {
+ // Get packages (args after install, excluding flags)
+ let packages = get_yum_packages(cmd);
+ // Check if any package is unpinned
+ packages.iter().any(|pkg| !is_pinned_yum_package(pkg))
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+/// Extract package names from yum install command
+fn get_yum_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> {
+ let mut packages = Vec::new();
+ let mut found_install = false;
+
+ for arg in &cmd.arguments {
+ if arg == "install" {
+ found_install = true;
+ continue;
+ }
+ if found_install && !arg.starts_with('-') {
+ packages.push(arg.as_str());
+ }
+ }
+
+ packages
+}
+
+/// Check if yum package is pinned
+fn is_pinned_yum_package(pkg: &str) -> bool {
+ // Skip flags
+ if pkg.starts_with('-') {
+ return true;
+ }
+
+ // Skip local RPM files
+ if pkg.ends_with(".rpm") {
+ return true;
+ }
+
+ // Yum version formats: package-version or package-version-release
+ // Simple heuristic: contains a hyphen followed by a digit
+ let parts: Vec<&str> = pkg.rsplitn(2, '-').collect();
+ if parts.len() >= 2 {
+ let potential_version = parts[0];
+ // Version typically starts with a digit
+ potential_version.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
+ } else {
+ false
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_yum_install_unpinned() {
+ let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3033"));
+ }
+
+ #[test]
+ fn test_yum_install_pinned() {
+ let result = lint_dockerfile("FROM centos:7\nRUN yum install -y nginx-1.20.1");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3033"));
+ }
+
+ #[test]
+ fn test_yum_install_local_rpm() {
+ let result = lint_dockerfile("FROM centos:7\nRUN yum install -y /tmp/package.rpm");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3033"));
+ }
+
+ #[test]
+ fn test_yum_update() {
+ let result = lint_dockerfile("FROM centos:7\nRUN yum update -y");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3033"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3034.rs b/src/analyzer/hadolint/rules/dl3034.rs
new file mode 100644
index 00000000..815f4e8e
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3034.rs
@@ -0,0 +1,57 @@
+//! DL3034: Non-interactive switch missing from zypper command
+//!
+//! zypper commands should use -n or --non-interactive.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3034",
+ Severity::Warning,
+ "Non-interactive switch missing from `zypper` command: `-n`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "zypper" {
+ !cmd.has_any_flag(&["n", "non-interactive"])
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_zypper_without_n() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper refresh");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3034"));
+ }
+
+ #[test]
+ fn test_zypper_with_n() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n refresh");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3034"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3035.rs b/src/analyzer/hadolint/rules/dl3035.rs
new file mode 100644
index 00000000..ed562ac8
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3035.rs
@@ -0,0 +1,53 @@
+//! DL3035: Do not use zypper update
+//!
+//! Using zypper update in a Dockerfile is not recommended.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3035",
+ Severity::Warning,
+ "Do not use `zypper update`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ cmd.name == "zypper" && cmd.has_any_arg(&["update", "up"])
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_zypper_update() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n update");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3035"));
+ }
+
+ #[test]
+ fn test_zypper_install() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3035"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3036.rs b/src/analyzer/hadolint/rules/dl3036.rs
new file mode 100644
index 00000000..9411fdc5
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3036.rs
@@ -0,0 +1,64 @@
+//! DL3036: zypper clean missing after zypper install
+//!
+//! Clean up zypper cache after installing packages.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3036",
+ Severity::Warning,
+ "`zypper clean` missing after zypper install.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ let has_install = shell.any_command(|cmd| {
+ cmd.name == "zypper" && cmd.has_any_arg(&["install", "in"])
+ });
+
+ if !has_install {
+ return true;
+ }
+
+ let has_clean = shell.any_command(|cmd| {
+ (cmd.name == "zypper" && cmd.has_any_arg(&["clean", "cc"]))
+ || (cmd.name == "rm" && cmd.arguments.iter().any(|a| a.contains("/var/cache/zypp")))
+ });
+
+ has_clean
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_zypper_without_clean() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3036"));
+ }
+
+ #[test]
+ fn test_zypper_with_clean() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx && zypper clean");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3036"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3037.rs b/src/analyzer/hadolint/rules/dl3037.rs
new file mode 100644
index 00000000..74122cba
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3037.rs
@@ -0,0 +1,86 @@
+//! DL3037: Pin versions in zypper install
+//!
+//! zypper packages should be pinned to specific versions.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3037",
+ Severity::Warning,
+ "Specify version with `zypper install =`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "zypper" && cmd.has_any_arg(&["install", "in"]) {
+ let packages = get_zypper_packages(cmd);
+ packages.iter().any(|pkg| !is_pinned_zypper_package(pkg))
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+fn get_zypper_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> {
+ let mut packages = Vec::new();
+ let mut found_install = false;
+
+ for arg in &cmd.arguments {
+ if arg == "install" || arg == "in" {
+ found_install = true;
+ continue;
+ }
+ if found_install && !arg.starts_with('-') {
+ packages.push(arg.as_str());
+ }
+ }
+
+ packages
+}
+
+fn is_pinned_zypper_package(pkg: &str) -> bool {
+ if pkg.starts_with('-') {
+ return true;
+ }
+ if pkg.ends_with(".rpm") {
+ return true;
+ }
+ // zypper uses = or >= for version pinning
+ pkg.contains('=') || pkg.contains(">=") || pkg.contains("<=")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_zypper_unpinned() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3037"));
+ }
+
+ #[test]
+ fn test_zypper_pinned() {
+ let result = lint_dockerfile("FROM opensuse:latest\nRUN zypper -n install nginx=1.20.0");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3037"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3038.rs b/src/analyzer/hadolint/rules/dl3038.rs
new file mode 100644
index 00000000..fc7b9bd6
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3038.rs
@@ -0,0 +1,57 @@
+//! DL3038: Use the -y switch to avoid prompts for dnf install
+//!
+//! dnf install should use -y to avoid prompts.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3038",
+ Severity::Warning,
+ "Use the `-y` switch to avoid prompts during `dnf install`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "dnf" && cmd.has_any_arg(&["install"]) {
+ !cmd.has_any_flag(&["y", "yes", "assumeyes"])
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_dnf_without_y() {
+ let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3038"));
+ }
+
+ #[test]
+ fn test_dnf_with_y() {
+ let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3038"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3039.rs b/src/analyzer/hadolint/rules/dl3039.rs
new file mode 100644
index 00000000..a1e3223b
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3039.rs
@@ -0,0 +1,53 @@
+//! DL3039: Do not use dnf update
+//!
+//! Using dnf update in a Dockerfile is not recommended.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3039",
+ Severity::Warning,
+ "Do not use `dnf update`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ cmd.name == "dnf" && cmd.has_any_arg(&["update", "upgrade"])
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_dnf_update() {
+ let result = lint_dockerfile("FROM fedora:latest\nRUN dnf update -y");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3039"));
+ }
+
+ #[test]
+ fn test_dnf_install() {
+ let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3039"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3040.rs b/src/analyzer/hadolint/rules/dl3040.rs
new file mode 100644
index 00000000..7f03b9a5
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3040.rs
@@ -0,0 +1,64 @@
+//! DL3040: dnf clean all missing after dnf install
+//!
+//! Clean up dnf cache after installing packages.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3040",
+ Severity::Warning,
+ "`dnf clean all` missing after dnf install.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ let has_install = shell.any_command(|cmd| {
+ cmd.name == "dnf" && cmd.has_any_arg(&["install"])
+ });
+
+ if !has_install {
+ return true;
+ }
+
+ let has_clean = shell.any_command(|cmd| {
+ (cmd.name == "dnf" && cmd.has_any_arg(&["clean"]))
+ || (cmd.name == "rm" && cmd.arguments.iter().any(|a| a.contains("/var/cache/dnf")))
+ });
+
+ has_clean
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_dnf_without_clean() {
+ let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3040"));
+ }
+
+ #[test]
+ fn test_dnf_with_clean() {
+ let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx && dnf clean all");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3040"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3041.rs b/src/analyzer/hadolint/rules/dl3041.rs
new file mode 100644
index 00000000..26204ec1
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3041.rs
@@ -0,0 +1,92 @@
+//! DL3041: Pin versions in dnf install
+//!
+//! dnf packages should be pinned to specific versions.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3041",
+ Severity::Warning,
+ "Specify version with `dnf install -`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "dnf" && cmd.has_any_arg(&["install"]) {
+ let packages = get_dnf_packages(cmd);
+ packages.iter().any(|pkg| !is_pinned_dnf_package(pkg))
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+fn get_dnf_packages(cmd: &crate::analyzer::hadolint::shell::Command) -> Vec<&str> {
+ let mut packages = Vec::new();
+ let mut found_install = false;
+
+ for arg in &cmd.arguments {
+ if arg == "install" {
+ found_install = true;
+ continue;
+ }
+ if found_install && !arg.starts_with('-') {
+ packages.push(arg.as_str());
+ }
+ }
+
+ packages
+}
+
+fn is_pinned_dnf_package(pkg: &str) -> bool {
+ if pkg.starts_with('-') {
+ return true;
+ }
+ if pkg.ends_with(".rpm") {
+ return true;
+ }
+ // dnf uses - for version: package-version-release
+ let parts: Vec<&str> = pkg.rsplitn(2, '-').collect();
+ if parts.len() >= 2 {
+ let potential_version = parts[0];
+ potential_version.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false)
+ } else {
+ false
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_dnf_unpinned() {
+ let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3041"));
+ }
+
+ #[test]
+ fn test_dnf_pinned() {
+ let result = lint_dockerfile("FROM fedora:latest\nRUN dnf install -y nginx-1.20.0");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3041"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3042.rs b/src/analyzer/hadolint/rules/dl3042.rs
new file mode 100644
index 00000000..261adac5
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3042.rs
@@ -0,0 +1,83 @@
+//! DL3042: Avoid use of cache directory with pip
+//!
+//! Use --no-cache-dir with pip install to reduce image size.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3042",
+ Severity::Warning,
+ "Avoid use of cache directory with pip. Use `pip install --no-cache-dir `.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if shell.is_pip_install(cmd) {
+ // Must have --no-cache-dir
+ !cmd.has_any_flag(&["no-cache-dir"])
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_pip_install_without_no_cache() {
+ let result = lint_dockerfile("FROM python:3.11\nRUN pip install flask");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3042"));
+ }
+
+ #[test]
+ fn test_pip_install_with_no_cache() {
+ let result = lint_dockerfile("FROM python:3.11\nRUN pip install --no-cache-dir flask");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3042"));
+ }
+
+ #[test]
+ fn test_pip3_install_without_no_cache() {
+ let result = lint_dockerfile("FROM python:3.11\nRUN pip3 install flask");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3042"));
+ }
+
+ #[test]
+ fn test_pip3_install_with_no_cache() {
+ let result = lint_dockerfile("FROM python:3.11\nRUN pip3 install --no-cache-dir flask");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3042"));
+ }
+
+ #[test]
+ fn test_python_m_pip_without_no_cache() {
+ let result = lint_dockerfile("FROM python:3.11\nRUN python -m pip install flask");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3042"));
+ }
+
+ #[test]
+ fn test_pip_freeze() {
+ // pip freeze doesn't need --no-cache-dir
+ let result = lint_dockerfile("FROM python:3.11\nRUN pip freeze > requirements.txt");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3042"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3043.rs b/src/analyzer/hadolint/rules/dl3043.rs
new file mode 100644
index 00000000..bdbc9bcc
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3043.rs
@@ -0,0 +1,65 @@
+//! DL3043: ONBUILD ONBUILD is not allowed
+//!
+//! Nested ONBUILD instructions are not allowed.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3043",
+ Severity::Error,
+ "`ONBUILD` combined with `ONBUILD` is not allowed.",
+ |instr, _shell| {
+ match instr {
+ Instruction::OnBuild(inner) => {
+ !matches!(inner.as_ref(), Instruction::OnBuild(_))
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+ use crate::analyzer::hadolint::parser::instruction::{Arguments, RunArgs, RunFlags};
+
+ #[test]
+ fn test_nested_onbuild() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ // ONBUILD ONBUILD RUN echo hello
+ let inner_run = Instruction::Run(RunArgs {
+ arguments: Arguments::Text("echo hello".to_string()),
+ flags: RunFlags::default(),
+ });
+ let inner_onbuild = Instruction::OnBuild(Box::new(inner_run));
+ let instr = Instruction::OnBuild(Box::new(inner_onbuild));
+
+ rule.check(&mut state, 1, &instr, None);
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3043");
+ }
+
+ #[test]
+ fn test_valid_onbuild() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ // ONBUILD RUN echo hello
+ let inner = Instruction::Run(RunArgs {
+ arguments: Arguments::Text("echo hello".to_string()),
+ flags: RunFlags::default(),
+ });
+ let instr = Instruction::OnBuild(Box::new(inner));
+
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3044.rs b/src/analyzer/hadolint/rules/dl3044.rs
new file mode 100644
index 00000000..eebab921
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3044.rs
@@ -0,0 +1,71 @@
+//! DL3044: Do not refer to an environment variable within the same ENV statement
+//!
+//! ENV variable references within the same statement may not work as expected.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3044",
+ Severity::Error,
+ "Do not refer to an environment variable within the same `ENV` statement where it is defined.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Env(pairs) => {
+ // Check if any value references a variable defined earlier in the same statement
+ // For each pair, only check against variables defined BEFORE it
+ let mut defined_vars: Vec<&str> = Vec::new();
+
+ for (key, value) in pairs {
+ for var in &defined_vars {
+ // Check for $VAR or ${VAR} patterns
+ if value.contains(&format!("${}", var))
+ || value.contains(&format!("${{{}}}", var))
+ {
+ return false;
+ }
+ }
+ // Add this key to defined vars for checking subsequent pairs
+ defined_vars.push(key.as_str());
+ }
+ true
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_self_reference() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nENV PATH=/app:$PATH");
+ // Note: PATH is not defined in this statement, so it's OK
+ // This rule checks for referencing a var defined IN THE SAME statement
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3044"));
+ }
+
+ #[test]
+ fn test_same_statement_reference() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nENV FOO=bar BAR=$FOO");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3044"));
+ }
+
+ #[test]
+ fn test_no_reference() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nENV FOO=bar BAR=baz");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3044"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3045.rs b/src/analyzer/hadolint/rules/dl3045.rs
new file mode 100644
index 00000000..b65d17e3
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3045.rs
@@ -0,0 +1,161 @@
+//! DL3045: COPY to a relative destination without WORKDIR set
+//!
+//! COPY to a relative path requires WORKDIR to be set to ensure
+//! predictable behavior.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL3045",
+ Severity::Warning,
+ "`COPY` to a relative destination without `WORKDIR` set.",
+ |state, line, instr, _shell| {
+ match instr {
+ Instruction::From(base) => {
+ // Track current stage
+ let stage_name = base.alias.as_ref()
+ .map(|a| a.as_str().to_string())
+ .unwrap_or_else(|| base.image.name.clone());
+ state.data.set_string("current_stage", &stage_name);
+
+ // Check if parent stage had WORKDIR set
+ let parent_had_workdir = state.data.set_contains("stages_with_workdir", &base.image.name);
+ if parent_had_workdir {
+ state.data.insert_to_set("stages_with_workdir", &stage_name);
+ }
+ }
+ Instruction::Workdir(_) => {
+ // Mark current stage as having WORKDIR set
+ let stage = state.data.get_string("current_stage")
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| "__none__".to_string());
+ state.data.insert_to_set("stages_with_workdir", &stage);
+ }
+ Instruction::Copy(args, _) => {
+ let dest = &args.dest;
+
+ // Check if current stage has WORKDIR set
+ let has_workdir = state.data.get_string("current_stage")
+ .map(|s| state.data.set_contains("stages_with_workdir", s))
+ .unwrap_or_else(|| state.data.set_contains("stages_with_workdir", "__none__"));
+
+ // Skip check if WORKDIR is set
+ if has_workdir {
+ return;
+ }
+
+ // Check if destination is absolute
+ let trimmed = dest.trim_matches(|c| c == '"' || c == '\'');
+
+ // Absolute paths are OK
+ if trimmed.starts_with('/') {
+ return;
+ }
+
+ // Windows absolute paths are OK
+ if is_windows_absolute(trimmed) {
+ return;
+ }
+
+ // Variable references are OK
+ if trimmed.starts_with('$') {
+ return;
+ }
+
+ // Relative path without WORKDIR
+ state.add_failure(
+ "DL3045",
+ Severity::Warning,
+ "`COPY` to a relative destination without `WORKDIR` set.",
+ line,
+ );
+ }
+ _ => {}
+ }
+ },
+ )
+}
+
+/// Check if path is a Windows absolute path.
+fn is_windows_absolute(path: &str) -> bool {
+ let chars: Vec = path.chars().collect();
+ chars.len() >= 2 && chars[0].is_ascii_alphabetic() && chars[1] == ':'
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::{BaseImage, CopyArgs, CopyFlags};
+ use crate::analyzer::hadolint::rules::Rule;
+
+ #[test]
+ fn test_absolute_dest() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let copy = Instruction::Copy(
+ CopyArgs::new(vec!["app.js".to_string()], "/app/"),
+ CopyFlags::default(),
+ );
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, ©, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_relative_dest_without_workdir() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let copy = Instruction::Copy(
+ CopyArgs::new(vec!["app.js".to_string()], "app/"),
+ CopyFlags::default(),
+ );
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, ©, None);
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3045");
+ }
+
+ #[test]
+ fn test_relative_dest_with_workdir() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let workdir = Instruction::Workdir("/app".to_string());
+ let copy = Instruction::Copy(
+ CopyArgs::new(vec!["app.js".to_string()], "."),
+ CopyFlags::default(),
+ );
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, &workdir, None);
+ rule.check(&mut state, 3, ©, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_variable_dest() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let copy = Instruction::Copy(
+ CopyArgs::new(vec!["app.js".to_string()], "$APP_DIR"),
+ CopyFlags::default(),
+ );
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, ©, None);
+ assert!(state.failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3046.rs b/src/analyzer/hadolint/rules/dl3046.rs
new file mode 100644
index 00000000..4597d88a
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3046.rs
@@ -0,0 +1,70 @@
+//! DL3046: useradd without -l flag may result in large layers
+//!
+//! When adding a user with useradd, use the -l flag to avoid creating
+//! large layers due to /var/log/lastlog growing.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3046",
+ Severity::Warning,
+ "`useradd` without flag `-l` and target UID not within `/etc/login.defs` may result in excessively large image.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ !shell.any_command(|cmd| {
+ if cmd.name == "useradd" {
+ // Check if -l or --no-log-init flag is present
+ // Also check combined flags like -lm
+ let has_l_flag = cmd.arguments.iter().any(|a| {
+ a == "-l" || a == "--no-log-init" ||
+ (a.starts_with('-') && !a.starts_with("--") && a.contains('l'))
+ });
+ !has_l_flag
+ } else {
+ false
+ }
+ })
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_useradd_without_l() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN useradd -m myuser");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3046"));
+ }
+
+ #[test]
+ fn test_useradd_with_l() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN useradd -l -m myuser");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3046"));
+ }
+
+ #[test]
+ fn test_useradd_with_no_log_init() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN useradd --no-log-init -m myuser");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3046"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3047.rs b/src/analyzer/hadolint/rules/dl3047.rs
new file mode 100644
index 00000000..5f546d02
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3047.rs
@@ -0,0 +1,103 @@
+//! DL3047: wget vs curl consistency
+//!
+//! Avoid using both wget and curl in the same Dockerfile.
+//! Pick one to reduce image size.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL3047",
+ Severity::Info,
+ "Avoid using both `wget` and `curl` since they serve the same purpose.",
+ |state, line, instr, shell| {
+ match instr {
+ Instruction::From(_) => {
+ // Reset tracking for new stage
+ state.data.set_bool("seen_wget", false);
+ state.data.set_bool("seen_curl", false);
+ state.data.set_bool("reported_dl3047", false);
+ }
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ let uses_wget = shell.using_program("wget");
+ let uses_curl = shell.using_program("curl");
+
+ if uses_wget {
+ state.data.set_bool("seen_wget", true);
+ }
+ if uses_curl {
+ state.data.set_bool("seen_curl", true);
+ }
+
+ // Report if both are now seen and not already reported
+ let seen_both = state.data.get_bool("seen_wget") && state.data.get_bool("seen_curl");
+ let already_reported = state.data.get_bool("reported_dl3047");
+
+ if seen_both && !already_reported {
+ state.add_failure(
+ "DL3047",
+ Severity::Info,
+ "Avoid using both `wget` and `curl` since they serve the same purpose.",
+ line,
+ );
+ state.data.set_bool("reported_dl3047", true);
+ }
+ }
+ }
+ _ => {}
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_wget_only() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN wget https://example.com/file");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3047"));
+ }
+
+ #[test]
+ fn test_curl_only() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN curl -O https://example.com/file");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3047"));
+ }
+
+ #[test]
+ fn test_both_wget_and_curl() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04\nRUN wget https://example.com/file1\nRUN curl -O https://example.com/file2"
+ );
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3047"));
+ }
+
+ #[test]
+ fn test_both_in_same_run() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04\nRUN wget https://a.com/f && curl -O https://b.com/g"
+ );
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3047"));
+ }
+
+ #[test]
+ fn test_different_stages() {
+ // Different stages should track separately
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04 AS stage1\nRUN wget https://a.com/f\nFROM ubuntu:20.04 AS stage2\nRUN curl https://b.com/g"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3047"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3048.rs b/src/analyzer/hadolint/rules/dl3048.rs
new file mode 100644
index 00000000..44e297e8
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3048.rs
@@ -0,0 +1,80 @@
+//! DL3048: Invalid label key
+//!
+//! Label keys should follow the OCI annotation specification.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3048",
+ Severity::Style,
+ "Invalid label key.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Label(pairs) => {
+ pairs.iter().all(|(key, _)| is_valid_label_key(key))
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+fn is_valid_label_key(key: &str) -> bool {
+ if key.is_empty() {
+ return false;
+ }
+
+ // Label keys must start with a letter or number
+ let first_char = key.chars().next().unwrap();
+ if !first_char.is_ascii_alphanumeric() {
+ return false;
+ }
+
+ // Label keys can only contain alphanumeric, -, _, .
+ key.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_valid_label() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL maintainer=\"test@test.com\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3048"));
+ }
+
+ #[test]
+ fn test_valid_oci_label() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.title=\"Test\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3048"));
+ }
+
+ #[test]
+ fn test_invalid_label_special_char() {
+ // Note: The parser may not accept labels starting with special chars,
+ // so this test validates the rule itself works with the unit test approach
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+ use crate::analyzer::hadolint::parser::instruction::Instruction;
+
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ // Manually test with an invalid key starting with @
+ let instr = Instruction::Label(vec![("@invalid".to_string(), "test".to_string())]);
+ rule.check(&mut state, 1, &instr, None);
+
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL3048");
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3049.rs b/src/analyzer/hadolint/rules/dl3049.rs
new file mode 100644
index 00000000..fa0f19e3
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3049.rs
@@ -0,0 +1,47 @@
+//! DL3049: Label `maintainer` is deprecated
+//!
+//! The maintainer label is deprecated. Use org.opencontainers.image.authors instead.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3049",
+ Severity::Info,
+ "Label `maintainer` is deprecated, use `org.opencontainers.image.authors` instead.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Label(pairs) => {
+ !pairs.iter().any(|(key, _)| key.to_lowercase() == "maintainer")
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_maintainer_label() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL maintainer=\"test@test.com\"");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3049"));
+ }
+
+ #[test]
+ fn test_oci_authors_label() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.authors=\"test@test.com\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3049"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3050.rs b/src/analyzer/hadolint/rules/dl3050.rs
new file mode 100644
index 00000000..64a8df7f
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3050.rs
@@ -0,0 +1,68 @@
+//! DL3050: Superfluous label present
+//!
+//! Some labels are redundant or should use OCI annotation keys.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3050",
+ Severity::Info,
+ "Superfluous label present.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Label(pairs) => {
+ // Check for deprecated/superfluous labels that should use OCI keys
+ let deprecated_labels = [
+ "description",
+ "version",
+ "build-date",
+ "vcs-url",
+ "vcs-ref",
+ "vendor",
+ "name",
+ "url",
+ "documentation",
+ "source",
+ "licenses",
+ "title",
+ "revision",
+ "created",
+ ];
+
+ !pairs.iter().any(|(key, _)| {
+ let key_lower = key.to_lowercase();
+ deprecated_labels.contains(&key_lower.as_str())
+ })
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_deprecated_description() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL description=\"Test image\"");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3050"));
+ }
+
+ #[test]
+ fn test_oci_description() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.description=\"Test image\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3050"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3051.rs b/src/analyzer/hadolint/rules/dl3051.rs
new file mode 100644
index 00000000..45350314
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3051.rs
@@ -0,0 +1,124 @@
+//! DL3051: Label `org.opencontainers.image.created` is empty or not a valid date
+//!
+//! The created label should contain a valid RFC3339 date.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3051",
+ Severity::Warning,
+ "Label `org.opencontainers.image.created` is empty or not a valid RFC3339 date.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Label(pairs) => {
+ for (key, value) in pairs {
+ if key == "org.opencontainers.image.created" {
+ if value.is_empty() || !is_valid_rfc3339(value) {
+ return false;
+ }
+ }
+ }
+ true
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+fn is_valid_rfc3339(date: &str) -> bool {
+ // Basic RFC3339 validation (YYYY-MM-DDTHH:MM:SSZ or with timezone offset)
+ // Full format: 2023-01-15T14:30:00Z or 2023-01-15T14:30:00+00:00
+ if date.len() < 20 {
+ return false;
+ }
+
+ let chars: Vec = date.chars().collect();
+
+ // Check date part
+ if chars.len() < 10 {
+ return false;
+ }
+
+ // YYYY-MM-DD
+ if !chars[0..4].iter().all(|c| c.is_ascii_digit()) { return false; }
+ if chars[4] != '-' { return false; }
+ if !chars[5..7].iter().all(|c| c.is_ascii_digit()) { return false; }
+ if chars[7] != '-' { return false; }
+ if !chars[8..10].iter().all(|c| c.is_ascii_digit()) { return false; }
+
+ // T separator
+ if chars.get(10) != Some(&'T') && chars.get(10) != Some(&'t') {
+ return false;
+ }
+
+ // HH:MM:SS
+ if chars.len() < 19 { return false; }
+ if !chars[11..13].iter().all(|c| c.is_ascii_digit()) { return false; }
+ if chars[13] != ':' { return false; }
+ if !chars[14..16].iter().all(|c| c.is_ascii_digit()) { return false; }
+ if chars[16] != ':' { return false; }
+ if !chars[17..19].iter().all(|c| c.is_ascii_digit()) { return false; }
+
+ // Timezone (Z or +/-HH:MM)
+ if chars.len() == 20 && chars[19] == 'Z' {
+ return true;
+ }
+
+ // Allow fractional seconds before timezone
+ let tz_start = if chars.get(19) == Some(&'.') {
+ // Find where fractional seconds end
+ let mut i = 20;
+ while i < chars.len() && chars[i].is_ascii_digit() {
+ i += 1;
+ }
+ i
+ } else {
+ 19
+ };
+
+ if chars.len() > tz_start {
+ let tz_char = chars[tz_start];
+ if tz_char == 'Z' || tz_char == 'z' {
+ return true;
+ }
+ if (tz_char == '+' || tz_char == '-') && chars.len() >= tz_start + 6 {
+ return true;
+ }
+ }
+
+ false
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_valid_date() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.created=\"2023-01-15T14:30:00Z\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3051"));
+ }
+
+ #[test]
+ fn test_empty_date() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.created=\"\"");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3051"));
+ }
+
+ #[test]
+ fn test_invalid_date() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.created=\"not-a-date\"");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3051"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3052.rs b/src/analyzer/hadolint/rules/dl3052.rs
new file mode 100644
index 00000000..8168f152
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3052.rs
@@ -0,0 +1,91 @@
+//! DL3052: Label `org.opencontainers.image.licenses` is not a valid SPDX expression
+//!
+//! The licenses label should contain a valid SPDX license identifier.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3052",
+ Severity::Warning,
+ "Label `org.opencontainers.image.licenses` is not a valid SPDX expression.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Label(pairs) => {
+ for (key, value) in pairs {
+ if key == "org.opencontainers.image.licenses" {
+ if value.is_empty() || !is_valid_spdx(value) {
+ return false;
+ }
+ }
+ }
+ true
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+fn is_valid_spdx(license: &str) -> bool {
+ // Common SPDX license identifiers
+ let common_licenses = [
+ "MIT", "Apache-2.0", "GPL-2.0", "GPL-2.0-only", "GPL-2.0-or-later",
+ "GPL-3.0", "GPL-3.0-only", "GPL-3.0-or-later", "BSD-2-Clause",
+ "BSD-3-Clause", "ISC", "MPL-2.0", "LGPL-2.1", "LGPL-2.1-only",
+ "LGPL-2.1-or-later", "LGPL-3.0", "LGPL-3.0-only", "LGPL-3.0-or-later",
+ "AGPL-3.0", "AGPL-3.0-only", "AGPL-3.0-or-later", "Unlicense",
+ "CC0-1.0", "CC-BY-4.0", "CC-BY-SA-4.0", "WTFPL", "Zlib", "0BSD",
+ "EPL-1.0", "EPL-2.0", "EUPL-1.2", "PostgreSQL", "OFL-1.1",
+ "Artistic-2.0", "BSL-1.0", "CDDL-1.0", "CDDL-1.1", "CPL-1.0",
+ ];
+
+ // Check for common licenses (case-insensitive)
+ let license_upper = license.to_uppercase();
+
+ // Handle compound expressions (AND, OR, WITH)
+ let parts: Vec<&str> = license_upper
+ .split(|c| c == '(' || c == ')' || c == ' ')
+ .filter(|s| !s.is_empty() && *s != "AND" && *s != "OR" && *s != "WITH")
+ .collect();
+
+ if parts.is_empty() {
+ return false;
+ }
+
+ parts.iter().all(|part| {
+ common_licenses.iter().any(|l| l.to_uppercase() == *part)
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_valid_spdx() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.licenses=\"MIT\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3052"));
+ }
+
+ #[test]
+ fn test_valid_compound_spdx() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.licenses=\"MIT OR Apache-2.0\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3052"));
+ }
+
+ #[test]
+ fn test_invalid_spdx() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.licenses=\"NotALicense\"");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3052"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3053.rs b/src/analyzer/hadolint/rules/dl3053.rs
new file mode 100644
index 00000000..1401e153
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3053.rs
@@ -0,0 +1,52 @@
+//! DL3053: Label `org.opencontainers.image.title` is empty
+//!
+//! The title label should not be empty.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3053",
+ Severity::Warning,
+ "Label `org.opencontainers.image.title` is empty.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Label(pairs) => {
+ for (key, value) in pairs {
+ if key == "org.opencontainers.image.title" && value.trim().is_empty() {
+ return false;
+ }
+ }
+ true
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_valid_title() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.title=\"My App\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3053"));
+ }
+
+ #[test]
+ fn test_empty_title() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.title=\"\"");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3053"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3054.rs b/src/analyzer/hadolint/rules/dl3054.rs
new file mode 100644
index 00000000..95519168
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3054.rs
@@ -0,0 +1,52 @@
+//! DL3054: Label `org.opencontainers.image.description` is empty
+//!
+//! The description label should not be empty.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3054",
+ Severity::Warning,
+ "Label `org.opencontainers.image.description` is empty.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Label(pairs) => {
+ for (key, value) in pairs {
+ if key == "org.opencontainers.image.description" && value.trim().is_empty() {
+ return false;
+ }
+ }
+ true
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_valid_description() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.description=\"A description\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3054"));
+ }
+
+ #[test]
+ fn test_empty_description() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.description=\"\"");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3054"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3055.rs b/src/analyzer/hadolint/rules/dl3055.rs
new file mode 100644
index 00000000..16b615af
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3055.rs
@@ -0,0 +1,63 @@
+//! DL3055: Label `org.opencontainers.image.documentation` is not a valid URL
+//!
+//! The documentation label should contain a valid URL.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3055",
+ Severity::Warning,
+ "Label `org.opencontainers.image.documentation` is not a valid URL.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Label(pairs) => {
+ for (key, value) in pairs {
+ if key == "org.opencontainers.image.documentation" {
+ if !is_valid_url(value) {
+ return false;
+ }
+ }
+ }
+ true
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+fn is_valid_url(url: &str) -> bool {
+ if url.is_empty() {
+ return false;
+ }
+
+ // Basic URL validation - must start with http:// or https://
+ url.starts_with("http://") || url.starts_with("https://")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_valid_url() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.documentation=\"https://example.com/docs\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3055"));
+ }
+
+ #[test]
+ fn test_invalid_url() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.documentation=\"not-a-url\"");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3055"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3056.rs b/src/analyzer/hadolint/rules/dl3056.rs
new file mode 100644
index 00000000..010d275f
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3056.rs
@@ -0,0 +1,63 @@
+//! DL3056: Label `org.opencontainers.image.source` is not a valid URL
+//!
+//! The source label should contain a valid URL.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3056",
+ Severity::Warning,
+ "Label `org.opencontainers.image.source` is not a valid URL.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Label(pairs) => {
+ for (key, value) in pairs {
+ if key == "org.opencontainers.image.source" {
+ if !is_valid_url(value) {
+ return false;
+ }
+ }
+ }
+ true
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+fn is_valid_url(url: &str) -> bool {
+ if url.is_empty() {
+ return false;
+ }
+
+ // Basic URL validation - must start with http:// or https://
+ url.starts_with("http://") || url.starts_with("https://")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_valid_url() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.source=\"https://github.com/example/repo\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3056"));
+ }
+
+ #[test]
+ fn test_invalid_url() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.source=\"not-a-url\"");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3056"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3057.rs b/src/analyzer/hadolint/rules/dl3057.rs
new file mode 100644
index 00000000..fa497d36
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3057.rs
@@ -0,0 +1,70 @@
+//! DL3057: HEALTHCHECK instruction missing
+//!
+//! Images should have a HEALTHCHECK instruction to allow the container orchestrator
+//! to monitor the health of the container.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{very_custom_rule, VeryCustomRule, RuleState, CheckFailure};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> VeryCustomRule<
+ impl Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync,
+ impl Fn(RuleState) -> Vec + Send + Sync
+> {
+ very_custom_rule(
+ "DL3057",
+ Severity::Info,
+ "HEALTHCHECK instruction missing.",
+ // Step function
+ |state, _line, instr, _shell| {
+ if matches!(instr, Instruction::Healthcheck(_)) {
+ state.data.set_bool("has_healthcheck", true);
+ }
+ // Track if we have any real instructions (not just FROM)
+ if !matches!(instr, Instruction::From(_) | Instruction::Comment(_)) {
+ state.data.set_bool("has_instructions", true);
+ }
+ },
+ // Finalize function - add failure if no healthcheck found
+ |state| {
+ // Only report if there are actual instructions beyond FROM
+ if !state.data.get_bool("has_healthcheck") && state.data.get_bool("has_instructions") {
+ let mut failures = state.failures;
+ failures.push(CheckFailure::new("DL3057", Severity::Info, "HEALTHCHECK instruction missing.", 1));
+ failures
+ } else {
+ state.failures
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_missing_healthcheck() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3057"));
+ }
+
+ #[test]
+ fn test_has_healthcheck() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nHEALTHCHECK CMD curl -f http://localhost/ || exit 1");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3057"));
+ }
+
+ #[test]
+ fn test_healthcheck_none() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nHEALTHCHECK NONE");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3057"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3058.rs b/src/analyzer/hadolint/rules/dl3058.rs
new file mode 100644
index 00000000..15129efc
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3058.rs
@@ -0,0 +1,63 @@
+//! DL3058: Label `org.opencontainers.image.url` is not a valid URL
+//!
+//! The url label should contain a valid URL.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3058",
+ Severity::Warning,
+ "Label `org.opencontainers.image.url` is not a valid URL.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Label(pairs) => {
+ for (key, value) in pairs {
+ if key == "org.opencontainers.image.url" {
+ if !is_valid_url(value) {
+ return false;
+ }
+ }
+ }
+ true
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+fn is_valid_url(url: &str) -> bool {
+ if url.is_empty() {
+ return false;
+ }
+
+ // Basic URL validation - must start with http:// or https://
+ url.starts_with("http://") || url.starts_with("https://")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_valid_url() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.url=\"https://example.com\"");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3058"));
+ }
+
+ #[test]
+ fn test_invalid_url() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nLABEL org.opencontainers.image.url=\"not-a-url\"");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3058"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3059.rs b/src/analyzer/hadolint/rules/dl3059.rs
new file mode 100644
index 00000000..a5029ce3
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3059.rs
@@ -0,0 +1,98 @@
+//! DL3059: Multiple consecutive RUN instructions
+//!
+//! Combine consecutive RUN instructions to reduce the number of layers.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL3059",
+ Severity::Info,
+ "Multiple consecutive `RUN` instructions. Consider consolidation.",
+ |state, line, instr, _shell| {
+ match instr {
+ Instruction::From(_) => {
+ // Reset tracking for new stage
+ state.data.set_int("consecutive_runs", 0);
+ state.data.set_int("last_run_line", 0);
+ }
+ Instruction::Run(_) => {
+ let consecutive = state.data.get_int("consecutive_runs");
+ state.data.set_int("consecutive_runs", consecutive + 1);
+ state.data.set_int("last_run_line", line as i64);
+
+ // Report on the second consecutive RUN
+ if consecutive >= 1 {
+ state.add_failure(
+ "DL3059",
+ Severity::Info,
+ "Multiple consecutive `RUN` instructions. Consider consolidation.",
+ line,
+ );
+ }
+ }
+ // Other instructions reset the counter
+ _ => {
+ state.data.set_int("consecutive_runs", 0);
+ }
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_consecutive_runs() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04\nRUN apt-get update\nRUN apt-get install -y nginx"
+ );
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3059"));
+ }
+
+ #[test]
+ fn test_single_run() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04\nRUN apt-get update && apt-get install -y nginx"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3059"));
+ }
+
+ #[test]
+ fn test_runs_separated_by_other() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04\nRUN apt-get update\nENV DEBIAN_FRONTEND=noninteractive\nRUN apt-get install -y nginx"
+ );
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3059"));
+ }
+
+ #[test]
+ fn test_three_consecutive_runs() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04\nRUN echo 1\nRUN echo 2\nRUN echo 3"
+ );
+ // Should report on 2nd and 3rd RUN
+ let count = result.failures.iter().filter(|f| f.code.as_str() == "DL3059").count();
+ assert_eq!(count, 2);
+ }
+
+ #[test]
+ fn test_different_stages() {
+ let result = lint_dockerfile(
+ "FROM ubuntu:20.04 AS stage1\nRUN echo 1\nFROM ubuntu:20.04 AS stage2\nRUN echo 2"
+ );
+ // Different stages, no consecutive RUNs
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3059"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3060.rs b/src/analyzer/hadolint/rules/dl3060.rs
new file mode 100644
index 00000000..32e89aa8
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3060.rs
@@ -0,0 +1,70 @@
+//! DL3060: yarn cache clean missing after yarn install
+//!
+//! Clean up yarn cache after installing packages.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3060",
+ Severity::Info,
+ "`yarn cache clean` missing after `yarn install`.",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ let has_install = shell.any_command(|cmd| {
+ (cmd.name == "yarn" && cmd.has_any_arg(&["install", "add"]))
+ });
+
+ if !has_install {
+ return true;
+ }
+
+ let has_clean = shell.any_command(|cmd| {
+ (cmd.name == "yarn" && cmd.has_any_arg(&["cache"]) && cmd.arguments.iter().any(|a| a == "clean"))
+ || (cmd.name == "rm" && cmd.arguments.iter().any(|a| a.contains("yarn") && a.contains("cache")))
+ });
+
+ has_clean
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_yarn_without_clean() {
+ let result = lint_dockerfile("FROM node:18\nRUN yarn install");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3060"));
+ }
+
+ #[test]
+ fn test_yarn_with_clean() {
+ let result = lint_dockerfile("FROM node:18\nRUN yarn install && yarn cache clean");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3060"));
+ }
+
+ #[test]
+ fn test_yarn_add_without_clean() {
+ let result = lint_dockerfile("FROM node:18\nRUN yarn add express");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3060"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3061.rs b/src/analyzer/hadolint/rules/dl3061.rs
new file mode 100644
index 00000000..15be18c4
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3061.rs
@@ -0,0 +1,93 @@
+//! DL3061: Invalid image name in FROM
+//!
+//! The image name in FROM should be valid.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL3061",
+ Severity::Error,
+ "Invalid image name in `FROM`.",
+ |instr, _shell| {
+ match instr {
+ Instruction::From(base_image) => {
+ is_valid_image_name(&base_image.image.name)
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+fn is_valid_image_name(name: &str) -> bool {
+ if name.is_empty() {
+ return false;
+ }
+
+ // Allow scratch as a special case
+ if name == "scratch" {
+ return true;
+ }
+
+ // Allow variable expansion
+ if name.starts_with('$') {
+ return true;
+ }
+
+ // Image name can have:
+ // - Registry prefix: registry.example.com/
+ // - Namespace: namespace/
+ // - Name: imagename
+
+ // Basic validation: should contain only valid chars
+ let valid_chars = |c: char| {
+ c.is_ascii_lowercase()
+ || c.is_ascii_digit()
+ || c == '-'
+ || c == '_'
+ || c == '.'
+ || c == '/'
+ || c == ':'
+ };
+
+ name.chars().all(valid_chars)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_valid_image() {
+ let result = lint_dockerfile("FROM ubuntu:20.04");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3061"));
+ }
+
+ #[test]
+ fn test_valid_registry_image() {
+ let result = lint_dockerfile("FROM registry.example.com/myimage:latest");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3061"));
+ }
+
+ #[test]
+ fn test_scratch() {
+ let result = lint_dockerfile("FROM scratch");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3061"));
+ }
+
+ #[test]
+ fn test_variable_image() {
+ let result = lint_dockerfile("ARG BASE=ubuntu\nFROM $BASE");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3061"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl3062.rs b/src/analyzer/hadolint/rules/dl3062.rs
new file mode 100644
index 00000000..7124bed6
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl3062.rs
@@ -0,0 +1,84 @@
+//! DL3062: COPY --from should reference a defined stage
+//!
+//! When using COPY --from, the source should be a defined build stage.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL3062",
+ Severity::Warning,
+ "`COPY --from` should reference a defined build stage or an external image.",
+ |state, line, instr, _shell| {
+ match instr {
+ Instruction::From(base_image) => {
+ // Track stage aliases
+ if let Some(alias) = &base_image.alias {
+ state.data.insert_to_set("stages", alias.as_str().to_string());
+ }
+ // Track stage count
+ let count = state.data.get_int("stage_count");
+ state.data.insert_to_set("stages", count.to_string());
+ state.data.set_int("stage_count", count + 1);
+ }
+ Instruction::Copy(_, flags) => {
+ if let Some(from) = &flags.from {
+ let from_str = from.as_str();
+
+ // It's valid if:
+ // 1. It references a defined stage alias
+ // 2. It references a stage by index
+ // 3. It's an external image (contains / or . or : for tags)
+
+ let is_stage_alias = state.data.set_contains("stages", from_str);
+ let is_stage_index = from_str.parse::().is_ok();
+ let is_external = from_str.contains('/') || from_str.contains('.') || from_str.contains(':');
+
+ if !is_stage_alias && !is_stage_index && !is_external {
+ state.add_failure("DL3062", Severity::Warning, "`COPY --from` should reference a defined build stage or an external image.", line);
+ }
+ }
+ }
+ _ => {}
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_copy_from_defined_stage() {
+ let result = lint_dockerfile("FROM ubuntu:20.04 AS builder\nRUN echo hello\nFROM alpine:3.14\nCOPY --from=builder /app /app");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3062"));
+ }
+
+ #[test]
+ fn test_copy_from_stage_index() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello\nFROM alpine:3.14\nCOPY --from=0 /app /app");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3062"));
+ }
+
+ #[test]
+ fn test_copy_from_external_image() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY --from=nginx:latest /etc/nginx /etc/nginx");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL3062"));
+ }
+
+ #[test]
+ fn test_copy_from_undefined_stage() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nCOPY --from=nonexistent /app /app");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL3062"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl4000.rs b/src/analyzer/hadolint/rules/dl4000.rs
new file mode 100644
index 00000000..1835c779
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl4000.rs
@@ -0,0 +1,46 @@
+//! DL4000: MAINTAINER is deprecated
+//!
+//! The MAINTAINER instruction is deprecated. Use LABEL instead.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL4000",
+ Severity::Error,
+ "MAINTAINER is deprecated",
+ |instr, _shell| {
+ !matches!(instr, Instruction::Maintainer(_))
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_no_maintainer() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::User("node".to_string());
+ rule.check(&mut state, 1, &instr, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_with_maintainer() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Maintainer("John Doe ".to_string());
+ rule.check(&mut state, 1, &instr, None);
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL4000");
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl4001.rs b/src/analyzer/hadolint/rules/dl4001.rs
new file mode 100644
index 00000000..8668da50
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl4001.rs
@@ -0,0 +1,91 @@
+//! DL4001: Either use wget or curl, but not both
+//!
+//! When downloading files, use either wget or curl consistently, not both.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{very_custom_rule, VeryCustomRule, RuleState, CheckFailure};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> VeryCustomRule<
+ impl Fn(&mut RuleState, u32, &Instruction, Option<&ParsedShell>) + Send + Sync,
+ impl Fn(RuleState) -> Vec + Send + Sync
+> {
+ very_custom_rule(
+ "DL4001",
+ Severity::Warning,
+ "Either use `wget` or `curl`, but not both.",
+ |state, line, instr, shell| {
+ if let Instruction::Run(_) = instr {
+ if let Some(shell) = shell {
+ if shell.any_command(|cmd| cmd.name == "wget") {
+ // Store wget lines as comma-separated string
+ let existing = state.data.get_string("wget_lines").unwrap_or("").to_string();
+ let new = if existing.is_empty() {
+ line.to_string()
+ } else {
+ format!("{},{}", existing, line)
+ };
+ state.data.set_string("wget_lines", new);
+ }
+ if shell.any_command(|cmd| cmd.name == "curl") {
+ let existing = state.data.get_string("curl_lines").unwrap_or("").to_string();
+ let new = if existing.is_empty() {
+ line.to_string()
+ } else {
+ format!("{},{}", existing, line)
+ };
+ state.data.set_string("curl_lines", new);
+ }
+ }
+ }
+ },
+ |state| {
+ let wget_lines = state.data.get_string("wget_lines").unwrap_or("");
+ let curl_lines = state.data.get_string("curl_lines").unwrap_or("");
+
+ // If both wget and curl are used, report failures
+ if !wget_lines.is_empty() && !curl_lines.is_empty() {
+ let mut failures = state.failures;
+ for line in wget_lines.split(',').filter_map(|s| s.parse::().ok()) {
+ failures.push(CheckFailure::new("DL4001", Severity::Warning, "Either use `wget` or `curl`, but not both.", line));
+ }
+ for line in curl_lines.split(',').filter_map(|s| s.parse::().ok()) {
+ failures.push(CheckFailure::new("DL4001", Severity::Warning, "Either use `wget` or `curl`, but not both.", line));
+ }
+ failures
+ } else {
+ state.failures
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_only_wget() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN wget http://example.com/file");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL4001"));
+ }
+
+ #[test]
+ fn test_only_curl() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN curl http://example.com/file");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL4001"));
+ }
+
+ #[test]
+ fn test_both_wget_and_curl() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN wget http://example.com/file\nRUN curl http://example.com/other");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL4001"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl4003.rs b/src/analyzer/hadolint/rules/dl4003.rs
new file mode 100644
index 00000000..84eb4b1f
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl4003.rs
@@ -0,0 +1,92 @@
+//! DL4003: Multiple CMD instructions
+//!
+//! Only one CMD instruction should be present. If multiple are present,
+//! only the last one takes effect.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL4003",
+ Severity::Warning,
+ "Multiple `CMD` instructions found. If you list more than one `CMD` then only the last `CMD` will take effect",
+ |state, line, instr, _shell| {
+ match instr {
+ Instruction::From(_) => {
+ // Reset count for each stage
+ state.data.set_int("cmd_count", 0);
+ }
+ Instruction::Cmd(_) => {
+ let count = state.data.get_int("cmd_count") + 1;
+ state.data.set_int("cmd_count", count);
+
+ if count > 1 {
+ state.add_failure(
+ "DL4003",
+ Severity::Warning,
+ "Multiple `CMD` instructions found. If you list more than one `CMD` then only the last `CMD` will take effect",
+ line,
+ );
+ }
+ }
+ _ => {}
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::{Arguments, BaseImage};
+ use crate::analyzer::hadolint::rules::Rule;
+
+ #[test]
+ fn test_single_cmd() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let cmd = Instruction::Cmd(Arguments::List(vec!["node".to_string()]));
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, &cmd, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_multiple_cmds() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let cmd1 = Instruction::Cmd(Arguments::List(vec!["node".to_string()]));
+ let cmd2 = Instruction::Cmd(Arguments::List(vec!["npm".to_string()]));
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, &cmd1, None);
+ rule.check(&mut state, 3, &cmd2, None);
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL4003");
+ }
+
+ #[test]
+ fn test_multiple_stages_ok() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from1 = Instruction::From(BaseImage::new("node"));
+ let cmd1 = Instruction::Cmd(Arguments::List(vec!["npm".to_string()]));
+ let from2 = Instruction::From(BaseImage::new("alpine"));
+ let cmd2 = Instruction::Cmd(Arguments::List(vec!["node".to_string()]));
+
+ rule.check(&mut state, 1, &from1, None);
+ rule.check(&mut state, 2, &cmd1, None);
+ rule.check(&mut state, 3, &from2, None);
+ rule.check(&mut state, 4, &cmd2, None);
+ assert!(state.failures.is_empty());
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl4004.rs b/src/analyzer/hadolint/rules/dl4004.rs
new file mode 100644
index 00000000..3f6f791b
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl4004.rs
@@ -0,0 +1,75 @@
+//! DL4004: Multiple ENTRYPOINT instructions
+//!
+//! Only one ENTRYPOINT instruction should be present. If multiple are present,
+//! only the last one takes effect.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{custom_rule, CustomRule, RuleState};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> CustomRule) + Send + Sync> {
+ custom_rule(
+ "DL4004",
+ Severity::Error,
+ "Multiple `ENTRYPOINT` instructions found. If you list more than one `ENTRYPOINT` then only the last `ENTRYPOINT` will take effect",
+ |state, line, instr, _shell| {
+ match instr {
+ Instruction::From(_) => {
+ // Reset count for each stage
+ state.data.set_int("entrypoint_count", 0);
+ }
+ Instruction::Entrypoint(_) => {
+ let count = state.data.get_int("entrypoint_count") + 1;
+ state.data.set_int("entrypoint_count", count);
+
+ if count > 1 {
+ state.add_failure(
+ "DL4004",
+ Severity::Error,
+ "Multiple `ENTRYPOINT` instructions found. If you list more than one `ENTRYPOINT` then only the last `ENTRYPOINT` will take effect",
+ line,
+ );
+ }
+ }
+ _ => {}
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::{Arguments, BaseImage};
+ use crate::analyzer::hadolint::rules::Rule;
+
+ #[test]
+ fn test_single_entrypoint() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let ep = Instruction::Entrypoint(Arguments::List(vec!["./entrypoint.sh".to_string()]));
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, &ep, None);
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_multiple_entrypoints() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let from = Instruction::From(BaseImage::new("ubuntu"));
+ let ep1 = Instruction::Entrypoint(Arguments::List(vec!["./script1.sh".to_string()]));
+ let ep2 = Instruction::Entrypoint(Arguments::List(vec!["./script2.sh".to_string()]));
+
+ rule.check(&mut state, 1, &from, None);
+ rule.check(&mut state, 2, &ep1, None);
+ rule.check(&mut state, 3, &ep2, None);
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL4004");
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl4005.rs b/src/analyzer/hadolint/rules/dl4005.rs
new file mode 100644
index 00000000..6e363e33
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl4005.rs
@@ -0,0 +1,65 @@
+//! DL4005: Use SHELL to change the default shell
+//!
+//! Instead of using shell commands to change the shell, use the SHELL instruction.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL4005",
+ Severity::Warning,
+ "Use `SHELL` to change the default shell.",
+ |instr, _shell| {
+ match instr {
+ Instruction::Run(args) => {
+ let cmd_text = match &args.arguments {
+ crate::analyzer::hadolint::parser::instruction::Arguments::Text(t) => t.as_str(),
+ crate::analyzer::hadolint::parser::instruction::Arguments::List(l) => {
+ if l.is_empty() {
+ return true;
+ }
+ l.first().map(|s| s.as_str()).unwrap_or("")
+ }
+ };
+
+ // Check for commands that try to change shell
+ !cmd_text.contains("ln -s")
+ || !cmd_text.contains("/bin/sh")
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::lint::{lint, LintResult};
+ use crate::analyzer::hadolint::config::HadolintConfig;
+
+ fn lint_dockerfile(content: &str) -> LintResult {
+ lint(content, &HadolintConfig::default())
+ }
+
+ #[test]
+ fn test_shell_instruction() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nSHELL [\"/bin/bash\", \"-c\"]");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL4005"));
+ }
+
+ #[test]
+ fn test_ln_s_shell() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN ln -s /bin/bash /bin/sh");
+ assert!(result.failures.iter().any(|f| f.code.as_str() == "DL4005"));
+ }
+
+ #[test]
+ fn test_normal_run() {
+ let result = lint_dockerfile("FROM ubuntu:20.04\nRUN echo hello");
+ assert!(!result.failures.iter().any(|f| f.code.as_str() == "DL4005"));
+ }
+}
diff --git a/src/analyzer/hadolint/rules/dl4006.rs b/src/analyzer/hadolint/rules/dl4006.rs
new file mode 100644
index 00000000..23bd9b1d
--- /dev/null
+++ b/src/analyzer/hadolint/rules/dl4006.rs
@@ -0,0 +1,62 @@
+//! DL4006: Set the SHELL option -o pipefail before RUN with a pipe in it
+//!
+//! If a pipe is used in RUN, the shell option pipefail should be set
+//! to ensure the entire pipeline fails if any command fails.
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::rules::{simple_rule, SimpleRule};
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::Severity;
+
+pub fn rule() -> SimpleRule) -> bool + Send + Sync> {
+ simple_rule(
+ "DL4006",
+ Severity::Warning,
+ "Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using /bin/sh in an alpine image or if your shell is symlinked to busybox then consider explicitly setting your SHELL to /bin/ash, or disable this check",
+ |instr, shell| {
+ match instr {
+ Instruction::Run(_) => {
+ if let Some(shell) = shell {
+ // If there are pipes, this rule fails
+ // (should have set pipefail)
+ // In a real implementation, we'd track if SHELL with pipefail was set
+ !shell.has_pipes
+ } else {
+ true
+ }
+ }
+ _ => true,
+ }
+ },
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::analyzer::hadolint::parser::instruction::RunArgs;
+ use crate::analyzer::hadolint::rules::{Rule, RuleState};
+
+ #[test]
+ fn test_no_pipe() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("apt-get update"));
+ let shell = ParsedShell::parse("apt-get update");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert!(state.failures.is_empty());
+ }
+
+ #[test]
+ fn test_with_pipe() {
+ let rule = rule();
+ let mut state = RuleState::new();
+
+ let instr = Instruction::Run(RunArgs::shell("cat file | grep pattern"));
+ let shell = ParsedShell::parse("cat file | grep pattern");
+ rule.check(&mut state, 1, &instr, Some(&shell));
+ assert_eq!(state.failures.len(), 1);
+ assert_eq!(state.failures[0].code.as_str(), "DL4006");
+ }
+}
diff --git a/src/analyzer/hadolint/rules/mod.rs b/src/analyzer/hadolint/rules/mod.rs
new file mode 100644
index 00000000..2da4120e
--- /dev/null
+++ b/src/analyzer/hadolint/rules/mod.rs
@@ -0,0 +1,497 @@
+//! Rule system framework for hadolint-rs.
+//!
+//! Provides the infrastructure for defining and running Dockerfile linting rules.
+//! The design matches hadolint's fold-based architecture:
+//!
+//! - `simple_rule` - Stateless rules that check each instruction independently
+//! - `custom_rule` - Stateful rules that accumulate state across instructions
+//! - `very_custom_rule` - Rules with custom finalization logic
+//! - `onbuild` - Wrapper to also check ONBUILD-wrapped instructions
+
+use crate::analyzer::hadolint::parser::instruction::Instruction;
+use crate::analyzer::hadolint::shell::ParsedShell;
+use crate::analyzer::hadolint::types::{CheckFailure, RuleCode, Severity};
+
+pub mod dl1001;
+pub mod dl3000;
+pub mod dl3001;
+pub mod dl3002;
+pub mod dl3003;
+pub mod dl3004;
+pub mod dl3005;
+pub mod dl3006;
+pub mod dl3007;
+pub mod dl3008;
+pub mod dl3009;
+pub mod dl3010;
+pub mod dl3011;
+pub mod dl3012;
+pub mod dl3013;
+pub mod dl3014;
+pub mod dl3015;
+pub mod dl3016;
+pub mod dl3017;
+pub mod dl3018;
+pub mod dl3019;
+pub mod dl3020;
+pub mod dl3021;
+pub mod dl3022;
+pub mod dl3023;
+pub mod dl3024;
+pub mod dl3025;
+pub mod dl3026;
+pub mod dl3027;
+pub mod dl3028;
+pub mod dl3029;
+pub mod dl3030;
+pub mod dl3031;
+pub mod dl3032;
+pub mod dl3033;
+pub mod dl3034;
+pub mod dl3035;
+pub mod dl3036;
+pub mod dl3037;
+pub mod dl3038;
+pub mod dl3039;
+pub mod dl3040;
+pub mod dl3041;
+pub mod dl3042;
+pub mod dl3043;
+pub mod dl3044;
+pub mod dl3045;
+pub mod dl3046;
+pub mod dl3047;
+pub mod dl3048;
+pub mod dl3049;
+pub mod dl3050;
+pub mod dl3051;
+pub mod dl3052;
+pub mod dl3053;
+pub mod dl3054;
+pub mod dl3055;
+pub mod dl3056;
+pub mod dl3057;
+pub mod dl3058;
+pub mod dl3059;
+pub mod dl3060;
+pub mod dl3061;
+pub mod dl3062;
+pub mod dl4000;
+pub mod dl4001;
+pub mod dl4003;
+pub mod dl4004;
+pub mod dl4005;
+pub mod dl4006;
+
+/// A rule that can check Dockerfile instructions.
+pub trait Rule: Send + Sync {
+ /// Check an instruction and potentially add failures to the state.
+ fn check(&self, state: &mut RuleState, line: u32, instruction: &Instruction, shell: Option<&ParsedShell>);
+
+ /// Finalize the rule and return any additional failures.
+ /// Called after all instructions have been processed.
+ fn finalize(&self, state: RuleState) -> Vec {
+ state.failures
+ }
+
+ /// Get the rule code.
+ fn code(&self) -> &RuleCode;
+
+ /// Get the default severity.
+ fn severity(&self) -> Severity;
+
+ /// Get the rule message.
+ fn message(&self) -> &str;
+}
+
+/// State for rule execution.
+#[derive(Debug, Clone, Default)]
+pub struct RuleState {
+ /// Accumulated failures.
+ pub failures: Vec,
+ /// Custom state data (serialized).
+ pub data: RuleData,
+}
+
+impl RuleState {
+ /// Create a new empty state.
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Add a failure.
+ pub fn add_failure(&mut self, code: impl Into, severity: Severity, message: impl Into, line: u32) {
+ self.failures.push(CheckFailure::new(code, severity, message, line));
+ }
+}
+
+/// Custom data storage for stateful rules.
+#[derive(Debug, Clone, Default)]
+pub struct RuleData {
+ /// Integer values.
+ pub ints: std::collections::HashMap<&'static str, i64>,
+ /// Boolean values.
+ pub bools: std::collections::HashMap<&'static str, bool>,
+ /// String values.
+ pub strings: std::collections::HashMap<&'static str, String>,
+ /// String set values.
+ pub string_sets: std::collections::HashMap<&'static str, std::collections::HashSet>,
+}
+
+impl RuleData {
+ pub fn get_int(&self, key: &'static str) -> i64 {
+ self.ints.get(key).copied().unwrap_or(0)
+ }
+
+ pub fn set_int(&mut self, key: &'static str, value: i64) {
+ self.ints.insert(key, value);
+ }
+
+ pub fn get_bool(&self, key: &'static str) -> bool {
+ self.bools.get(key).copied().unwrap_or(false)
+ }
+
+ pub fn set_bool(&mut self, key: &'static str, value: bool) {
+ self.bools.insert(key, value);
+ }
+
+ pub fn get_string(&self, key: &'static str) -> Option<&str> {
+ self.strings.get(key).map(|s| s.as_str())
+ }
+
+ pub fn set_string(&mut self, key: &'static str, value: impl Into) {
+ self.strings.insert(key, value.into());
+ }
+
+ pub fn get_string_set(&self, key: &'static str) -> Option<&std::collections::HashSet